teksilo-widgets 0.9.0

Widget library for Teksilo — over a hundred widgets and layout primitives, from Button to TreeTableView.
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
1001
1002
1003
1004
1005
1006
1007
1008
1009
1010
1011
1012
1013
1014
1015
1016
1017
1018
1019
1020
1021
1022
1023
1024
1025
1026
1027
1028
1029
1030
1031
1032
1033
1034
1035
1036
1037
1038
1039
1040
1041
1042
1043
1044
1045
1046
1047
1048
1049
1050
1051
1052
1053
1054
1055
1056
1057
1058
1059
1060
1061
1062
1063
1064
1065
1066
1067
1068
1069
1070
1071
1072
1073
1074
1075
1076
1077
1078
1079
1080
1081
1082
1083
1084
1085
1086
1087
1088
1089
1090
1091
1092
1093
1094
1095
1096
1097
1098
1099
1100
1101
1102
1103
1104
1105
1106
1107
1108
1109
1110
1111
1112
1113
1114
1115
1116
1117
1118
1119
1120
1121
1122
1123
1124
1125
1126
1127
1128
1129
1130
1131
1132
1133
1134
1135
1136
1137
1138
1139
1140
// SPDX-License-Identifier: MPL-2.0
// SPDX-FileCopyrightText: 2026 FernTech

//! The panel / content layer of [`DockingLayout`](super::DockingLayout):
//! the app-facing [`DockWidget`] declaration, the content-factory registry,
//! and the widgets that render a side's tabs → Splitter/ToolBox arrangement →
//! draggable dock panels (with five-zone drop targets).

use std::cell::RefCell;
use std::collections::HashMap;
use std::rc::Rc;

use teksilo_canvas::{Rect, SizeProposal};
use teksilo_core::WidgetBuilder;
use teksilo_core::accessibility::AccessNodeBuilder;
use teksilo_core::binding::BindingLevel;
use teksilo_core::build_context::BuildContext;
use teksilo_core::signal::Signal;
use teksilo_core::widget::{LayoutContext, LayoutResponse, Widget, WidgetPlacement};
use teksilo_core::widget_builder::HandlerSet;
use teksilo_core::widget_id::WidgetId;
use teksilo_core::{DragPayload, DropFeedback};
use teksilo_i18n::{LocalizedString, lit};
use teksilo_tokens::{SurfaceRole, TextRole, TextStyleRole};

use crate::DropRegion;
use crate::accordion::{
    ACCORDION_FILL_HEADER_EXTENT, ACCORDION_HEADER_PADDING_HORIZONTAL, Accordion,
    AccordionOrientation,
};
use crate::drop_target::DropTarget;
use crate::icon_button::{IconButton, IconButtonSize};
use crate::popover_widget::PopoverIconButton;
use crate::primitives::{
    Center, Divider, Expand, HStack, IconWidget, MinSize, Padding, RectWidget, Spacer, TextWidget,
    VStack,
};
use crate::splitter::Splitter;
use crate::toolbar::{Toolbar, ToolbarItem, ToolbarOrientation};
use teksilo_core::overlay::OverlayPlacement;

use super::context_menu::{
    DockMenuKind, activity_context_menu, background_menu, dock_has_options, dock_options_menu,
};
use super::drag::{DockDragData, dropped_dock_tab, dropped_dock_widget};
use super::geometry::DockSide;
use super::model::{
    DockHeaderActionsFactory, DockIconFactory, DockOpenLocation, DockTabId, DockTabView,
    DockWidgetId, DockWidgetMeta, DockingModel, side_orientation,
};

/// Builds a dock widget's content on demand (keyed by its [`DockWidgetId`]).
pub type DockContentFactory = Rc<dyn Fn(DockWidgetId) -> Box<dyn Widget>>;

/// App-facing declaration of a dock widget: identity, chrome metadata, and a
/// lazy content factory. Collect these on [`DockingLayout::dock`](super::DockingLayout::dock).
pub struct DockWidget {
    id: DockWidgetId,
    title: LocalizedString,
    icon: Option<DockIconFactory>,
    default: DockOpenLocation,
    factory: DockContentFactory,
    header_actions: Option<DockHeaderActionsFactory>,
    show_header: bool,
}

