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