Skip to main content

teksilo_widgets/
docking.rs

1// SPDX-License-Identifier: MPL-2.0
2// SPDX-FileCopyrightText: 2026 FernTech
3
4//! `DockingLayout` — a VS Code-style dockable layout: a fixed centre slot
5//! (the app's main content) surrounded by four collapsible, splittable,
6//! draggable side regions (leading / trailing / top / bottom), backed by a
7//! cloneable, serializable [`DockingModel`].
8//!
9//! See `docs/docking.md` for the full reference. The structure is four
10//! levels deep:
11//!
12//! ```text
13//! DockingLayout
14//! └── Centre + 4 Sides
15//!     └── Side = [optional DockActivityBar rail] + collapsible content region
16//!         └── content region holds ONE TabWidget (strip optional / replaced
17//!             by the rail)
18//!             └── Tab → DockArrangement (a Splitter of panes, each a single
19//!                 DockWidget or a ToolBox of DockWidgets)
20//!                 └── DockWidget — the atomic dockable unit
21//! ```
22
23mod a11y;
24mod activity_bar;
25mod context_menu;
26mod drag;
27mod geometry;
28mod model;
29mod panel;
30mod resize_handle;
31mod state;
32#[cfg(test)]
33mod tests;
34
35pub use activity_bar::{DockAction, DockActionId, DockActionPlacement, DockRail, DockRailSlot};
36pub use geometry::{CornerOwners, DockCorner, DockSide, DockingRects, SideLayout, SideRects};
37pub use model::{
38    DockIconFactory, DockLoc, DockOpenLocation, DockOpenMode, DockPolicy, DockRailItemSize,
39    DockTabDisplay, DockTabId, DockWidgetId, DockingModel, TabPresentation,
40};
41pub use panel::{DockContentFactory, DockWidget};
42pub use state::{DockLayoutState, DockSideState, DockTabState};
43
44use std::cell::{Cell, RefCell};
45use std::collections::HashMap;
46use std::rc::Rc;
47
48use teksilo_canvas::{Point, Rect, Size, SizeProposal};
49use teksilo_core::accessibility::AccessNodeBuilder;
50use teksilo_core::binding::BindingLevel;
51use teksilo_core::build_context::BuildContext;
52use teksilo_core::widget::{LayoutContext, LayoutResponse, Widget, WidgetPlacement};
53use teksilo_core::widget_id::WidgetId;
54use teksilo_tokens::SurfaceRole;
55
56use crate::primitives::RectWidget;
57
58use activity_bar::DockActivityBar;
59use geometry::compute_rects;
60use panel::{DockContentRegistry, DockSidePanel};
61use resize_handle::{DockResizeHandle, DockResizeHandleConfig};
62
63/// Below this collapse progress a side's content is parked dormant (out of
64/// paint / focus / AT), so a fully-collapsed side never bleeds past its 0-size
65/// clip. Matches the Splitter `ClipPane` epsilon.
66const COLLAPSED_EPS: f32 = 0.01;
67/// Default resize-gutter thickness between a side and the centre.
68const DOCK_GUTTER: f32 = 6.0;
69
70/// The docking layout widget. See the module docs and `docs/docking.md`.
71///
72/// ```ignore
73/// let model = DockingModel::new();
74/// // …declare panels + an initial layout on `model`…
75/// DockingLayout::new(model.clone())
76///     .center(editor)
77///     .dock(DockWidget::new(EXPLORER, lit!("Explorer"), |_| Explorer::new()))
78/// ```
79pub struct DockingLayout {
80    model: DockingModel,
81    registry: Rc<RefCell<DockContentRegistry>>,
82    center: Option<Box<dyn Widget>>,
83    center_id: Option<WidgetId>,
84    container_bounds: Rc<Cell<Rect>>,
85    progress: HashMap<DockSide, teksilo_core::signal::Signal<f32>>,
86    /// Per-side activity-rail configuration (size / slots / overflow).
87    rails: HashMap<DockSide, DockRail>,
88    /// Per-side `WidgetId` of the `DockSidePanel` content region (the
89    /// `Role::Complementary` landmark), recorded in `build()`. Threaded into
90    /// each side's `DockActivityBar` so its rail tabs can advertise an AT
91    /// `controls` relationship pointing at the content region they govern
92    /// (the ARIA tab → tabpanel link). Owned per-`DockingLayout` instance so
93    /// it stays correct even if a model is shared across views.
94    side_panel_ids: Rc<RefCell<HashMap<DockSide, WidgetId>>>,
95    /// Children in a fixed order so `place_children` can index them:
96    /// `[center, (content, rail, handle) × {leading, trailing, top, bottom}]`.
97    ordered: Vec<WidgetId>,
98}
99
100impl std::fmt::Debug for DockingLayout {
101    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
102        f.debug_struct("DockingLayout").finish()
103    }
104}
105
106impl DockingLayout {
107    /// Create a docking layout over a model.
108    pub fn new(model: DockingModel) -> Self {
109        Self {
110            model,
111            registry: Rc::new(RefCell::new(DockContentRegistry::default())),
112            center: None,
113            center_id: None,
114            container_bounds: Rc::new(Cell::new(Rect::ZERO)),
115            progress: HashMap::new(),
116            rails: HashMap::new(),
117            side_panel_ids: Rc::new(RefCell::new(HashMap::new())),
118            ordered: Vec::new(),
119        }
120    }
121
122    /// Configure a side's activity rail (item size, top/bottom slots, overflow
123    /// trigger). The side still needs [`DockingModel::set_side_rail`] to put it
124    /// in Rail presentation; this only styles the rail. See [`DockRail`].
125    pub fn rail(mut self, rail: DockRail) -> Self {
126        self.rails.insert(rail.side(), rail);
127        self
128    }
129
130    /// Set the always-present centre content (the app's main area).
131    pub fn center(mut self, widget: impl Widget + 'static) -> Self {
132        self.center = Some(Box::new(widget));
133        self
134    }
135
136    /// Lock down end-user layout edits (sugar for [`DockingModel::set_policy`]).
137    /// See [`DockPolicy`].
138    pub fn policy(self, policy: DockPolicy) -> Self {
139        self.model.set_policy(policy);
140        self
141    }
142
143    /// Disable a side (sugar for [`DockingModel::set_side_enabled`]`(side, false)`):
144    /// it renders nothing, reserves no space, and rejects docks.
145    pub fn disable_side(self, side: DockSide) -> Self {
146        self.model.set_side_enabled(side, false);
147        self
148    }
149
150    /// Set the centre content by a pre-registered id.
151    pub fn center_id(mut self, id: WidgetId) -> Self {
152        self.center_id = Some(id);
153        self
154    }
155
156    /// Declare a dock widget (its content factory + chrome metadata). The
157    /// dock is registered immediately, so the app may set the initial layout
158    /// on the model (`open_dock` / `import_state`) before mounting.
159    pub fn dock(self, dock: DockWidget) -> Self {
160        let (id, meta, factory) = dock.into_parts();
161        self.model.register_meta(id, meta);
162        self.registry.borrow_mut().insert(id, factory);
163        self
164    }
165}
166
167const SIDES_ORDER: [DockSide; 4] = [
168    DockSide::Leading,
169    DockSide::Trailing,
170    DockSide::Top,
171    DockSide::Bottom,
172];
173
174impl Widget for DockingLayout {
175    fn build(&mut self, ctx: &mut BuildContext) -> Vec<WidgetId> {
176        let self_id = ctx.self_id();
177
178        // Structural change → Rebuild; geometry change → Relayout.
179        self.model
180            .version()
181            .bind_to(self_id, ctx.binding_registry(), BindingLevel::Rebuild);
182        self.model.geometry_version().bind_to(
183            self_id,
184            ctx.binding_registry(),
185            BindingLevel::Relayout,
186        );
187
188        // Content is built **in-context** by each side's panels (via the
189        // registry handle passed down) — never pre-built here, so it is
190        // correctly parented where it is placed. (v1: rebuilt on each
191        // structural change; the Rebuild/Relayout split keeps resize / show-
192        // hide / tab-switch from rebuilding.)
193
194        // Centre preservation across rebuilds. `self.center` is a one-shot
195        // `take()`, so a rebuild (a rail / dock / side change re-runs `build()`)
196        // would otherwise find it `None` and fall back to a blank placeholder —
197        // blanking the editor. `preserves_children_on_rebuild()` (below) stops
198        // the framework from auto-destroying our children on a rebuild, so we
199        // manage them here: keep the centre subtree (index 0) and destroy +
200        // rebuild only the model-derived sides.
201        let prior = std::mem::take(&mut self.ordered);
202        let preserved_center = prior.first().copied();
203        for &old_side in prior.iter().skip(1) {
204            ctx.destroy_subtree(old_side);
205        }
206
207        // Centre.
208        let center = if let Some(c) = preserved_center {
209            c
210        } else {
211            let inner = if let Some(id) = self.center_id {
212                id
213            } else if let Some(w) = self.center.take() {
214                ctx.add_boxed(w)
215            } else {
216                ctx.add(RectWidget::new().background(SurfaceRole::Content))
217            };
218            ctx.add(crate::primitives::Expand::new().child_id(inner))
219        };
220
221        let mut ordered = vec![center];
222        let anim = ctx.animate().collapse().standard();
223
224        // Re-derive the side → content-region id map on every (re)build; a
225        // disabled or rail-less side leaves no entry, so a rail tab simply
226        // omits its `controls` relation rather than dangling at a stale id.
227        self.side_panel_ids.borrow_mut().clear();
228
229        for side in SIDES_ORDER {
230            // A disabled side renders nothing and reserves no space. Push three
231            // transparent placeholders so the fixed child order
232            // (`[center, (content, rail, handle) × 4]`) the placement code
233            // indexes by stays intact; `place_children` gives it zero extent.
234            if !self.model.is_side_enabled(side) {
235                let blank = || RectWidget::new().background(SurfaceRole::Transparent);
236                ordered.push(ctx.add(blank()));
237                ordered.push(ctx.add(blank()));
238                ordered.push(ctx.add(blank()));
239                continue;
240            }
241
242            let visible = self.model.side_visible_signal(side);
243            let progress = ctx.animated_signal(if visible.get() { 1.0 } else { 0.0 });
244            progress.bind_to(self_id, ctx.binding_registry(), BindingLevel::Relayout);
245            self.progress.insert(side, progress.clone());
246
247            // A rail size-mode change (Default / Compact / Labeled) changes the
248            // rail strip's width → relayout so the activity bar itself follows
249            // the switch, not just its items.
250            self.model.rail_size_signal(side).bind_to(
251                self_id,
252                ctx.binding_registry(),
253                BindingLevel::Relayout,
254            );
255
256            // Animate progress toward the side's visibility.
257            {
258                let spec = anim.clone();
259                let p = progress.clone();
260                ctx.effect(&visible, move |&v| {
261                    spec.to_or_snap(&p, if v { 1.0 } else { 0.0 });
262                });
263            }
264
265            // Content is laid out at full size and clipped (sliding out the
266            // side's outer edge) by `SideClipPane` — never reflowed at the
267            // shrinking width, so the collapse animation costs nothing per
268            // frame beyond moving + clipping. Disabled when hidden so Tab
269            // skips it; gate on `visible` (one change per toggle), never on the
270            // per-frame `progress` signal.
271            // One rail config per side, shared by both presentations: the Rail
272            // half (items, slots, actions) is `DockActivityBar`'s, the Strip
273            // half (`leading_slot`/`trailing_slot`) is `DockSidePanel`'s. Built
274            // once here so a side declared with `.rail(..)` keeps its chrome
275            // whichever presentation it is currently in.
276            let config = self
277                .rails
278                .get(&side)
279                .cloned()
280                .unwrap_or_else(|| DockRail::new(side));
281            let panel = ctx.add(DockSidePanel::new(
282                side,
283                self.model.clone(),
284                self.registry.clone(),
285                config.clone(),
286            ));
287            // Record the content region's id so this side's rail tabs can
288            // advertise `controls` → this panel (ARIA tab → tabpanel link).
289            self.side_panel_ids.borrow_mut().insert(side, panel);
290            // Park the content dormant (out of paint/focus/AT) once the side is
291            // fully collapsed, so it never bleeds past its 0-size clip. This is
292            // `visible_when` (dormancy toggled only on the flip) — NOT
293            // `enabled_when` (which would repaint the subtree every frame).
294            ctx.visible_when(panel, progress.map(|p| *p > COLLAPSED_EPS));
295            let content = ctx.add(SideClipPane {
296                side,
297                model: self.model.clone(),
298                child: panel,
299            });
300
301            // Rail (always present; empty when the side has no rail).
302            let rail = if self.model.side_has_rail(side) {
303                ctx.add(DockActivityBar::new(
304                    side,
305                    self.model.clone(),
306                    config,
307                    self.side_panel_ids.clone(),
308                ))
309            } else {
310                ctx.add(RectWidget::new().background(SurfaceRole::Transparent))
311            };
312
313            // Resize handle (disabled when the side is hidden).
314            let handle = ctx.add(DockResizeHandle::new(DockResizeHandleConfig {
315                side,
316                model: self.model.clone(),
317                enabled: true,
318                is_rtl: false,
319                container_bounds: self.container_bounds.clone(),
320            }));
321            ctx.enabled_when(handle, visible.clone());
322
323            ordered.push(content);
324            ordered.push(rail);
325            ordered.push(handle);
326        }
327
328        self.ordered = ordered.clone();
329        ordered
330    }
331
332    fn layout_response(&self, proposal: SizeProposal, ctx: &LayoutContext) -> LayoutResponse {
333        // Min = Σ visible side (rail + min + gutter) + centre child min.
334        let mut min_w = 0.0_f32;
335        let mut min_h = 0.0_f32;
336        if let Some(&center) = self.ordered.first()
337            && let Some(c) = ctx.child_size(
338                center,
339                SizeProposal {
340                    width: None,
341                    height: None,
342                },
343            )
344        {
345            min_w += c.width.min(80.0);
346            min_h += c.height.min(80.0);
347        }
348        for side in SIDES_ORDER {
349            if self.model.is_side_enabled(side) && self.model.is_side_visible(side) {
350                let extent = self.model.side_min_size(side)
351                    + DOCK_GUTTER
352                    + self.model.side_rail_thickness(side);
353                if side.is_horizontal_axis() {
354                    min_w += extent;
355                } else {
356                    min_h += extent;
357                }
358            }
359        }
360        LayoutResponse::shrinkable(
361            proposal.resolve(min_w, min_h),
362            teksilo_canvas::Size::new(min_w, min_h),
363            1.0,
364        )
365    }
366
367    fn place_children(
368        &self,
369        bounds: Rect,
370        _proposal: SizeProposal,
371        children: &mut [WidgetPlacement],
372        ctx: &LayoutContext,
373    ) {
374        self.container_bounds.set(bounds);
375        let rtl = ctx.is_rtl();
376
377        let side_layout = |side: DockSide| -> SideLayout {
378            // A disabled side contributes nothing (its placeholders are placed
379            // at the zero rect compute_rects returns; the centre reclaims it).
380            if !self.model.is_side_enabled(side) {
381                return SideLayout {
382                    size: 0.0,
383                    visible_progress: 0.0,
384                    gutter: DOCK_GUTTER,
385                    min_size: 0.0,
386                    rail_thickness: 0.0,
387                    has_rail: false,
388                };
389            }
390            let p = self.progress.get(&side).map(|s| s.get()).unwrap_or(
391                if self.model.is_side_visible(side) {
392                    1.0
393                } else {
394                    0.0
395                },
396            );
397            // The rail strip width follows the side's size mode (it shrinks for
398            // Compact), derived from the rail's configured item size.
399            let rail_thickness = if self.model.side_has_rail(side) {
400                let mode = self.model.side_rail_size(side);
401                self.rails
402                    .get(&side)
403                    .map(|r| r.effective_thickness(mode))
404                    .unwrap_or_else(|| DockRail::new(side).effective_thickness(mode))
405            } else {
406                0.0
407            };
408            SideLayout {
409                size: self.model.side_size(side),
410                visible_progress: p,
411                gutter: DOCK_GUTTER,
412                min_size: self.model.side_min_size(side),
413                rail_thickness,
414                has_rail: self.model.side_has_rail(side),
415            }
416        };
417
418        // RTL: swap leading/trailing inputs, then swap the outputs back.
419        let (lead_in, trail_in) = if rtl {
420            (
421                side_layout(DockSide::Trailing),
422                side_layout(DockSide::Leading),
423            )
424        } else {
425            (
426                side_layout(DockSide::Leading),
427                side_layout(DockSide::Trailing),
428            )
429        };
430        let rects = compute_rects(
431            bounds,
432            lead_in,
433            trail_in,
434            side_layout(DockSide::Top),
435            side_layout(DockSide::Bottom),
436            self.model.corners(),
437            rtl,
438        );
439        let leading = if rtl { rects.trailing } else { rects.leading };
440        let trailing = if rtl { rects.leading } else { rects.trailing };
441
442        // children order matches `self.ordered`:
443        // [center, L(content,rail,handle), T(content,rail,handle),
444        //  Top(...), Bottom(...)]
445        let place = |children: &mut [WidgetPlacement], idx: usize, rect: Rect| {
446            if let Some(c) = children.get_mut(idx) {
447                c.origin = rect.origin();
448                c.size = rect.size();
449            }
450        };
451        place(children, 0, rects.center);
452        let side_rects = [
453            (leading.content, leading.rail, leading.handle),
454            (trailing.content, trailing.rail, trailing.handle),
455            (rects.top.content, rects.top.rail, rects.top.handle),
456            (rects.bottom.content, rects.bottom.rail, rects.bottom.handle),
457        ];
458        for (i, (content, rail, handle)) in side_rects.into_iter().enumerate() {
459            let base = 1 + i * 3;
460            place(children, base, content);
461            place(children, base + 1, rail);
462            place(children, base + 2, handle);
463        }
464    }
465
466    fn clips_children(&self) -> bool {
467        true
468    }
469
470    /// We manage our own children across rebuilds (see `build`): the centre is
471    /// a one-shot passed-in widget that must survive structural rebuilds, so we
472    /// preserve it and explicitly destroy + rebuild only the model-derived
473    /// sides. Without this the framework auto-destroys every child on rebuild,
474    /// and the centre (already `take()`n) falls back to a blank placeholder.
475    fn preserves_children_on_rebuild(&self) -> bool {
476        true
477    }
478
479    fn accessibility(&self, builder: &mut AccessNodeBuilder) {
480        builder.set_role(teksilo_core::accesskit::Role::GenericContainer);
481    }
482
483    fn children(&self) -> Vec<WidgetId> {
484        self.ordered.clone()
485    }
486}
487
488/// Wraps a side's content: lays it out at its **full** size and clips, so a
489/// collapsing side **slides its content out** the outer edge instead of
490/// reflowing it at the shrinking width (the Splitter `ClipPane` trick). The
491/// child's layout stays at a stable full size every frame — the animation
492/// only moves + clips.
493struct SideClipPane {
494    side: DockSide,
495    model: DockingModel,
496    child: WidgetId,
497}
498
499impl std::fmt::Debug for SideClipPane {
500    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
501        f.debug_struct("SideClipPane")
502            .field("side", &self.side)
503            .finish()
504    }
505}
506
507impl Widget for SideClipPane {
508    fn layout_response(&self, proposal: SizeProposal, ctx: &LayoutContext) -> LayoutResponse {
509        // Measure the child at the side's FULL extent (not the shrinking
510        // proposal), so its whole subtree lays out at full size — content fills
511        // the dock width, and it stays stable across the collapse (full size
512        // doesn't change), so there's still no per-frame reflow. We then report
513        // the proposal size (the orchestrator forces our actual bounds).
514        let full = self.model.side_size(self.side).max(0.0);
515        let full_proposal = if self.side.is_horizontal_axis() {
516            SizeProposal {
517                width: Some(full.max(proposal.width.unwrap_or(0.0))),
518                height: proposal.height,
519            }
520        } else {
521            SizeProposal {
522                width: proposal.width,
523                height: Some(full.max(proposal.height.unwrap_or(0.0))),
524            }
525        };
526        let _ = ctx.child_size(self.child, full_proposal);
527        proposal
528            .resolve(
529                proposal.width.unwrap_or(0.0),
530                proposal.height.unwrap_or(0.0),
531            )
532            .into()
533    }
534
535    fn place_children(
536        &self,
537        bounds: Rect,
538        _proposal: SizeProposal,
539        children: &mut [WidgetPlacement],
540        _ctx: &LayoutContext,
541    ) {
542        // Full main extent = the side's stored size (≥ the current, shrinking
543        // bounds). Anchor the content's INNER edge to the bounds' inner edge so
544        // it slides out the OUTER edge as the side collapses.
545        let full = self.model.side_size(self.side).max(0.0);
546        let (size, origin) = match self.side {
547            DockSide::Leading => {
548                let w = full.max(bounds.width);
549                (
550                    Size::new(w, bounds.height),
551                    Point::new(bounds.x + bounds.width - w, bounds.y),
552                )
553            }
554            DockSide::Trailing => {
555                let w = full.max(bounds.width);
556                (Size::new(w, bounds.height), Point::new(bounds.x, bounds.y))
557            }
558            DockSide::Top => {
559                let h = full.max(bounds.height);
560                (
561                    Size::new(bounds.width, h),
562                    Point::new(bounds.x, bounds.y + bounds.height - h),
563                )
564            }
565            DockSide::Bottom => {
566                let h = full.max(bounds.height);
567                (Size::new(bounds.width, h), Point::new(bounds.x, bounds.y))
568            }
569        };
570        for child in children.iter_mut() {
571            child.origin = origin;
572            child.size = size;
573        }
574    }
575
576    fn clips_children(&self) -> bool {
577        true
578    }
579
580    fn children(&self) -> Vec<WidgetId> {
581        vec![self.child]
582    }
583}