impl DockWidget {
    /// Declare a dock widget. `factory` builds its content the first time the
    /// dock appears (and after it is closed and re-opened).
    pub fn new<W: Widget + 'static>(
        id: DockWidgetId,
        title: impl Into<LocalizedString>,
        factory: impl Fn(DockWidgetId) -> W + 'static,
    ) -> Self {
        Self {
            id,
            title: title.into(),
            icon: None,
            default: DockOpenLocation::side(DockSide::Leading),
            factory: Rc::new(move |i| Box::new(factory(i)) as Box<dyn Widget>),
            header_actions: None,
            show_header: false,
        }
    }

    /// Set the dock's tab / rail icon.
    pub fn icon(mut self, f: impl Fn() -> IconWidget + 'static) -> Self {
        self.icon = Some(Rc::new(f));
        self
    }

    /// Attach a factory for the dock's **inline header actions** — a flat list
    /// of [`ToolbarAction`](crate::toolbar::ToolbarAction)s shown before the `⋮` options button, the VS Code
    /// "view actions" pattern ("New File", "Collapse All", …). Built on demand
    /// each time the dock is placed into a header. The framework hosts them in a
    /// [`Toolbar`], so the actions gain **overflow** (when the header is tight,
    /// the lowest-[`priority`](crate::toolbar::ToolbarAction::priority) actions collapse into a
    /// `⌄` menu) and the correct **axis** for free — a horizontal row on leading
    /// / trailing sides, a vertical column on the rotated top / bottom strip. The
    /// actions appear in any header the dock has: the multi-pane [`Accordion`]
    /// header always, and the sole-pane (bare) header when
    /// [`show_header(true)`](Self::show_header) is set.
    ///
    /// Each item is a [`ToolbarItem`] — a collapsible
    /// [`ToolbarAction`](crate::toolbar::ToolbarAction) via
    /// [`ToolbarItem::action`], or a pinned arbitrary widget (a `SplitButton`, a
    /// search field, …) via [`ToolbarItem::custom`].
    ///
    /// ```ignore
    /// DockWidget::new(id, lit!("Explorer"), build).header_actions(|_| vec![
    ///     ToolbarItem::action(ToolbarAction::new(lit!("New File"), new_icon).on_activate(..)),
    ///     ToolbarItem::custom(CreateSplitButton::new(..)),
    /// ])
    /// ```
    pub fn header_actions(
        mut self,
        f: impl Fn(DockWidgetId) -> Vec<ToolbarItem> + 'static,
    ) -> Self {
        self.header_actions = Some(Rc::new(f));
        self
    }

    /// Give a **sole-pane** (bare) dock its own header bar (title + actions +
    /// `⋮` options). Default `false`. The multi-pane Accordion header is always
    /// present regardless; this only governs the bare case. Turn it on to get a
    /// discoverable options button (and inline `header_actions`) on a dock that
    /// is the only one on its side.
    pub fn show_header(mut self, show: bool) -> Self {
        self.show_header = show;
        self
    }

    /// The location used when the dock is opened via `toggle` / `reveal`
    /// without an explicit target.
    pub fn default_location(mut self, loc: DockOpenLocation) -> Self {
        self.default = loc;
        self
    }

    pub(crate) fn id(&self) -> DockWidgetId {
        self.id
    }

    pub(crate) fn into_parts(self) -> (DockWidgetId, DockWidgetMeta, DockContentFactory) {
        (
            self.id,
            DockWidgetMeta {
                title: self.title,
                icon: self.icon,
                min_size: None,
                default: self.default,
                header_actions: self.header_actions,
                show_header: self.show_header,
            },
            self.factory,
        )
    }
}

/// Registry of content factories, owned by the layout, shared into the panel
/// widgets so closed-then-reopened docks rebuild fresh content.
#[derive(Default)]
pub(crate) struct DockContentRegistry {
    factories: HashMap<DockWidgetId, DockContentFactory>,
}

impl std::fmt::Debug for DockContentRegistry {
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        f.debug_struct("DockContentRegistry")
            .field("factories", &self.factories.len())
            .finish()
    }
}

impl DockContentRegistry {
    pub(crate) fn insert(&mut self, id: DockWidgetId, factory: DockContentFactory) {
        self.factories.insert(id, factory);
    }
    pub(crate) fn build(&self, id: DockWidgetId) -> Option<Box<dyn Widget>> {
        self.factories.get(&id).map(|f| f(id))
    }
}

/// A shared handle to the content-factory registry, passed down so each dock
/// panel builds its content **in-context** (where it is placed), avoiding
/// cross-build-context parenting.
pub(crate) type DockContent = Rc<RefCell<DockContentRegistry>>;

/// Kind tag for a side's dynamic dock tabs (so `dynamic_tab` registers them).
const DOCK_TAB_KIND: &str = "__dock_tab__";

/// The dynamic-tab payload carried by a side's `TabWidget` — identifies the
/// DockTab so cross-side whole-tab drag (`accept_external_tabs`) can relocate
/// it via [`DockingModel::move_tab`].
#[derive(Clone, Copy)]
struct DockTabPayload {
    tab_id: DockTabId,
}

// ───────────────────────────────────────────────────────────────────────
// DockSidePanel — a side's content: optional in-side tab strip + Switcher.
// ───────────────────────────────────────────────────────────────────────

#[derive(Debug)]
pub(crate) struct DockSidePanel {
    side: DockSide,
    model: DockingModel,
    content: DockContent,
    /// This side's rail config. Only its Strip-presentation half is used here
    /// (`leading_slot` / `trailing_slot`); the Rail half is `DockActivityBar`'s.
    /// The two presentations share one config object so an app declares a
    /// side's chrome in one place.
    config: super::DockRail,
    root: Option<WidgetId>,
}

impl DockSidePanel {
    pub(crate) fn new(
        side: DockSide,
        model: DockingModel,
        content: DockContent,
        config: super::DockRail,
    ) -> Self {
        Self {
            side,
            model,
            content,
            config,
            root: None,
        }
    }

    /// Compose this side's app-declared bar slots (and, on the trailing edge,
    /// the framework's own "hidden activities" hamburger) into at most one
    /// widget per edge.
    ///
    /// `TabWidget`'s `BarSlot` is a single last-write-wins `Option`, so calling
    /// `bar_trailing_slot` twice silently drops one of the two — most likely
    /// the hamburger, which is the only way back once every activity on the
    /// side is hidden. Composing into one `HStack` per edge is therefore
    /// mandatory, not stylistic.
    fn compose_bar_slots(
        &self,
        ctx: &mut BuildContext,
        needs_hamburger: bool,
    ) -> (Option<WidgetId>, Option<WidgetId>) {
        let leading = self
            .config
            .leading_slot
            .as_ref()
            .map(|f| ctx.add_boxed((f)()));

        let mut trailing: Vec<WidgetId> = Vec::new();
        if let Some(f) = self.config.trailing_slot.as_ref() {
            trailing.push(ctx.add_boxed((f)()));
        }
        if needs_hamburger {
            let m = self.model.clone();
            let hb_side = self.side;
            trailing.push(
                ctx.add(
                    PopoverIconButton::new(IconButton::menu().tooltip(lit!("Hidden activities")))
                        .content(background_menu(&m, hb_side, DockMenuKind::Strip))
                        .placement(OverlayPlacement::BelowPreferred),
                ),
            );
        }
        let trailing = match trailing.len() {
            0 => None,
            // A lone widget needs no wrapper — keeps the common case free of an
            // extra layout node.
            1 => Some(trailing[0]),
            _ => {
                let mut row = HStack::new().spacing(2.0);
                for id in &trailing {
                    row = row.add_child(*id);
                }
                Some(ctx.add(row))
            }
        };
        (leading, trailing)
    }
}

