Skip to main content

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}