teksilo_widgets/docking/panel.rs
1// SPDX-License-Identifier: MPL-2.0
2// SPDX-FileCopyrightText: 2026 FernTech
3
4//! The panel / content layer of [`DockingLayout`](super::DockingLayout):
5//! the app-facing [`DockWidget`] declaration, the content-factory registry,
6//! and the widgets that render a side's tabs → Splitter/Accordion arrangement →
7//! draggable dock panels (with five-zone drop targets).
8//!
9//! ## Touch and pen
10//!
11//! The pane's five split/stack zones are the reusable
12//! [`crate::drop_target::DropTarget`]'s, not a hand-computed set of
13//! fifths, which is what makes them inherit its per-axis floor: on a narrow pane an
14//! edge zone is raised to the density's target size instead of staying a fifth no
15//! finger can land in.
16
17use std::cell::RefCell;
18use std::collections::HashMap;
19use std::rc::Rc;
20
21use teksilo_canvas::{Rect, SizeProposal};
22use teksilo_core::WidgetBuilder;
23use teksilo_core::accessibility::AccessNodeBuilder;
24use teksilo_core::binding::BindingLevel;
25use teksilo_core::build_context::BuildContext;
26use teksilo_core::signal::Signal;
27use teksilo_core::widget::{LayoutContext, LayoutResponse, Widget, WidgetPlacement};
28use teksilo_core::widget_builder::HandlerSet;
29use teksilo_core::widget_id::WidgetId;
30use teksilo_core::{DragPayload, DropFeedback};
31use teksilo_i18n::{LocalizedString, lit};
32use teksilo_tokens::{SurfaceRole, TextRole, TextStyleRole};
33
34use crate::DropRegion;
35use crate::accordion::{Accordion, AccordionOrientation};
36use crate::drop_target::DropTarget;
37use crate::icon_button::{IconButton, IconButtonSize};
38use crate::popover_widget::PopoverIconButton;
39use crate::primitives::{
40 Center, Divider, Expand, HStack, IconWidget, MinSize, Padding, RectWidget, Spacer, TextWidget,
41 VStack,
42};
43use crate::splitter::Splitter;
44use crate::toolbar::{Toolbar, ToolbarItem, ToolbarOrientation};
45use teksilo_core::overlay::OverlayPlacement;
46
47use super::context_menu::{
48 DockMenuKind, activity_context_menu, background_menu, dock_has_options, dock_options_menu,
49};
50use super::drag::{DockDragData, dropped_dock_tab, dropped_dock_widget};
51use super::geometry::DockSide;
52use super::model::{
53 DockHeaderActionsFactory, DockIconFactory, DockOpenLocation, DockTabId, DockTabView,
54 DockWidgetId, DockWidgetMeta, DockingModel, side_orientation,
55};
56
57/// Builds a dock widget's content on demand (keyed by its [`DockWidgetId`]).
58pub type DockContentFactory = Rc<dyn Fn(DockWidgetId) -> Box<dyn Widget>>;
59
60/// App-facing declaration of a dock widget: identity, chrome metadata, and a
61/// lazy content factory. Collect these on [`DockingLayout::dock`](super::DockingLayout::dock).
62pub struct DockWidget {
63 id: DockWidgetId,
64 title: LocalizedString,
65 icon: Option<DockIconFactory>,
66 default: DockOpenLocation,
67 factory: DockContentFactory,
68 header_actions: Option<DockHeaderActionsFactory>,
69 show_header: bool,
70}
71
72impl DockWidget {
73 /// Declare a dock widget. `factory` builds its content the first time the
74 /// dock appears (and after it is closed and re-opened).
75 pub fn new<W: Widget + 'static>(
76 id: DockWidgetId,
77 title: impl Into<LocalizedString>,
78 factory: impl Fn(DockWidgetId) -> W + 'static,
79 ) -> Self {
80 Self {
81 id,
82 title: title.into(),
83 icon: None,
84 default: DockOpenLocation::side(DockSide::Leading),
85 factory: Rc::new(move |i| Box::new(factory(i)) as Box<dyn Widget>),
86 header_actions: None,
87 show_header: false,
88 }
89 }
90
91 /// Set the dock's tab / rail icon.
92 pub fn icon(mut self, f: impl Fn() -> IconWidget + 'static) -> Self {
93 self.icon = Some(Rc::new(f));
94 self
95 }
96
97 /// Attach a factory for the dock's **inline header actions** — a flat list
98 /// of [`ToolbarAction`](crate::toolbar::ToolbarAction)s shown before the `⋮` options button, the VS Code
99 /// "view actions" pattern ("New File", "Collapse All", …). Built on demand
100 /// each time the dock is placed into a header. The framework hosts them in a
101 /// [`Toolbar`], so the actions gain **overflow** (when the header is tight,
102 /// the lowest-[`priority`](crate::toolbar::ToolbarAction::priority) actions collapse into a
103 /// `⌄` menu) and the correct **axis** for free — a horizontal row on leading
104 /// / trailing sides, a vertical column on the rotated top / bottom strip. The
105 /// actions appear in any header the dock has: the multi-pane [`Accordion`]
106 /// header always, and the sole-pane (bare) header when
107 /// [`show_header(true)`](Self::show_header) is set.
108 ///
109 /// Each item is a [`ToolbarItem`] — a collapsible
110 /// [`ToolbarAction`](crate::toolbar::ToolbarAction) via
111 /// [`ToolbarItem::action`], or a pinned arbitrary widget (a `SplitButton`, a
112 /// search field, …) via [`ToolbarItem::custom`].
113 ///
114 /// ```ignore
115 /// DockWidget::new(id, lit!("Explorer"), build).header_actions(|_| vec![
116 /// ToolbarItem::action(ToolbarAction::new(lit!("New File"), new_icon).on_activate(..)),
117 /// ToolbarItem::custom(CreateSplitButton::new(..)),
118 /// ])
119 /// ```
120 pub fn header_actions(
121 mut self,
122 f: impl Fn(DockWidgetId) -> Vec<ToolbarItem> + 'static,
123 ) -> Self {
124 self.header_actions = Some(Rc::new(f));
125 self
126 }
127
128 /// Give a **sole-pane** (bare) dock its own header bar (title + actions +
129 /// `⋮` options). Default `false`. The multi-pane Accordion header is always
130 /// present regardless; this only governs the bare case. Turn it on to get a
131 /// discoverable options button (and inline `header_actions`) on a dock that
132 /// is the only one on its side.
133 pub fn show_header(mut self, show: bool) -> Self {
134 self.show_header = show;
135 self
136 }
137
138 /// The location used when the dock is opened via `toggle` / `reveal`
139 /// without an explicit target.
140 pub fn default_location(mut self, loc: DockOpenLocation) -> Self {
141 self.default = loc;
142 self
143 }
144
145 pub(crate) fn id(&self) -> DockWidgetId {
146 self.id
147 }
148
149 pub(crate) fn into_parts(self) -> (DockWidgetId, DockWidgetMeta, DockContentFactory) {
150 (
151 self.id,
152 DockWidgetMeta {
153 title: self.title,
154 icon: self.icon,
155 min_size: None,
156 default: self.default,
157 header_actions: self.header_actions,
158 show_header: self.show_header,
159 },
160 self.factory,
161 )
162 }
163}
164
165/// Registry of content factories, owned by the layout, shared into the panel
166/// widgets so closed-then-reopened docks rebuild fresh content.
167#[derive(Default)]
168pub(crate) struct DockContentRegistry {
169 factories: HashMap<DockWidgetId, DockContentFactory>,
170}
171
172impl std::fmt::Debug for DockContentRegistry {
173 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
174 f.debug_struct("DockContentRegistry")
175 .field("factories", &self.factories.len())
176 .finish()
177 }
178}
179
180impl DockContentRegistry {
181 pub(crate) fn insert(&mut self, id: DockWidgetId, factory: DockContentFactory) {
182 self.factories.insert(id, factory);
183 }
184 pub(crate) fn build(&self, id: DockWidgetId) -> Option<Box<dyn Widget>> {
185 self.factories.get(&id).map(|f| f(id))
186 }
187}
188
189/// A shared handle to the content-factory registry, passed down so each dock
190/// panel builds its content **in-context** (where it is placed), avoiding
191/// cross-build-context parenting.
192pub(crate) type DockContent = Rc<RefCell<DockContentRegistry>>;
193
194/// Kind tag for a side's dynamic dock tabs (so `dynamic_tab` registers them).
195const DOCK_TAB_KIND: &str = "__dock_tab__";
196
197/// The dynamic-tab payload carried by a side's `TabWidget` — identifies the
198/// DockTab so cross-side whole-tab drag (`accept_external_tabs`) can relocate
199/// it via [`DockingModel::move_tab`].
200#[derive(Clone, Copy)]
201struct DockTabPayload {
202 tab_id: DockTabId,
203}
204
205// ───────────────────────────────────────────────────────────────────────
206// DockSidePanel — a side's content: optional in-side tab strip + Switcher.
207// ───────────────────────────────────────────────────────────────────────
208
209#[derive(Debug)]
210pub(crate) struct DockSidePanel {
211 side: DockSide,
212 model: DockingModel,
213 content: DockContent,
214 /// This side's rail config. Only its Strip-presentation half is used here
215 /// (`leading_slot` / `trailing_slot`); the Rail half is `DockActivityBar`'s.
216 /// The two presentations share one config object so an app declares a
217 /// side's chrome in one place.
218 config: super::DockRail,
219 root: Option<WidgetId>,
220}
221
222impl DockSidePanel {
223 pub(crate) fn new(
224 side: DockSide,
225 model: DockingModel,
226 content: DockContent,
227 config: super::DockRail,
228 ) -> Self {
229 Self {
230 side,
231 model,
232 content,
233 config,
234 root: None,
235 }
236 }
237
238 /// Compose this side's app-declared bar slots (and, on the trailing edge,
239 /// the framework's own "hidden activities" hamburger) into at most one
240 /// widget per edge.
241 ///
242 /// `TabWidget`'s `BarSlot` is a single last-write-wins `Option`, so calling
243 /// `bar_trailing_slot` twice silently drops one of the two — most likely
244 /// the hamburger, which is the only way back once every activity on the
245 /// side is hidden. Composing into one `HStack` per edge is therefore
246 /// mandatory, not stylistic.
247 fn compose_bar_slots(
248 &self,
249 ctx: &mut BuildContext,
250 needs_hamburger: bool,
251 ) -> (Option<WidgetId>, Option<WidgetId>) {
252 let leading = self
253 .config
254 .leading_slot
255 .as_ref()
256 .map(|f| ctx.add_boxed((f)()));
257
258 let mut trailing: Vec<WidgetId> = Vec::new();
259 if let Some(f) = self.config.trailing_slot.as_ref() {
260 trailing.push(ctx.add_boxed((f)()));
261 }
262 if needs_hamburger {
263 let m = self.model.clone();
264 let hb_side = self.side;
265 trailing.push(
266 ctx.add(
267 PopoverIconButton::new(IconButton::menu().tooltip(lit!("Hidden activities")))
268 .content(background_menu(&m, hb_side, DockMenuKind::Strip))
269 .placement(OverlayPlacement::BelowPreferred),
270 ),
271 );
272 }
273 let trailing = match trailing.len() {
274 0 => None,
275 // A lone widget needs no wrapper — keeps the common case free of an
276 // extra layout node.
277 1 => Some(trailing[0]),
278 _ => {
279 let mut row = HStack::new().spacing(2.0);
280 for id in &trailing {
281 row = row.child(*id);
282 }
283 Some(ctx.add(row))
284 }
285 };
286 (leading, trailing)
287 }
288}
289
290/// The drop target shown when a side has **no** docks, so a revealed-but-empty
291/// side (opened from a toolbar button, the rail, or a drag-reveal strip) still
292/// accepts content. Accepts a whole tab (`DockTabDragData` → `move_tab`) or a
293/// single dock (`DockDragData` → `move_dock`); both reveal the side.
294fn empty_side_drop_target(
295 ctx: &mut BuildContext,
296 model: &DockingModel,
297 side: DockSide,
298) -> WidgetId {
299 let text = ctx.add(
300 TextWidget::new(lit!("Drop a panel here"))
301 .style(TextStyleRole::Body)
302 .color(TextRole::Secondary),
303 );
304 let label = ctx.add(Center::new().child(text));
305 let m = model.clone();
306 ctx.add(
307 DropTarget::new()
308 .child(label)
309 .accept_when(|p| dropped_dock_tab(p).is_some() || dropped_dock_widget(p).is_some())
310 .on_drop(move |p, _pos, ctx| {
311 if !m.is_side_enabled(side) {
312 return false;
313 }
314 if let Some(tab_id) = dropped_dock_tab(&p) {
315 m.move_tab(tab_id, side, 0);
316 m.set_side_visible(side, true);
317 ctx.request_accessibility_update();
318 true
319 } else if let Some(dock_id) = dropped_dock_widget(&p) {
320 m.move_dock(dock_id, DockOpenLocation::side(side));
321 m.set_side_visible(side, true);
322 ctx.request_accessibility_update();
323 true
324 } else {
325 false
326 }
327 }),
328 )
329}
330
331impl Widget for DockSidePanel {
332 fn build(&mut self, ctx: &mut BuildContext) -> Vec<WidgetId> {
333 use crate::tab_widget::{
334 TabBarVisibility, TabDisplayMode, TabHandle, TabId, TabInfo, TabWidget,
335 };
336 use std::any::Any;
337 use std::num::NonZeroU64;
338 use teksilo_data::ListModel;
339
340 let all_tabs = self.model.side_tabs(self.side);
341 if all_tabs.is_empty() {
342 // A side with no docks. When it's visible (revealed from a button,
343 // the rail, or a drag-reveal strip) it shows a drop target so the
344 // first dock can be dragged in; when hidden it's dormant anyway.
345 let drop = empty_side_drop_target(ctx, &self.model, self.side);
346 // A configured bar slot must still render here. This branch returns
347 // before the `TabWidget` is ever built, so without this an app that
348 // set `leading_slot`/`trailing_slot` would silently see nothing
349 // whenever the side happens to hold no docks — a reachable state,
350 // not a misuse. (Qt's `QTabWidget::setCornerWidget` has exactly this
351 // bug: the corner widget only shows while at least one tab exists.)
352 let (leading, trailing) = self.compose_bar_slots(ctx, false);
353 if leading.is_none() && trailing.is_none() {
354 self.root = Some(drop);
355 return vec![drop];
356 }
357 let mut bar = HStack::new().spacing(2.0);
358 if let Some(id) = leading {
359 bar = bar.child(id);
360 }
361 bar = bar.child(ctx.add(Spacer::new()));
362 if let Some(id) = trailing {
363 bar = bar.child(id);
364 }
365 let bar = ctx.add(bar);
366 let body = ctx.add(Expand::new().child(drop));
367 let root = ctx.add(VStack::new().child(bar).child(body));
368 self.root = Some(root);
369 return vec![root];
370 }
371
372 // Rebuild the strip when this side's tab-display pref flips (context
373 // menu "Tab size"). The bar then re-derives its headers in the chosen
374 // mode (a scoped, content-preserving rebuild).
375 let self_id = ctx.self_id();
376 self.model.tab_display_signal(self.side).bind_to(
377 self_id,
378 ctx.binding_registry(),
379 BindingLevel::Rebuild,
380 );
381 let display = match self.model.side_tab_display(self.side) {
382 super::model::DockTabDisplay::Icon => TabDisplayMode::Icon,
383 super::model::DockTabDisplay::IconText => TabDisplayMode::IconText,
384 super::model::DockTabDisplay::Text => TabDisplayMode::Text,
385 };
386
387 // Stable TabWidget id per dock tab (dock tab ids start at 1).
388 let to_tab_id = |t: &DockTabView| {
389 TabId::from_raw(NonZeroU64::new(t.id.raw()).unwrap_or(NonZeroU64::MIN))
390 };
391 // model-index → TabId for the whole side (selection maps through it).
392 let all_tab_ids: Vec<TabId> = all_tabs.iter().map(&to_tab_id).collect();
393
394 // Only non-hidden tabs render in the strip; remember each shown tab's
395 // model index (visible-position → model-index) for selection + drop
396 // routing.
397 let model_indices: Vec<usize> = all_tabs
398 .iter()
399 .enumerate()
400 .filter(|(_, t)| !t.hidden)
401 .map(|(i, _)| i)
402 .collect();
403 let presentation = self.model.side_presentation(self.side);
404 if model_indices.is_empty() && presentation == super::model::TabPresentation::Rail {
405 // Every activity hidden in Rail presentation: the activity rail (with
406 // its own background menu) is the restore affordance — blank content.
407 let empty = ctx.add(RectWidget::new().background(SurfaceRole::Transparent));
408 self.root = Some(empty);
409 return vec![empty];
410 }
411 // In Strip presentation we still build the bar below — even with zero
412 // visible tabs — so its trailing "hidden activities" hamburger can
413 // restore them (right-clicking a tab is impossible when none show).
414
415 let dock_selected = self.model.side_selected_tab_signal(self.side);
416 let initial = all_tab_ids
417 .get(dock_selected.get().min(all_tab_ids.len().saturating_sub(1)))
418 .copied();
419 let tw_selected: Signal<Option<TabId>> = ctx.signal(initial);
420
421 // model → TabWidget: map the selected model index to its TabId,
422 // resolved against the **live** model (not the build-time `all_tab_ids`
423 // snapshot). This is the exact inverse of effect 2's live id → index
424 // lookup, so the round-trip is the identity and the equality guards
425 // stop the chain at once. A stale snapshot here would disagree with
426 // effect 2 after a reorder (idx 1 → snapshot id B, id B → live idx 2,
427 // idx 2 → snapshot id A, …) and feed back unboundedly — the
428 // "Signal notification nested 257 deep" panic when an activity is
429 // imported onto a side and then reordered within it.
430 {
431 let model = self.model.clone();
432 let side = self.side;
433 let tw = tw_selected.clone();
434 ctx.effect(&dock_selected, move |&idx| {
435 let target = model.tab_id_at(side, idx).map(|id| {
436 TabId::from_raw(NonZeroU64::new(id.raw()).unwrap_or(NonZeroU64::MIN))
437 });
438 if tw.get() != target {
439 tw.set(target);
440 }
441 });
442 }
443 // TabWidget → model (an in-strip click) — position-independent so a
444 // hidden tab in the middle doesn't shift the mapping.
445 {
446 let model = self.model.clone();
447 let side = self.side;
448 ctx.effect(&tw_selected, move |maybe| {
449 if let Some(tid) = maybe {
450 model.select_tab_by_id(side, DockTabId::from_raw(tid.raw().get()));
451 }
452 });
453 }
454
455 // Rail presentation → the in-side strip is hidden (the activity rail is
456 // the selector). Strip → always show the real TabWidget bar (so even a
457 // single-panel side reads as a TabWidget tab, not a custom title bar).
458 let bar_visibility = match presentation {
459 super::model::TabPresentation::Rail => TabBarVisibility::Never,
460 super::model::TabPresentation::Strip => TabBarVisibility::Always,
461 };
462 // No visible tab → no tab to right-click, so the bar needs a trailing
463 // hamburger to reach the activities menu. When at least one tab shows,
464 // its own right-click menu already lists (and restores) the hidden ones.
465 let needs_hamburger = model_indices.is_empty();
466
467 // Build the visible tabs as a dynamic `ListModel<TabHandle>` so a whole
468 // tab can be dragged between sides via TabWidget's `accept_external_tabs`.
469 // Tabs are not closable (you hide the side / move the dock, you don't
470 // close a view container from its tab). Each tab carries a context menu
471 // and renders per the side's tab-display mode.
472 let mut handles: Vec<TabHandle> = Vec::with_capacity(model_indices.len());
473 for &model_i in &model_indices {
474 let tab = &all_tabs[model_i];
475 // Label / icon: explicit activity title (set_tab_title) → primary
476 // (first non-collapsed) pane's dock → "Panel" / no-icon.
477 let label = self.model.activity_label(tab);
478 let icon_factory = self.model.activity_icon(tab);
479
480 // Each tab declares its title + icon; the bar's reactive
481 // `tab_display` (wired below from the side's "Tab size" pref) decides
482 // what's painted — icon, text, or both — and handles the icon-only
483 // sizing, tooltip promotion, and icon-less initial-letter fallback.
484 let mut info = TabInfo::new().closable(false).title(label.clone());
485 if let Some(icf) = icon_factory {
486 info = info.icon(move || (icf)());
487 }
488 {
489 let m = self.model.clone();
490 let menu_side = self.side;
491 let tid = tab.id;
492 info = info.context_menu(move |_pos, _ctx| {
493 Some(Box::new(activity_context_menu(
494 &m,
495 menu_side,
496 tid,
497 DockMenuKind::Strip,
498 )))
499 });
500 }
501 handles.push(TabHandle::dynamic(
502 to_tab_id(tab),
503 DOCK_TAB_KIND,
504 info,
505 DockTabPayload { tab_id: tab.id },
506 ));
507 }
508 let list: ListModel<TabHandle> = ListModel::from_vec(handles);
509
510 let side = self.side;
511 let factory_model = self.model.clone();
512 let factory_content = self.content.clone();
513 // The bar deals in *visible* positions; translate them back to model
514 // tab indices (a no-op when nothing is hidden) for `move_tab`.
515 let ext_indices = model_indices.clone();
516 let ext_model = self.model.clone();
517 // Appending past the last visible tab must land just **after the last
518 // visible tab's model index**, not at the absolute end — otherwise a
519 // dropped/promoted tab is ordered after any trailing *hidden* tabs and
520 // reappears out of place when they are restored.
521 let after_last_visible = model_indices
522 .last()
523 .map(|&i| i + 1)
524 .unwrap_or(all_tab_ids.len());
525
526 let policy = self.model.policy();
527 let mut tw = TabWidget::new(tw_selected)
528 .bar_visibility(bar_visibility)
529 // Dock side strips use the denser compact (38 dp) tab bar, each tab
530 // sized to its own content (not a shared width) — and a compact min
531 // so an icon-only tab shrinks to its icon and an icon + text tab
532 // grows to fit both, instead of all clamping to the editor-tab min.
533 .compact_bar()
534 .tab_sizing(crate::tab_widget::TabSizing::Independent)
535 .tab_display(display)
536 .min_tab_width(40.0)
537 .dynamic_model(list)
538 .dynamic_tab::<DockTabPayload>(DOCK_TAB_KIND, move |_handle, payload| {
539 match factory_model.tab_view_by_id(payload.tab_id) {
540 Some((tside, view)) => Box::new(DockTabContentWidget::new(
541 tside,
542 view,
543 factory_model.clone(),
544 factory_content.clone(),
545 )) as Box<dyn Widget>,
546 None => Box::new(RectWidget::new().background(SurfaceRole::Transparent)),
547 }
548 })
549 // A drop from a source that ISN'T a peer `TabBar<TabHandle>` — an
550 // **activity-rail item** (`DockTabDragData`) or a single dock (a
551 // split-pane header, `DockDragData`). The native `on_tab_received`
552 // path only fires for `TabBarDragData<TabHandle>`; without this the
553 // bar would be the drop target (`find_drop_target_at_or_above` stops
554 // at the first handler) and silently reject the rail drag. `idx` is
555 // this bar's visible insertion position → model tab index. (Kept
556 // unconditionally — when a lock is on, the gated source simply never
557 // produces the matching payload, so the branch is inert.)
558 .on_external_drop(move |payload, idx, ctx| {
559 // A disabled side never mutates from a UI drop (its panel isn't
560 // even built — this keeps that a local invariant rather than
561 // consuming the drop while the model silently rejects it).
562 if !ext_model.is_side_enabled(side) {
563 return false;
564 }
565 let at = ext_indices.get(idx).copied().unwrap_or(after_last_visible);
566 if let Some(tab_id) = dropped_dock_tab(payload) {
567 ext_model.move_tab(tab_id, side, at);
568 ctx.request_accessibility_update();
569 true
570 } else if let Some(dock_id) = dropped_dock_widget(payload) {
571 // A lone dock becomes a new activity at the drop position.
572 ext_model.promote_to_tab(dock_id, side, at);
573 ctx.request_accessibility_update();
574 true
575 } else {
576 false
577 }
578 });
579 // Activity drag-and-drop (reorder within a side + transfer between
580 // sides) is a user affordance — gate it on the policy. When off, the
581 // tab headers are neither drag sources nor reorder/transfer targets.
582 if policy.allow_activity_drag {
583 let reorder_model = self.model.clone();
584 let recv_model = self.model.clone();
585 let reorder_indices = model_indices.clone();
586 let recv_indices = model_indices.clone();
587 tw = tw
588 .reorderable(true)
589 .accept_external_tabs(true)
590 // Same-side reorder.
591 .on_reorder(move |tid, dest, _ctx| {
592 let at = reorder_indices
593 .get(dest)
594 .copied()
595 .unwrap_or(after_last_visible);
596 reorder_model.move_tab(DockTabId::from_raw(tid.raw().get()), side, at);
597 })
598 // Cross-side drop: relocate the whole tab to this side.
599 .on_tab_received(move |handle, idx, ctx| {
600 if let Some(p) =
601 (handle.payload.as_ref() as &dyn Any).downcast_ref::<DockTabPayload>()
602 {
603 let at = recv_indices.get(idx).copied().unwrap_or(after_last_visible);
604 recv_model.move_tab(p.tab_id, side, at);
605 ctx.request_accessibility_update();
606 }
607 })
608 // The source side: `move_tab` (above) already removed the tab
609 // from the model; the rebuild reconciles this side's list.
610 .on_transfer_out(|_tid, _ctx| {});
611 }
612
613 // Bar slots: the app's `leading_slot`/`trailing_slot`, composed with the
614 // framework's own trailing **hamburger** — which opens the activities
615 // checklist and is the only restore affordance left once *every*
616 // activity is hidden and no tab can be right-clicked.
617 let (leading_slot, trailing_slot) = self.compose_bar_slots(ctx, needs_hamburger);
618 if let Some(id) = leading_slot {
619 tw = tw.bar_leading_slot(id);
620 }
621 if let Some(id) = trailing_slot {
622 tw = tw.bar_trailing_slot(id);
623 }
624 let root = ctx.add(tw);
625 self.root = Some(root);
626
627 // Side-level drop target for a whole-tab drag (an activity-rail button
628 // or a tab header from another side). A drop landing on a *pane* is
629 // consumed by that `DockPanePane` (split / stack); a drop landing on
630 // the **tab bar** (or any non-pane chrome) bubbles up to here and
631 // relocates the tab to the end of this side.
632 let drop_model = self.model.clone();
633 let drop_side = self.side;
634 ctx.apply_self_handlers(
635 HandlerSet::new()
636 .on_drag_hover(move |_payload, _pos, _ctx| {
637 // Accept silently; the drop is routed in `on_drop`. (A pane
638 // under the pointer paints its own five-zone overlay; the
639 // bar just needs to register as a valid target.)
640 DropFeedback::NoFeedback
641 })
642 .on_drop(move |payload, _pos, ctx| {
643 if !drop_model.is_side_enabled(drop_side) {
644 return false;
645 }
646 // A drop landing on non-pane chrome (the strip, gaps): a tab
647 // relocates to this side; a single dock joins it too.
648 if let Some(tab_id) = dropped_dock_tab(&payload) {
649 let at = drop_model.side_append_index(drop_side);
650 drop_model.move_tab(tab_id, drop_side, at);
651 ctx.request_accessibility_update();
652 true
653 } else if let Some(dock_id) = dropped_dock_widget(&payload) {
654 drop_model.move_dock(dock_id, DockOpenLocation::side(drop_side));
655 ctx.request_accessibility_update();
656 true
657 } else {
658 false
659 }
660 }),
661 );
662 vec![root]
663 }
664
665 fn layout_response(&self, proposal: SizeProposal, ctx: &LayoutContext) -> LayoutResponse {
666 self.root
667 .and_then(|id| ctx.child_size(id, proposal))
668 .unwrap_or_else(|| proposal.resolve(0.0, 0.0))
669 .into()
670 }
671
672 fn place_children(
673 &self,
674 bounds: Rect,
675 _proposal: SizeProposal,
676 children: &mut [WidgetPlacement],
677 _ctx: &LayoutContext,
678 ) {
679 for child in children.iter_mut() {
680 child.origin = bounds.origin();
681 child.size = bounds.size();
682 }
683 }
684
685 fn accessibility(&self, builder: &mut AccessNodeBuilder) {
686 use teksilo_core::accesskit::Role;
687 builder.set_role(Role::Complementary);
688 builder.set_name(super::a11y::side_label(self.side).resolve_now());
689 }
690
691 fn children(&self) -> Vec<WidgetId> {
692 self.root.into_iter().collect()
693 }
694}
695
696// ───────────────────────────────────────────────────────────────────────
697// DockTabContentWidget — one tab's Splitter of panes.
698// ───────────────────────────────────────────────────────────────────────
699
700struct DockTabContentWidget {
701 side: DockSide,
702 tab: DockTabView,
703 model: DockingModel,
704 content: DockContent,
705 root: Option<WidgetId>,
706}
707
708impl std::fmt::Debug for DockTabContentWidget {
709 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
710 f.debug_struct("DockTabContentWidget")
711 .field("side", &self.side)
712 .field("panes", &self.tab.panes.len())
713 .finish()
714 }
715}
716
717impl DockTabContentWidget {
718 fn new(side: DockSide, tab: DockTabView, model: DockingModel, content: DockContent) -> Self {
719 Self {
720 side,
721 tab,
722 model,
723 content,
724 root: None,
725 }
726 }
727
728 /// Build a dock's content widget in-context via the registry.
729 fn build_dock_content(&self, ctx: &mut BuildContext, dock: DockWidgetId) -> WidgetId {
730 match self.content.borrow().build(dock) {
731 Some(w) => ctx.add_boxed(w),
732 None => ctx.add(TextWidget::new(lit!("(missing content)"))),
733 }
734 }
735}
736
737impl Widget for DockTabContentWidget {
738 fn build(&mut self, ctx: &mut BuildContext) -> Vec<WidgetId> {
739 // Find this tab's index in the side for drop-routing.
740 let tab_idx = self
741 .model
742 .side_tabs(self.side)
743 .iter()
744 .position(|t| t.id == self.tab.id)
745 .unwrap_or(0);
746
747 let root = if self.tab.panes.len() <= 1 {
748 // Single pane: render the dock bare (a 1-pane Splitter is
749 // degenerate). The side's tab / rail is its header.
750 match self.tab.panes.first() {
751 Some(dock) => {
752 let inner = self.build_pane_inner(ctx, *dock, 0, None);
753 ctx.add(DockPanePane::new(
754 self.side,
755 tab_idx,
756 0,
757 self.model.clone(),
758 inner,
759 ))
760 }
761 None => ctx.add(RectWidget::new().background(SurfaceRole::Transparent)),
762 }
763 } else {
764 // Split panes: each dock is its own Accordion, separated by the
765 // Splitter. Collapsing an accordion collapses its Splitter pane.
766 let splitter_model = self.tab.splitter.clone();
767 let mut splitter = Splitter::new(splitter_model.clone());
768 for (pane_idx, dock) in self.tab.panes.iter().enumerate() {
769 let inner = self.build_pane_inner(ctx, *dock, pane_idx, Some(&splitter_model));
770 let pane_widget = ctx.add(DockPanePane::new(
771 self.side,
772 tab_idx,
773 pane_idx,
774 self.model.clone(),
775 inner,
776 ));
777 splitter = splitter.pane(pane_widget);
778 }
779 ctx.add(splitter)
780 };
781 self.root = Some(root);
782 vec![root]
783 }
784
785 fn layout_response(&self, proposal: SizeProposal, ctx: &LayoutContext) -> LayoutResponse {
786 self.root
787 .and_then(|id| ctx.child_size(id, proposal))
788 .unwrap_or_else(|| proposal.resolve(0.0, 0.0))
789 .into()
790 }
791
792 fn place_children(
793 &self,
794 bounds: Rect,
795 _proposal: SizeProposal,
796 children: &mut [WidgetPlacement],
797 _ctx: &LayoutContext,
798 ) {
799 for child in children.iter_mut() {
800 child.origin = bounds.origin();
801 child.size = bounds.size();
802 }
803 }
804
805 fn children(&self) -> Vec<WidgetId> {
806 self.root.into_iter().collect()
807 }
808}
809
810impl DockTabContentWidget {
811 /// Render one pane = one dock.
812 ///
813 /// A **sole** pane (`splitter == None`) is rendered bare — the side's tab /
814 /// rail is already its header. A **split** pane is wrapped in an
815 /// [`Accordion`] whose draggable header titles the dock, is the drag handle,
816 /// and collapses the dock on click. The accordion fills the pane (`fill`);
817 /// toggling it **collapses its Splitter pane** to just the header (siblings
818 /// grow), and re-expands it to the same size — wired here via the pane's
819 /// `expanded` signal driving `SplitterModel::set_collapsed`.
820 fn build_pane_inner(
821 &self,
822 ctx: &mut BuildContext,
823 dock: DockWidgetId,
824 pane_idx: usize,
825 splitter: Option<&crate::splitter::SplitterModel>,
826 ) -> WidgetId {
827 let content = self.build_dock_content(ctx, dock);
828 let multi_pane = splitter.is_some();
829 let Some(splitter) = splitter else {
830 // Sole-pane (bare) dock. By default it renders headerless (the side
831 // tab / rail is its header). Opting in (`DockWidget::show_header`)
832 // gives it a VS Code–style header bar carrying its own actions + the
833 // `⋮` options menu.
834 if !self.model.dock_show_header(dock) {
835 return content;
836 }
837 return self.build_bare_dock_header(ctx, dock, content);
838 };
839 let title = self.model.dock_title(dock).unwrap_or_else(|| lit!("Panel"));
840 // Initial expanded state follows the Splitter (so a rebuild preserves a
841 // collapsed pane); toggling drives the pane collapse/expand.
842 let expanded = ctx.signal(!splitter.is_collapsed(pane_idx));
843 splitter.set_collapsed_size(pane_idx, crate::accordion::ACCORDION_FILL_COLLAPSED_EXTENT);
844 {
845 let sp = splitter.clone();
846 ctx.effect(&expanded, move |&e| {
847 sp.set_collapsed(pane_idx, !e);
848 });
849 }
850 let mut accordion = Accordion::new(title, expanded)
851 .orientation(
852 if side_orientation(self.side) == teksilo_tokens::Orientation::Vertical {
853 AccordionOrientation::Vertical
854 } else {
855 AccordionOrientation::Horizontal
856 },
857 )
858 .fill(true);
859 // The dock's header actions (app-supplied) + the framework `⋮` options
860 // menu sit in the accordion header's trailing slot.
861 if let Some(trailing) = self.dock_header_trailing(ctx, dock, multi_pane) {
862 accordion = accordion.trailing(trailing);
863 }
864 // The accordion header is the dock's drag handle — only when the policy
865 // allows dragging a single dock out of a split pane.
866 if self.model.policy().allow_dock_drag {
867 accordion = accordion.on_header_drag(move |ctx| {
868 ctx.start_drag(content, DragPayload::typed(DockDragData { dock_id: dock }));
869 });
870 }
871 ctx.add(accordion.content(content))
872 }
873
874 /// Build the trailing cluster of a dock header — the app's inline
875 /// `header_actions` plus the framework `⋮` options button
876 /// ([`dock_options_menu`]) — hosted in a [`Toolbar`] so excess actions
877 /// overflow into a `⌄` menu and everything follows the header's axis. Returns
878 /// `None` when there is nothing to show (no app actions and an empty options
879 /// menu).
880 fn dock_header_trailing(
881 &self,
882 ctx: &mut BuildContext,
883 dock: DockWidgetId,
884 multi_pane: bool,
885 ) -> Option<WidgetId> {
886 let actions = self.model.dock_header_actions(dock);
887 let has_options = dock_has_options(&self.model, self.side, multi_pane);
888 if actions.is_none() && !has_options {
889 return None;
890 }
891 // A *multi-pane* dock on a top / bottom side renders the accordion header
892 // as a rotated *vertical* strip (`AccordionOrientation::Horizontal`), so
893 // the cluster stacks vertically. Every other header — leading / trailing
894 // accordions and every bare (`!multi_pane`) bar, which is always
895 // horizontal regardless of side — lays out horizontally.
896 let vertical =
897 multi_pane && side_orientation(self.side) == teksilo_tokens::Orientation::Horizontal;
898 let title = self.model.dock_title(dock).unwrap_or_else(|| lit!("Panel"));
899
900 // The app's header actions, hosted in a compact shrink-to-fit `Toolbar`
901 // that collapses its excess into a `⌄` when the header is narrow. Only
902 // built when the dock declares actions.
903 let toolbar_id = actions.map(|factory| {
904 let mut bar = Toolbar::new()
905 .orientation(if vertical {
906 ToolbarOrientation::Vertical
907 } else {
908 ToolbarOrientation::Horizontal
909 })
910 .compact(true)
911 .spacing(2.0)
912 .label(lit!(format!("{} actions", title.resolve_now())));
913 for item in factory(dock) {
914 bar = bar.item(item);
915 }
916 ctx.add(bar)
917 });
918
919 // The framework `⋮` dock-options menu, kept **separate from and after**
920 // the actions toolbar, so it stays the last / outermost affordance even
921 // when the toolbar collapses its own actions into a `⌄` (Move-to / Hide
922 // must never hide behind the overflow). `.bare()` makes the `MenuList`
923 // the popover content directly (not a menu-on-a-popover); it carries the
924 // Move-to *submenu* a flat toolbar overflow row could not express.
925 let options_id = has_options.then(|| {
926 let menu = dock_options_menu(&self.model, self.side, self.tab.id, dock, multi_pane);
927 ctx.add(
928 PopoverIconButton::new(IconButton::more().size(IconButtonSize::Compact))
929 .bare()
930 .content(menu)
931 .placement(OverlayPlacement::BelowPreferred)
932 .access_label(lit!(format!("More actions: {}", title.resolve_now()))),
933 )
934 });
935
936 // Arrange `[toolbar] [⋮]` along the header axis. A lone child (only
937 // actions, or only the `⋮`) needs no wrapper.
938 let kids: Vec<WidgetId> = [toolbar_id, options_id].into_iter().flatten().collect();
939 match kids.as_slice() {
940 [] => None,
941 [only] => Some(*only),
942 _ => {
943 let cluster = if vertical {
944 let mut col = VStack::new().spacing(2.0);
945 for k in &kids {
946 col = col.child(*k);
947 }
948 ctx.add(col)
949 } else {
950 let mut row = HStack::new().spacing(2.0);
951 for k in &kids {
952 row = row.child(*k);
953 }
954 ctx.add(row)
955 };
956 Some(cluster)
957 }
958 }
959 }
960
961 /// The sole-pane dock header bar (opt-in via `DockWidget::show_header`):
962 /// `[title] [Spacer] [actions + ⋮]` above the content, matching the VS Code
963 /// view-header layout. Always a horizontal bar regardless of side.
964 fn build_bare_dock_header(
965 &self,
966 ctx: &mut BuildContext,
967 dock: DockWidgetId,
968 content: WidgetId,
969 ) -> WidgetId {
970 let title = self.model.dock_title(dock).unwrap_or_else(|| lit!("Panel"));
971 // The title is rigid: it never truncates. When the header is tight the
972 // trailing toolbar (shrinkable) absorbs the deficit and collapses its
973 // actions into the `⌄`, so the dock name always stays fully readable.
974 let title_id = ctx.add(
975 TextWidget::new(title)
976 .style(TextStyleRole::BodyBold)
977 .color(TextRole::Primary)
978 .single_line()
979 .no_shrink(),
980 );
981 let spacer_id = ctx.add(Spacer::new());
982 let mut row = HStack::new().spacing(2.0).child(title_id).child(spacer_id);
983 if let Some(trailing) = self.dock_header_trailing(ctx, dock, false) {
984 row = row.child(trailing);
985 }
986 let row_id = ctx.add(row);
987 let padded = ctx.add(
988 Padding::symmetric(
989 2.0,
990 crate::accordion::accordion_header_padding_horizontal(&ctx.theme().input),
991 )
992 .child(row_id),
993 );
994 // Fixed-height header bar (matching the Accordion header extent) with a
995 // 1 dp divider beneath it, above the content.
996 let header = ctx.add(
997 MinSize::new(
998 0.0,
999 crate::accordion::accordion_fill_header_extent(&ctx.theme().input),
1000 )
1001 .child(padded),
1002 );
1003 let divider = ctx.add(Divider::horizontal());
1004 ctx.add(
1005 VStack::new()
1006 .child(header)
1007 .child(divider)
1008 .child(Expand::new().flex(1.0).child(content)),
1009 )
1010 }
1011}
1012
1013// ───────────────────────────────────────────────────────────────────────
1014// DockPanePane — a Splitter pane that is a five-zone drop target.
1015// ───────────────────────────────────────────────────────────────────────
1016
1017/// A Splitter pane wrapped as a drop target. The five split/stack zones for a
1018/// **single dock** are the reusable [`DropTarget`] (centre = stack, edge zones =
1019/// split before/after — `zone_size_factor` proportional, no per-pane px cap). A
1020/// whole-**tab** drag never splits a pane, so the DropTarget doesn't accept it;
1021/// instead `DockPanePane` itself engages for a tab (this handler sits one level
1022/// *above* the DropTarget in the tree) and relocates it to the side — a local
1023/// bubble (DropTarget → DockPanePane) that shows no per-zone overlay for a tab,
1024/// exactly as before. A drop landing on non-pane chrome bubbles further to
1025/// [`DockSidePanel`] / the tab bar, unchanged.
1026#[derive(Debug)]
1027pub(crate) struct DockPanePane {
1028 side: DockSide,
1029 tab_idx: usize,
1030 pane_idx: usize,
1031 model: DockingModel,
1032 inner: WidgetId,
1033 root: Option<WidgetId>,
1034}
1035
1036impl DockPanePane {
1037 pub(crate) fn new(
1038 side: DockSide,
1039 tab_idx: usize,
1040 pane_idx: usize,
1041 model: DockingModel,
1042 inner: WidgetId,
1043 ) -> Self {
1044 Self {
1045 side,
1046 tab_idx,
1047 pane_idx,
1048 model,
1049 inner,
1050 root: None,
1051 }
1052 }
1053}
1054
1055impl Widget for DockPanePane {
1056 fn build(&mut self, ctx: &mut BuildContext) -> Vec<WidgetId> {
1057 let side = self.side;
1058 let tab_idx = self.tab_idx;
1059 let pane_idx = self.pane_idx;
1060
1061 // The single-dock split/stack zones — the reusable multi-zone DropTarget.
1062 // It accepts only a single DockWidget, so a whole-tab drag falls through
1063 // (NoFeedback) to this pane's own tab handler below and shows no zones.
1064 let split_model = self.model.clone();
1065 let target = DropTarget::new()
1066 .child(self.inner)
1067 .zone_size_factor(0.2)
1068 .region(DropRegion::Center, |z| z)
1069 .region(DropRegion::Leading, |z| z)
1070 .region(DropRegion::Trailing, |z| z)
1071 .region(DropRegion::Top, |z| z)
1072 .region(DropRegion::Bottom, |z| z)
1073 .accept_when(|p| dropped_dock_widget(p).is_some())
1074 .on_region_drop(move |region, payload, _pos, ctx| {
1075 let Some(dock) = dropped_dock_widget(&payload) else {
1076 return false;
1077 };
1078 match region {
1079 // Centre = join this tab as another Splitter pane; an edge =
1080 // split before / after the target pane.
1081 DropRegion::Center => split_model.stack_into_tab(dock, side, tab_idx),
1082 DropRegion::Leading | DropRegion::Top => {
1083 split_model.split_into_tab(dock, side, tab_idx, pane_idx, true)
1084 }
1085 DropRegion::Trailing | DropRegion::Bottom => {
1086 split_model.split_into_tab(dock, side, tab_idx, pane_idx, false)
1087 }
1088 }
1089 ctx.request_accessibility_update();
1090 true
1091 });
1092 let root = ctx.add(target);
1093 self.root = Some(root);
1094
1095 // A whole-tab drag: engage here (one level above the DropTarget) so the
1096 // drop routes locally and relocates the tab to this side — no zones. The
1097 // DropTarget already engaged for a single dock, so this only ever fires
1098 // for a tab. (Dock-widget drops never reach this handler.)
1099 let tab_model = self.model.clone();
1100 ctx.apply_self_handlers(
1101 HandlerSet::new()
1102 .on_drag_hover(move |payload, _pos, _ctx| {
1103 if dropped_dock_tab(payload).is_some() {
1104 DropFeedback::Accept
1105 } else {
1106 DropFeedback::NoFeedback
1107 }
1108 })
1109 .on_drop(move |payload, _pos, ctx| {
1110 if let Some(tab_id) = dropped_dock_tab(&payload) {
1111 // Append after the last *visible* tab (not past trailing
1112 // hidden ones).
1113 let at = tab_model.side_append_index(side);
1114 tab_model.move_tab(tab_id, side, at);
1115 ctx.request_accessibility_update();
1116 true
1117 } else {
1118 false
1119 }
1120 }),
1121 );
1122 vec![root]
1123 }
1124
1125 fn layout_response(&self, proposal: SizeProposal, ctx: &LayoutContext) -> LayoutResponse {
1126 // Delegate to the DropTarget (which forwards the wrapped content's
1127 // grow/shrink/floor) so a flexible pane stays flexible inside the Splitter.
1128 self.root
1129 .and_then(|id| ctx.child_layout_response(id, proposal))
1130 .unwrap_or_else(|| proposal.resolve(0.0, 0.0).into())
1131 }
1132
1133 fn place_children(
1134 &self,
1135 bounds: Rect,
1136 _proposal: SizeProposal,
1137 children: &mut [WidgetPlacement],
1138 _ctx: &LayoutContext,
1139 ) {
1140 for child in children.iter_mut() {
1141 child.origin = bounds.origin();
1142 child.size = bounds.size();
1143 }
1144 }
1145
1146 fn clips_children(&self) -> bool {
1147 true
1148 }
1149
1150 fn children(&self) -> Vec<WidgetId> {
1151 self.root.into_iter().collect()
1152 }
1153}