/// The drop target shown when a side has **no** docks, so a revealed-but-empty
/// side (opened from a toolbar button, the rail, or a drag-reveal strip) still
/// accepts content. Accepts a whole tab (`DockTabDragData` → `move_tab`) or a
/// single dock (`DockDragData` → `move_dock`); both reveal the side.
fn empty_side_drop_target(
    ctx: &mut BuildContext,
    model: &DockingModel,
    side: DockSide,
) -> WidgetId {
    let text = ctx.add(
        TextWidget::new(lit!("Drop a panel here"))
            .style(TextStyleRole::Body)
            .color(TextRole::Secondary),
    );
    let label = ctx.add(Center::new().child_id(text));
    let m = model.clone();
    ctx.add(
        DropTarget::new()
            .child_id(label)
            .accept_when(|p| dropped_dock_tab(p).is_some() || dropped_dock_widget(p).is_some())
            .on_drop(move |p, _pos, ctx| {
                if !m.is_side_enabled(side) {
                    return false;
                }
                if let Some(tab_id) = dropped_dock_tab(&p) {
                    m.move_tab(tab_id, side, 0);
                    m.set_side_visible(side, true);
                    ctx.request_accessibility_update();
                    true
                } else if let Some(dock_id) = dropped_dock_widget(&p) {
                    m.move_dock(dock_id, DockOpenLocation::side(side));
                    m.set_side_visible(side, true);
                    ctx.request_accessibility_update();
                    true
                } else {
                    false
                }
            }),
    )
}

