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    /// Set the always-present centre content (the app's main area).
130    pub fn center(mut self, widget: impl Widget + 'static) -> Self {
131        self.center = Some(Box::new(widget));
132        self
133    }
134
135    /// Lock down end-user layout edits (sugar for [`DockingModel::set_policy`]).
136    /// See [`DockPolicy`].
137    pub fn policy(self, policy: DockPolicy) -> Self {
138        self.model.set_policy(policy);
139        self
140    }
141
142    /// Disable a side (sugar for [`DockingModel::set_side_enabled`]`(side, false)`):
143    /// it renders nothing, reserves no space, and rejects docks.
144    pub fn disable_side(self, side: DockSide) -> Self {
145        self.model.set_side_enabled(side, false);
146        self
147    }
148
149    /// Set the centre content by a pre-registered id.
150    pub fn center_id(mut self, id: WidgetId) -> Self {
151        self.center_id = Some(id);
152        self
153    }
154
155    /// Declare a dock widget (its content factory + chrome metadata). The
156    /// dock is registered immediately, so the app may set the initial layout
157    /// on the model (`open_dock` / `import_state`) before mounting.
158    pub fn dock(self, dock: DockWidget) -> Self {
159        let (id, meta, factory) = dock.into_parts();
160        self.model.register_meta(id, meta);
161        self.registry.borrow_mut().insert(id, factory);
162        self
163    }
164}
165
166const SIDES_ORDER: [DockSide; 4] = [
167    DockSide::Leading,
168    DockSide::Trailing,
169    DockSide::Top,
170    DockSide::Bottom,
171];
172
173impl Widget for DockingLayout {
174    fn build(&mut self, ctx: &mut BuildContext) -> Vec<WidgetId> {
175        let self_id = ctx.self_id();
176
177        // Structural change → Rebuild; geometry change → Relayout.
178        self.model
179            .version()
180            .bind_to(self_id, ctx.binding_registry(), BindingLevel::Rebuild);
181        self.model.geometry_version().bind_to(
182            self_id,
183            ctx.binding_registry(),
184            BindingLevel::Relayout,
185        );
186
187        // Content is built **in-context** by each side's panels (via the
188        // registry handle passed down) — never pre-built here, so it is
189        // correctly parented where it is placed. (v1: rebuilt on each
190        // structural change; the Rebuild/Relayout split keeps resize / show-
191        // hide / tab-switch from rebuilding.)
192
193        // Centre preservation across rebuilds. `self.center` is a one-shot
194        // `take()`, so a rebuild (a rail / dock / side change re-runs `build()`)
195        // would otherwise find it `None` and fall back to a blank placeholder —
196        // blanking the editor. `preserves_children_on_rebuild()` (below) stops
197        // the framework from auto-destroying our children on a rebuild, so we
198        // manage them here: keep the centre subtree (index 0) and destroy +
199        // rebuild only the model-derived sides.
200        let prior = std::mem::take(&mut self.ordered);
201        let preserved_center = prior.first().copied();
202        for &old_side in prior.iter().skip(1) {
203            ctx.destroy_subtree(old_side);
204        }
205
206        // Centre.
207        let center = if let Some(c) = preserved_center {
208            c
209        } else {
210            let inner = if let Some(id) = self.center_id {
211                id
212            } else if let Some(w) = self.center.take() {
213                ctx.add_boxed(w)
214            } else {
215                ctx.add(RectWidget::new().background(SurfaceRole::Content))
216            };
217            ctx.add(crate::primitives::Expand::new().child_id(inner))
218        };
219
220        let mut ordered = vec![center];
221        let anim = ctx.animate().collapse().standard();
222
223        // Re-derive the side → content-region id map on every (re)build; a
224        // disabled or rail-less side leaves no entry, so a rail tab simply
225        // omits its `controls` relation rather than dangling at a stale id.
226        self.side_panel_ids.borrow_mut().clear();
227
228        for side in SIDES_ORDER {
229            // A disabled side renders nothing and reserves no space. Push three
230            // transparent placeholders so the fixed child order
231            // (`[center, (content, rail, handle) × 4]`) the placement code
232            // indexes by stays intact; `place_children` gives it zero extent.
233            if !self.model.is_side_enabled(side) {
234                let blank = || RectWidget::new().background(SurfaceRole::Transparent);
235                ordered.push(ctx.add(blank()));
236                ordered.push(ctx.add(blank()));
237                ordered.push(ctx.add(blank()));
238                continue;
239            }
240
241            let visible = self.model.side_visible_signal(side);
242            let progress = ctx.animated_signal(if visible.get() { 1.0 } else { 0.0 });
243            progress.bind_to(self_id, ctx.binding_registry(), BindingLevel::Relayout);
244            self.progress.insert(side, progress.clone());
245
246            // A rail size-mode change (Default / Compact / Labeled) changes the
247            // rail strip's width → relayout so the activity bar itself follows
248            // the switch, not just its items.
249            self.model.rail_size_signal(side).bind_to(
250                self_id,
251                ctx.binding_registry(),
252                BindingLevel::Relayout,
253            );
254
255            // Animate progress toward the side's visibility.
256            {
257                let spec = anim.clone();
258                let p = progress.clone();
259                ctx.effect(&visible, move |&v| {
260                    spec.to_or_snap(&p, if v { 1.0 } else { 0.0 });
261                });
262            }
263
264            // Content is laid out at full size and clipped (sliding out the
265            // side's outer edge) by `SideClipPane` — never reflowed at the
266            // shrinking width, so the collapse animation costs nothing per
267            // frame beyond moving + clipping. Disabled when hidden so Tab
268            // skips it; gate on `visible` (one change per toggle), never on the
269            // per-frame `progress` signal.
270            // One rail config per side, shared by both presentations: the Rail
271            // half (items, slots, actions) is `DockActivityBar`'s, the Strip
272            // half (`leading_slot`/`trailing_slot`) is `DockSidePanel`'s. Built
273            // once here so a side declared with `.rail(..)` keeps its chrome
274            // whichever presentation it is currently in.
275            let config = self
276                .rails
277                .get(&side)
278                .cloned()
279                .unwrap_or_else(|| DockRail::new(side));
280            let panel = ctx.add(DockSidePanel::new(
281                side,
282                self.model.clone(),
283                self.registry.clone(),
284                config.clone(),
285            ));
286            // Record the content region's id so this side's rail tabs can
287            // advertise `controls` → this panel (ARIA tab → tabpanel link).
288            self.side_panel_ids.borrow_mut().insert(side, panel);
289            // Park the content dormant (out of paint/focus/AT) once the side is
290            // fully collapsed, so it never bleeds past its 0-size clip. This is
291            // `visible_when` (dormancy toggled only on the flip) — NOT
292            // `enabled_when` (which would repaint the subtree every frame).
293            ctx.visible_when(panel, progress.map(|p| *p > COLLAPSED_EPS));
294            let content = ctx.add(SideClipPane {
295                side,
296                model: self.model.clone(),
297                child: panel,
298            });
299
300            // Rail (always present; empty when the side has no rail).
301            let rail = if self.model.side_has_rail(side) {
302                ctx.add(DockActivityBar::new(
303                    side,
304                    self.model.clone(),
305                    config,
306                    self.side_panel_ids.clone(),
307                ))
308            } else {
309                ctx.add(RectWidget::new().background(SurfaceRole::Transparent))
310            };
311
312            // Resize handle (disabled when the side is hidden).
313            let handle = ctx.add(DockResizeHandle::new(DockResizeHandleConfig {
314                side,
315                model: self.model.clone(),
316                enabled: true,
317                is_rtl: false,
318                container_bounds: self.container_bounds.clone(),
319            }));
320            ctx.enabled_when(handle, visible.clone());
321
322            ordered.push(content);
323            ordered.push(rail);
324            ordered.push(handle);
325        }
326
327        self.ordered = ordered.clone();
328        ordered
329    }
330
331    fn layout_response(&self, proposal: SizeProposal, ctx: &LayoutContext) -> LayoutResponse {
332        // Min = Σ visible side (rail + min + gutter) + centre child min.
333        let mut min_w = 0.0_f32;
334        let mut min_h = 0.0_f32;
335        if let Some(&center) = self.ordered.first()
336            && let Some(c) = ctx.child_size(
337                center,
338                SizeProposal {
339                    width: None,
340                    height: None,
341                },
342            )
343        {
344            min_w += c.width.min(80.0);
345            min_h += c.height.min(80.0);
346        }
347        for side in SIDES_ORDER {
348            if self.model.is_side_enabled(side) && self.model.is_side_visible(side) {
349                let extent = self.model.side_min_size(side)
350                    + DOCK_GUTTER
351                    + self.model.side_rail_thickness(side);
352                if side.is_horizontal_axis() {
353                    min_w += extent;
354                } else {
355                    min_h += extent;
356                }
357            }
358        }
359        LayoutResponse::shrinkable(
360            proposal.resolve(min_w, min_h),
361            teksilo_canvas::Size::new(min_w, min_h),
362            1.0,
363        )
364    }
365
366    fn place_children(
367        &self,
368        bounds: Rect,
369        _proposal: SizeProposal,
370        children: &mut [WidgetPlacement],
371        ctx: &LayoutContext,
372    ) {
373        self.container_bounds.set(bounds);
374        let rtl = ctx.is_rtl();
375
376        let side_layout = |side: DockSide| -> SideLayout {
377            // A disabled side contributes nothing (its placeholders are placed
378            // at the zero rect compute_rects returns; the centre reclaims it).
379            if !self.model.is_side_enabled(side) {
380                return SideLayout {
381                    size: 0.0,
382                    visible_progress: 0.0,
383                    gutter: DOCK_GUTTER,
384                    min_size: 0.0,
385                    rail_thickness: 0.0,
386                    has_rail: false,
387                };
388            }
389            let p = self.progress.get(&side).map(|s| s.get()).unwrap_or(
390                if self.model.is_side_visible(side) {
391                    1.0
392                } else {
393                    0.0
394                },
395            );
396            // The rail strip width follows the side's size mode (it shrinks for
397            // Compact), derived from the rail's configured item size.
398            let rail_thickness = if self.model.side_has_rail(side) {
399                let mode = self.model.side_rail_size(side);
400                self.rails
401                    .get(&side)
402                    .map(|r| r.effective_thickness(mode))
403                    .unwrap_or_else(|| DockRail::new(side).effective_thickness(mode))
404            } else {
405                0.0
406            };
407            SideLayout {
408                size: self.model.side_size(side),
409                visible_progress: p,
410                gutter: DOCK_GUTTER,
411                min_size: self.model.side_min_size(side),
412                rail_thickness,
413                has_rail: self.model.side_has_rail(side),
414            }
415        };
416
417        // RTL: swap leading/trailing inputs, then swap the outputs back.
418        let (lead_in, trail_in) = if rtl {
419            (
420                side_layout(DockSide::Trailing),
421                side_layout(DockSide::Leading),
422            )
423        } else {
424            (
425                side_layout(DockSide::Leading),
426                side_layout(DockSide::Trailing),
427            )
428        };
429        let rects = compute_rects(
430            bounds,
431            lead_in,
432            trail_in,
433            side_layout(DockSide::Top),
434            side_layout(DockSide::Bottom),
435            self.model.corners(),
436            rtl,
437        );
438        let leading = if rtl { rects.trailing } else { rects.leading };
439        let trailing = if rtl { rects.leading } else { rects.trailing };
440
441        // children order matches `self.ordered`:
442        // [center, L(content,rail,handle), T(content,rail,handle),
443        //  Top(...), Bottom(...)]
444        let place = |children: &mut [WidgetPlacement], idx: usize, rect: Rect| {
445            if let Some(c) = children.get_mut(idx) {
446                c.origin = rect.origin();
447                c.size = rect.size();
448            }
449        };
450        place(children, 0, rects.center);
451        let side_rects = [
452            (leading.content, leading.rail, leading.handle),
453            (trailing.content, trailing.rail, trailing.handle),
454            (rects.top.content, rects.top.rail, rects.top.handle),
455            (rects.bottom.content, rects.bottom.rail, rects.bottom.handle),
456        ];
457        for (i, (content, rail, handle)) in side_rects.into_iter().enumerate() {
458            let base = 1 + i * 3;
459            place(children, base, content);
460            place(children, base + 1, rail);
461            place(children, base + 2, handle);
462        }
463    }
464
465    fn clips_children(&self) -> bool {
466        true
467    }
468
469    /// We manage our own children across rebuilds (see `build`): the centre is
470    /// a one-shot passed-in widget that must survive structural rebuilds, so we
471    /// preserve it and explicitly destroy + rebuild only the model-derived
472    /// sides. Without this the framework auto-destroys every child on rebuild,
473    /// and the centre (already `take()`n) falls back to a blank placeholder.
474    fn preserves_children_on_rebuild(&self) -> bool {
475        true
476    }
477
478    fn accessibility(&self, builder: &mut AccessNodeBuilder) {
479        builder.set_role(teksilo_core::accesskit::Role::GenericContainer);
480    }
481
482    fn children(&self) -> Vec<WidgetId> {
483        self.ordered.clone()
484    }
485}
486
487/// Wraps a side's content: lays it out at its **full** size and clips, so a
488/// collapsing side **slides its content out** the outer edge instead of
489/// reflowing it at the shrinking width (the Splitter `ClipPane` trick). The
490/// child's layout stays at a stable full size every frame — the animation
491/// only moves + clips.
492struct SideClipPane {
493    side: DockSide,
494    model: DockingModel,
495    child: WidgetId,
496}
497
498impl std::fmt::Debug for SideClipPane {
499    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
500        f.debug_struct("SideClipPane")
501            .field("side", &self.side)
502            .finish()
503    }
504}
505
506impl Widget for SideClipPane {
507    fn layout_response(&self, proposal: SizeProposal, ctx: &LayoutContext) -> LayoutResponse {
508        // Measure the child at the side's FULL extent (not the shrinking
509        // proposal), so its whole subtree lays out at full size — content fills
510        // the dock width, and it stays stable across the collapse (full size
511        // doesn't change), so there's still no per-frame reflow. We then report
512        // the proposal size (the orchestrator forces our actual bounds).
513        let full = self.model.side_size(self.side).max(0.0);
514        let full_proposal = if self.side.is_horizontal_axis() {
515            SizeProposal {
516                width: Some(full.max(proposal.width.unwrap_or(0.0))),
517                height: proposal.height,
518            }
519        } else {
520            SizeProposal {
521                width: proposal.width,
522                height: Some(full.max(proposal.height.unwrap_or(0.0))),
523            }
524        };
525        let _ = ctx.child_size(self.child, full_proposal);
526        proposal
527            .resolve(
528                proposal.width.unwrap_or(0.0),
529                proposal.height.unwrap_or(0.0),
530            )
531            .into()
532    }
533
534    fn place_children(
535        &self,
536        bounds: Rect,
537        _proposal: SizeProposal,
538        children: &mut [WidgetPlacement],
539        _ctx: &LayoutContext,
540    ) {
541        // Full main extent = the side's stored size (≥ the current, shrinking
542        // bounds). Anchor the content's INNER edge to the bounds' inner edge so
543        // it slides out the OUTER edge as the side collapses.
544        let full = self.model.side_size(self.side).max(0.0);
545        let (size, origin) = match self.side {
546            DockSide::Leading => {
547                let w = full.max(bounds.width);
548                (
549                    Size::new(w, bounds.height),
550                    Point::new(bounds.x + bounds.width - w, bounds.y),
551                )
552            }
553            DockSide::Trailing => {
554                let w = full.max(bounds.width);
555                (Size::new(w, bounds.height), Point::new(bounds.x, bounds.y))
556            }
557            DockSide::Top => {
558                let h = full.max(bounds.height);
559                (
560                    Size::new(bounds.width, h),
561                    Point::new(bounds.x, bounds.y + bounds.height - h),
562                )
563            }
564            DockSide::Bottom => {
565                let h = full.max(bounds.height);
566                (Size::new(bounds.width, h), Point::new(bounds.x, bounds.y))
567            }
568        };
569        for child in children.iter_mut() {
570            child.origin = origin;
571            child.size = size;
572        }
573    }
574
575    fn clips_children(&self) -> bool {
576        true
577    }
578
579    fn children(&self) -> Vec<WidgetId> {
580        vec![self.child]
581    }
582}