Skip to main content

teksilo_widgets/docking/
model.rs

1// SPDX-License-Identifier: MPL-2.0
2// SPDX-FileCopyrightText: 2026 FernTech
3
4//! [`DockingModel`] — the shared, cloneable, serializable handle that backs a
5//! [`DockingLayout`](super::DockingLayout). Mirrors the
6//! [`SplitterModel`] / `SceneModel` pattern:
7//! an `Rc<RefCell<…>>` with `&self` mutators that drop the borrow before
8//! bumping `version`, so observers run with the model unlocked.
9
10use std::cell::RefCell;
11use std::collections::HashMap;
12use std::rc::Rc;
13use std::sync::atomic::{AtomicU64, Ordering};
14
15use teksilo_canvas::Size;
16use teksilo_core::signal::Signal;
17use teksilo_i18n::{LocalizedString, lit};
18use teksilo_settings::Versioned;
19use teksilo_tokens::Orientation;
20
21use crate::primitives::IconWidget;
22use crate::splitter::{PaneDescriptor, SplitterModel};
23use crate::toolbar::ToolbarItem;
24
25pub use super::geometry::{CornerOwners, DockCorner, DockSide};
26
27/// Builds an icon for a dock widget's tab / rail item on demand.
28pub type DockIconFactory = Rc<dyn Fn() -> IconWidget>;
29
30/// Builds a dock's inline header items on demand: a flat list of
31/// [`ToolbarItem`]s — collapsible
32/// [`ToolbarAction`](crate::toolbar::ToolbarAction)s (the VS Code "view actions"
33/// pattern, e.g. "New File" / "Collapse All") and/or pinned custom widgets via
34/// [`ToolbarItem::custom`]. Shown inside the dock header, before the framework
35/// options (`⋮`) button. The framework hosts them in a
36/// [`Toolbar`](crate::toolbar::Toolbar), so they gain **overflow** (excess actions
37/// collapse into a `⌄` menu) and the correct **axis** for free — a horizontal row
38/// on leading / trailing sides, a vertical column on the rotated top / bottom
39/// strip — and an app never repeats the orientation or overflow logic.
40pub type DockHeaderActionsFactory = Rc<dyn Fn(DockWidgetId) -> Vec<ToolbarItem>>;
41
42/// Process-unique identity for a registered dock widget (the atomic unit).
43#[derive(
44    Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord, serde::Serialize, serde::Deserialize,
45)]
46pub struct DockWidgetId(pub u64);
47
48impl DockWidgetId {
49    /// Mint a fresh, process-unique id.
50    pub fn fresh() -> Self {
51        static COUNTER: AtomicU64 = AtomicU64::new(1);
52        Self(COUNTER.fetch_add(1, Ordering::Relaxed))
53    }
54    pub fn from_raw(v: u64) -> Self {
55        Self(v)
56    }
57    pub fn raw(self) -> u64 {
58        self.0
59    }
60}
61
62/// Process-unique identity for a dock tab (a tab of a side's TabWidget).
63#[derive(
64    Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord, serde::Serialize, serde::Deserialize,
65)]
66pub struct DockTabId(pub u64);
67
68impl DockTabId {
69    pub fn fresh() -> Self {
70        static COUNTER: AtomicU64 = AtomicU64::new(1);
71        Self(COUNTER.fetch_add(1, Ordering::Relaxed))
72    }
73    pub fn from_raw(v: u64) -> Self {
74        Self(v)
75    }
76    pub fn raw(self) -> u64 {
77        self.0
78    }
79}
80
81/// How a side surfaces its tabs: an in-side strip (the TabWidget's own bar) or
82/// an always-visible activity rail outboard of the collapsible content.
83#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
84pub enum TabPresentation {
85    /// In-side tab strip (hidden when a single tab is present).
86    Strip,
87    /// External always-visible activity rail; the in-side strip is suppressed.
88    Rail,
89}
90
91/// Placement mode for a programmatically-opened dock.
92#[derive(Debug, Clone, Copy, PartialEq, Eq)]
93pub enum DockOpenMode {
94    /// Stack into the side's currently-selected tab (as a ToolBox section).
95    Stack,
96    /// Create a brand-new tab holding just this dock.
97    NewTab,
98}
99
100/// Target for [`DockingModel::open_dock`] / [`DockingModel::move_dock`].
101#[derive(Debug, Clone, Copy, PartialEq, Eq)]
102pub struct DockOpenLocation {
103    pub side: DockSide,
104    pub mode: DockOpenMode,
105}
106
107impl DockOpenLocation {
108    /// Default placement on a side (stack into the active tab).
109    pub fn side(side: DockSide) -> Self {
110        Self {
111            side,
112            mode: DockOpenMode::Stack,
113        }
114    }
115    /// Stack into the side's active tab.
116    pub fn stack(mut self) -> Self {
117        self.mode = DockOpenMode::Stack;
118        self
119    }
120    /// Open as a fresh tab.
121    pub fn new_tab(mut self) -> Self {
122        self.mode = DockOpenMode::NewTab;
123        self
124    }
125}
126
127/// Activity-bar item size for a side's rail (context-menu "Activity bar size").
128#[derive(Debug, Clone, Copy, PartialEq, Eq)]
129pub enum DockRailItemSize {
130    /// The rail's configured size (`DockRail::size`); icon only, title on hover.
131    Default = 0,
132    /// Compact items — the standard
133    /// [`IconButtonSize::Default`](crate::icon_button::IconButtonSize::Default)
134    /// regardless of the rail's configured (larger) size; icon only, title on
135    /// hover. Not the extra-small `Compact` button: a rail glyph is the
136    /// activity's identifier and must stay legible.
137    Compact = 1,
138    /// Icon at the configured size **plus** a 90°-rotated title beneath it (the
139    /// vertical-accordion look). The title shows inline, so no hover tooltip.
140    Labeled = 2,
141}
142
143impl DockRailItemSize {
144    fn from_usize(v: usize) -> Self {
145        match v {
146            1 => Self::Compact,
147            2 => Self::Labeled,
148            _ => Self::Default,
149        }
150    }
151
152    /// Whether this mode paints the title inline (rotated) rather than only as a
153    /// hover tooltip.
154    pub fn shows_label(self) -> bool {
155        matches!(self, Self::Labeled)
156    }
157}
158
159/// How a side's dock tabs render (context-menu "Tab size").
160#[derive(Debug, Clone, Copy, PartialEq, Eq)]
161pub enum DockTabDisplay {
162    /// Title text only (the default).
163    Text = 0,
164    /// The dock's icon only (falls back to the title initial if it has none).
165    Icon = 1,
166    /// Icon + title.
167    IconText = 2,
168}
169
170impl DockTabDisplay {
171    fn from_usize(v: usize) -> Self {
172        match v {
173            1 => Self::Icon,
174            2 => Self::IconText,
175            _ => Self::Text,
176        }
177    }
178    /// Whether this mode shows the icon glyph.
179    pub fn shows_icon(self) -> bool {
180        matches!(self, Self::Icon | Self::IconText)
181    }
182    /// Whether this mode shows the title text.
183    pub fn shows_text(self) -> bool {
184        matches!(self, Self::Text | Self::IconText)
185    }
186}
187
188/// App-declared policy that **locks down end-user layout edits** on a
189/// [`DockingLayout`](super::DockingLayout). Each flag removes a *user
190/// affordance* only — the programmatic [`DockingModel`] API (a "Toggle panel"
191/// button, `open_dock`, `set_tab_hidden`, …) keeps working regardless, so the
192/// app can still drive the layout it has locked for the user.
193///
194/// App-declared each run (like `rail_thickness` / `min_size` / [`DockRail`](super::DockRail))
195/// — **not** persisted in [`DockLayoutState`](super::DockLayoutState). Set it with
196/// [`DockingModel::set_policy`]. Default = everything allowed; [`DockPolicy::locked`]
197/// = everything forbidden.
198#[derive(Debug, Clone, Copy, PartialEq, Eq)]
199pub struct DockPolicy {
200    /// The user may drag activities (rail items / tab headers) to reorder them
201    /// or move them between sides, and relocate them via the context-menu
202    /// "Move to" submenu. Default `true`.
203    pub allow_activity_drag: bool,
204    /// The user may drag a single dock out of a split pane (its accordion
205    /// header is a drag handle). Default `true`.
206    pub allow_dock_drag: bool,
207    /// The user may hide / collapse a whole side — via the resize handle
208    /// (drag-past-min snap, double-click, keyboard) or by clicking the active
209    /// activity-rail item. Default `true`.
210    pub allow_side_collapse: bool,
211    /// The user may hide an activity from the context menu. Default `true`.
212    pub allow_activity_hide: bool,
213}
214
215impl Default for DockPolicy {
216    fn default() -> Self {
217        Self {
218            allow_activity_drag: true,
219            allow_dock_drag: true,
220            allow_side_collapse: true,
221            allow_activity_hide: true,
222        }
223    }
224}
225
226impl DockPolicy {
227    /// A fully **locked** layout — no user drag, no collapse, no activity hide.
228    /// The app's programmatic API still drives it.
229    pub fn locked() -> Self {
230        Self {
231            allow_activity_drag: false,
232            allow_dock_drag: false,
233            allow_side_collapse: false,
234            allow_activity_hide: false,
235        }
236    }
237}
238
239/// Static metadata for a registered dock widget. App-declared; reconstructed
240/// each run (never serialized).
241pub(crate) struct DockWidgetMeta {
242    pub title: LocalizedString,
243    pub icon: Option<DockIconFactory>,
244    #[allow(dead_code)]
245    pub min_size: Option<Size>,
246    pub default: DockOpenLocation,
247    /// App-supplied inline header actions (e.g. "New File" / "Collapse All")
248    /// shown in the dock header before the `⋮` options button.
249    pub header_actions: Option<DockHeaderActionsFactory>,
250    /// Whether a **sole-pane** (bare) dock renders a header bar (title +
251    /// actions + `⋮`). The multi-pane Accordion header is always present; this
252    /// only governs the bare case. Default `false`.
253    pub show_header: bool,
254}
255
256/// One tab of a side's TabWidget: a `Splitter` arrangement of panes, each pane
257/// a single dock. Stacking two docks side-by-side adds a Splitter pane (each
258/// rendered as a single-item ToolBox) — there is no multi-section pane.
259#[derive(Clone)]
260pub(crate) struct DockTab {
261    pub id: DockTabId,
262    pub title: Option<LocalizedString>,
263    pub icon: Option<DockIconFactory>,
264    pub splitter: SplitterModel,
265    /// One dock per Splitter pane.
266    pub panes: Vec<DockWidgetId>,
267    /// User-hidden ("hide this activity"): kept in the model so it stays
268    /// listable + restorable, but not shown in the rail / tab strip.
269    pub hidden: bool,
270}
271
272/// Runtime state for one side.
273pub(crate) struct SideState {
274    pub tabs: Vec<DockTab>,
275    pub selected_tab: usize,
276    pub presentation: TabPresentation,
277    pub rail_thickness: f32,
278    pub size: f32,
279    pub min_size: f32,
280    pub visible: bool,
281    /// App policy: a **disabled** side renders nothing, reserves no space, is
282    /// not a drop target, and rejects placement / moves to it (its docks stay
283    /// in the model and reappear when re-enabled). Default `true`.
284    pub enabled: bool,
285    pub visible_sig: Signal<bool>,
286    pub selected_tab_sig: Signal<usize>,
287    /// Activity-bar item size for this side's rail: `0` = the rail's configured
288    /// (default) size, `1` = compact. Reactive — the rail binds it at `Rebuild`
289    /// and re-reads the size when it flips.
290    pub rail_size_sig: Signal<usize>,
291    /// How this side's dock tabs render: `0` = text only, `1` = icon only,
292    /// `2` = icon + text. Reactive — the tab strip binds it at `Rebuild`.
293    pub tab_display_sig: Signal<usize>,
294}
295
296/// Where a dock currently lives. One dock per Splitter pane, so a
297/// `(side, tab_idx, pane_idx)` triple addresses it exactly.
298#[derive(Debug, Clone, Copy, PartialEq, Eq)]
299pub struct DockLoc {
300    pub side: DockSide,
301    pub tab_idx: usize,
302    pub pane_idx: usize,
303}
304
305/// What [`DockingModel::detach`] pruned, so a caller holding a target index in
306/// the same side can correct for the resulting shift before re-inserting.
307#[derive(Debug, Clone, Copy)]
308struct DetachOutcome {
309    loc: DockLoc,
310    removed_pane: bool,
311    removed_tab: bool,
312}
313
314impl DetachOutcome {
315    /// Adjust a `(tab_idx, pane_idx)` drop target on `side` for the panes /
316    /// tabs this detach removed *before* it. (A removal *at* the target index
317    /// is the drop-on-self case, handled by an explicit no-op guard upstream.)
318    fn adjust_target(&self, side: DockSide, tab_idx: usize, pane_idx: usize) -> (usize, usize) {
319        if self.loc.side != side {
320            return (tab_idx, pane_idx);
321        }
322        if self.removed_tab {
323            // A whole tab vanished: indices after it shift down by one.
324            let tab_idx = if self.loc.tab_idx < tab_idx {
325                tab_idx - 1
326            } else {
327                tab_idx
328            };
329            return (tab_idx, pane_idx);
330        }
331        if self.removed_pane && self.loc.tab_idx == tab_idx && self.loc.pane_idx < pane_idx {
332            return (tab_idx, pane_idx - 1);
333        }
334        (tab_idx, pane_idx)
335    }
336}
337
338/// The splitter orientation a side uses to arrange its panes: leading /
339/// trailing columns stack vertically; top / bottom strips run horizontally.
340pub(crate) fn side_orientation(side: DockSide) -> Orientation {
341    if side.is_horizontal_axis() {
342        Orientation::Vertical
343    } else {
344        Orientation::Horizontal
345    }
346}
347
348fn default_side_state(side: DockSide) -> SideState {
349    let (size, rail, min) = if side.is_horizontal_axis() {
350        (260.0, 0.0, 120.0)
351    } else {
352        (200.0, 0.0, 80.0)
353    };
354    SideState {
355        tabs: Vec::new(),
356        selected_tab: 0,
357        presentation: TabPresentation::Strip,
358        rail_thickness: rail,
359        size,
360        min_size: min,
361        visible: false,
362        enabled: true,
363        visible_sig: Signal::new(false),
364        selected_tab_sig: Signal::new(0),
365        rail_size_sig: Signal::new(DockRailItemSize::Default as usize),
366        tab_display_sig: Signal::new(DockTabDisplay::Text as usize),
367    }
368}
369
370struct Inner {
371    sides: HashMap<DockSide, SideState>,
372    docks: HashMap<DockWidgetId, DockWidgetMeta>,
373    locations: HashMap<DockWidgetId, DockLoc>,
374    open_sigs: HashMap<DockWidgetId, Signal<bool>>,
375    /// Per-activity `hidden` reflect signals (the context-menu checklist binds
376    /// these so the checkmark tracks live model state). Cached per `DockTabId`,
377    /// kept in sync by `sync_derived`. Mirrors `open_sigs`.
378    tab_hidden_sigs: HashMap<DockTabId, Signal<bool>>,
379    corners: CornerOwners,
380    /// App-declared lock policy (not persisted). Read by the widgets in
381    /// `build()` to gate user affordances.
382    policy: DockPolicy,
383    /// Bumped on **structural** change (tab / pane / section / side
384    /// add-remove) — the widget binds it at `BindingLevel::Rebuild`.
385    version: Signal<u64>,
386    /// Bumped on **geometry** change (side size / visibility / corners) —
387    /// the widget binds it at `BindingLevel::Relayout`. Avoids a full rebuild
388    /// (and content teardown) for resize / show-hide / corner flips.
389    geometry_version: Signal<u64>,
390    animate_next: bool,
391}
392
393/// A read-only view of one tab, handed to the widget layer for rendering.
394/// `panes` holds one dock per Splitter pane.
395#[derive(Clone)]
396pub(crate) struct DockTabView {
397    pub id: DockTabId,
398    pub title: Option<LocalizedString>,
399    pub icon: Option<DockIconFactory>,
400    pub splitter: SplitterModel,
401    pub panes: Vec<DockWidgetId>,
402    pub hidden: bool,
403}
404
405/// The shared docking-layout model. `Clone` = share-by-handle.
406pub struct DockingModel(Rc<RefCell<Inner>>);
407
408impl Clone for DockingModel {
409    fn clone(&self) -> Self {
410        Self(self.0.clone())
411    }
412}
413
414impl std::fmt::Debug for DockingModel {
415    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
416        let inner = self.0.borrow();
417        f.debug_struct("DockingModel")
418            .field("docks", &inner.docks.len())
419            .field("open", &inner.locations.len())
420            .finish()
421    }
422}
423
424impl Default for DockingModel {
425    fn default() -> Self {
426        Self::new()
427    }
428}
429
430impl DockingModel {
431    /// A fresh model: four empty, hidden sides and the default corner owners.
432    pub fn new() -> Self {
433        let mut sides = HashMap::new();
434        for side in DockSide::ALL {
435            sides.insert(side, default_side_state(side));
436        }
437        Self(Rc::new(RefCell::new(Inner {
438            sides,
439            docks: HashMap::new(),
440            locations: HashMap::new(),
441            open_sigs: HashMap::new(),
442            tab_hidden_sigs: HashMap::new(),
443            corners: CornerOwners::default(),
444            policy: DockPolicy::default(),
445            version: Signal::new(0),
446            geometry_version: Signal::new(0),
447            animate_next: true,
448        })))
449    }
450
451    /// Structural version — bump on tab / pane / section / side add-remove.
452    /// The widget binds this at `BindingLevel::Rebuild`.
453    pub fn version(&self) -> Signal<u64> {
454        self.0.borrow().version.clone()
455    }
456
457    /// Geometry version — bump on side size / visibility / corner change.
458    /// The widget binds this at `BindingLevel::Relayout`.
459    pub fn geometry_version(&self) -> Signal<u64> {
460        self.0.borrow().geometry_version.clone()
461    }
462
463    /// Read-and-reset the "animate the next side show/hide" latch.
464    pub fn consume_animate_flag(&self) -> bool {
465        let mut inner = self.0.borrow_mut();
466        std::mem::replace(&mut inner.animate_next, true)
467    }
468
469    // ─── notify ────────────────────────────────────────────────────────
470
471    /// Re-sync all derived signals (visible / selected-tab / open) with the
472    /// borrow dropped first so observers may read the model.
473    fn sync_derived(&self) {
474        let mut updates_b: Vec<(Signal<bool>, bool)> = Vec::new();
475        let mut updates_u: Vec<(Signal<usize>, usize)> = Vec::new();
476        {
477            let inner = self.0.borrow();
478            for st in inner.sides.values() {
479                updates_b.push((st.visible_sig.clone(), st.visible));
480                updates_u.push((st.selected_tab_sig.clone(), st.selected_tab));
481            }
482            for (id, sig) in &inner.open_sigs {
483                updates_b.push((sig.clone(), inner.locations.contains_key(id)));
484            }
485            for (tab_id, sig) in &inner.tab_hidden_sigs {
486                let hidden = inner
487                    .sides
488                    .values()
489                    .any(|st| st.tabs.iter().any(|t| t.id == *tab_id && t.hidden));
490                updates_b.push((sig.clone(), hidden));
491            }
492        }
493        for (sig, val) in updates_b {
494            if sig.get() != val {
495                sig.set(val);
496            }
497        }
498        for (sig, val) in updates_u {
499            if sig.get() != val {
500                sig.set(val);
501            }
502        }
503    }
504
505    /// Structural change: bump `version` (→ Rebuild) + re-sync derived.
506    fn notify(&self) {
507        let version = self.0.borrow().version.clone();
508        version.set(version.get().wrapping_add(1));
509        self.sync_derived();
510    }
511
512    /// Geometry change: bump `geometry_version` (→ Relayout) + re-sync derived.
513    /// Does NOT rebuild, so content subtrees are preserved.
514    fn relayout(&self) {
515        let gv = self.0.borrow().geometry_version.clone();
516        gv.set(gv.get().wrapping_add(1));
517        self.sync_derived();
518    }
519
520    /// Mark the next side-visibility change as non-animated (snap).
521    fn set_no_animate(&self) {
522        self.0.borrow_mut().animate_next = false;
523    }
524
525    // ─── registration ──────────────────────────────────────────────────
526
527    /// Register a dock widget's metadata. App-declared; idempotent (last
528    /// wins). Does not place the dock — use [`open_dock`](Self::open_dock).
529    pub(crate) fn register_meta(&self, id: DockWidgetId, meta: DockWidgetMeta) {
530        self.0.borrow_mut().docks.insert(id, meta);
531    }
532
533    /// Whether a dock id is known (its content factory + meta are registered).
534    pub fn is_registered(&self, id: DockWidgetId) -> bool {
535        self.0.borrow().docks.contains_key(&id)
536    }
537
538    // ─── side configuration ────────────────────────────────────────────
539
540    /// Set a side's activity-rail thickness and presentation. A non-zero rail
541    /// switches the side to [`TabPresentation::Rail`]; the in-side strip is
542    /// then suppressed.
543    pub fn set_side_rail(&self, side: DockSide, thickness: f32) {
544        {
545            let mut inner = self.0.borrow_mut();
546            if let Some(st) = inner.sides.get_mut(&side) {
547                st.rail_thickness = thickness.max(0.0);
548                st.presentation = if thickness > 0.0 {
549                    TabPresentation::Rail
550                } else {
551                    TabPresentation::Strip
552                };
553            }
554        }
555        self.notify();
556    }
557
558    /// Set a side's stored content size (px). Relayout only (no rebuild).
559    pub fn set_side_size(&self, side: DockSide, size: f32) {
560        {
561            let mut inner = self.0.borrow_mut();
562            if let Some(st) = inner.sides.get_mut(&side) {
563                st.size = size.max(0.0);
564            }
565        }
566        self.relayout();
567    }
568
569    /// Set a side's minimum content size (px).
570    pub fn set_side_min_size(&self, side: DockSide, min: f32) {
571        {
572            let mut inner = self.0.borrow_mut();
573            if let Some(st) = inner.sides.get_mut(&side) {
574                st.min_size = min.max(0.0);
575            }
576        }
577        self.relayout();
578    }
579
580    // ─── policy / per-side enable (app-declared lock-down) ─────────────────
581
582    /// Set the app's [`DockPolicy`] — locks down end-user layout edits (the
583    /// programmatic API keeps working). Structural → rebuild.
584    pub fn set_policy(&self, policy: DockPolicy) {
585        let changed = {
586            let mut inner = self.0.borrow_mut();
587            let changed = inner.policy != policy;
588            inner.policy = policy;
589            changed
590        };
591        if changed {
592            self.notify();
593        }
594    }
595
596    /// The app's current [`DockPolicy`] (cheap `Copy`; read by the widgets in
597    /// `build()` to gate their user affordances).
598    pub fn policy(&self) -> DockPolicy {
599        self.0.borrow().policy
600    }
601
602    /// Enable / disable a whole side. A disabled side renders nothing, reserves
603    /// no space, is not a drop target, and rejects placement / moves to it; its
604    /// docks stay in the model and reappear when re-enabled. Structural →
605    /// rebuild.
606    pub fn set_side_enabled(&self, side: DockSide, enabled: bool) {
607        let changed = {
608            let mut inner = self.0.borrow_mut();
609            match inner.sides.get_mut(&side) {
610                Some(st) if st.enabled != enabled => {
611                    st.enabled = enabled;
612                    true
613                }
614                _ => false,
615            }
616        };
617        if changed {
618            self.notify();
619        }
620    }
621
622    /// Whether a side is enabled (default `true`).
623    pub fn is_side_enabled(&self, side: DockSide) -> bool {
624        self.0.borrow().sides.get(&side).is_none_or(|s| s.enabled)
625    }
626
627    /// Show / hide a whole side (animated).
628    pub fn set_side_visible(&self, side: DockSide, visible: bool) {
629        self.set_side_visible_inner(side, visible, true);
630    }
631
632    /// Show / hide a side immediately (no animation — drag-driven).
633    pub fn set_side_visible_immediate(&self, side: DockSide, visible: bool) {
634        self.set_side_visible_inner(side, visible, false);
635    }
636
637    fn set_side_visible_inner(&self, side: DockSide, visible: bool, animate: bool) {
638        let changed = {
639            let mut inner = self.0.borrow_mut();
640            match inner.sides.get_mut(&side) {
641                Some(st) if st.visible != visible => {
642                    st.visible = visible;
643                    true
644                }
645                _ => false,
646            }
647        };
648        if changed {
649            if !animate {
650                self.set_no_animate();
651            }
652            // Relayout only: the side's content is pre-built and parked
653            // dormant when hidden, so showing / hiding animates without a
654            // rebuild (content preserved).
655            self.relayout();
656        }
657    }
658
659    /// Toggle a side's visibility (animated).
660    pub fn toggle_side_visible(&self, side: DockSide) {
661        let cur = self.is_side_visible(side);
662        self.set_side_visible(side, !cur);
663    }
664
665    /// Select the active tab of a side. Repaint only (the Switcher swaps via
666    /// its bound `selected_tab` signal — no rebuild, no relayout).
667    pub fn select_tab(&self, side: DockSide, tab_idx: usize) {
668        {
669            let mut inner = self.0.borrow_mut();
670            if let Some(st) = inner.sides.get_mut(&side)
671                && tab_idx < st.tabs.len()
672            {
673                st.selected_tab = tab_idx;
674            }
675        }
676        self.sync_derived();
677    }
678
679    /// Select a side's active tab by id (position-independent — used by the
680    /// rail / strip, whose visible order may skip hidden tabs).
681    pub fn select_tab_by_id(&self, side: DockSide, tab_id: DockTabId) {
682        let idx = {
683            let inner = self.0.borrow();
684            inner
685                .sides
686                .get(&side)
687                .and_then(|st| st.tabs.iter().position(|t| t.id == tab_id))
688        };
689        if let Some(i) = idx {
690            self.select_tab(side, i);
691        }
692    }
693
694    // ─── per-activity hide (context-menu "Hide" + checkable list) ──────────
695
696    /// Hide / show one activity (tab). A hidden activity stays registered (so
697    /// it remains listable + restorable) but is dropped from the rail and tab
698    /// strip. Hiding the selected tab moves the selection to the nearest still-
699    /// visible tab. Structural → rebuild.
700    pub fn set_tab_hidden(&self, tab_id: DockTabId, hidden: bool) {
701        let mut changed = false;
702        {
703            let mut inner = self.0.borrow_mut();
704            'outer: for st in inner.sides.values_mut() {
705                if let Some(ti) = st.tabs.iter().position(|t| t.id == tab_id) {
706                    if st.tabs[ti].hidden == hidden {
707                        break 'outer;
708                    }
709                    st.tabs[ti].hidden = hidden;
710                    changed = true;
711                    // If we just hid the selected tab, move selection to the
712                    // nearest non-hidden tab (forward first, then backward).
713                    // When *no* tab is left visible (we hid the last one), reset
714                    // to a coherent `0` rather than leaving `selected_tab` on the
715                    // now-hidden index — otherwise the derived `selected_tab`
716                    // signal points at an absent tab and the strip's
717                    // `tw_selected` holds a `TabId` not in its visible list.
718                    if hidden && st.selected_tab == ti {
719                        st.selected_tab = Self::nearest_visible_tab(&st.tabs, ti + 1).unwrap_or(0);
720                    }
721                    break 'outer;
722                }
723            }
724        }
725        if changed {
726            self.notify();
727        }
728    }
729
730    /// Whether an activity (tab) is currently hidden.
731    pub fn is_tab_hidden(&self, tab_id: DockTabId) -> bool {
732        let inner = self.0.borrow();
733        inner
734            .sides
735            .values()
736            .any(|st| st.tabs.iter().any(|t| t.id == tab_id && t.hidden))
737    }
738
739    /// Count of a side's non-hidden tabs (the rail / strip item count).
740    pub(crate) fn side_visible_tab_count(&self, side: DockSide) -> usize {
741        let inner = self.0.borrow();
742        inner
743            .sides
744            .get(&side)
745            .map(|st| st.tabs.iter().filter(|t| !t.hidden).count())
746            .unwrap_or(0)
747    }
748
749    // ─── per-side display prefs (rail item size / tab display) ─────────────
750
751    /// The reactive rail-size selector for a side (`0` = configured size, `1` =
752    /// compact). The rail binds it at `Rebuild`; the context-menu radio writes
753    /// it.
754    pub(crate) fn rail_size_signal(&self, side: DockSide) -> Signal<usize> {
755        self.0
756            .borrow()
757            .sides
758            .get(&side)
759            .map(|st| st.rail_size_sig.clone())
760            .unwrap_or_else(|| Signal::new(0))
761    }
762
763    /// Current activity-bar item size for a side.
764    pub fn side_rail_size(&self, side: DockSide) -> DockRailItemSize {
765        DockRailItemSize::from_usize(self.rail_size_signal(side).get())
766    }
767
768    /// Set a side's activity-bar item size (reactive → the rail rebuilds).
769    pub fn set_side_rail_size(&self, side: DockSide, size: DockRailItemSize) {
770        self.rail_size_signal(side).set(size as usize);
771    }
772
773    /// Reactive activity-bar **size mode** for a side — fires whenever the user
774    /// switches Default / Compact / Icon + Label (via the context menu or
775    /// [`set_side_rail_size`](Self::set_side_rail_size)). Bind it to adapt any
776    /// external widget — a rail's slotted controls, an app toolbar — to the
777    /// rail's current item size. (The rail rebuilds its slots on every change,
778    /// so a slot factory that reads this signal stays in step.)
779    pub fn rail_size_mode_signal(&self, side: DockSide) -> Signal<DockRailItemSize> {
780        self.rail_size_signal(side)
781            .map(|v| DockRailItemSize::from_usize(*v))
782    }
783
784    /// The reactive tab-display mode for a side (`0`/`1`/`2`). The tab strip
785    /// binds it at `Rebuild`; the context-menu radio writes it.
786    pub(crate) fn tab_display_signal(&self, side: DockSide) -> Signal<usize> {
787        self.0
788            .borrow()
789            .sides
790            .get(&side)
791            .map(|st| st.tab_display_sig.clone())
792            .unwrap_or_else(|| Signal::new(0))
793    }
794
795    /// Current dock-tab display mode for a side.
796    pub fn side_tab_display(&self, side: DockSide) -> DockTabDisplay {
797        DockTabDisplay::from_usize(self.tab_display_signal(side).get())
798    }
799
800    /// Set a side's dock-tab display mode (reactive → the strip rebuilds).
801    pub fn set_side_tab_display(&self, side: DockSide, display: DockTabDisplay) {
802        self.tab_display_signal(side).set(display as usize);
803    }
804
805    /// Set the owner of a corner (must be one of its two adjacent sides).
806    pub fn set_corner(&self, corner: DockCorner, owner: DockSide) {
807        {
808            let mut inner = self.0.borrow_mut();
809            inner.corners.set(corner, owner);
810        }
811        self.relayout();
812    }
813
814    // ─── placement / movement ──────────────────────────────────────────
815
816    /// Detach a dock from wherever it currently lives, pruning the emptied
817    /// pane (and the tab, if that was its last pane). Returns a
818    /// [`DetachOutcome`] describing the dock's prior location and whether the
819    /// prune removed the enclosing pane / tab (so callers holding a target
820    /// index in the same side can adjust for the shift). Caller must
821    /// `notify()` afterwards.
822    fn detach(inner: &mut Inner, id: DockWidgetId) -> Option<DetachOutcome> {
823        let loc = inner.locations.remove(&id)?;
824        let mut removed_pane = false;
825        let mut removed_tab = false;
826        if let Some(st) = inner.sides.get_mut(&loc.side)
827            && let Some(tab) = st.tabs.get_mut(loc.tab_idx)
828            && loc.pane_idx < tab.panes.len()
829        {
830            // One dock per pane → detaching always removes the pane.
831            removed_pane = true;
832            tab.panes.remove(loc.pane_idx);
833            tab.splitter.remove_pane(loc.pane_idx);
834            if tab.panes.is_empty() {
835                removed_tab = true;
836                st.tabs.remove(loc.tab_idx);
837                Self::fix_selection_after_remove(st, loc.tab_idx);
838            }
839        }
840        // Rebuild every location for the affected side (indices shifted).
841        Self::reindex_side(inner, loc.side);
842        Some(DetachOutcome {
843            loc,
844            removed_pane,
845            removed_tab,
846        })
847    }
848
849    /// Fix up `selected_tab` after the tab at `removed_idx` was removed from
850    /// `st.tabs`. Three cases: the active tab sat *after* the removed one (it
851    /// shifts down by one, so follow it); the active tab ran off the end (clamp
852    /// to the new last); the side is now empty (reset + hide). Removing the
853    /// active tab from the middle deliberately advances selection to the tab
854    /// that shifted into its slot. Keeping this in one place avoids the
855    /// off-by-one that an inlined copy missed (the `removed_idx < selected`
856    /// branch). Caller must `notify()` afterwards.
857    fn fix_selection_after_remove(st: &mut SideState, removed_idx: usize) {
858        if removed_idx < st.selected_tab {
859            st.selected_tab -= 1;
860        } else if st.selected_tab >= st.tabs.len() && !st.tabs.is_empty() {
861            st.selected_tab = st.tabs.len() - 1;
862        }
863        if st.tabs.is_empty() {
864            st.selected_tab = 0;
865            st.visible = false;
866        }
867    }
868
869    /// The nearest non-hidden tab index, searching forward from `from` and then
870    /// backward before it. `None` when every tab is hidden. Shared by
871    /// `set_tab_hidden` (re-home the selection off a just-hidden tab) and
872    /// `import_state` (clamp a persisted selection that landed on a hidden tab).
873    fn nearest_visible_tab(tabs: &[DockTab], from: usize) -> Option<usize> {
874        let n = tabs.len();
875        (from..n)
876            .find(|&j| !tabs[j].hidden)
877            .or_else(|| (0..from.min(n)).rev().find(|&j| !tabs[j].hidden))
878    }
879
880    /// Recompute `locations` for every dock on a side after structural edits.
881    fn reindex_side(inner: &mut Inner, side: DockSide) {
882        let entries: Vec<(DockWidgetId, DockLoc)> = {
883            let Some(st) = inner.sides.get(&side) else {
884                return;
885            };
886            let mut out = Vec::new();
887            for (ti, tab) in st.tabs.iter().enumerate() {
888                for (pi, dock) in tab.panes.iter().enumerate() {
889                    out.push((
890                        *dock,
891                        DockLoc {
892                            side,
893                            tab_idx: ti,
894                            pane_idx: pi,
895                        },
896                    ));
897                }
898            }
899            out
900        };
901        // Drop stale entries for this side, then re-insert.
902        inner.locations.retain(|_, l| l.side != side);
903        for (id, loc) in entries {
904            inner.locations.insert(id, loc);
905        }
906    }
907
908    fn new_tab(side: DockSide, dock: DockWidgetId) -> DockTab {
909        DockTab {
910            id: DockTabId::fresh(),
911            title: None,
912            icon: None,
913            splitter: SplitterModel::new(1, side_orientation(side)),
914            panes: vec![dock],
915            hidden: false,
916        }
917    }
918
919    /// Open (or move) a dock onto a side. Already-open docks are relocated
920    /// (never duplicated).
921    pub fn open_dock(&self, id: DockWidgetId, loc: DockOpenLocation) {
922        {
923            let mut inner = self.0.borrow_mut();
924            if !inner.docks.contains_key(&id) {
925                debug_assert!(false, "open_dock on unregistered dock {id:?}");
926                return;
927            }
928            if !Self::side_accepts(&inner, loc.side) {
929                return; // disabled side rejects placement
930            }
931            // Ensure-open semantics: re-opening a dock already on the target
932            // side is a no-op (no version churn). Moving across sides, and the
933            // explicit promote/split/move mutators, still re-place it.
934            if inner.locations.get(&id).map(|l| l.side) == Some(loc.side) {
935                return;
936            }
937            Self::detach(&mut inner, id);
938            Self::place(&mut inner, id, loc);
939        }
940        self.notify();
941    }
942
943    /// Whether `side` accepts placement (disabled sides reject it). Read from
944    /// an already-borrowed `Inner` by the placement mutators.
945    fn side_accepts(inner: &Inner, side: DockSide) -> bool {
946        inner.sides.get(&side).is_none_or(|s| s.enabled)
947    }
948
949    fn place(inner: &mut Inner, id: DockWidgetId, loc: DockOpenLocation) {
950        let side = loc.side;
951        let orientation = side_orientation(side);
952        if let Some(st) = inner.sides.get_mut(&side) {
953            st.visible = true;
954            match loc.mode {
955                DockOpenMode::NewTab => {
956                    st.tabs.push(DockTab {
957                        id: DockTabId::fresh(),
958                        title: None,
959                        icon: None,
960                        splitter: SplitterModel::new(1, orientation),
961                        panes: vec![id],
962                        hidden: false,
963                    });
964                    st.selected_tab = st.tabs.len() - 1;
965                }
966                DockOpenMode::Stack => {
967                    if st.tabs.is_empty() {
968                        st.tabs.push(DockTab {
969                            id: DockTabId::fresh(),
970                            title: None,
971                            icon: None,
972                            splitter: SplitterModel::new(1, orientation),
973                            panes: vec![id],
974                            hidden: false,
975                        });
976                        st.selected_tab = 0;
977                    } else {
978                        // Stacking adds a Splitter pane to the selected tab
979                        // (each pane is its own single-item ToolBox), not a
980                        // multi-section pane.
981                        let ti = st.selected_tab.min(st.tabs.len() - 1);
982                        let tab = &mut st.tabs[ti];
983                        let at = tab.panes.len();
984                        tab.panes.push(id);
985                        tab.splitter
986                            .insert_pane(at, PaneDescriptor::new().stretch(1.0));
987                        st.selected_tab = ti;
988                    }
989                }
990            }
991        }
992        Self::reindex_side(inner, side);
993    }
994
995    /// Drag a dock out into its own new tab on `side`, inserted at `at_tab`.
996    pub fn promote_to_tab(&self, id: DockWidgetId, side: DockSide, at_tab: usize) {
997        {
998            let mut inner = self.0.borrow_mut();
999            if !inner.docks.contains_key(&id) || !Self::side_accepts(&inner, side) {
1000                return;
1001            }
1002            // Already its own sole tab on this side at this index → no-op.
1003            if let Some(loc) = inner.locations.get(&id)
1004                && loc.side == side
1005                && loc.tab_idx == at_tab
1006                && inner
1007                    .sides
1008                    .get(&side)
1009                    .and_then(|st| st.tabs.get(at_tab))
1010                    .map(|t| t.panes.len() == 1)
1011                    .unwrap_or(false)
1012            {
1013                return;
1014            }
1015            let mut at_tab = at_tab;
1016            if let Some(outcome) = Self::detach(&mut inner, id) {
1017                (at_tab, _) = outcome.adjust_target(side, at_tab, 0);
1018            }
1019            let tab = Self::new_tab(side, id);
1020            if let Some(st) = inner.sides.get_mut(&side) {
1021                let at = at_tab.min(st.tabs.len());
1022                st.tabs.insert(at, tab);
1023                st.selected_tab = at;
1024                st.visible = true;
1025            }
1026            Self::reindex_side(&mut inner, side);
1027        }
1028        self.notify();
1029    }
1030
1031    /// Drop a dock into an existing tab's Splitter as a new `Single` pane,
1032    /// before (`before = true`) or after the pane at `pane_idx`.
1033    pub fn split_into_tab(
1034        &self,
1035        id: DockWidgetId,
1036        side: DockSide,
1037        tab_idx: usize,
1038        pane_idx: usize,
1039        before: bool,
1040    ) {
1041        {
1042            let mut inner = self.0.borrow_mut();
1043            if !inner.docks.contains_key(&id) || !Self::side_accepts(&inner, side) {
1044                return;
1045            }
1046            // Drop-on-self: splitting the pane a dock already solely occupies
1047            // with that same dock changes nothing. No-op (no churn).
1048            if let Some(loc) = inner.locations.get(&id)
1049                && loc.side == side
1050                && loc.tab_idx == tab_idx
1051                && loc.pane_idx == pane_idx
1052            {
1053                return;
1054            }
1055            // Detach first, then correct the target for any pane / tab the
1056            // detach pruned at an earlier index in this side.
1057            let (mut tab_idx, mut pane_idx) = (tab_idx, pane_idx);
1058            if let Some(outcome) = Self::detach(&mut inner, id) {
1059                (tab_idx, pane_idx) = outcome.adjust_target(side, tab_idx, pane_idx);
1060            }
1061            if let Some(st) = inner.sides.get_mut(&side)
1062                && let Some(tab) = st.tabs.get_mut(tab_idx)
1063            {
1064                let at = if before { pane_idx } else { pane_idx + 1 };
1065                let at = at.min(tab.panes.len());
1066                tab.panes.insert(at, id);
1067                tab.splitter
1068                    .insert_pane(at, PaneDescriptor::new().stretch(1.0));
1069                st.visible = true;
1070                st.selected_tab = tab_idx;
1071            }
1072            Self::reindex_side(&mut inner, side);
1073        }
1074        self.notify();
1075    }
1076
1077    /// Drop a dock into a tab as a new Splitter pane appended after its
1078    /// existing panes (the "centre" drop — join this group without choosing a
1079    /// split direction). Each pane is its own single-item ToolBox.
1080    pub fn stack_into_tab(&self, id: DockWidgetId, side: DockSide, tab_idx: usize) {
1081        {
1082            let mut inner = self.0.borrow_mut();
1083            if !inner.docks.contains_key(&id) || !Self::side_accepts(&inner, side) {
1084                return;
1085            }
1086            // Drop-on-self: a dock that is already the sole pane of this tab.
1087            if let Some(loc) = inner.locations.get(&id)
1088                && loc.side == side
1089                && loc.tab_idx == tab_idx
1090                && inner
1091                    .sides
1092                    .get(&side)
1093                    .and_then(|st| st.tabs.get(tab_idx))
1094                    .map(|t| t.panes.len() == 1)
1095                    .unwrap_or(false)
1096            {
1097                return;
1098            }
1099            let mut tab_idx = tab_idx;
1100            if let Some(outcome) = Self::detach(&mut inner, id) {
1101                (tab_idx, _) = outcome.adjust_target(side, tab_idx, 0);
1102            }
1103            if let Some(st) = inner.sides.get_mut(&side)
1104                && let Some(tab) = st.tabs.get_mut(tab_idx)
1105            {
1106                let at = tab.panes.len();
1107                tab.panes.push(id);
1108                tab.splitter
1109                    .insert_pane(at, PaneDescriptor::new().stretch(1.0));
1110                st.visible = true;
1111                st.selected_tab = tab_idx;
1112            }
1113            Self::reindex_side(&mut inner, side);
1114        }
1115        self.notify();
1116    }
1117
1118    /// Move a dock to another location (close + open in one notify).
1119    pub fn move_dock(&self, id: DockWidgetId, loc: DockOpenLocation) {
1120        self.open_dock(id, loc);
1121    }
1122
1123    /// Close a whole tab (and every dock it holds).
1124    pub fn close_tab(&self, tab_id: DockTabId) {
1125        {
1126            let mut inner = self.0.borrow_mut();
1127            let mut found: Option<(DockSide, usize)> = None;
1128            for side in DockSide::ALL {
1129                if let Some(st) = inner.sides.get(&side)
1130                    && let Some(ti) = st.tabs.iter().position(|t| t.id == tab_id)
1131                {
1132                    found = Some((side, ti));
1133                    break;
1134                }
1135            }
1136            let Some((side, ti)) = found else {
1137                return;
1138            };
1139            if let Some(st) = inner.sides.get_mut(&side) {
1140                st.tabs.remove(ti);
1141                Self::fix_selection_after_remove(st, ti);
1142            }
1143            Self::reindex_side(&mut inner, side);
1144        }
1145        self.notify();
1146    }
1147
1148    /// Move a whole tab (its arrangement + every dock + selection) to another
1149    /// side, re-deriving the Splitter orientation. Inserted at `at_tab`.
1150    pub fn move_tab(&self, tab_id: DockTabId, target_side: DockSide, at_tab: usize) {
1151        {
1152            let mut inner = self.0.borrow_mut();
1153            if !Self::side_accepts(&inner, target_side) {
1154                return; // disabled side rejects moves
1155            }
1156            // Find + remove the tab from its current side.
1157            let mut found: Option<(DockSide, usize)> = None;
1158            for side in DockSide::ALL {
1159                if let Some(st) = inner.sides.get(&side)
1160                    && let Some(ti) = st.tabs.iter().position(|t| t.id == tab_id)
1161                {
1162                    found = Some((side, ti));
1163                    break;
1164                }
1165            }
1166            let Some((src_side, src_ti)) = found else {
1167                return;
1168            };
1169            if src_side == target_side {
1170                // Same-side reorder.
1171                if let Some(st) = inner.sides.get_mut(&src_side) {
1172                    let tab = st.tabs.remove(src_ti);
1173                    let at = at_tab.min(st.tabs.len());
1174                    st.tabs.insert(at, tab);
1175                    st.selected_tab = at;
1176                }
1177                Self::reindex_side(&mut inner, src_side);
1178            } else {
1179                let tab = {
1180                    let st = inner.sides.get_mut(&src_side).unwrap();
1181                    let tab = st.tabs.remove(src_ti);
1182                    Self::fix_selection_after_remove(st, src_ti);
1183                    tab
1184                };
1185                // Re-derive orientation for the destination side.
1186                tab.splitter.set_orientation(side_orientation(target_side));
1187                if let Some(dst) = inner.sides.get_mut(&target_side) {
1188                    let at = at_tab.min(dst.tabs.len());
1189                    dst.tabs.insert(at, tab);
1190                    dst.selected_tab = at;
1191                    dst.visible = true;
1192                }
1193                Self::reindex_side(&mut inner, src_side);
1194                Self::reindex_side(&mut inner, target_side);
1195            }
1196        }
1197        self.notify();
1198    }
1199
1200    /// Close (remove) a dock from the layout.
1201    pub fn close_dock(&self, id: DockWidgetId) {
1202        {
1203            let mut inner = self.0.borrow_mut();
1204            Self::detach(&mut inner, id);
1205        }
1206        self.notify();
1207    }
1208
1209    /// Toggle a dock: close it if open, else open it on its default location.
1210    pub fn toggle_dock(&self, id: DockWidgetId) {
1211        if self.is_dock_open(id) {
1212            self.close_dock(id);
1213        } else {
1214            let default = self.0.borrow().docks.get(&id).map(|m| m.default);
1215            if let Some(loc) = default {
1216                self.open_dock(id, loc);
1217            }
1218        }
1219    }
1220
1221    /// Reveal a dock: ensure it is open, show + select its side / tab.
1222    pub fn reveal_dock(&self, id: DockWidgetId) {
1223        let loc = self.0.borrow().locations.get(&id).copied();
1224        match loc {
1225            Some(loc) => {
1226                // **Silent when there is nothing to reveal.** Notifying anyway
1227                // rebuilds every dock's content, and a dock's content is where a
1228                // view's own state lives -- a list's scroll position, a tree's.
1229                // Revealing an already-revealed dock is the common case, not the
1230                // rare one: it is what every "show me this result" does on the
1231                // second and every subsequent result, and each one was sending the
1232                // panel back to the top.
1233                let changed = {
1234                    let mut inner = self.0.borrow_mut();
1235                    let tabs = inner
1236                        .sides
1237                        .get(&loc.side)
1238                        .map_or(0, |st| st.tabs.len())
1239                        .saturating_sub(1);
1240                    match inner.sides.get_mut(&loc.side) {
1241                        Some(st) => {
1242                            let tab = loc.tab_idx.min(tabs);
1243                            let changed = !st.visible || st.selected_tab != tab;
1244                            st.visible = true;
1245                            st.selected_tab = tab;
1246                            changed
1247                        }
1248                        None => false,
1249                    }
1250                };
1251                if changed {
1252                    self.notify();
1253                }
1254            }
1255            None => {
1256                let default = self.0.borrow().docks.get(&id).map(|m| m.default);
1257                if let Some(loc) = default {
1258                    self.open_dock(id, loc);
1259                }
1260            }
1261        }
1262    }
1263
1264    // ─── queries ───────────────────────────────────────────────────────
1265
1266    pub fn is_dock_open(&self, id: DockWidgetId) -> bool {
1267        self.0.borrow().locations.contains_key(&id)
1268    }
1269
1270    /// Every currently-open dock id (used by the layout to materialize
1271    /// content).
1272    pub(crate) fn open_dock_ids(&self) -> Vec<DockWidgetId> {
1273        self.0.borrow().locations.keys().copied().collect()
1274    }
1275
1276    pub fn dock_location(&self, id: DockWidgetId) -> Option<DockLoc> {
1277        self.0.borrow().locations.get(&id).copied()
1278    }
1279
1280    /// A reactive `true`-while-open signal for an external rail / toolbar.
1281    pub fn dock_open_signal(&self, id: DockWidgetId) -> Signal<bool> {
1282        let mut inner = self.0.borrow_mut();
1283        let open = inner.locations.contains_key(&id);
1284        inner
1285            .open_sigs
1286            .entry(id)
1287            .or_insert_with(|| Signal::new(open))
1288            .clone()
1289    }
1290
1291    pub fn is_side_visible(&self, side: DockSide) -> bool {
1292        self.0.borrow().sides.get(&side).is_some_and(|s| s.visible)
1293    }
1294
1295    pub fn side_visible_signal(&self, side: DockSide) -> Signal<bool> {
1296        self.0
1297            .borrow()
1298            .sides
1299            .get(&side)
1300            .map(|s| s.visible_sig.clone())
1301            .unwrap_or_else(|| Signal::new(false))
1302    }
1303
1304    pub fn side_selected_tab_signal(&self, side: DockSide) -> Signal<usize> {
1305        self.0
1306            .borrow()
1307            .sides
1308            .get(&side)
1309            .map(|s| s.selected_tab_sig.clone())
1310            .unwrap_or_else(|| Signal::new(0))
1311    }
1312
1313    pub fn side_selected_tab(&self, side: DockSide) -> usize {
1314        self.0
1315            .borrow()
1316            .sides
1317            .get(&side)
1318            .map(|s| s.selected_tab)
1319            .unwrap_or(0)
1320    }
1321
1322    pub fn side_presentation(&self, side: DockSide) -> TabPresentation {
1323        self.0
1324            .borrow()
1325            .sides
1326            .get(&side)
1327            .map(|s| s.presentation)
1328            .unwrap_or(TabPresentation::Strip)
1329    }
1330
1331    pub fn side_size(&self, side: DockSide) -> f32 {
1332        self.0
1333            .borrow()
1334            .sides
1335            .get(&side)
1336            .map(|s| s.size)
1337            .unwrap_or(0.0)
1338    }
1339
1340    pub fn side_min_size(&self, side: DockSide) -> f32 {
1341        self.0
1342            .borrow()
1343            .sides
1344            .get(&side)
1345            .map(|s| s.min_size)
1346            .unwrap_or(0.0)
1347    }
1348
1349    pub fn side_rail_thickness(&self, side: DockSide) -> f32 {
1350        self.0
1351            .borrow()
1352            .sides
1353            .get(&side)
1354            .map(|s| {
1355                if matches!(s.presentation, TabPresentation::Rail) {
1356                    s.rail_thickness
1357                } else {
1358                    0.0
1359                }
1360            })
1361            .unwrap_or(0.0)
1362    }
1363
1364    pub fn side_has_rail(&self, side: DockSide) -> bool {
1365        self.side_rail_thickness(side) > 0.0
1366    }
1367
1368    pub fn corner_owner(&self, corner: DockCorner) -> DockSide {
1369        self.0.borrow().corners.owner(corner)
1370    }
1371
1372    pub(crate) fn corners(&self) -> CornerOwners {
1373        self.0.borrow().corners
1374    }
1375
1376    pub fn tab_count(&self, side: DockSide) -> usize {
1377        self.0
1378            .borrow()
1379            .sides
1380            .get(&side)
1381            .map(|s| s.tabs.len())
1382            .unwrap_or(0)
1383    }
1384
1385    /// The model index at which a whole-tab "append to this side" should insert:
1386    /// just **after the last non-hidden tab**, so the appended tab reappears in
1387    /// the right relative order even when hidden tabs trail in the model. Equal
1388    /// to `tab_count` when no tab is hidden. Used by the whole-tab drop paths
1389    /// (rail / strip / pane / empty-side) instead of a blind `tab_count`.
1390    pub(crate) fn side_append_index(&self, side: DockSide) -> usize {
1391        let inner = self.0.borrow();
1392        match inner.sides.get(&side) {
1393            Some(st) => st
1394                .tabs
1395                .iter()
1396                .rposition(|t| !t.hidden)
1397                .map(|i| i + 1)
1398                .unwrap_or(st.tabs.len()),
1399            None => 0,
1400        }
1401    }
1402
1403    /// Title + icon for a registered dock (for tab / rail rendering).
1404    pub(crate) fn dock_title(&self, id: DockWidgetId) -> Option<LocalizedString> {
1405        self.0.borrow().docks.get(&id).map(|m| m.title.clone())
1406    }
1407
1408    pub(crate) fn dock_icon(&self, id: DockWidgetId) -> Option<DockIconFactory> {
1409        self.0.borrow().docks.get(&id).and_then(|m| m.icon.clone())
1410    }
1411
1412    /// Snapshot a side's tabs for rendering.
1413    pub(crate) fn side_tabs(&self, side: DockSide) -> Vec<DockTabView> {
1414        let inner = self.0.borrow();
1415        let Some(st) = inner.sides.get(&side) else {
1416            return Vec::new();
1417        };
1418        st.tabs
1419            .iter()
1420            .map(|tab| DockTabView {
1421                id: tab.id,
1422                title: tab.title.clone(),
1423                icon: tab.icon.clone(),
1424                splitter: tab.splitter.clone(),
1425                panes: tab.panes.clone(),
1426                hidden: tab.hidden,
1427            })
1428            .collect()
1429    }
1430
1431    /// All docks held by a tab (one per pane).
1432    pub(crate) fn tab_docks(&self, tab_id: DockTabId) -> Vec<DockWidgetId> {
1433        let inner = self.0.borrow();
1434        for st in inner.sides.values() {
1435            if let Some(tab) = st.tabs.iter().find(|t| t.id == tab_id) {
1436                return tab.panes.clone();
1437            }
1438        }
1439        Vec::new()
1440    }
1441
1442    /// Find a tab anywhere by id, returning its current side + a render view.
1443    pub(crate) fn tab_view_by_id(&self, tab_id: DockTabId) -> Option<(DockSide, DockTabView)> {
1444        for side in DockSide::ALL {
1445            if let Some(view) = self.side_tabs(side).into_iter().find(|t| t.id == tab_id) {
1446                return Some((side, view));
1447            }
1448        }
1449        None
1450    }
1451
1452    /// The id of the tab at `idx` in a side's full tab list. The live inverse
1453    /// of [`select_tab_by_id`](Self::select_tab_by_id) — the strip's
1454    /// index → id selection sync uses it so both directions resolve against the
1455    /// *current* order and agree across a reorder (a build-time snapshot would
1456    /// disagree and feed back unboundedly).
1457    pub fn tab_id_at(&self, side: DockSide, idx: usize) -> Option<DockTabId> {
1458        let inner = self.0.borrow();
1459        inner
1460            .sides
1461            .get(&side)
1462            .and_then(|st| st.tabs.get(idx).map(|t| t.id))
1463    }
1464
1465    // ─── activity naming + label/icon derivation ───────────────────────────
1466
1467    /// The dock whose title + icon represent an activity in the rail / strip:
1468    /// the first **non-collapsed** pane (so the label follows the panel the user
1469    /// actually sees), falling back to the first pane.
1470    pub(crate) fn primary_dock_of(view: &DockTabView) -> Option<DockWidgetId> {
1471        view.panes
1472            .iter()
1473            .enumerate()
1474            .find(|(i, _)| !view.splitter.is_collapsed(*i))
1475            .map(|(_, d)| *d)
1476            .or_else(|| view.panes.first().copied())
1477    }
1478
1479    /// The displayed name for an activity: an explicit [`set_tab_title`](Self::set_tab_title)
1480    /// override, else the primary pane's dock title, else the `"Panel"` fallback.
1481    pub(crate) fn activity_label(&self, view: &DockTabView) -> LocalizedString {
1482        view.title
1483            .clone()
1484            .or_else(|| Self::primary_dock_of(view).and_then(|d| self.dock_title(d)))
1485            .unwrap_or_else(|| lit!("Panel"))
1486    }
1487
1488    /// The icon for an activity: an explicit tab icon, else the primary pane's
1489    /// dock icon, else `None` (rail / strip then fall back to the title initial).
1490    pub(crate) fn activity_icon(&self, view: &DockTabView) -> Option<DockIconFactory> {
1491        view.icon
1492            .clone()
1493            .or_else(|| Self::primary_dock_of(view).and_then(|d| self.dock_icon(d)))
1494    }
1495
1496    /// Give an activity (tab) a stable, explicit name, independent of which dock
1497    /// occupies pane 0 (e.g. a grouped "Source Control" activity holding a file
1498    /// tree and a git pane). Pass `None` to clear it (the label then derives from
1499    /// the primary dock again). App-config — reconstructed each run, like dock
1500    /// titles; not persisted. Structural → rebuild.
1501    pub fn set_tab_title(&self, tab_id: DockTabId, title: Option<LocalizedString>) {
1502        let mut changed = false;
1503        {
1504            let mut inner = self.0.borrow_mut();
1505            'outer: for st in inner.sides.values_mut() {
1506                if let Some(tab) = st.tabs.iter_mut().find(|t| t.id == tab_id) {
1507                    tab.title = title;
1508                    changed = true;
1509                    break 'outer;
1510                }
1511            }
1512        }
1513        if changed {
1514            self.notify();
1515        }
1516    }
1517
1518    /// The explicit title set on an activity (`None` when it derives from its
1519    /// primary dock).
1520    pub fn tab_title(&self, tab_id: DockTabId) -> Option<LocalizedString> {
1521        let inner = self.0.borrow();
1522        inner
1523            .sides
1524            .values()
1525            .flat_map(|st| st.tabs.iter())
1526            .find(|t| t.id == tab_id)
1527            .and_then(|t| t.title.clone())
1528    }
1529
1530    /// The activity (tab) currently holding a dock — apps hold stable
1531    /// [`DockWidgetId`]s, so this is the bridge to address the enclosing tab.
1532    pub fn activity_of(&self, dock_id: DockWidgetId) -> Option<DockTabId> {
1533        let inner = self.0.borrow();
1534        let loc = inner.locations.get(&dock_id)?;
1535        inner
1536            .sides
1537            .get(&loc.side)
1538            .and_then(|st| st.tabs.get(loc.tab_idx))
1539            .map(|t| t.id)
1540    }
1541
1542    /// Sugar: name the activity that currently holds `dock_id`. The natural way
1543    /// to title a grouped activity from app code that holds the dock id.
1544    pub fn set_dock_activity_title(
1545        &self,
1546        dock_id: DockWidgetId,
1547        title: impl Into<LocalizedString>,
1548    ) {
1549        if let Some(tab_id) = self.activity_of(dock_id) {
1550            self.set_tab_title(tab_id, Some(title.into()));
1551        }
1552    }
1553
1554    /// The enabled sides a tab / dock on `from` can be relocated to (every side
1555    /// except `from`, keeping only [`is_side_enabled`](Self::is_side_enabled)).
1556    /// The "Move to" menus iterate this so a disabled side is never offered as a
1557    /// silently-rejected target.
1558    pub fn enabled_move_targets(&self, from: DockSide) -> Vec<DockSide> {
1559        DockSide::ALL
1560            .into_iter()
1561            .filter(|&s| s != from && self.is_side_enabled(s))
1562            .collect()
1563    }
1564
1565    /// A reactive `true`-while-hidden signal for an activity, cached per id —
1566    /// the context-menu checklist binds it so its checkmark tracks live model
1567    /// state (`set_tab_hidden` from any source updates it via `sync_derived`).
1568    /// Mirrors [`dock_open_signal`](Self::dock_open_signal).
1569    pub(crate) fn tab_hidden_signal(&self, tab_id: DockTabId) -> Signal<bool> {
1570        let mut inner = self.0.borrow_mut();
1571        let hidden = inner
1572            .sides
1573            .values()
1574            .any(|st| st.tabs.iter().any(|t| t.id == tab_id && t.hidden));
1575        inner
1576            .tab_hidden_sigs
1577            .entry(tab_id)
1578            .or_insert_with(|| Signal::new(hidden))
1579            .clone()
1580    }
1581
1582    /// App-supplied inline header-action factory for a dock, if declared.
1583    pub(crate) fn dock_header_actions(&self, id: DockWidgetId) -> Option<DockHeaderActionsFactory> {
1584        self.0
1585            .borrow()
1586            .docks
1587            .get(&id)
1588            .and_then(|m| m.header_actions.clone())
1589    }
1590
1591    /// Whether a sole-pane (bare) dock should render its own header bar.
1592    pub(crate) fn dock_show_header(&self, id: DockWidgetId) -> bool {
1593        self.0
1594            .borrow()
1595            .docks
1596            .get(&id)
1597            .map(|m| m.show_header)
1598            .unwrap_or(false)
1599    }
1600
1601    // ─── persistence ───────────────────────────────────────────────────
1602
1603    /// Serialize the user-controllable layout state (sizes / visibility /
1604    /// selections / arrangement structure / corners). App-config (rail
1605    /// thickness, mins, content factories) is reconstructed each run.
1606    pub fn export_state(&self) -> super::state::DockLayoutState {
1607        use super::state::*;
1608        let inner = self.0.borrow();
1609        let side_state = |side: DockSide| -> DockSideState {
1610            let Some(st) = inner.sides.get(&side) else {
1611                return DockSideState::default();
1612            };
1613            DockSideState {
1614                presentation: st.presentation,
1615                size_px: st.size,
1616                visible: st.visible,
1617                selected_tab: st.selected_tab,
1618                tabs: st
1619                    .tabs
1620                    .iter()
1621                    .map(|tab| DockTabState {
1622                        id: tab.id.raw(),
1623                        splitter: tab.splitter.export_state(),
1624                        panes: tab.panes.iter().map(|d| d.raw()).collect(),
1625                        hidden: tab.hidden,
1626                    })
1627                    .collect(),
1628                rail_size: st
1629                    .rail_size_sig
1630                    .get()
1631                    .min(DockRailItemSize::Labeled as usize),
1632                tab_display: st.tab_display_sig.get(),
1633            }
1634        };
1635        DockLayoutState {
1636            version: DockLayoutState::CURRENT_VERSION,
1637            leading: side_state(DockSide::Leading),
1638            trailing: side_state(DockSide::Trailing),
1639            top: side_state(DockSide::Top),
1640            bottom: side_state(DockSide::Bottom),
1641            corners: inner.corners,
1642        }
1643    }
1644
1645    /// Restore a previously-exported state. Unknown dock ids are dropped,
1646    /// emptied panes / tabs pruned, selections clamped. Bumps `version`.
1647    pub fn import_state(&self, state: &super::state::DockLayoutState) {
1648        use super::state::*;
1649        {
1650            let mut inner = self.0.borrow_mut();
1651            inner.corners = state.corners;
1652            let known: std::collections::HashSet<u64> =
1653                inner.docks.keys().map(|k| k.raw()).collect();
1654
1655            let restore = |inner: &mut Inner, side: DockSide, dto: &DockSideState| {
1656                let orientation = side_orientation(side);
1657                let mut tabs: Vec<DockTab> = Vec::new();
1658                for tab_dto in &dto.tabs {
1659                    let mut panes: Vec<DockWidgetId> = Vec::new();
1660                    for dock in &tab_dto.panes {
1661                        if known.contains(dock) {
1662                            panes.push(DockWidgetId::from_raw(*dock));
1663                        }
1664                    }
1665                    if !panes.is_empty() {
1666                        let splitter = SplitterModel::new(panes.len(), orientation);
1667                        // Best-effort: import sizes only when the pane count
1668                        // matches the surviving panes.
1669                        if splitter.pane_count() == tab_dto.panes.len() {
1670                            splitter.import_state(&tab_dto.splitter);
1671                        }
1672                        tabs.push(DockTab {
1673                            id: DockTabId::from_raw(tab_dto.id),
1674                            title: None,
1675                            icon: None,
1676                            splitter,
1677                            panes,
1678                            hidden: tab_dto.hidden,
1679                        });
1680                    }
1681                }
1682                // Clamp the persisted selection into range, then re-home it off
1683                // a hidden tab onto the nearest visible one. A session that was
1684                // closed with a hidden tab selected would otherwise restore with
1685                // `selected_tab` pointing at a hidden tab — no active highlight
1686                // in the rail, a wrong roving tab-stop — until the first click.
1687                let selected_tab = if tabs.is_empty() {
1688                    0
1689                } else {
1690                    let clamped = dto.selected_tab.min(tabs.len() - 1);
1691                    Self::nearest_visible_tab(&tabs, clamped).unwrap_or(clamped)
1692                };
1693                if let Some(st) = inner.sides.get_mut(&side) {
1694                    st.presentation = dto.presentation;
1695                    st.size = dto.size_px;
1696                    st.visible = dto.visible && !tabs.is_empty();
1697                    st.selected_tab = selected_tab;
1698                    st.tabs = tabs;
1699                    st.rail_size_sig
1700                        .set(dto.rail_size.min(DockRailItemSize::Labeled as usize));
1701                    st.tab_display_sig.set(dto.tab_display.min(2));
1702                }
1703            };
1704
1705            restore(&mut inner, DockSide::Leading, &state.leading);
1706            restore(&mut inner, DockSide::Trailing, &state.trailing);
1707            restore(&mut inner, DockSide::Top, &state.top);
1708            restore(&mut inner, DockSide::Bottom, &state.bottom);
1709
1710            inner.locations.clear();
1711            for side in DockSide::ALL {
1712                Self::reindex_side(&mut inner, side);
1713            }
1714        }
1715        self.notify();
1716    }
1717}
1718
1719#[cfg(test)]
1720mod tests {
1721    use super::*;
1722    use teksilo_i18n::lit;
1723
1724    fn model() -> DockingModel {
1725        DockingModel::new()
1726    }
1727
1728    fn reg(m: &DockingModel, side: DockSide) -> DockWidgetId {
1729        let id = DockWidgetId::fresh();
1730        m.register_meta(
1731            id,
1732            DockWidgetMeta {
1733                title: lit!("Dock"),
1734                icon: None,
1735                min_size: None,
1736                default: DockOpenLocation::side(side),
1737                header_actions: None,
1738                show_header: false,
1739            },
1740        );
1741        id
1742    }
1743
1744    #[test]
1745    fn open_dock_places_and_shows_side() {
1746        let m = model();
1747        let id = reg(&m, DockSide::Leading);
1748        assert!(!m.is_side_visible(DockSide::Leading));
1749        m.open_dock(id, DockOpenLocation::side(DockSide::Leading));
1750        assert!(m.is_dock_open(id));
1751        assert!(m.is_side_visible(DockSide::Leading));
1752        assert_eq!(m.dock_location(id).unwrap().side, DockSide::Leading);
1753    }
1754
1755    #[test]
1756    fn stack_adds_a_splitter_pane_to_one_tab() {
1757        let m = model();
1758        let a = reg(&m, DockSide::Leading);
1759        let b = reg(&m, DockSide::Leading);
1760        m.open_dock(a, DockOpenLocation::side(DockSide::Leading));
1761        m.open_dock(b, DockOpenLocation::side(DockSide::Leading).stack());
1762        let tabs = m.side_tabs(DockSide::Leading);
1763        assert_eq!(tabs.len(), 1, "stacking shares one tab");
1764        assert_eq!(tabs[0].panes, vec![a, b], "stacking splits into two panes");
1765        assert_eq!(tabs[0].splitter.pane_count(), 2);
1766    }
1767
1768    #[test]
1769    fn new_tab_creates_a_second_tab() {
1770        let m = model();
1771        let a = reg(&m, DockSide::Bottom);
1772        let b = reg(&m, DockSide::Bottom);
1773        m.open_dock(a, DockOpenLocation::side(DockSide::Bottom));
1774        m.open_dock(b, DockOpenLocation::side(DockSide::Bottom).new_tab());
1775        assert_eq!(m.tab_count(DockSide::Bottom), 2);
1776    }
1777
1778    #[test]
1779    fn single_location_invariant_no_duplicate() {
1780        let m = model();
1781        let id = reg(&m, DockSide::Leading);
1782        m.open_dock(id, DockOpenLocation::side(DockSide::Leading));
1783        m.open_dock(id, DockOpenLocation::side(DockSide::Trailing));
1784        // Only one location; it moved to trailing.
1785        assert_eq!(m.dock_location(id).unwrap().side, DockSide::Trailing);
1786        assert!(
1787            !m.is_side_visible(DockSide::Leading),
1788            "leading emptied → hidden"
1789        );
1790    }
1791
1792    #[test]
1793    fn close_last_dock_hides_side() {
1794        let m = model();
1795        let id = reg(&m, DockSide::Trailing);
1796        m.open_dock(id, DockOpenLocation::side(DockSide::Trailing));
1797        m.close_dock(id);
1798        assert!(!m.is_dock_open(id));
1799        assert!(!m.is_side_visible(DockSide::Trailing));
1800        assert_eq!(m.tab_count(DockSide::Trailing), 0);
1801    }
1802
1803    #[test]
1804    fn close_one_of_two_stacked_panes_leaves_the_other() {
1805        let m = model();
1806        let a = reg(&m, DockSide::Leading);
1807        let b = reg(&m, DockSide::Leading);
1808        m.open_dock(a, DockOpenLocation::side(DockSide::Leading));
1809        m.open_dock(b, DockOpenLocation::side(DockSide::Leading).stack());
1810        m.close_dock(b);
1811        // The tab collapses back to a single pane holding `a`.
1812        let tabs = m.side_tabs(DockSide::Leading);
1813        assert_eq!(tabs[0].panes, vec![a]);
1814        assert_eq!(tabs[0].splitter.pane_count(), 1);
1815    }
1816
1817    #[test]
1818    fn toggle_dock_round_trips() {
1819        let m = model();
1820        let id = reg(&m, DockSide::Leading);
1821        m.toggle_dock(id);
1822        assert!(m.is_dock_open(id));
1823        m.toggle_dock(id);
1824        assert!(!m.is_dock_open(id));
1825    }
1826
1827    #[test]
1828    fn reveal_closed_dock_opens_on_default_side() {
1829        let m = model();
1830        let id = reg(&m, DockSide::Bottom);
1831        m.reveal_dock(id);
1832        assert!(m.is_dock_open(id));
1833        assert_eq!(m.dock_location(id).unwrap().side, DockSide::Bottom);
1834    }
1835
1836    /// **Revealing an already-revealed dock changes nothing, and says so.**
1837    ///
1838    /// The version drives every dock's content rebuild, and a view's own state —
1839    /// a list's scroll position, a tree's — lives in that content. A "show me
1840    /// this" that reveals an already-visible dock is the common case, not the rare
1841    /// one: it is what every result activation after the first one does, and each
1842    /// gratuitous notify sent the panel it came from back to the top.
1843    #[test]
1844    fn revealing_an_already_revealed_dock_does_not_notify() {
1845        let m = model();
1846        let id = reg(&m, DockSide::Bottom);
1847        m.reveal_dock(id);
1848        assert!(m.is_dock_open(id));
1849
1850        let settled = m.version().get();
1851        m.reveal_dock(id);
1852        assert_eq!(
1853            m.version().get(),
1854            settled,
1855            "nothing changed, so nothing downstream should be rebuilt"
1856        );
1857    }
1858
1859    /// It still notifies when it really does reveal something.
1860    #[test]
1861    fn revealing_a_hidden_dock_notifies() {
1862        let m = model();
1863        let id = reg(&m, DockSide::Bottom);
1864        m.reveal_dock(id);
1865        m.set_side_visible(DockSide::Bottom, false);
1866
1867        let settled = m.version().get();
1868        m.reveal_dock(id);
1869        assert_ne!(
1870            m.version().get(),
1871            settled,
1872            "it was hidden, so this is a change"
1873        );
1874    }
1875
1876    #[test]
1877    fn split_into_tab_adds_a_splitter_pane() {
1878        let m = model();
1879        let a = reg(&m, DockSide::Bottom);
1880        let b = reg(&m, DockSide::Bottom);
1881        m.open_dock(a, DockOpenLocation::side(DockSide::Bottom));
1882        m.split_into_tab(b, DockSide::Bottom, 0, 0, false);
1883        let tabs = m.side_tabs(DockSide::Bottom);
1884        assert_eq!(tabs[0].panes.len(), 2, "split added a second pane");
1885        assert_eq!(tabs[0].splitter.pane_count(), 2);
1886    }
1887
1888    #[test]
1889    fn promote_to_tab_makes_its_own_tab() {
1890        let m = model();
1891        let a = reg(&m, DockSide::Leading);
1892        let b = reg(&m, DockSide::Leading);
1893        m.open_dock(a, DockOpenLocation::side(DockSide::Leading));
1894        m.open_dock(b, DockOpenLocation::side(DockSide::Leading).stack());
1895        // a + b share one tab; promote b into its own.
1896        m.promote_to_tab(b, DockSide::Leading, 1);
1897        assert_eq!(m.tab_count(DockSide::Leading), 2);
1898        assert_eq!(m.dock_location(b).unwrap().tab_idx, 1);
1899    }
1900
1901    #[test]
1902    fn move_tab_relocates_whole_tab_and_reorients() {
1903        let m = model();
1904        let a = reg(&m, DockSide::Leading);
1905        m.open_dock(a, DockOpenLocation::side(DockSide::Leading));
1906        let tab_id = m.side_tabs(DockSide::Leading)[0].id;
1907        // Leading uses a vertical splitter; bottom a horizontal one.
1908        let splitter = m.side_tabs(DockSide::Leading)[0].splitter.clone();
1909        assert_eq!(splitter.orientation(), Orientation::Vertical);
1910        m.move_tab(tab_id, DockSide::Bottom, 0);
1911        assert_eq!(m.dock_location(a).unwrap().side, DockSide::Bottom);
1912        assert!(!m.is_side_visible(DockSide::Leading));
1913        assert_eq!(
1914            splitter.orientation(),
1915            Orientation::Horizontal,
1916            "re-derived"
1917        );
1918    }
1919
1920    #[test]
1921    fn closing_a_tab_before_the_active_one_keeps_the_active_tab() {
1922        // [A, B, C] with B (index 1) selected; close A (index 0). B and C
1923        // shift down to fill the gap, so selection must follow B to index 0
1924        // — not stay at index 1 (which now holds C).
1925        let m = model();
1926        let a = reg(&m, DockSide::Leading);
1927        let b = reg(&m, DockSide::Leading);
1928        let c = reg(&m, DockSide::Leading);
1929        m.open_dock(a, DockOpenLocation::side(DockSide::Leading));
1930        m.open_dock(b, DockOpenLocation::side(DockSide::Leading).new_tab());
1931        m.open_dock(c, DockOpenLocation::side(DockSide::Leading).new_tab());
1932        let tab_a = m.side_tabs(DockSide::Leading)[0].id;
1933        let tab_b = m.side_tabs(DockSide::Leading)[1].id;
1934        m.select_tab(DockSide::Leading, 1);
1935        assert_eq!(m.side_selected_tab(DockSide::Leading), 1);
1936
1937        m.close_tab(tab_a);
1938
1939        assert_eq!(m.tab_count(DockSide::Leading), 2);
1940        assert_eq!(m.side_selected_tab(DockSide::Leading), 0, "B followed down");
1941        assert_eq!(
1942            m.side_tabs(DockSide::Leading)[0].id,
1943            tab_b,
1944            "the selected tab is still B, not C",
1945        );
1946    }
1947
1948    #[test]
1949    fn moving_a_tab_before_the_active_one_away_keeps_the_active_tab() {
1950        // Same off-by-one, reached through move_tab's cross-side removal: B is
1951        // selected, A (before it) is moved to another side, so selection must
1952        // follow B down to index 0.
1953        let m = model();
1954        let a = reg(&m, DockSide::Leading);
1955        let b = reg(&m, DockSide::Leading);
1956        let c = reg(&m, DockSide::Leading);
1957        m.open_dock(a, DockOpenLocation::side(DockSide::Leading));
1958        m.open_dock(b, DockOpenLocation::side(DockSide::Leading).new_tab());
1959        m.open_dock(c, DockOpenLocation::side(DockSide::Leading).new_tab());
1960        let tab_a = m.side_tabs(DockSide::Leading)[0].id;
1961        let tab_b = m.side_tabs(DockSide::Leading)[1].id;
1962        m.select_tab(DockSide::Leading, 1);
1963
1964        m.move_tab(tab_a, DockSide::Trailing, 0);
1965
1966        assert_eq!(m.tab_count(DockSide::Leading), 2);
1967        assert_eq!(m.side_selected_tab(DockSide::Leading), 0, "B followed down");
1968        assert_eq!(m.side_tabs(DockSide::Leading)[0].id, tab_b);
1969    }
1970
1971    #[test]
1972    fn closing_the_last_active_tab_clamps_selection() {
1973        // [A, B] with B (last) selected; close B. Selection clamps to A.
1974        let m = model();
1975        let a = reg(&m, DockSide::Leading);
1976        let b = reg(&m, DockSide::Leading);
1977        m.open_dock(a, DockOpenLocation::side(DockSide::Leading));
1978        m.open_dock(b, DockOpenLocation::side(DockSide::Leading).new_tab());
1979        let tab_b = m.side_tabs(DockSide::Leading)[1].id;
1980        m.select_tab(DockSide::Leading, 1);
1981
1982        m.close_tab(tab_b);
1983
1984        assert_eq!(m.tab_count(DockSide::Leading), 1);
1985        assert_eq!(m.side_selected_tab(DockSide::Leading), 0);
1986    }
1987
1988    #[test]
1989    fn version_bumps_on_mutation() {
1990        let m = model();
1991        let id = reg(&m, DockSide::Leading);
1992        let v = m.version();
1993        let before = v.get();
1994        m.open_dock(id, DockOpenLocation::side(DockSide::Leading));
1995        assert!(v.get() > before);
1996    }
1997
1998    #[test]
1999    fn dock_open_signal_and_side_visible_signal_track() {
2000        let m = model();
2001        let id = reg(&m, DockSide::Leading);
2002        let open = m.dock_open_signal(id);
2003        let vis = m.side_visible_signal(DockSide::Leading);
2004        assert!(!open.get());
2005        assert!(!vis.get());
2006        m.open_dock(id, DockOpenLocation::side(DockSide::Leading));
2007        assert!(open.get());
2008        assert!(vis.get());
2009        m.close_dock(id);
2010        assert!(!open.get());
2011        assert!(!vis.get());
2012    }
2013
2014    #[test]
2015    fn export_import_round_trips() {
2016        let m = model();
2017        let a = reg(&m, DockSide::Leading);
2018        let b = reg(&m, DockSide::Bottom);
2019        m.open_dock(a, DockOpenLocation::side(DockSide::Leading));
2020        m.open_dock(b, DockOpenLocation::side(DockSide::Bottom));
2021        let state = m.export_state();
2022
2023        // Fresh model with the same registry restores the same shape.
2024        let m2 = DockingModel::new();
2025        m2.register_meta(
2026            a,
2027            DockWidgetMeta {
2028                title: lit!("A"),
2029                icon: None,
2030                min_size: None,
2031                default: DockOpenLocation::side(DockSide::Leading),
2032                header_actions: None,
2033                show_header: false,
2034            },
2035        );
2036        m2.register_meta(
2037            b,
2038            DockWidgetMeta {
2039                title: lit!("B"),
2040                icon: None,
2041                min_size: None,
2042                default: DockOpenLocation::side(DockSide::Bottom),
2043                header_actions: None,
2044                show_header: false,
2045            },
2046        );
2047        m2.import_state(&state);
2048        assert!(m2.is_dock_open(a));
2049        assert!(m2.is_dock_open(b));
2050        assert_eq!(m2.dock_location(a).unwrap().side, DockSide::Leading);
2051    }
2052
2053    #[test]
2054    fn import_drops_unknown_dock_ids() {
2055        let m = model();
2056        let a = reg(&m, DockSide::Leading);
2057        m.open_dock(a, DockOpenLocation::side(DockSide::Leading));
2058        let state = m.export_state();
2059        // Fresh model that does NOT know `a`.
2060        let m2 = DockingModel::new();
2061        m2.import_state(&state);
2062        assert!(!m2.is_dock_open(a), "unknown dock id dropped on import");
2063        assert!(!m2.is_side_visible(DockSide::Leading));
2064    }
2065
2066    // ─── drop edge cases ────────────────────────────────────────────────
2067
2068    #[test]
2069    fn split_a_dock_onto_its_own_pane_is_a_noop() {
2070        // Drop a dock onto the very `Single` pane it solely occupies → nothing
2071        // changes, no version churn (the classic "drop on itself").
2072        let m = model();
2073        let a = reg(&m, DockSide::Bottom);
2074        m.open_dock(a, DockOpenLocation::side(DockSide::Bottom));
2075        let loc_before = m.dock_location(a).unwrap();
2076        let v = m.version().get();
2077
2078        m.split_into_tab(a, DockSide::Bottom, 0, 0, true);
2079        assert_eq!(m.version().get(), v, "no-op must not bump the version");
2080        assert_eq!(
2081            m.dock_location(a).unwrap(),
2082            loc_before,
2083            "location unchanged"
2084        );
2085        assert_eq!(
2086            m.side_tabs(DockSide::Bottom)[0].panes.len(),
2087            1,
2088            "still one pane"
2089        );
2090
2091        m.split_into_tab(a, DockSide::Bottom, 0, 0, false);
2092        assert_eq!(m.version().get(), v, "after-split self-drop also a no-op");
2093        assert_eq!(m.side_tabs(DockSide::Bottom)[0].panes.len(), 1);
2094    }
2095
2096    #[test]
2097    fn stack_a_dock_onto_its_own_sole_tab_is_a_noop() {
2098        let m = model();
2099        let a = reg(&m, DockSide::Leading);
2100        m.open_dock(a, DockOpenLocation::side(DockSide::Leading));
2101        let v = m.version().get();
2102        m.stack_into_tab(a, DockSide::Leading, 0);
2103        assert_eq!(m.version().get(), v, "stacking a dock on itself is a no-op");
2104        let tabs = m.side_tabs(DockSide::Leading);
2105        assert_eq!(tabs[0].panes, vec![a], "still a single pane");
2106    }
2107
2108    #[test]
2109    fn stack_into_tab_appends_a_splitter_pane() {
2110        let m = model();
2111        let a = reg(&m, DockSide::Leading);
2112        let b = reg(&m, DockSide::Leading);
2113        m.open_dock(a, DockOpenLocation::side(DockSide::Leading));
2114        m.stack_into_tab(b, DockSide::Leading, 0);
2115        let tabs = m.side_tabs(DockSide::Leading);
2116        assert_eq!(tabs.len(), 1, "stacked into the same tab");
2117        assert_eq!(tabs[0].panes, vec![a, b], "appended as a second pane");
2118        assert_eq!(tabs[0].splitter.pane_count(), 2);
2119    }
2120
2121    #[test]
2122    fn promote_a_dock_already_its_own_sole_tab_is_a_noop() {
2123        let m = model();
2124        let a = reg(&m, DockSide::Bottom);
2125        m.open_dock(a, DockOpenLocation::side(DockSide::Bottom).new_tab());
2126        let v = m.version().get();
2127        m.promote_to_tab(a, DockSide::Bottom, 0);
2128        assert_eq!(
2129            m.version().get(),
2130            v,
2131            "promoting an already-sole tab is a no-op"
2132        );
2133        assert_eq!(m.tab_count(DockSide::Bottom), 1);
2134    }
2135
2136    #[test]
2137    fn split_targets_the_right_pane_after_an_earlier_pane_is_pruned() {
2138        // Tab = [A(0), B(1), C(2)]; drop A *after* B. Detaching A removes pane 0
2139        // and shifts B/C down, so the naive index would land A after C. The
2140        // adjustment keeps it where the user aimed: [B, A, C].
2141        let m = model();
2142        let a = reg(&m, DockSide::Bottom);
2143        let b = reg(&m, DockSide::Bottom);
2144        let c = reg(&m, DockSide::Bottom);
2145        m.open_dock(a, DockOpenLocation::side(DockSide::Bottom).new_tab());
2146        m.split_into_tab(b, DockSide::Bottom, 0, 0, false); // [A, B]
2147        m.split_into_tab(c, DockSide::Bottom, 0, 1, false); // [A, B, C]
2148
2149        m.split_into_tab(a, DockSide::Bottom, 0, 1, false); // drop A after B
2150
2151        assert_eq!(
2152            m.side_tabs(DockSide::Bottom)[0].panes.len(),
2153            3,
2154            "still 3 panes"
2155        );
2156        assert_eq!(m.dock_location(b).unwrap().pane_idx, 0, "B leads");
2157        assert_eq!(m.dock_location(a).unwrap().pane_idx, 1, "A lands after B");
2158        assert_eq!(m.dock_location(c).unwrap().pane_idx, 2, "C trails");
2159    }
2160
2161    #[test]
2162    fn stack_into_a_later_tab_after_an_earlier_tab_is_pruned_keeps_the_dock() {
2163        // Side = [tab0 = Single(A)], [tab1 = Single(B)]; stack A into tab1.
2164        // Detaching A removes tab0 (tab1 shifts to index 0); without the index
2165        // fix the stale tab index misses and the dock is lost.
2166        let m = model();
2167        let a = reg(&m, DockSide::Bottom);
2168        let b = reg(&m, DockSide::Bottom);
2169        m.open_dock(a, DockOpenLocation::side(DockSide::Bottom).new_tab());
2170        m.open_dock(b, DockOpenLocation::side(DockSide::Bottom).new_tab());
2171        assert_eq!(m.tab_count(DockSide::Bottom), 2);
2172
2173        m.stack_into_tab(a, DockSide::Bottom, 1); // drop A into B's tab
2174
2175        assert!(m.is_dock_open(a), "A must not be lost");
2176        assert_eq!(m.tab_count(DockSide::Bottom), 1, "A's old tab was pruned");
2177        assert_eq!(
2178            m.side_tabs(DockSide::Bottom)[0].panes,
2179            vec![b, a],
2180            "A appended to B's tab"
2181        );
2182    }
2183
2184    // ─── context-menu state: hide / select-by-id / rail size / tab display ──
2185
2186    #[test]
2187    fn set_tab_hidden_hides_and_restores_an_activity() {
2188        let m = model();
2189        let a = reg(&m, DockSide::Leading);
2190        let b = reg(&m, DockSide::Leading);
2191        m.open_dock(a, DockOpenLocation::side(DockSide::Leading).new_tab());
2192        m.open_dock(b, DockOpenLocation::side(DockSide::Leading).new_tab());
2193        let tabs = m.side_tabs(DockSide::Leading);
2194        let (ta, tb) = (tabs[0].id, tabs[1].id);
2195
2196        assert_eq!(m.side_visible_tab_count(DockSide::Leading), 2);
2197        m.set_tab_hidden(ta, true);
2198        assert!(m.is_tab_hidden(ta));
2199        assert_eq!(
2200            m.side_visible_tab_count(DockSide::Leading),
2201            1,
2202            "hidden activity drops out of the visible count"
2203        );
2204        // The tab still exists in the model (restorable).
2205        assert_eq!(m.tab_count(DockSide::Leading), 2);
2206        // Restore.
2207        m.set_tab_hidden(ta, false);
2208        assert!(!m.is_tab_hidden(ta));
2209        assert_eq!(m.side_visible_tab_count(DockSide::Leading), 2);
2210        let _ = tb;
2211    }
2212
2213    #[test]
2214    fn hiding_the_selected_tab_moves_selection_to_a_visible_one() {
2215        let m = model();
2216        let a = reg(&m, DockSide::Bottom);
2217        let b = reg(&m, DockSide::Bottom);
2218        m.open_dock(a, DockOpenLocation::side(DockSide::Bottom).new_tab());
2219        m.open_dock(b, DockOpenLocation::side(DockSide::Bottom).new_tab());
2220        let tabs = m.side_tabs(DockSide::Bottom);
2221        let (ta, tb) = (tabs[0].id, tabs[1].id);
2222
2223        m.select_tab_by_id(DockSide::Bottom, tb);
2224        assert_eq!(m.side_selected_tab(DockSide::Bottom), 1);
2225        // Hide the selected tab → selection must move to the other visible one.
2226        m.set_tab_hidden(tb, true);
2227        assert_eq!(
2228            m.side_selected_tab(DockSide::Bottom),
2229            0,
2230            "selection moved off the hidden tab"
2231        );
2232        let _ = ta;
2233    }
2234
2235    #[test]
2236    fn select_tab_by_id_is_position_independent() {
2237        let m = model();
2238        let a = reg(&m, DockSide::Leading);
2239        let b = reg(&m, DockSide::Leading);
2240        m.open_dock(a, DockOpenLocation::side(DockSide::Leading).new_tab());
2241        m.open_dock(b, DockOpenLocation::side(DockSide::Leading).new_tab());
2242        let id_b = m.side_tabs(DockSide::Leading)[1].id;
2243        m.select_tab_by_id(DockSide::Leading, id_b);
2244        assert_eq!(m.side_selected_tab(DockSide::Leading), 1);
2245    }
2246
2247    #[test]
2248    fn rail_size_and_tab_display_round_trip_reactively() {
2249        use crate::docking::{DockRailItemSize, DockTabDisplay};
2250        let m = model();
2251        // Defaults.
2252        assert_eq!(
2253            m.side_rail_size(DockSide::Leading),
2254            DockRailItemSize::Default
2255        );
2256        assert_eq!(m.side_tab_display(DockSide::Leading), DockTabDisplay::Text);
2257
2258        // The reactive signals the rail / strip bind to.
2259        let rail_sig = m.rail_size_signal(DockSide::Leading);
2260        let disp_sig = m.tab_display_signal(DockSide::Leading);
2261
2262        m.set_side_rail_size(DockSide::Leading, DockRailItemSize::Compact);
2263        assert_eq!(
2264            m.side_rail_size(DockSide::Leading),
2265            DockRailItemSize::Compact
2266        );
2267        assert_eq!(
2268            rail_sig.get(),
2269            1,
2270            "signal reflects the change (drives rebuild)"
2271        );
2272        assert!(!DockRailItemSize::Compact.shows_label());
2273
2274        // The third "icon + 90° label" rail mode.
2275        m.set_side_rail_size(DockSide::Leading, DockRailItemSize::Labeled);
2276        assert_eq!(
2277            m.side_rail_size(DockSide::Leading),
2278            DockRailItemSize::Labeled
2279        );
2280        assert_eq!(rail_sig.get(), 2, "labeled mode drives a rebuild too");
2281        assert!(DockRailItemSize::Labeled.shows_label());
2282        assert!(!DockRailItemSize::Default.shows_label());
2283
2284        m.set_side_tab_display(DockSide::Leading, DockTabDisplay::IconText);
2285        assert_eq!(
2286            m.side_tab_display(DockSide::Leading),
2287            DockTabDisplay::IconText
2288        );
2289        assert_eq!(disp_sig.get(), 2);
2290    }
2291
2292    #[test]
2293    fn rail_size_mode_signal_tracks_the_mode() {
2294        use crate::docking::DockRailItemSize;
2295        let m = model();
2296        // The public signal external widgets (rail slots, toolbars) bind to.
2297        let sig = m.rail_size_mode_signal(DockSide::Leading);
2298        assert_eq!(sig.get(), DockRailItemSize::Default);
2299
2300        m.set_side_rail_size(DockSide::Leading, DockRailItemSize::Compact);
2301        assert_eq!(sig.get(), DockRailItemSize::Compact, "mode signal fires");
2302        m.set_side_rail_size(DockSide::Leading, DockRailItemSize::Labeled);
2303        assert_eq!(sig.get(), DockRailItemSize::Labeled);
2304    }
2305
2306    #[test]
2307    fn export_import_round_trips_hidden_and_display_prefs() {
2308        use crate::docking::{DockRailItemSize, DockTabDisplay};
2309        let m = model();
2310        let a = reg(&m, DockSide::Leading);
2311        let b = reg(&m, DockSide::Leading);
2312        m.open_dock(a, DockOpenLocation::side(DockSide::Leading).new_tab());
2313        m.open_dock(b, DockOpenLocation::side(DockSide::Leading).new_tab());
2314        let tb = m.side_tabs(DockSide::Leading)[1].id;
2315        m.set_tab_hidden(tb, true);
2316        m.set_side_rail_size(DockSide::Leading, DockRailItemSize::Labeled);
2317        m.set_side_tab_display(DockSide::Leading, DockTabDisplay::Icon);
2318
2319        let state = m.export_state();
2320
2321        // Restore into a fresh model with the *same* dock ids registered (import
2322        // matches by id, dropping unknowns).
2323        let m2 = model();
2324        for id in [a, b] {
2325            m2.register_meta(
2326                id,
2327                DockWidgetMeta {
2328                    title: lit!("Dock"),
2329                    icon: None,
2330                    min_size: None,
2331                    default: DockOpenLocation::side(DockSide::Leading),
2332                    header_actions: None,
2333                    show_header: false,
2334                },
2335            );
2336        }
2337        m2.import_state(&state);
2338
2339        assert!(m2.is_tab_hidden(tb), "hidden activity restored");
2340        assert_eq!(
2341            m2.side_rail_size(DockSide::Leading),
2342            DockRailItemSize::Labeled
2343        );
2344        assert_eq!(m2.side_tab_display(DockSide::Leading), DockTabDisplay::Icon);
2345    }
2346
2347    // ─── selection coherence: hiding / importing onto a hidden tab ─────────
2348
2349    #[test]
2350    fn hiding_the_only_visible_tab_resets_selection_to_zero() {
2351        // Bug #1: hiding the last visible activity used to leave `selected_tab`
2352        // pointing at the now-hidden tab, so the strip's `tw_selected` held a
2353        // TabId absent from its (empty) list. Selection must land on a coherent
2354        // value (0) instead.
2355        let m = model();
2356        let a = reg(&m, DockSide::Leading);
2357        let b = reg(&m, DockSide::Leading);
2358        m.open_dock(a, DockOpenLocation::side(DockSide::Leading).new_tab());
2359        m.open_dock(b, DockOpenLocation::side(DockSide::Leading).new_tab());
2360        let (ta, tb) = {
2361            let t = m.side_tabs(DockSide::Leading);
2362            (t[0].id, t[1].id)
2363        };
2364        // Hide both — the second hide makes the side have zero visible tabs.
2365        m.set_tab_hidden(ta, true);
2366        m.select_tab_by_id(DockSide::Leading, tb);
2367        assert_eq!(m.side_selected_tab(DockSide::Leading), 1);
2368        m.set_tab_hidden(tb, true);
2369        assert_eq!(m.side_visible_tab_count(DockSide::Leading), 0);
2370        assert_eq!(
2371            m.side_selected_tab(DockSide::Leading),
2372            0,
2373            "selection resets to a coherent index when no tab is visible"
2374        );
2375    }
2376
2377    #[test]
2378    fn import_state_clamps_selection_off_a_hidden_tab() {
2379        // Bug #5: a session persisted with a hidden tab selected used to restore
2380        // with `selected_tab` on the hidden tab. import must re-home it onto a
2381        // visible tab.
2382        let m = model();
2383        let a = reg(&m, DockSide::Bottom);
2384        let b = reg(&m, DockSide::Bottom);
2385        m.open_dock(a, DockOpenLocation::side(DockSide::Bottom).new_tab());
2386        m.open_dock(b, DockOpenLocation::side(DockSide::Bottom).new_tab());
2387        // Select tab 1 (b), then hide it: selection moves to a (0) and b stays
2388        // hidden. Forge a state that *says* selected = 1 (the hidden tab).
2389        let tb = m.side_tabs(DockSide::Bottom)[1].id;
2390        m.set_tab_hidden(tb, true);
2391        let mut state = m.export_state();
2392        state.bottom.selected_tab = 1; // hidden tab b
2393
2394        let m2 = model();
2395        for id in [a, b] {
2396            m2.register_meta(
2397                id,
2398                DockWidgetMeta {
2399                    title: lit!("Dock"),
2400                    icon: None,
2401                    min_size: None,
2402                    default: DockOpenLocation::side(DockSide::Bottom),
2403                    header_actions: None,
2404                    show_header: false,
2405                },
2406            );
2407        }
2408        m2.import_state(&state);
2409        assert_eq!(
2410            m2.side_selected_tab(DockSide::Bottom),
2411            0,
2412            "selection clamped onto the visible tab, not the hidden one"
2413        );
2414    }
2415
2416    // ─── #6: append after the last *visible* tab (not past hidden ones) ────
2417
2418    #[test]
2419    fn side_append_index_lands_after_last_visible_tab() {
2420        let m = model();
2421        let a = reg(&m, DockSide::Bottom);
2422        let b = reg(&m, DockSide::Bottom);
2423        let c = reg(&m, DockSide::Bottom);
2424        m.open_dock(a, DockOpenLocation::side(DockSide::Bottom).new_tab());
2425        m.open_dock(b, DockOpenLocation::side(DockSide::Bottom).new_tab());
2426        m.open_dock(c, DockOpenLocation::side(DockSide::Bottom).new_tab());
2427        // Hide the trailing two (b, c) → only a visible at model index 0.
2428        let (tb, tc) = {
2429            let t = m.side_tabs(DockSide::Bottom);
2430            (t[1].id, t[2].id)
2431        };
2432        m.set_tab_hidden(tb, true);
2433        m.set_tab_hidden(tc, true);
2434        // Appending must land at index 1 (right after the last visible tab a),
2435        // NOT at 3 (past the trailing hidden b, c).
2436        assert_eq!(m.side_append_index(DockSide::Bottom), 1);
2437    }
2438
2439    #[test]
2440    fn move_tab_appends_before_trailing_hidden_tabs() {
2441        // A whole-tab move that "appends to the side" must land before any
2442        // trailing hidden tabs, so order is preserved when they are restored.
2443        let m = model();
2444        let a = reg(&m, DockSide::Leading);
2445        let b = reg(&m, DockSide::Leading);
2446        let mover = reg(&m, DockSide::Trailing);
2447        m.open_dock(a, DockOpenLocation::side(DockSide::Leading).new_tab());
2448        m.open_dock(b, DockOpenLocation::side(DockSide::Leading).new_tab());
2449        m.open_dock(mover, DockOpenLocation::side(DockSide::Trailing).new_tab());
2450        // Hide b on the leading side (it trails a in the model).
2451        let tb = m.side_tabs(DockSide::Leading)[1].id;
2452        m.set_tab_hidden(tb, true);
2453        // Move `mover` to leading, appending after the last visible tab (a).
2454        let mover_tab = m.side_tabs(DockSide::Trailing)[0].id;
2455        m.move_tab(
2456            mover_tab,
2457            DockSide::Leading,
2458            m.side_append_index(DockSide::Leading),
2459        );
2460        // Restore b and verify order: [a, mover, b] — mover before the hidden b.
2461        m.set_tab_hidden(tb, false);
2462        let order: Vec<_> = m
2463            .side_tabs(DockSide::Leading)
2464            .iter()
2465            .map(|t| t.panes[0])
2466            .collect();
2467        assert_eq!(order, vec![a, mover, b], "moved tab sits before hidden b");
2468    }
2469
2470    // ─── naming + primary-pane label derivation ────────────────────────────
2471
2472    #[test]
2473    fn set_tab_title_overrides_the_derived_label() {
2474        let m = model();
2475        let a = reg(&m, DockSide::Leading);
2476        m.open_dock(a, DockOpenLocation::side(DockSide::Leading));
2477        let tab = m.side_tabs(DockSide::Leading)[0].id;
2478        assert_eq!(m.activity_of(a), Some(tab));
2479        assert!(m.tab_title(tab).is_none());
2480        m.set_dock_activity_title(a, lit!("Source Control"));
2481        assert_eq!(
2482            m.tab_title(tab).unwrap().resolve_now(),
2483            "Source Control",
2484            "explicit activity title set via the dock id"
2485        );
2486        let view = m.side_tabs(DockSide::Leading)[0].clone();
2487        assert_eq!(m.activity_label(&view).resolve_now(), "Source Control");
2488    }
2489
2490    #[test]
2491    fn activity_label_follows_first_non_collapsed_pane() {
2492        // A grouped activity's label tracks the first *non-collapsed* pane, so
2493        // collapsing the lead pane surfaces the next pane's title.
2494        let m = model();
2495        let a = DockWidgetId::fresh();
2496        let b = DockWidgetId::fresh();
2497        m.register_meta(
2498            a,
2499            DockWidgetMeta {
2500                title: lit!("Alpha"),
2501                icon: None,
2502                min_size: None,
2503                default: DockOpenLocation::side(DockSide::Leading),
2504                header_actions: None,
2505                show_header: false,
2506            },
2507        );
2508        m.register_meta(
2509            b,
2510            DockWidgetMeta {
2511                title: lit!("Beta"),
2512                icon: None,
2513                min_size: None,
2514                default: DockOpenLocation::side(DockSide::Leading),
2515                header_actions: None,
2516                show_header: false,
2517            },
2518        );
2519        m.open_dock(a, DockOpenLocation::side(DockSide::Leading));
2520        m.open_dock(b, DockOpenLocation::side(DockSide::Leading).stack());
2521        let view = m.side_tabs(DockSide::Leading)[0].clone();
2522        assert_eq!(m.activity_label(&view).resolve_now(), "Alpha");
2523        // Collapse pane 0 → the label follows pane 1 (Beta).
2524        view.splitter.set_collapsed(0, true);
2525        let view = m.side_tabs(DockSide::Leading)[0].clone();
2526        assert_eq!(
2527            m.activity_label(&view).resolve_now(),
2528            "Beta",
2529            "label follows the first non-collapsed pane"
2530        );
2531    }
2532
2533    // ─── side availability ─────────────────────────────────────────────────
2534
2535    #[test]
2536    fn enabled_move_targets_excludes_self_and_disabled() {
2537        let m = model();
2538        assert_eq!(
2539            m.enabled_move_targets(DockSide::Leading),
2540            vec![DockSide::Trailing, DockSide::Top, DockSide::Bottom]
2541        );
2542        m.set_side_enabled(DockSide::Bottom, false);
2543        assert_eq!(
2544            m.enabled_move_targets(DockSide::Leading),
2545            vec![DockSide::Trailing, DockSide::Top],
2546            "a disabled side is never a move target"
2547        );
2548        m.set_side_enabled(DockSide::Top, false);
2549        m.set_side_enabled(DockSide::Trailing, false);
2550        assert!(
2551            m.enabled_move_targets(DockSide::Leading).is_empty(),
2552            "no enabled target → empty (the Move-to entry is then omitted)"
2553        );
2554    }
2555
2556    #[test]
2557    fn tab_hidden_signal_tracks_live_state() {
2558        let m = model();
2559        let a = reg(&m, DockSide::Leading);
2560        m.open_dock(a, DockOpenLocation::side(DockSide::Leading));
2561        let tab = m.side_tabs(DockSide::Leading)[0].id;
2562        let sig = m.tab_hidden_signal(tab);
2563        assert!(!sig.get());
2564        m.set_tab_hidden(tab, true);
2565        assert!(sig.get(), "signal follows an external set_tab_hidden");
2566        m.set_tab_hidden(tab, false);
2567        assert!(!sig.get());
2568    }
2569}