impl Widget for DockSidePanel {
    fn build(&mut self, ctx: &mut BuildContext) -> Vec<WidgetId> {
        use crate::tab_widget::{
            TabBarVisibility, TabDisplayMode, TabHandle, TabId, TabInfo, TabWidget,
        };
        use std::any::Any;
        use std::num::NonZeroU64;
        use teksilo_data::ListModel;

        let all_tabs = self.model.side_tabs(self.side);
        if all_tabs.is_empty() {
            // A side with no docks. When it's visible (revealed from a button,
            // the rail, or a drag-reveal strip) it shows a drop target so the
            // first dock can be dragged in; when hidden it's dormant anyway.
            let drop = empty_side_drop_target(ctx, &self.model, self.side);
            // A configured bar slot must still render here. This branch returns
            // before the `TabWidget` is ever built, so without this an app that
            // set `leading_slot`/`trailing_slot` would silently see nothing
            // whenever the side happens to hold no docks — a reachable state,
            // not a misuse. (Qt's `QTabWidget::setCornerWidget` has exactly this
            // bug: the corner widget only shows while at least one tab exists.)
            let (leading, trailing) = self.compose_bar_slots(ctx, false);
            if leading.is_none() && trailing.is_none() {
                self.root = Some(drop);
                return vec![drop];
            }
            let mut bar = HStack::new().spacing(2.0);
            if let Some(id) = leading {
                bar = bar.add_child(id);
            }
            bar = bar.add_child(ctx.add(Spacer::new()));
            if let Some(id) = trailing {
                bar = bar.add_child(id);
            }
            let bar = ctx.add(bar);
            let body = ctx.add(Expand::new().child_id(drop));
            let root = ctx.add(VStack::new().add_child(bar).add_child(body));
            self.root = Some(root);
            return vec![root];
        }

        // Rebuild the strip when this side's tab-display pref flips (context
        // menu "Tab size"). The bar then re-derives its headers in the chosen
        // mode (a scoped, content-preserving rebuild).
        let self_id = ctx.self_id();
        self.model.tab_display_signal(self.side).bind_to(
            self_id,
            ctx.binding_registry(),
            BindingLevel::Rebuild,
        );
        let display = match self.model.side_tab_display(self.side) {
            super::model::DockTabDisplay::Icon => TabDisplayMode::Icon,
            super::model::DockTabDisplay::IconText => TabDisplayMode::IconText,
            super::model::DockTabDisplay::Text => TabDisplayMode::Text,
        };

        // Stable TabWidget id per dock tab (dock tab ids start at 1).
        let to_tab_id = |t: &DockTabView| {
            TabId::from_raw(NonZeroU64::new(t.id.raw()).unwrap_or(NonZeroU64::MIN))
        };
        // model-index → TabId for the whole side (selection maps through it).
        let all_tab_ids: Vec<TabId> = all_tabs.iter().map(&to_tab_id).collect();

        // Only non-hidden tabs render in the strip; remember each shown tab's
        // model index (visible-position → model-index) for selection + drop
        // routing.
        let model_indices: Vec<usize> = all_tabs
            .iter()
            .enumerate()
            .filter(|(_, t)| !t.hidden)
            .map(|(i, _)| i)
            .collect();
        let presentation = self.model.side_presentation(self.side);
        if model_indices.is_empty() && presentation == super::model::TabPresentation::Rail {
            // Every activity hidden in Rail presentation: the activity rail (with
            // its own background menu) is the restore affordance — blank content.
            let empty = ctx.add(RectWidget::new().background(SurfaceRole::Transparent));
            self.root = Some(empty);
            return vec![empty];
        }
        // In Strip presentation we still build the bar below — even with zero
        // visible tabs — so its trailing "hidden activities" hamburger can
        // restore them (right-clicking a tab is impossible when none show).

        let dock_selected = self.model.side_selected_tab_signal(self.side);
        let initial = all_tab_ids
            .get(dock_selected.get().min(all_tab_ids.len().saturating_sub(1)))
            .copied();
        let tw_selected: Signal<Option<TabId>> = ctx.signal(initial);

        // model → TabWidget: map the selected model index to its TabId,
        // resolved against the **live** model (not the build-time `all_tab_ids`
        // snapshot). This is the exact inverse of effect 2's live id → index
        // lookup, so the round-trip is the identity and the equality guards
        // stop the chain at once. A stale snapshot here would disagree with
        // effect 2 after a reorder (idx 1 → snapshot id B, id B → live idx 2,
        // idx 2 → snapshot id A, …) and feed back unboundedly — the
        // "Signal notification nested 257 deep" panic when an activity is
        // imported onto a side and then reordered within it.
        {
            let model = self.model.clone();
            let side = self.side;
            let tw = tw_selected.clone();
            ctx.effect(&dock_selected, move |&idx| {
                let target = model.tab_id_at(side, idx).map(|id| {
                    TabId::from_raw(NonZeroU64::new(id.raw()).unwrap_or(NonZeroU64::MIN))
                });
                if tw.get() != target {
                    tw.set(target);
                }
            });
        }
        // TabWidget → model (an in-strip click) — position-independent so a
        // hidden tab in the middle doesn't shift the mapping.
        {
            let model = self.model.clone();
            let side = self.side;
            ctx.effect(&tw_selected, move |maybe| {
                if let Some(tid) = maybe {
                    model.select_tab_by_id(side, DockTabId::from_raw(tid.raw().get()));
                }
            });
        }

        // Rail presentation → the in-side strip is hidden (the activity rail is
        // the selector). Strip → always show the real TabWidget bar (so even a
        // single-panel side reads as a TabWidget tab, not a custom title bar).
        let bar_visibility = match presentation {
            super::model::TabPresentation::Rail => TabBarVisibility::Never,
            super::model::TabPresentation::Strip => TabBarVisibility::Always,
        };
        // No visible tab → no tab to right-click, so the bar needs a trailing
        // hamburger to reach the activities menu. When at least one tab shows,
        // its own right-click menu already lists (and restores) the hidden ones.
        let needs_hamburger = model_indices.is_empty();

        // Build the visible tabs as a dynamic `ListModel<TabHandle>` so a whole
        // tab can be dragged between sides via TabWidget's `accept_external_tabs`.
        // Tabs are not closable (you hide the side / move the dock, you don't
        // close a view container from its tab). Each tab carries a context menu
        // and renders per the side's tab-display mode.
        let mut handles: Vec<TabHandle> = Vec::with_capacity(model_indices.len());
        for &model_i in &model_indices {
            let tab = &all_tabs[model_i];
            // Label / icon: explicit activity title (set_tab_title) → primary
            // (first non-collapsed) pane's dock → "Panel" / no-icon.
            let label = self.model.activity_label(tab);
            let icon_factory = self.model.activity_icon(tab);

            // Each tab declares its title + icon; the bar's reactive
            // `tab_display` (wired below from the side's "Tab size" pref) decides
            // what's painted — icon, text, or both — and handles the icon-only
            // sizing, tooltip promotion, and icon-less initial-letter fallback.
            let mut info = TabInfo::new().closable(false).title(label.clone());
            if let Some(icf) = icon_factory {
                info = info.icon(move || (icf)());
            }
            {
                let m = self.model.clone();
                let menu_side = self.side;
                let tid = tab.id;
                info = info.context_menu(move |_pos, _ctx| {
                    Some(Box::new(activity_context_menu(
                        &m,
                        menu_side,
                        tid,
                        DockMenuKind::Strip,
                    )))
                });
            }
            handles.push(TabHandle::dynamic(
                to_tab_id(tab),
                DOCK_TAB_KIND,
                info,
                DockTabPayload { tab_id: tab.id },
            ));
        }
        let list: ListModel<TabHandle> = ListModel::from_vec(handles);

        let side = self.side;
        let factory_model = self.model.clone();
        let factory_content = self.content.clone();
        // The bar deals in *visible* positions; translate them back to model
        // tab indices (a no-op when nothing is hidden) for `move_tab`.
        let ext_indices = model_indices.clone();
        let ext_model = self.model.clone();
        // Appending past the last visible tab must land just **after the last
        // visible tab's model index**, not at the absolute end — otherwise a
        // dropped/promoted tab is ordered after any trailing *hidden* tabs and
        // reappears out of place when they are restored.
        let after_last_visible = model_indices
            .last()
            .map(|&i| i + 1)
            .unwrap_or(all_tab_ids.len());

        let policy = self.model.policy();
        let mut tw = TabWidget::new(tw_selected)
            .bar_visibility(bar_visibility)
            // Dock side strips use the denser compact (38 dp) tab bar, each tab
            // sized to its own content (not a shared width) — and a compact min
            // so an icon-only tab shrinks to its icon and an icon + text tab
            // grows to fit both, instead of all clamping to the editor-tab min.
            .compact_bar()
            .tab_sizing(crate::tab_widget::TabSizing::Independent)
            .tab_display(display)
            .min_tab_width(40.0)
            .dynamic_model(list)
            .dynamic_tab::<DockTabPayload>(DOCK_TAB_KIND, move |_handle, payload| {
                match factory_model.tab_view_by_id(payload.tab_id) {
                    Some((tside, view)) => Box::new(DockTabContentWidget::new(
                        tside,
                        view,
                        factory_model.clone(),
                        factory_content.clone(),
                    )) as Box<dyn Widget>,
                    None => Box::new(RectWidget::new().background(SurfaceRole::Transparent)),
                }
            })
            // A drop from a source that ISN'T a peer `TabBar<TabHandle>` — an
            // **activity-rail item** (`DockTabDragData`) or a single dock (a
            // split-pane header, `DockDragData`). The native `on_tab_received`
            // path only fires for `TabBarDragData<TabHandle>`; without this the
            // bar would be the drop target (`find_drop_target_at_or_above` stops
            // at the first handler) and silently reject the rail drag. `idx` is
            // this bar's visible insertion position → model tab index. (Kept
            // unconditionally — when a lock is on, the gated source simply never
            // produces the matching payload, so the branch is inert.)
            .on_external_drop(move |payload, idx, ctx| {
                // A disabled side never mutates from a UI drop (its panel isn't
                // even built — this keeps that a local invariant rather than
                // consuming the drop while the model silently rejects it).
                if !ext_model.is_side_enabled(side) {
                    return false;
                }
                let at = ext_indices.get(idx).copied().unwrap_or(after_last_visible);
                if let Some(tab_id) = dropped_dock_tab(payload) {
                    ext_model.move_tab(tab_id, side, at);
                    ctx.request_accessibility_update();
                    true
                } else if let Some(dock_id) = dropped_dock_widget(payload) {
                    // A lone dock becomes a new activity at the drop position.
                    ext_model.promote_to_tab(dock_id, side, at);
                    ctx.request_accessibility_update();
                    true
                } else {
                    false
                }
            });
        // Activity drag-and-drop (reorder within a side + transfer between
        // sides) is a user affordance — gate it on the policy. When off, the
        // tab headers are neither drag sources nor reorder/transfer targets.
        if policy.allow_activity_drag {
            let reorder_model = self.model.clone();
            let recv_model = self.model.clone();
            let reorder_indices = model_indices.clone();
            let recv_indices = model_indices.clone();
            tw = tw
                .reorderable(true)
                .accept_external_tabs(true)
                // Same-side reorder.
                .on_reorder(move |tid, dest, _ctx| {
                    let at = reorder_indices
                        .get(dest)
                        .copied()
                        .unwrap_or(after_last_visible);
                    reorder_model.move_tab(DockTabId::from_raw(tid.raw().get()), side, at);
                })
                // Cross-side drop: relocate the whole tab to this side.
                .on_tab_received(move |handle, idx, ctx| {
                    if let Some(p) =
                        (handle.payload.as_ref() as &dyn Any).downcast_ref::<DockTabPayload>()
                    {
                        let at = recv_indices.get(idx).copied().unwrap_or(after_last_visible);
                        recv_model.move_tab(p.tab_id, side, at);
                        ctx.request_accessibility_update();
                    }
                })
                // The source side: `move_tab` (above) already removed the tab
                // from the model; the rebuild reconciles this side's list.
                .on_transfer_out(|_tid, _ctx| {});
        }

        // Bar slots: the app's `leading_slot`/`trailing_slot`, composed with the
        // framework's own trailing **hamburger** — which opens the activities
        // checklist and is the only restore affordance left once *every*
        // activity is hidden and no tab can be right-clicked.
        let (leading_slot, trailing_slot) = self.compose_bar_slots(ctx, needs_hamburger);
        if let Some(id) = leading_slot {
            tw = tw.bar_leading_slot_id(id);
        }
        if let Some(id) = trailing_slot {
            tw = tw.bar_trailing_slot_id(id);
        }
        let root = ctx.add(tw);
        self.root = Some(root);

        // Side-level drop target for a whole-tab drag (an activity-rail button
        // or a tab header from another side). A drop landing on a *pane* is
        // consumed by that `DockPanePane` (split / stack); a drop landing on
        // the **tab bar** (or any non-pane chrome) bubbles up to here and
        // relocates the tab to the end of this side.
        let drop_model = self.model.clone();
        let drop_side = self.side;
        ctx.apply_self_handlers(
            HandlerSet::new()
                .on_drag_hover(move |_payload, _pos, _ctx| {
                    // Accept silently; the drop is routed in `on_drop`. (A pane
                    // under the pointer paints its own five-zone overlay; the
                    // bar just needs to register as a valid target.)
                    DropFeedback::NoFeedback
                })
                .on_drop(move |payload, _pos, ctx| {
                    if !drop_model.is_side_enabled(drop_side) {
                        return false;
                    }
                    // A drop landing on non-pane chrome (the strip, gaps): a tab
                    // relocates to this side; a single dock joins it too.
                    if let Some(tab_id) = dropped_dock_tab(&payload) {
                        let at = drop_model.side_append_index(drop_side);
                        drop_model.move_tab(tab_id, drop_side, at);
                        ctx.request_accessibility_update();
                        true
                    } else if let Some(dock_id) = dropped_dock_widget(&payload) {
                        drop_model.move_dock(dock_id, DockOpenLocation::side(drop_side));
                        ctx.request_accessibility_update();
                        true
                    } else {
                        false
                    }
                }),
        );
        vec![root]
    }

