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