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