    fn layout_response(&self, proposal: SizeProposal, ctx: &LayoutContext) -> LayoutResponse {
        self.root
            .and_then(|id| ctx.child_size(id, proposal))
            .unwrap_or_else(|| proposal.resolve(0.0, 0.0))
            .into()
    }

    fn place_children(
        &self,
        bounds: Rect,
        _proposal: SizeProposal,
        children: &mut [WidgetPlacement],
        _ctx: &LayoutContext,
    ) {
        for child in children.iter_mut() {
            child.origin = bounds.origin();
            child.size = bounds.size();
        }
    }

    fn accessibility(&self, builder: &mut AccessNodeBuilder) {
        use teksilo_core::accesskit::Role;
        builder.set_role(Role::Complementary);
        builder.set_name(super::a11y::side_label(self.side).resolve_now());
    }

    fn children(&self) -> Vec<WidgetId> {
        self.root.into_iter().collect()
    }
}

// ───────────────────────────────────────────────────────────────────────
// DockTabContentWidget — one tab's Splitter of panes.
// ───────────────────────────────────────────────────────────────────────

struct DockTabContentWidget {
    side: DockSide,
    tab: DockTabView,
    model: DockingModel,
    content: DockContent,
    root: Option<WidgetId>,
}

impl std::fmt::Debug for DockTabContentWidget {
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        f.debug_struct("DockTabContentWidget")
            .field("side", &self.side)
            .field("panes", &self.tab.panes.len())
            .finish()
    }
}

