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(¢er) = 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}