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