impl DockTabContentWidget {
    fn new(side: DockSide, tab: DockTabView, model: DockingModel, content: DockContent) -> Self {
        Self {
            side,
            tab,
            model,
            content,
            root: None,
        }
    }

    /// Build a dock's content widget in-context via the registry.
    fn build_dock_content(&self, ctx: &mut BuildContext, dock: DockWidgetId) -> WidgetId {
        match self.content.borrow().build(dock) {
            Some(w) => ctx.add_boxed(w),
            None => ctx.add(TextWidget::new(lit!("(missing content)"))),
        }
    }
}

impl Widget for DockTabContentWidget {
    fn build(&mut self, ctx: &mut BuildContext) -> Vec<WidgetId> {
        // Find this tab's index in the side for drop-routing.
        let tab_idx = self
            .model
            .side_tabs(self.side)
            .iter()
            .position(|t| t.id == self.tab.id)
            .unwrap_or(0);

        let root = if self.tab.panes.len() <= 1 {
            // Single pane: render the dock bare (a 1-pane Splitter is
            // degenerate). The side's tab / rail is its header.
            match self.tab.panes.first() {
                Some(dock) => {
                    let inner = self.build_pane_inner(ctx, *dock, 0, None);
                    ctx.add(DockPanePane::new(
                        self.side,
                        tab_idx,
                        0,
                        self.model.clone(),
                        inner,
                    ))
                }
                None => ctx.add(RectWidget::new().background(SurfaceRole::Transparent)),
            }
        } else {
            // Split panes: each dock is its own Accordion, separated by the
            // Splitter. Collapsing an accordion collapses its Splitter pane.
            let splitter_model = self.tab.splitter.clone();
            let mut splitter = Splitter::new(splitter_model.clone());
            for (pane_idx, dock) in self.tab.panes.iter().enumerate() {
                let inner = self.build_pane_inner(ctx, *dock, pane_idx, Some(&splitter_model));
                let pane_widget = ctx.add(DockPanePane::new(
                    self.side,
                    tab_idx,
                    pane_idx,
                    self.model.clone(),
                    inner,
                ));
                splitter = splitter.pane_id(pane_widget);
            }
            ctx.add(splitter)
        };
        self.root = Some(root);
        vec![root]
    }

    fn layout_response(&self, proposal: SizeProposal, ctx: &LayoutContext) -> LayoutResponse {
        self.root
            .and_then(|id| ctx.child_size(id, proposal))
            .unwrap_or_else(|| proposal.resolve(0.0, 0.0))
            .into()
    }

    fn place_children(
        &self,
        bounds: Rect,
        _proposal: SizeProposal,
        children: &mut [WidgetPlacement],
        _ctx: &LayoutContext,
    ) {
        for child in children.iter_mut() {
            child.origin = bounds.origin();
            child.size = bounds.size();
        }
    }

    fn children(&self) -> Vec<WidgetId> {
        self.root.into_iter().collect()
    }
}

impl DockTabContentWidget {
    /// Render one pane = one dock.
    ///
    /// A **sole** pane (`splitter == None`) is rendered bare — the side's tab /
    /// rail is already its header. A **split** pane is wrapped in an
    /// [`Accordion`] whose draggable header titles the dock, is the drag handle,
    /// and collapses the dock on click. The accordion fills the pane (`fill`);
    /// toggling it **collapses its Splitter pane** to just the header (siblings
    /// grow), and re-expands it to the same size — wired here via the pane's
    /// `expanded` signal driving `SplitterModel::set_collapsed`.
    fn build_pane_inner(
        &self,
        ctx: &mut BuildContext,
        dock: DockWidgetId,
        pane_idx: usize,
        splitter: Option<&crate::splitter::SplitterModel>,
    ) -> WidgetId {
        let content = self.build_dock_content(ctx, dock);
        let multi_pane = splitter.is_some();
        let Some(splitter) = splitter else {
            // Sole-pane (bare) dock. By default it renders headerless (the side
            // tab / rail is its header). Opting in (`DockWidget::show_header`)
            // gives it a VS Code–style header bar carrying its own actions + the
            // `⋮` options menu.
            if !self.model.dock_show_header(dock) {
                return content;
            }
            return self.build_bare_dock_header(ctx, dock, content);
        };
        let title = self.model.dock_title(dock).unwrap_or_else(|| lit!("Panel"));
        // Initial expanded state follows the Splitter (so a rebuild preserves a
        // collapsed pane); toggling drives the pane collapse/expand.
        let expanded = ctx.signal(!splitter.is_collapsed(pane_idx));
        splitter.set_collapsed_size(pane_idx, crate::accordion::ACCORDION_FILL_COLLAPSED_EXTENT);
        {
            let sp = splitter.clone();
            ctx.effect(&expanded, move |&e| {
                sp.set_collapsed(pane_idx, !e);
            });
        }
        let mut accordion = Accordion::new(title, expanded)
            .orientation(
                if side_orientation(self.side) == teksilo_tokens::Orientation::Vertical {
                    AccordionOrientation::Vertical
                } else {
                    AccordionOrientation::Horizontal
                },
            )
            .fill(true);
        // The dock's header actions (app-supplied) + the framework `⋮` options
        // menu sit in the accordion header's trailing slot.
        if let Some(trailing) = self.dock_header_trailing(ctx, dock, multi_pane) {
            accordion = accordion.trailing_id(trailing);
        }
        // The accordion header is the dock's drag handle — only when the policy
        // allows dragging a single dock out of a split pane.
        if self.model.policy().allow_dock_drag {
            accordion = accordion.on_header_drag(move |ctx| {
                ctx.start_drag(content, DragPayload::typed(DockDragData { dock_id: dock }));
            });
        }
        ctx.add(accordion.content_id(content))
    }

    /// Build the trailing cluster of a dock header — the app's inline
    /// `header_actions` plus the framework `⋮` options button
    /// ([`dock_options_menu`]) — hosted in a [`Toolbar`] so excess actions
    /// overflow into a `⌄` menu and everything follows the header's axis. Returns
    /// `None` when there is nothing to show (no app actions and an empty options
    /// menu).
    fn dock_header_trailing(
        &self,
        ctx: &mut BuildContext,
        dock: DockWidgetId,
        multi_pane: bool,
    ) -> Option<WidgetId> {
        let actions = self.model.dock_header_actions(dock);
        let has_options = dock_has_options(&self.model, self.side, multi_pane);
        if actions.is_none() && !has_options {
            return None;
        }
        // A *multi-pane* dock on a top / bottom side renders the accordion header
        // as a rotated *vertical* strip (`AccordionOrientation::Horizontal`), so
        // the cluster stacks vertically. Every other header — leading / trailing
        // accordions and every bare (`!multi_pane`) bar, which is always
        // horizontal regardless of side — lays out horizontally.
        let vertical =
            multi_pane && side_orientation(self.side) == teksilo_tokens::Orientation::Horizontal;
        let title = self.model.dock_title(dock).unwrap_or_else(|| lit!("Panel"));

        // The app's header actions, hosted in a compact shrink-to-fit `Toolbar`
        // that collapses its excess into a `⌄` when the header is narrow. Only
        // built when the dock declares actions.
        let toolbar_id = actions.map(|factory| {
            let mut bar = Toolbar::new()
                .orientation(if vertical {
                    ToolbarOrientation::Vertical
                } else {
                    ToolbarOrientation::Horizontal
                })
                .compact(true)
                .spacing(2.0)
                .label(lit!(format!("{} actions", title.resolve_now())));
            for item in factory(dock) {
                bar = bar.item(item);
            }
            ctx.add(bar)
        });

        // The framework `⋮` dock-options menu, kept **separate from and after**
        // the actions toolbar, so it stays the last / outermost affordance even
        // when the toolbar collapses its own actions into a `⌄` (Move-to / Hide
        // must never hide behind the overflow). `.bare()` makes the `MenuList`
        // the popover content directly (not a menu-on-a-popover); it carries the
        // Move-to *submenu* a flat toolbar overflow row could not express.
        let options_id = has_options.then(|| {
            let menu = dock_options_menu(&self.model, self.side, self.tab.id, dock, multi_pane);
            ctx.add(
                PopoverIconButton::new(IconButton::more().size(IconButtonSize::Compact))
                    .bare()
                    .content(menu)
                    .placement(OverlayPlacement::BelowPreferred)
                    .access_label(lit!(format!("More actions: {}", title.resolve_now()))),
            )
        });

        // Arrange `[toolbar] [⋮]` along the header axis. A lone child (only
        // actions, or only the `⋮`) needs no wrapper.
        let kids: Vec<WidgetId> = [toolbar_id, options_id].into_iter().flatten().collect();
        match kids.as_slice() {
            [] => None,
            [only] => Some(*only),
            _ => {
                let cluster = if vertical {
                    let mut col = VStack::new().spacing(2.0);
                    for k in &kids {
                        col = col.add_child(*k);
                    }
                    ctx.add(col)
                } else {
                    let mut row = HStack::new().spacing(2.0);
                    for k in &kids {
                        row = row.add_child(*k);
                    }
                    ctx.add(row)
                };
                Some(cluster)
            }
        }
    }

    /// The sole-pane dock header bar (opt-in via `DockWidget::show_header`):
    /// `[title] [Spacer] [actions + ⋮]` above the content, matching the VS Code
    /// view-header layout. Always a horizontal bar regardless of side.
    fn build_bare_dock_header(
        &self,
        ctx: &mut BuildContext,
        dock: DockWidgetId,
        content: WidgetId,
    ) -> WidgetId {
        let title = self.model.dock_title(dock).unwrap_or_else(|| lit!("Panel"));
        // The title is rigid: it never truncates. When the header is tight the
        // trailing toolbar (shrinkable) absorbs the deficit and collapses its
        // actions into the `⌄`, so the dock name always stays fully readable.
        let title_id = ctx.add(
            TextWidget::new(title)
                .style(TextStyleRole::BodyBold)
                .color(TextRole::Primary)
                .single_line()
                .no_shrink(),
        );
        let spacer_id = ctx.add(Spacer::new());
        let mut row = HStack::new()
            .spacing(2.0)
            .add_child(title_id)
            .add_child(spacer_id);
        if let Some(trailing) = self.dock_header_trailing(ctx, dock, false) {
            row = row.add_child(trailing);
        }
        let row_id = ctx.add(row);
        let padded =
            ctx.add(Padding::symmetric(2.0, ACCORDION_HEADER_PADDING_HORIZONTAL).child_id(row_id));
        // Fixed-height header bar (matching the Accordion header extent) with a
        // 1 dp divider beneath it, above the content.
        let header = ctx.add(MinSize::new(0.0, ACCORDION_FILL_HEADER_EXTENT).child_id(padded));
        let divider = ctx.add(Divider::horizontal());
        ctx.add(
            VStack::new()
                .add_child(header)
                .add_child(divider)
                .child(Expand::new().flex(1.0).child_id(content)),
        )
    }
}

// ───────────────────────────────────────────────────────────────────────
// DockPanePane — a Splitter pane that is a five-zone drop target.
// ───────────────────────────────────────────────────────────────────────

/// A Splitter pane wrapped as a drop target. The five split/stack zones for a
/// **single dock** are the reusable [`DropTarget`] (centre = stack, edge zones =
/// split before/after — `zone_size_factor` proportional, no per-pane px cap). A
/// whole-**tab** drag never splits a pane, so the DropTarget doesn't accept it;
/// instead `DockPanePane` itself engages for a tab (this handler sits one level
/// *above* the DropTarget in the tree) and relocates it to the side — a local
/// bubble (DropTarget → DockPanePane) that shows no per-zone overlay for a tab,
/// exactly as before. A drop landing on non-pane chrome bubbles further to
/// [`DockSidePanel`] / the tab bar, unchanged.
#[derive(Debug)]
pub(crate) struct DockPanePane {
    side: DockSide,
    tab_idx: usize,
    pane_idx: usize,
    model: DockingModel,
    inner: WidgetId,
    root: Option<WidgetId>,
}

impl DockPanePane {
    pub(crate) fn new(
        side: DockSide,
        tab_idx: usize,
        pane_idx: usize,
        model: DockingModel,
        inner: WidgetId,
    ) -> Self {
        Self {
            side,
            tab_idx,
            pane_idx,
            model,
            inner,
            root: None,
        }
    }
}

impl Widget for DockPanePane {
    fn build(&mut self, ctx: &mut BuildContext) -> Vec<WidgetId> {
        let side = self.side;
        let tab_idx = self.tab_idx;
        let pane_idx = self.pane_idx;

        // The single-dock split/stack zones — the reusable multi-zone DropTarget.
        // It accepts only a single DockWidget, so a whole-tab drag falls through
        // (NoFeedback) to this pane's own tab handler below and shows no zones.
        let split_model = self.model.clone();
        let target = DropTarget::new()
            .child_id(self.inner)
            .zone_size_factor(0.2)
            .region(DropRegion::Center, |z| z)
            .region(DropRegion::Leading, |z| z)
            .region(DropRegion::Trailing, |z| z)
            .region(DropRegion::Top, |z| z)
            .region(DropRegion::Bottom, |z| z)
            .accept_when(|p| dropped_dock_widget(p).is_some())
            .on_region_drop(move |region, payload, _pos, ctx| {
                let Some(dock) = dropped_dock_widget(&payload) else {
                    return false;
                };
                match region {
                    // Centre = join this tab as another Splitter pane; an edge =
                    // split before / after the target pane.
                    DropRegion::Center => split_model.stack_into_tab(dock, side, tab_idx),
                    DropRegion::Leading | DropRegion::Top => {
                        split_model.split_into_tab(dock, side, tab_idx, pane_idx, true)
                    }
                    DropRegion::Trailing | DropRegion::Bottom => {
                        split_model.split_into_tab(dock, side, tab_idx, pane_idx, false)
                    }
                }
                ctx.request_accessibility_update();
                true
            });
        let root = ctx.add(target);
        self.root = Some(root);

        // A whole-tab drag: engage here (one level above the DropTarget) so the
        // drop routes locally and relocates the tab to this side — no zones. The
        // DropTarget already engaged for a single dock, so this only ever fires
        // for a tab. (Dock-widget drops never reach this handler.)
        let tab_model = self.model.clone();
        ctx.apply_self_handlers(
            HandlerSet::new()
                .on_drag_hover(move |payload, _pos, _ctx| {
                    if dropped_dock_tab(payload).is_some() {
                        DropFeedback::Accept
                    } else {
                        DropFeedback::NoFeedback
                    }
                })
                .on_drop(move |payload, _pos, ctx| {
                    if let Some(tab_id) = dropped_dock_tab(&payload) {
                        // Append after the last *visible* tab (not past trailing
                        // hidden ones).
                        let at = tab_model.side_append_index(side);
                        tab_model.move_tab(tab_id, side, at);
                        ctx.request_accessibility_update();
                        true
                    } else {
                        false
                    }
                }),
        );
        vec![root]
    }

    fn layout_response(&self, proposal: SizeProposal, ctx: &LayoutContext) -> LayoutResponse {
        // Delegate to the DropTarget (which forwards the wrapped content's
        // grow/shrink/floor) so a flexible pane stays flexible inside the Splitter.
        self.root
            .and_then(|id| ctx.child_layout_response(id, proposal))
            .unwrap_or_else(|| proposal.resolve(0.0, 0.0).into())
    }

    fn place_children(
        &self,
        bounds: Rect,
        _proposal: SizeProposal,
        children: &mut [WidgetPlacement],
        _ctx: &LayoutContext,
    ) {
        for child in children.iter_mut() {
            child.origin = bounds.origin();
            child.size = bounds.size();
        }
    }

    fn clips_children(&self) -> bool {
        true
    }

    fn children(&self) -> Vec<WidgetId> {
        self.root.into_iter().collect()
    }
}