Skip to main content

teksilo_widgets/
tab_widget.rs

1// SPDX-License-Identifier: MPL-2.0
2// SPDX-FileCopyrightText: 2026 FernTech
3
4//! Tabbed-container widgets.
5//!
6//! Two public entry points:
7//!
8//! - [`TabBar<T>`] — a header strip driven by a `ListModel<T>` /
9//!   [`ListDataSource`](teksilo_data::ListDataSource) and a
10//!   [`TabDelegate<T>`]. Use it stand-alone when you want only the
11//!   tab strip (e.g., a document tab strip whose content lives in a
12//!   different panel or window).
13//!
14//! - [`TabWidget`] — the all-in-one composition: bar above, content
15//!   `Switcher` below, sharing one selection signal. Two
16//!   construction flavors:
17//!     - [`static_tab(info, content)`](TabWidget::static_tab) —
18//!       fixed tabs accumulated at construction.
19//!     - [`dynamic_tab::<S>(kind, factory)`](TabWidget::dynamic_tab) +
20//!       [`dynamic_model(model)`](TabWidget::dynamic_model) — apps
21//!       register a content factory per tab `kind` (`"plain-text-doc"`,
22//!       `"image"`, …); the live tab list is a mutable
23//!       `ListModel<TabHandle>` mutated at runtime (open / close /
24//!       reorder).
25//!
26//! Static tabs always render first, in declaration order; dynamic
27//! tabs follow. Selection is by stable [`TabId`] — drag-reorder and
28//! model mutations never silently send the active selection to a
29//! different tab.
30//!
31//! ## Activating a tab scrolls it into view
32//!
33//! When more tabs are open than the strip can show, activating one
34//! always reveals it — including when the activation is programmatic
35//! (writing the selection signal, the "show all tabs" overflow
36//! dropdown, an assistive-technology click). Pointer and keyboard
37//! activation move focus and would be revealed by the framework's focus
38//! follow anyway; the other paths move no focus, so the bar scrolls the
39//! header in itself, by the minimum needed to bring it fully inside the
40//! viewport.
41//!
42//! The reveal is edge-triggered on the selection changing, not an
43//! invariant re-asserted every layout pass: once the reader has scrolled
44//! away from the active tab by hand, a rebuild for an unrelated reason —
45//! a retitled tab, a locale change, a tab opened elsewhere in the strip —
46//! leaves the viewport where they left it.
47//!
48//! ## Accessibility
49//!
50//! Both [`TabWidget`] and [`TabBar`] emit `Role::TabList` on the bar
51//! and `Role::Tab` on each header. ARIA APG ([tabs
52//! pattern](https://www.w3.org/WAI/ARIA/apg/patterns/tabs/))
53//! recommends providing an accessible name for the tab list
54//! whenever a page hosts more than one — call
55//! [`.access_label(tr!(editor_tabs()))`](teksilo_core::widget_builder::WidgetBuilder::access_label)
56//! on the widget so screen readers can distinguish "editor tabs"
57//! from "tool tabs":
58//!
59//! ```ignore
60//! TabWidget::new(selected)
61//!     .static_tab(TabInfo::new().title(tr!(welcome())), welcome_panel)
62//!     // ...
63//!     .access_label(tr!(editor_tabs()))
64//! ```
65//!
66//! Panels with no focusable descendants (a static text-only "About"
67//! tab, a chart-only metrics tab) are unreachable by Tab key unless
68//! opted in via [`TabInfo::focusable_panel(true)`](TabInfo::focusable_panel).
69
70use std::any::Any;
71use std::cell::RefCell;
72use std::collections::{HashMap, HashSet};
73use std::rc::Rc;
74use teksilo_i18n::lit;
75
76use teksilo_canvas::{Rect, SizeProposal};
77use teksilo_core::accessibility::AccessNodeBuilder;
78use teksilo_core::binding::BindingLevel;
79use teksilo_core::build_context::BuildContext;
80use teksilo_core::drag_payload::DragPayload;
81use teksilo_core::signal::{Prop, Signal};
82use teksilo_core::widget::{
83    EventContext, LayoutContext, LayoutResponse, PendingChild, Widget, WidgetPlacement,
84};
85use teksilo_core::widget_id::WidgetId;
86use teksilo_data::ListModel;
87
88use crate::primitives::{Expand, Switcher, VStack};
89
90mod bar;
91mod delegate;
92mod handle;
93mod header;
94mod id;
95mod info;
96
97#[cfg(test)]
98mod a11y_tests;
99#[cfg(test)]
100mod tests;
101
102pub use bar::{
103    DEFAULT_BAR_SLOT_SPACING, DEFAULT_MAX_TAB_WIDTH, DEFAULT_MIN_TAB_WIDTH,
104    DEFAULT_PINNED_TAB_WIDTH, DEFAULT_TAB_SPACING, TabBar, TabBarDragData,
105};
106pub use delegate::{
107    ContextMenuFactory, TabBarOrientation, TabDelegate, TabDisplayMode, TabOverflowButton,
108    TabSizing,
109};
110pub use handle::{STATIC_KIND, TabHandle};
111pub use id::TabId;
112pub use info::{IconFactory, TabInfo};
113use teksilo_i18n::LocalizedString;
114
115// ─── Static + dynamic content factory types ─────────────────────────
116
117/// Closure that builds a static tab's content widget. Called once
118/// per static tab — on the [`TabWidget`]'s first build that includes
119/// it. The resulting pane is then memoized: rebuilds caused by
120/// adjacent dynamic-model mutations reuse the same pane WidgetId, so
121/// internal state (focus, scroll, animation progress, …) survives.
122pub type StaticContentFactory = Rc<dyn Fn(&TabHandle) -> Box<dyn Widget>>;
123
124/// Closure that builds a dynamic tab's content widget from its
125/// handle and downcast typed payload. Internal — apps register via
126/// [`TabWidget::dynamic_tab::<S>`](TabWidget::dynamic_tab) which
127/// hides the `Any` downcast behind the type parameter.
128pub(crate) type DynamicContentFactory = Rc<dyn Fn(&TabHandle, &dyn Any) -> Box<dyn Widget>>;
129
130// ─── Static-tab content shapes ──────────────────────────────────────
131
132/// One static tab's content + presentation. Three shapes:
133///
134/// - `Owned`: a one-shot `Box<dyn Widget>` from `static_tab(impl Widget)`.
135///   Consumed on the slot's first registration.
136/// - `Factory`: a `Fn(&TabHandle) -> Box<dyn Widget>` from
137///   `static_tab_factory`. Called once on the slot's first
138///   registration.
139/// - `PreId`: a pre-registered `WidgetId` from `static_tab(info, id)`,
140///   wrapped in an alias on first registration. Stable for the
141///   widget's lifetime.
142enum StaticContentSource {
143    Owned(Option<Box<dyn Widget>>),
144    Factory(StaticContentFactory),
145    PreId(Option<WidgetId>),
146}
147
148impl StaticContentSource {
149    #[allow(clippy::wrong_self_convention)]
150    fn into_widget(&mut self, handle: &TabHandle) -> Box<dyn Widget> {
151        match self {
152            StaticContentSource::Owned(opt) => opt
153                .take()
154                .expect("static tab content has already been consumed"),
155            StaticContentSource::Factory(f) => f(handle),
156            StaticContentSource::PreId(opt) => {
157                let id = opt
158                    .take()
159                    .expect("static tab pre-registered id has already been consumed");
160                Box::new(AliasWidget {
161                    target: Some(id),
162                    child_id: None,
163                })
164            }
165        }
166    }
167}
168
169/// One static tab slot. The `pane_id` is `None` until the slot's
170/// first build and stable thereafter — that's what makes static
171/// content survive sibling rebuilds.
172struct StaticTabSlot {
173    handle: TabHandle,
174    source: StaticContentSource,
175    pane_id: Option<WidgetId>,
176}
177
178/// One bar slot (leading or trailing). Memoized: registered on
179/// first build via [`Self::resolve`], reused on subsequent builds.
180struct BarSlot {
181    pending: Option<PendingChild>,
182    resolved: Option<WidgetId>,
183}
184
185impl BarSlot {
186    fn new(child: PendingChild) -> Self {
187        Self {
188            pending: Some(child),
189            resolved: None,
190        }
191    }
192
193    /// Resolve the slot to a stable WidgetId, registering the pending
194    /// widget on first call. Subsequent calls return the same id.
195    fn resolve(&mut self, ctx: &mut BuildContext) -> WidgetId {
196        if let Some(id) = self.resolved {
197            return id;
198        }
199        let id = match self
200            .pending
201            .take()
202            .expect("bar slot already resolved without id")
203        {
204            PendingChild::Id(id) => id,
205            PendingChild::Deferred(w) => ctx.add_boxed(w),
206        };
207        self.resolved = Some(id);
208        id
209    }
210}
211
212// ─── TabWidget — the public composition ─────────────────────────────
213
214/// All-in-one tabbed container. Builds a [`TabBar`] above a
215/// `Switcher` of content panes, sharing one selection signal.
216pub struct TabWidget {
217    selected_id: Signal<Option<TabId>>,
218    /// Internal index signal driving the inner `Switcher`'s
219    /// visibility. Self-owned (persists across rebuilds) and kept in
220    /// sync with `selected_id` via a single one-way effect installed
221    /// in [`build`](Widget::build) — the bar manages its own id↔index
222    /// bridge for keyboard / click / scroll, so this is just the
223    /// content-pane mirror.
224    switcher_index: Signal<usize>,
225
226    /// Bar orientation — **reactive**. `Horizontal` (default) places
227    /// the bar above the content; `Vertical` places it on the leading
228    /// edge with content on the trailing side. Bound at
229    /// [`BindingLevel::Rebuild`]
230    /// in [`build`](Widget::build), so flipping it from outside the
231    /// widget re-runs the build with the new layout (the inner
232    /// content panes are memoized across this rebuild — their
233    /// internal state is preserved).
234    orientation: Signal<TabBarOrientation>,
235
236    static_tabs: Vec<StaticTabSlot>,
237    dynamic_registry: HashMap<&'static str, DynamicContentFactory>,
238    dynamic_model: Option<ListModel<TabHandle>>,
239
240    /// Lazily-populated map from a dynamic tab's stable [`TabId`] to
241    /// its content-pane WidgetId. Lets pane widgets (with their
242    /// internal mutable state — focus, scroll, animation, …) survive
243    /// across rebuilds caused by reorder, pin/unpin toggles, or
244    /// adjacent insertions / removals. Pruned every build to drop
245    /// entries whose tab is no longer in the model.
246    dyn_pane_ids: HashMap<TabId, WidgetId>,
247
248    // Bar configuration — forwarded to the inner TabBar.
249    /// Optional tab-strip height override (the strip's cross-axis extent).
250    /// `None` keeps the style's `editor_tab_height`. Set via
251    /// [`Self::tab_bar_height`] / [`Self::compact_bar`].
252    tab_bar_height: Option<f32>,
253    /// Reactive sizing strategy. `None` until `.tab_sizing(...)`
254    /// or `.sizing(...)` is called; defaulted by the bar
255    /// (`TabSizing::Shared`) otherwise. `TabSizing::Fill` stretches the
256    /// tabs across the bar (the nav-rail look). When a signal is bound,
257    /// the [`TabWidget`] also binds it at
258    /// [`BindingLevel::Rebuild`]
259    /// so toggling the signal swaps the sizing mode live.
260    sizing: Option<Signal<TabSizing>>,
261    /// Reactive tab display mode (icon / text / icon+text). `None` until
262    /// `.tab_display(...)` is called; defaulted by the bar
263    /// ([`TabDisplayMode::Auto`]) otherwise. Bound at [`BindingLevel::Rebuild`]
264    /// like `sizing`, so flipping it swaps what the tabs show live.
265    tab_display: Option<Signal<TabDisplayMode>>,
266    /// All-states per-tab background shorthand. Set via
267    /// [`Self::tab_background`]. `None` (default) means transparent.
268    tab_background: Option<teksilo_core::color_prop::ColorProp>,
269    /// Background for the selected tab. Set via [`Self::selected_tab_background`].
270    selected_tab_background: Option<teksilo_core::color_prop::ColorProp>,
271    /// Background for the hovered (non-selected) tab. Set via
272    /// [`Self::hover_tab_background`].
273    hover_tab_background: Option<teksilo_core::color_prop::ColorProp>,
274    /// Background for idle tabs. Set via [`Self::idle_tab_background`].
275    idle_tab_background: Option<teksilo_core::color_prop::ColorProp>,
276    /// Bar-strip backdrop fill. Set via [`Self::bar_background`].
277    bar_background: Option<teksilo_core::color_prop::ColorProp>,
278    /// Draw a divider between consecutive tabs. Set via [`Self::tab_dividers`].
279    tab_dividers: bool,
280    /// Colour for the inter-tab dividers. Set via [`Self::tab_divider_color`].
281    tab_divider_color: Option<teksilo_core::color_prop::ColorProp>,
282    /// Active-tab highlight edge. Set via [`Self::active_indicator`].
283    active_indicator: Option<teksilo_core::styles::TabIndicatorPosition>,
284    /// Text role used for the label (and matching icon tint) on the
285    /// selected tab. Set via [`Self::selected_text_role`]. `None`
286    /// defaults to [`teksilo_tokens::TextRole::Primary`] (Int UI
287    /// editor-strip convention).
288    selected_text_role: Option<teksilo_tokens::TextRole>,
289    /// Text role used for the label (and matching icon tint) on idle
290    /// tabs. Set via [`Self::idle_text_role`]. `None` defaults to
291    /// [`teksilo_tokens::TextRole::Secondary`].
292    idle_text_role: Option<teksilo_tokens::TextRole>,
293    min_tab_width: Option<f32>,
294    max_tab_width: Option<f32>,
295    pinned_tab_width: Option<f32>,
296    show_scroll_arrows: Option<bool>,
297    overflow_button: Option<TabOverflowButton>,
298    reorderable: bool,
299    on_close: Option<Rc<dyn Fn(TabId, &mut EventContext)>>,
300    on_reorder: Option<Rc<dyn Fn(TabId, usize, &mut EventContext)>>,
301    on_pin_toggle: Option<Rc<dyn Fn(TabId, bool, &mut EventContext)>>,
302    /// Cross-bar transfer opt-in. Enables this `TabWidget` to both
303    /// hand its (dynamic) tabs to other accepting `TabWidget`s and
304    /// receive tabs from them.
305    accept_external_tabs: bool,
306    /// Target-side override: insert a received tab. Receives the moved
307    /// [`TabHandle`] and the insertion index *within the dynamic
308    /// region*. Defaults to inserting into [`dynamic_model`](Self::dynamic_model).
309    on_tab_received: Option<Rc<dyn Fn(TabHandle, usize, &mut EventContext)>>,
310    /// Source-side override: one of this widget's tabs was accepted by
311    /// another `TabWidget`. Receives the transferred [`TabId`].
312    /// Defaults to removing it from [`dynamic_model`](Self::dynamic_model).
313    on_transfer_out: Option<Rc<dyn Fn(TabId, &mut EventContext)>>,
314    /// Handler for **non-tab** drops (an in-app foreign drag carrying
315    /// app data, or an OS file/text/URL drop). Receives the raw
316    /// payload and the insertion index *within the dynamic region*.
317    on_external_drop: Option<Rc<dyn Fn(&DragPayload, usize, &mut EventContext) -> bool>>,
318    bar_leading_slot: Option<BarSlot>,
319    bar_trailing_slot: Option<BarSlot>,
320    /// Tab-strip visibility policy, statically or reactively. See
321    /// [`TabBarVisibility`]. Bound at [`BindingLevel::Rebuild`] so a flip
322    /// re-runs `build` and re-derives `show_bar`.
323    bar_visibility: Prop<TabBarVisibility>,
324
325    root_child_id: Option<WidgetId>,
326
327    /// Whole-widget enabled state, statically or reactively. Forwarded to
328    /// the arena via `ctx.enabled_when(self_id, self.enabled.clone())` at
329    /// build time; a disabled `TabWidget` greys out and stops accepting
330    /// focus / selection / keyboard input (arena-gated). Distinct from
331    /// per-tab `TabInfo::enabled`.
332    enabled: Prop<bool>,
333}
334
335/// Controls whether a [`TabWidget`]'s tab strip is shown.
336///
337/// The default is [`Always`](TabBarVisibility::Always) — fully
338/// back-compatible with the historical behaviour. [`WhenMultiple`](
339/// TabBarVisibility::WhenMultiple) hides the strip while a single tab
340/// is present (the content fills the whole area) and shows it again
341/// once a second tab appears; the evaluation is reactive because a
342/// dynamic-model mutation already rebuilds the `TabWidget`.
343/// [`Never`](TabBarVisibility::Never) always hides the strip (the
344/// selector lives elsewhere — e.g. a docking activity rail).
345#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
346pub enum TabBarVisibility {
347    /// Always render the tab strip (historical default).
348    #[default]
349    Always,
350    /// Show the strip only when two or more tabs are present.
351    WhenMultiple,
352    /// Never render the strip; the content fills the whole area.
353    Never,
354}
355
356impl std::fmt::Debug for TabWidget {
357    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
358        f.debug_struct("TabWidget")
359            .field("selected", &self.selected_id.get())
360            .field("static_tabs", &self.static_tabs.len())
361            .field(
362                "dynamic_registry",
363                &self.dynamic_registry.keys().collect::<Vec<_>>(),
364            )
365            .field("has_dynamic_model", &self.dynamic_model.is_some())
366            .finish()
367    }
368}
369
370impl TabWidget {
371    /// Construct an empty `TabWidget`. Selection is `None` until
372    /// the first `static_tab(...)` / `dynamic_model(...)` adds a
373    /// tab and the framework activates it.
374    pub fn new(selected: Signal<Option<TabId>>) -> Self {
375        Self {
376            selected_id: selected,
377            switcher_index: Signal::new(0_usize),
378            orientation: Signal::new(TabBarOrientation::Horizontal),
379            static_tabs: Vec::new(),
380            dynamic_registry: HashMap::new(),
381            dynamic_model: None,
382            dyn_pane_ids: HashMap::new(),
383            sizing: None,
384            tab_display: None,
385            tab_background: None,
386            selected_tab_background: None,
387            hover_tab_background: None,
388            idle_tab_background: None,
389            bar_background: None,
390            tab_dividers: false,
391            tab_divider_color: None,
392            active_indicator: None,
393            selected_text_role: None,
394            idle_text_role: None,
395            min_tab_width: None,
396            max_tab_width: None,
397            pinned_tab_width: None,
398            show_scroll_arrows: None,
399            overflow_button: None,
400            reorderable: false,
401            on_close: None,
402            on_reorder: None,
403            on_pin_toggle: None,
404            accept_external_tabs: false,
405            on_tab_received: None,
406            on_transfer_out: None,
407            on_external_drop: None,
408            bar_leading_slot: None,
409            bar_trailing_slot: None,
410            tab_bar_height: None,
411            bar_visibility: Prop::Static(TabBarVisibility::Always),
412            root_child_id: None,
413            enabled: Prop::Static(true),
414        }
415    }
416
417    /// Enable or disable the whole widget. A disabled `TabWidget` greys out
418    /// and stops accepting focus / selection / keyboard input
419    /// (arena-gated). Distinct from per-tab `TabInfo::enabled`.
420    pub fn enabled(mut self, enabled: impl Into<Prop<bool>>) -> Self {
421        self.enabled = enabled.into();
422        self
423    }
424
425    /// Set the tab-strip visibility policy (default
426    /// [`TabBarVisibility::Always`]). Use [`TabBarVisibility::WhenMultiple`]
427    /// to hide the strip while a single tab is present, or
428    /// [`TabBarVisibility::Never`] when an external selector (e.g. a
429    /// docking activity rail) drives selection.
430    ///
431    /// Accepts a plain [`TabBarVisibility`] or a `Signal<TabBarVisibility>`.
432    /// Bound reactively, the strip appears and disappears in place — the
433    /// `TabWidget` itself is never torn down, so per-tab content state
434    /// (caret, scroll offset, focus) survives the flip. That is the point
435    /// of binding rather than swapping two `TabWidget`s in a `Switcher`:
436    /// an app-level "hide the chrome" mode must not cost the user their
437    /// place in the document.
438    ///
439    /// A derived signal (`.map(..)` / `.zip(..)`) is fine here: binding
440    /// resolves through to the mutable roots and never calls `observe`.
441    pub fn bar_visibility(mut self, visibility: impl Into<Prop<TabBarVisibility>>) -> Self {
442        self.bar_visibility = visibility.into();
443        self
444    }
445
446    /// Override the tab-strip height (its cross-axis extent). `None` /
447    /// unset keeps the style's `editor_tab_height` (50 dp). Use for a denser
448    /// strip — e.g. dock side panels.
449    pub fn tab_bar_height(mut self, dp: f32) -> Self {
450        self.tab_bar_height = Some(dp.max(0.0));
451        self
452    }
453
454    /// Shorthand for a **compact** (38 dp) tab strip — denser than the standard
455    /// 50 dp editor strip. Equivalent to `self.tab_bar_height(38.0)`.
456    pub fn compact_bar(self) -> Self {
457        self.tab_bar_height(38.0)
458    }
459
460    /// Configure the bar to render vertically — pills stacked
461    /// top-to-bottom on the leading edge, content fills the trailing
462    /// area (sidebar / IDE-perspective convention). Equivalent to
463    /// `self.orientation(TabBarOrientation::Vertical)`.
464    pub fn vertical(self) -> Self {
465        self.orientation.set(TabBarOrientation::Vertical);
466        self
467    }
468
469    /// Configure the bar to render horizontally — pills laid out
470    /// left-to-right above the content (browser tab convention).
471    /// This is the default.
472    pub fn horizontal(self) -> Self {
473        self.orientation.set(TabBarOrientation::Horizontal);
474        self
475    }
476
477    /// Set the bar orientation, statically or reactively. Passing a
478    /// `Signal<TabBarOrientation>` replaces the internal orientation
479    /// signal with the external one — lets a parent widget toggle
480    /// orientation reactively (e.g. a "View → Vertical Tabs" toolbar
481    /// button) without recreating the `TabWidget`.
482    pub fn orientation(mut self, orientation: impl Into<Prop<TabBarOrientation>>) -> Self {
483        self.orientation = orientation.into().as_signal();
484        self
485    }
486
487    /// Add a static tab — fixed for the widget's lifetime, with a
488    /// pre-built content widget. The content is registered in the
489    /// arena on the [`TabWidget`]'s first build and **memoized** —
490    /// subsequent rebuilds (caused by adjacent dynamic-model
491    /// mutations) reuse the same pane WidgetId, preserving any
492    /// internal state the content owns.
493    pub fn static_tab(mut self, info: TabInfo, content: impl teksilo_core::IntoTeksiChild) -> Self {
494        match teksilo_core::IntoTeksiChild::into_pending(content) {
495            teksilo_core::PendingChild::Id(id) => {
496                let handle = TabHandle::static_handle(TabId::fresh(), info);
497                self.static_tabs.push(StaticTabSlot {
498                    handle,
499                    source: StaticContentSource::PreId(Some(id)),
500                    pane_id: None,
501                });
502                self
503            }
504            teksilo_core::PendingChild::Deferred(w) => {
505                let handle = TabHandle::static_handle(TabId::fresh(), info);
506                self.static_tabs.push(StaticTabSlot {
507                    handle,
508                    source: StaticContentSource::Owned(Some(w)),
509                    pane_id: None,
510                });
511                self
512            }
513        }
514    }
515
516    /// Add several static tabs from an iterator of `(info, content)` pairs.
517    ///
518    /// The loop form of [`static_tab`](Self::static_tab). Reach for it when the
519    /// tab set is data-driven and each tab needs more than a title; when a title
520    /// is all it needs, [`tabs`](Self::tabs) is shorter.
521    pub fn static_tabs<W>(self, tabs: impl IntoIterator<Item = (TabInfo, W)>) -> Self
522    where
523        W: teksilo_core::IntoTeksiChild,
524    {
525        tabs.into_iter()
526            .fold(self, |w, (info, content)| w.static_tab(info, content))
527    }
528
529    /// Ergonomic shorthand for a title-only static tab:
530    /// `tab(label, content)` is `static_tab(TabInfo::new().title(label),
531    /// content)`. `label` accepts `tr!(...)` (translated) or `lit!(...)`.
532    /// This is the method the `teksu!` `tab:` slot lowers to
533    /// (`tab: lit!("Overview"), Card { … }`).
534    pub fn tab(
535        self,
536        label: impl Into<LocalizedString>,
537        content: impl teksilo_core::IntoTeksiChild,
538    ) -> Self {
539        self.static_tab(TabInfo::new().title(label), content)
540    }
541
542    /// Add several title-only static tabs from an iterator of
543    /// `(label, content)` pairs.
544    ///
545    /// The loop form of [`tab`](Self::tab), and the usual one once the tab set
546    /// comes from data rather than being written out tab by tab.
547    pub fn tabs<L, W>(self, tabs: impl IntoIterator<Item = (L, W)>) -> Self
548    where
549        L: Into<LocalizedString>,
550        W: teksilo_core::IntoTeksiChild,
551    {
552        tabs.into_iter()
553            .fold(self, |w, (label, content)| w.tab(label, content))
554    }
555
556    /// Several title-only static tabs from an iterator of `(label, id)` pairs.
557    ///
558    /// [`tabs`](Self::tabs) accepts ids too, so this is the spelling that names
559    /// the id type rather than a capability the other method lacks. Reach for it
560    /// when a loop has already registered its panes and holds the `WidgetId`s.
561    pub fn tab_ids<L>(self, tabs: impl IntoIterator<Item = (L, WidgetId)>) -> Self
562    where
563        L: Into<LocalizedString>,
564    {
565        tabs.into_iter()
566            .fold(self, |w, (label, id)| w.tab(label, id))
567    }
568
569    /// Add a static tab whose content is constructed by a factory
570    /// closure. The factory is called once — on the slot's first
571    /// build — and the resulting pane is memoized just like
572    /// [`static_tab`](Self::static_tab).
573    pub fn static_tab_factory(
574        mut self,
575        info: TabInfo,
576        factory: impl Fn(&TabHandle) -> Box<dyn Widget> + 'static,
577    ) -> Self {
578        let handle = TabHandle::static_handle(TabId::fresh(), info);
579        self.static_tabs.push(StaticTabSlot {
580            handle,
581            source: StaticContentSource::Factory(Rc::new(factory)),
582            pane_id: None,
583        });
584        self
585    }
586
587    /// Add several static tabs from an iterator of `(info, content_id)` pairs.
588    ///
589    /// [`static_tabs`](Self::static_tabs) accepts ids too; this is the spelling
590    /// that names the id type, for panes the caller has already registered.
591    pub fn static_tab_ids(self, tabs: impl IntoIterator<Item = (TabInfo, WidgetId)>) -> Self {
592        tabs.into_iter()
593            .fold(self, |w, (info, id)| w.static_tab(info, id))
594    }
595
596    /// Add a static tab with a caller-provided [`TabId`] — useful
597    /// when external code (an app-event handler, a session-restore
598    /// path, a deep link) needs to flip selection to this tab by id.
599    /// The pane is memoized like [`static_tab`](Self::static_tab).
600    pub fn static_tab_with_id(
601        mut self,
602        id: TabId,
603        info: TabInfo,
604        content: impl Widget + 'static,
605    ) -> Self {
606        let handle = TabHandle::static_handle(id, info);
607        self.static_tabs.push(StaticTabSlot {
608            handle,
609            source: StaticContentSource::Owned(Some(Box::new(content))),
610            pane_id: None,
611        });
612        self
613    }
614
615    /// Factory variant of [`static_tab_with_id`](Self::static_tab_with_id).
616    pub fn static_tab_factory_with_id(
617        mut self,
618        id: TabId,
619        info: TabInfo,
620        factory: impl Fn(&TabHandle) -> Box<dyn Widget> + 'static,
621    ) -> Self {
622        let handle = TabHandle::static_handle(id, info);
623        self.static_tabs.push(StaticTabSlot {
624            handle,
625            source: StaticContentSource::Factory(Rc::new(factory)),
626            pane_id: None,
627        });
628        self
629    }
630
631    /// Register a dynamic-tab content factory keyed by `kind`. The
632    /// `<S>` type parameter pins the payload type — the framework
633    /// downcasts `handle.payload` to `S` before calling the
634    /// factory and panics with a clear message on kind/payload
635    /// mismatch, so `Any` never leaks into app code.
636    pub fn dynamic_tab<S: Any + 'static>(
637        mut self,
638        kind: &'static str,
639        factory: impl Fn(&TabHandle, &S) -> Box<dyn Widget> + 'static,
640    ) -> Self {
641        assert!(
642            kind != STATIC_KIND,
643            "tab kind '{}' is reserved by the framework for static tabs",
644            STATIC_KIND
645        );
646        debug_assert!(
647            !self.dynamic_registry.contains_key(kind),
648            "dynamic_tab kind '{kind}' is already registered — duplicate registration"
649        );
650        let kind_for_panic = kind;
651        let typed_factory: DynamicContentFactory = Rc::new(move |handle, payload| {
652            let typed = payload.downcast_ref::<S>().unwrap_or_else(|| {
653                panic!(
654                    "tab kind '{}' was registered for {} but the handle's \
655                     payload has a different type",
656                    kind_for_panic,
657                    std::any::type_name::<S>(),
658                )
659            });
660            factory(handle, typed)
661        });
662        self.dynamic_registry.insert(kind, typed_factory);
663        self
664    }
665
666    /// Connect the dynamic-tab data source. Mutations rebuild the
667    /// dynamic-tab subtree; static tabs are unaffected.
668    pub fn dynamic_model(mut self, model: ListModel<TabHandle>) -> Self {
669        self.dynamic_model = Some(model);
670        self
671    }
672
673    // ── Bar configuration (forwarded to inner TabBar) ──────────────
674
675    /// Set the per-tab sizing strategy as a static value. Internally
676    /// stores it as a `Signal<TabSizing>` so the widget can be
677    /// retrofitted to reactive control via [`Self::sizing`]
678    /// without breaking existing call sites.
679    pub fn tab_sizing(mut self, mode: TabSizing) -> Self {
680        self.sizing = Some(Signal::new(mode));
681        self
682    }
683
684    /// Bind the per-tab sizing strategy, statically or reactively —
685    /// flipping a bound signal swaps between Shared / Independent / Fill
686    /// live, with no rebuild on the parent's part. The signal is bound at
687    /// `BindingLevel::Rebuild` inside [`build`](Widget::build);
688    /// memoized panes survive the rebuild so per-tab state is
689    /// preserved.
690    pub fn sizing(mut self, sizing: impl Into<Prop<TabSizing>>) -> Self {
691        self.sizing = Some(sizing.into().as_signal());
692        self
693    }
694
695    /// Choose what every tab shows — icon, label, or both
696    /// ([`TabDisplayMode`]), statically or reactively. A bound signal can be
697    /// flipped to swap icon / text / icon+text live (the bar rebuilds,
698    /// memoized panes survive), with no rebuild on the parent's part. Bound
699    /// at `BindingLevel::Rebuild`.
700    pub fn tab_display(mut self, mode: impl Into<Prop<TabDisplayMode>>) -> Self {
701        self.tab_display = Some(mode.into().as_signal());
702        self
703    }
704
705    /// All-states shorthand for the per-tab background — every tab
706    /// (selected, idle, hovered) paints this unless a per-state override
707    /// is set. Accepts any `Color`, `SurfaceRole`, or `Signal<Color>` (via
708    /// [`ColorProp`](teksilo_core::color_prop::ColorProp)). Default is
709    /// transparent. To tint the bar's backdrop instead, use
710    /// [`bar_background`](Self::bar_background).
711    pub fn tab_background(mut self, color: impl Into<teksilo_core::color_prop::ColorProp>) -> Self {
712        self.tab_background = Some(color.into());
713        self
714    }
715
716    /// Background for the **selected** tab. Falls back to
717    /// [`tab_background`](Self::tab_background), then transparent.
718    pub fn selected_tab_background(
719        mut self,
720        color: impl Into<teksilo_core::color_prop::ColorProp>,
721    ) -> Self {
722        self.selected_tab_background = Some(color.into());
723        self
724    }
725
726    /// Background for the **hovered** (non-selected) tab. Falls back to
727    /// [`tab_background`](Self::tab_background), then transparent.
728    pub fn hover_tab_background(
729        mut self,
730        color: impl Into<teksilo_core::color_prop::ColorProp>,
731    ) -> Self {
732        self.hover_tab_background = Some(color.into());
733        self
734    }
735
736    /// Background for **idle** tabs (not selected, not hovered). Falls back
737    /// to [`tab_background`](Self::tab_background), then transparent.
738    pub fn idle_tab_background(
739        mut self,
740        color: impl Into<teksilo_core::color_prop::ColorProp>,
741    ) -> Self {
742        self.idle_tab_background = Some(color.into());
743        self
744    }
745
746    /// Set the bar-strip backdrop fill (behind headers, slots, arrows),
747    /// independent of the per-tab backgrounds. Default transparent.
748    pub fn bar_background(mut self, color: impl Into<teksilo_core::color_prop::ColorProp>) -> Self {
749        self.bar_background = Some(color.into());
750        self
751    }
752
753    /// Draw a 1 dp divider between consecutive tabs. Off by default.
754    pub fn tab_dividers(mut self) -> Self {
755        self.tab_dividers = true;
756        self
757    }
758
759    /// Like [`tab_dividers`](Self::tab_dividers) with an explicit colour
760    /// (`Color`, [`BorderRole`](teksilo_tokens::BorderRole), or
761    /// `Signal<Color>`). Implies `tab_dividers()`.
762    pub fn tab_divider_color(
763        mut self,
764        color: impl Into<teksilo_core::color_prop::ColorProp>,
765    ) -> Self {
766        self.tab_dividers = true;
767        self.tab_divider_color = Some(color.into());
768        self
769    }
770
771    /// Choose which edge the active-tab highlight indicator hugs. Default
772    /// [`TabIndicatorPosition::OuterEdge`](teksilo_core::styles::TabIndicatorPosition);
773    /// [`InnerEdge`](teksilo_core::styles::TabIndicatorPosition::InnerEdge)
774    /// puts it below the label (horizontal) / trailing edge (vertical).
775    pub fn active_indicator(
776        mut self,
777        position: teksilo_core::styles::TabIndicatorPosition,
778    ) -> Self {
779        self.active_indicator = Some(position);
780        self
781    }
782
783    /// Set the text role used for the label (and matching icon tint)
784    /// on the **selected** tab. Default: [`teksilo_tokens::TextRole::Primary`]
785    /// — the Int UI editor-strip convention. Override to e.g.
786    /// [`teksilo_tokens::TextRole::Accent`] when the strip sits over a
787    /// tinted surface.
788    pub fn selected_text_role(mut self, role: teksilo_tokens::TextRole) -> Self {
789        self.selected_text_role = Some(role);
790        self
791    }
792
793    /// Set the text role used for the label (and matching icon tint)
794    /// on **idle** tabs (not selected, not disabled). Default:
795    /// [`teksilo_tokens::TextRole::Secondary`]. Disabled tabs always read
796    /// as [`teksilo_tokens::TextRole::Disabled`] regardless of this
797    /// setting.
798    pub fn idle_text_role(mut self, role: teksilo_tokens::TextRole) -> Self {
799        self.idle_text_role = Some(role);
800        self
801    }
802    /// Minimum scrollable-tab width in logical pixels. Default
803    /// [`DEFAULT_MIN_TAB_WIDTH`].
804    pub fn min_tab_width(mut self, dp: f32) -> Self {
805        self.min_tab_width = Some(dp);
806        self
807    }
808    /// Maximum scrollable-tab width in logical pixels. Default
809    /// [`DEFAULT_MAX_TAB_WIDTH`].
810    pub fn max_tab_width(mut self, dp: f32) -> Self {
811        self.max_tab_width = Some(dp);
812        self
813    }
814    /// Fixed width for pinned (icon-only) tabs in logical pixels. Default
815    /// [`DEFAULT_PINNED_TAB_WIDTH`].
816    pub fn pinned_tab_width(mut self, dp: f32) -> Self {
817        self.pinned_tab_width = Some(dp);
818        self
819    }
820    /// Show or hide the leading/trailing scroll-arrow buttons when tabs overflow.
821    /// Default (unset) uses the style's preference.
822    pub fn show_scroll_arrows(mut self, on: bool) -> Self {
823        self.show_scroll_arrows = Some(on);
824        self
825    }
826    /// When the trailing "show all tabs" overflow dropdown appears. Default
827    /// (unset) is [`TabOverflowButton::Auto`] — shown only when the tab headers
828    /// overflow the bar's viewport. See [`TabOverflowButton`] for
829    /// `Always` / `Never`.
830    pub fn overflow_button(mut self, mode: TabOverflowButton) -> Self {
831        self.overflow_button = Some(mode);
832        self
833    }
834    /// Convenience over [`overflow_button`](Self::overflow_button): `true` maps
835    /// to [`TabOverflowButton::Always`], `false` to [`TabOverflowButton::Never`].
836    pub fn show_overflow_dropdown(mut self, on: bool) -> Self {
837        self.overflow_button = Some(if on {
838            TabOverflowButton::Always
839        } else {
840            TabOverflowButton::Never
841        });
842        self
843    }
844    /// Allow drag-to-reorder of tabs within the bar. Default `false`.
845    /// Setting [`on_reorder`](Self::on_reorder) implies `reorderable(true)`.
846    pub fn reorderable(mut self, on: bool) -> Self {
847        self.reorderable = on;
848        self
849    }
850
851    /// Install a close-tab handler. Receives the [`TabId`] of the
852    /// closed tab (not its index — indices are presentation-only)
853    /// and the firing [`EventContext`]. The latter lets the handler
854    /// open a confirmation dialog
855    /// (`MessageBox::question(...).present(ctx)`), dispatch an
856    /// intent, or otherwise route the close request before mutating
857    /// the underlying model. To veto, do nothing in the handler; to
858    /// confirm-then-close, only call the model mutator on accept.
859    ///
860    /// If unset, the default behavior is to remove the tab from
861    /// [`dynamic_model`](Self::dynamic_model) without a prompt
862    /// (static tabs cannot be closed by default).
863    pub fn on_close(mut self, f: impl Fn(TabId, &mut EventContext) + 'static) -> Self {
864        self.on_close = Some(Rc::new(f));
865        self
866    }
867
868    /// Install a reorder handler. Receives `(moved_tab_id,
869    /// destination_index, ctx)` in the unified static-then-dynamic
870    /// ordering. The firing [`EventContext`] lets the handler
871    /// confirm or dispatch the reorder via a dialog / intent
872    /// before mutating the model. If unset, the default behavior
873    /// is to reorder within the dynamic region of
874    /// [`dynamic_model`](Self::dynamic_model). Implies
875    /// [`reorderable(true)`](Self::reorderable).
876    pub fn on_reorder(mut self, f: impl Fn(TabId, usize, &mut EventContext) + 'static) -> Self {
877        self.on_reorder = Some(Rc::new(f));
878        self.reorderable = true;
879        self
880    }
881
882    /// Install a pin-toggle handler — receives `(tab_id,
883    /// new_pinned_flag, ctx)` when the user drags a tab across the
884    /// pinned ↔ unpinned boundary. The firing [`EventContext`]
885    /// lets the handler confirm or dispatch the transition via a
886    /// dialog / intent. Apps decide whether to actually mutate the
887    /// tab's `info.pinned`.
888    pub fn on_pin_toggle(mut self, f: impl Fn(TabId, bool, &mut EventContext) + 'static) -> Self {
889        self.on_pin_toggle = Some(Rc::new(f));
890        self
891    }
892
893    /// Opt into cross-`TabWidget` tab transfer (app-internal
894    /// drag-and-drop between two tabbed containers). When enabled,
895    /// this widget's **dynamic** tabs can be dragged out to any other
896    /// accepting `TabWidget`, and it accepts tabs dragged in from one,
897    /// painting an insertion-line indicator between its tabs.
898    ///
899    /// The dragged [`TabHandle`] moves intact — its `Rc<dyn Any>`
900    /// payload (the heavy per-tab state) is preserved, not rebuilt —
901    /// so the receiving widget must register a content factory for the
902    /// tab's `kind` via [`dynamic_tab`](Self::dynamic_tab).
903    ///
904    /// **Static tabs are excluded**: they have no factory on a
905    /// receiving widget, so they can never be transferred out (they
906    /// still reorder in place if [`reorderable`](Self::reorderable)).
907    ///
908    /// By default, accepting a tab inserts it into this widget's
909    /// [`dynamic_model`](Self::dynamic_model) and transferring one out
910    /// removes it from this widget's model. Override either side with
911    /// [`on_tab_received`](Self::on_tab_received) /
912    /// [`on_transfer_out`](Self::on_transfer_out). Default: off.
913    pub fn accept_external_tabs(mut self, on: bool) -> Self {
914        self.accept_external_tabs = on;
915        self
916    }
917
918    /// Override the target-side behaviour when a foreign tab is
919    /// dropped onto this widget. Receives `(handle, insertion_index,
920    /// ctx)` where `insertion_index` is within the **dynamic** tab
921    /// region. The app inserts the handle into its own model. Implies
922    /// [`accept_external_tabs(true)`](Self::accept_external_tabs).
923    ///
924    /// If unset, the default inserts the handle into
925    /// [`dynamic_model`](Self::dynamic_model) at the drop position.
926    pub fn on_tab_received(
927        mut self,
928        f: impl Fn(TabHandle, usize, &mut EventContext) + 'static,
929    ) -> Self {
930        self.on_tab_received = Some(Rc::new(f));
931        self.accept_external_tabs = true;
932        self
933    }
934
935    /// Override the source-side behaviour after one of this widget's
936    /// tabs has been accepted by another `TabWidget`. Receives the
937    /// transferred [`TabId`]; the app removes it from its own model.
938    /// Implies [`accept_external_tabs(true)`](Self::accept_external_tabs).
939    ///
940    /// If unset, the default removes the tab from
941    /// [`dynamic_model`](Self::dynamic_model).
942    pub fn on_transfer_out(mut self, f: impl Fn(TabId, &mut EventContext) + 'static) -> Self {
943        self.on_transfer_out = Some(Rc::new(f));
944        self.accept_external_tabs = true;
945        self
946    }
947
948    /// Accept **non-tab** drops onto the tab bar — an in-app foreign
949    /// drag (e.g. a file dragged from a `TreeView`, carrying app data)
950    /// or an OS file/text/URL drop. The bar shows an insertion-line
951    /// indicator while such a payload hovers; on drop, `f` runs with
952    /// the raw [`DragPayload`], the insertion index *within the dynamic
953    /// region*, and the firing context. Inspect the payload
954    /// (`get_typed::<T>()` / `files()` / `text()` / `uris()`) and, e.g.,
955    /// push a new `TabHandle` into your [`dynamic_model`](Self::dynamic_model);
956    /// return `true` if accepted.
957    ///
958    /// This is the "open a dropped file as a tab" hook (VS Code style).
959    /// Independent of [`accept_external_tabs`](Self::accept_external_tabs).
960    /// OS drops also require `TeksiloAppBuilder::install_external_dnd()`.
961    pub fn on_external_drop(
962        mut self,
963        f: impl Fn(&DragPayload, usize, &mut EventContext) -> bool + 'static,
964    ) -> Self {
965        self.on_external_drop = Some(Rc::new(f));
966        self
967    }
968
969    /// Place a widget on the leading edge of the tab strip (before the first
970    /// tab). Memoized: registered once on first build, reused on rebuilds.
971    pub fn bar_leading_slot(mut self, w: impl teksilo_core::IntoTeksiChild) -> Self {
972        self.bar_leading_slot = Some(BarSlot::new(teksilo_core::IntoTeksiChild::into_pending(w)));
973        self
974    }
975    /// Place a widget on the trailing edge of the tab strip (after the last
976    /// tab and overflow button). Memoized like
977    /// [`bar_leading_slot`](Self::bar_leading_slot).
978    pub fn bar_trailing_slot(mut self, w: impl teksilo_core::IntoTeksiChild) -> Self {
979        self.bar_trailing_slot = Some(BarSlot::new(teksilo_core::IntoTeksiChild::into_pending(w)));
980        self
981    }
982}
983
984impl TabWidget {
985    // ── build() helpers ────────────────────────────────────────────
986    //
987    // `build()` is decomposed into three self-contained steps so the
988    // method body reads as orchestration rather than implementation.
989    // Each helper captures only `&self` (plus the build-local lookup
990    // tables it needs) and has no side effects beyond the arena
991    // registrations it performs through `ctx`.
992
993    /// Translate `TabInfo` fields into the [`TabDelegate`]'s
994    /// closure-shaped accessors. Pure — captures nothing from the
995    /// surrounding `build()`.
996    fn build_delegate(&self) -> TabDelegate<TabHandle> {
997        let mut delegate =
998            TabDelegate::new(|_, h: &TabHandle| h.info.title.clone().unwrap_or_else(|| lit!("")))
999                .icon(|_, h: &TabHandle| h.info.icon.as_ref().map(|f| f()))
1000                .closable(|_, h: &TabHandle| h.info.closable)
1001                .pinned(|_, h: &TabHandle| h.info.pinned)
1002                .enabled(|_, h: &TabHandle| h.info.initial_enabled.get())
1003                .tooltip(|_, h: &TabHandle| {
1004                    // Pinned tabs render icon-only; promote `title` to the
1005                    // tooltip if the caller didn't set one explicitly so
1006                    // the user can still identify the tab on hover.
1007                    if h.info.pinned
1008                        && h.info.tooltip.is_none()
1009                        && h.info.rich_tooltip.is_none()
1010                        && h.info.composite_tooltip.is_none()
1011                    {
1012                        h.info.title.clone()
1013                    } else {
1014                        h.info.tooltip.clone()
1015                    }
1016                });
1017        // Bypass the tooltip-clearing setters here: TabInfo already
1018        // enforces mutual exclusion across plain / rich / composite,
1019        // so each closure returns `Some` only for its flavor.
1020        delegate.rich_tooltip_key = Some(Box::new(|_, h: &TabHandle| match &h.info.rich_tooltip {
1021            Some(crate::tooltip::RichTooltipSource::Key(k)) => Some(k.clone()),
1022            _ => None,
1023        }));
1024        delegate.rich_tooltip_content =
1025            Some(Box::new(|_, h: &TabHandle| match &h.info.rich_tooltip {
1026                Some(crate::tooltip::RichTooltipSource::Content(c)) => Some(c.clone()),
1027                _ => None,
1028            }));
1029        delegate.composite_tooltip = Some(Box::new(|_, h: &TabHandle| {
1030            h.info.composite_tooltip.as_ref().map(|factory| factory())
1031        }));
1032        delegate = delegate.context_menu(|_, h: &TabHandle| h.info.context_menu.clone());
1033        delegate
1034    }
1035
1036    /// Wrap the bar's index-shaped callbacks (close / reorder / pin /
1037    /// cross-bar transfer / non-tab drop) into the app's id-shaped
1038    /// callbacks, translating at the boundary via `index_to_id` and
1039    /// `saturating_sub(static_count)` for the unified→dynamic index map.
1040    fn wire_bar_callbacks(
1041        &self,
1042        mut bar: TabBar<TabHandle>,
1043        index_to_id: &Rc<Vec<TabId>>,
1044        static_count: usize,
1045    ) -> TabBar<TabHandle> {
1046        // Wrap callbacks: bar speaks in indices, app speaks in
1047        // TabIds. We translate at the boundary using the
1048        // `index_to_id` lookup captured at build time.
1049        let close_cb = self.on_close.clone();
1050        let dyn_model_for_close = self.dynamic_model.clone();
1051        let idx_to_id_for_close = index_to_id.clone();
1052        bar = bar.on_close(move |i: usize, ctx: &mut EventContext| {
1053            if let Some(&id) = idx_to_id_for_close.get(i) {
1054                if let Some(ref f) = close_cb {
1055                    f(id, ctx);
1056                } else if i >= static_count {
1057                    // Default: remove from dynamic_model. Static
1058                    // tabs are not auto-closable.
1059                    if let Some(ref model) = dyn_model_for_close {
1060                        let dyn_idx = i - static_count;
1061                        if dyn_idx < model.len() {
1062                            let _ = model.remove(dyn_idx);
1063                        }
1064                    }
1065                }
1066            }
1067        });
1068
1069        // `on_reorder(...)` setter sets `reorderable = true`, so the
1070        // single `self.reorderable` flag is the only gate we need.
1071        let reorder_cb = self.on_reorder.clone();
1072        let dyn_model_for_reorder = self.dynamic_model.clone();
1073        let idx_to_id_for_reorder = index_to_id.clone();
1074        if self.reorderable {
1075            bar = bar.on_reorder(move |from: usize, to: usize, ctx: &mut EventContext| {
1076                if let Some(&id) = idx_to_id_for_reorder.get(from) {
1077                    if let Some(ref f) = reorder_cb {
1078                        f(id, to, ctx);
1079                    } else if from >= static_count && to >= static_count {
1080                        // Default: reorder within the dynamic region
1081                        // only. Static tabs are pinned in place.
1082                        if let Some(ref model) = dyn_model_for_reorder {
1083                            let from_dyn = from - static_count;
1084                            let to_dyn = to - static_count;
1085                            if from_dyn < model.len() && to_dyn < model.len() {
1086                                model.move_item(from_dyn, to_dyn);
1087                            }
1088                        }
1089                    } else {
1090                        // Cross-boundary reorder: silently rejected
1091                        // by the default handler. Surface it once
1092                        // per process so developers don't chase a
1093                        // ghost — install an explicit `on_reorder`
1094                        // to interleave static and dynamic tabs.
1095                        warn_cross_boundary_reorder_once(from, to, static_count);
1096                    }
1097                }
1098            });
1099        }
1100
1101        if let Some(f) = self.on_pin_toggle.clone() {
1102            let idx_to_id = index_to_id.clone();
1103            bar = bar.on_pin_toggle(move |i: usize, pinned: bool, ctx: &mut EventContext| {
1104                if let Some(&id) = idx_to_id.get(i) {
1105                    f(id, pinned, ctx);
1106                }
1107            });
1108        }
1109
1110        // Cross-bar transfer wiring. The bar speaks in unified model
1111        // indices (static tabs first, then dynamic); the app speaks in
1112        // dynamic-region indices and TabIds. Static tabs are excluded
1113        // from transfer — they have no factory on a receiving widget.
1114        if self.accept_external_tabs {
1115            bar = bar
1116                .accept_external_tabs(true)
1117                .with_transferable_predicate(|_, h: &TabHandle| h.kind != STATIC_KIND);
1118
1119            // Target side: insert the received handle. The bar's
1120            // insertion index is in unified model space; translate to
1121            // a dynamic-region index for the app / default model.
1122            let received_cb = self.on_tab_received.clone();
1123            let dyn_model_for_recv = self.dynamic_model.clone();
1124            bar = bar.on_tab_received_rc(Rc::new(
1125                move |handle: TabHandle, to_model: usize, ctx: &mut EventContext| {
1126                    let dyn_index = to_model.saturating_sub(static_count);
1127                    if let Some(ref f) = received_cb {
1128                        f(handle, dyn_index, ctx);
1129                    } else if let Some(ref model) = dyn_model_for_recv {
1130                        let idx = dyn_index.min(model.len());
1131                        model.insert(idx, handle);
1132                    }
1133                },
1134            ));
1135
1136            // Source side: remove the transferred tab by id.
1137            let transfer_out_cb = self.on_transfer_out.clone();
1138            let dyn_model_for_out = self.dynamic_model.clone();
1139            bar = bar.on_transfer_out_rc(Rc::new(move |tab_id: TabId, ctx: &mut EventContext| {
1140                if let Some(ref f) = transfer_out_cb {
1141                    f(tab_id, ctx);
1142                } else if let Some(ref model) = dyn_model_for_out {
1143                    let pos =
1144                        (0..model.len()).find(|&i| model.with_item(i, |h| h.id) == Some(tab_id));
1145                    if let Some(pos) = pos {
1146                        let _ = model.remove(pos);
1147                    }
1148                }
1149            }));
1150        }
1151
1152        // Non-tab drops (foreign in-app drag / OS file drop). Translate
1153        // the bar's unified model index to a dynamic-region index for
1154        // the app callback. Independent of `accept_external_tabs`.
1155        if let Some(external_cb) = self.on_external_drop.clone() {
1156            bar = bar.on_external_drop_rc(Rc::new(
1157                move |payload: &DragPayload, to_model: usize, ctx: &mut EventContext| {
1158                    let dyn_index = to_model.saturating_sub(static_count);
1159                    (external_cb)(payload, dyn_index, ctx)
1160                },
1161            ));
1162        }
1163
1164        bar
1165    }
1166
1167    /// Build (or reuse) the content panes. Static and dynamic panes
1168    /// both memoize their pane `WidgetId` — once registered, the pane
1169    /// outlives sibling rebuilds (caused by dynamic-model mutations) so
1170    /// internal state survives. Static panes cache in
1171    /// [`StaticTabSlot::pane_id`]; dynamic panes cache in
1172    /// [`Self::dyn_pane_ids`] keyed by [`TabId`], pruned at the end to
1173    /// drop tabs no longer in the model.
1174    fn build_panes(
1175        &mut self,
1176        ctx: &mut BuildContext,
1177        all_handles: &[TabHandle],
1178        static_count: usize,
1179        dyn_count: usize,
1180        panel_ids: &Rc<RefCell<Vec<WidgetId>>>,
1181        header_ids: &Rc<RefCell<Vec<WidgetId>>>,
1182    ) -> Vec<WidgetId> {
1183        let mut pane_ids: Vec<WidgetId> = Vec::with_capacity(static_count + dyn_count);
1184
1185        for slot in self.static_tabs.iter_mut() {
1186            let pane_id = match slot.pane_id {
1187                Some(id) => id,
1188                None => {
1189                    let content = slot.source.into_widget(&slot.handle);
1190                    let id = ctx.add(TabPane::new(
1191                        slot.handle.clone(),
1192                        content,
1193                        panel_ids.clone(),
1194                        header_ids.clone(),
1195                    ));
1196                    slot.pane_id = Some(id);
1197                    id
1198                }
1199            };
1200            pane_ids.push(pane_id);
1201        }
1202
1203        let mut alive_dyn: HashSet<TabId> = HashSet::with_capacity(dyn_count);
1204        for handle in all_handles.iter().skip(static_count) {
1205            alive_dyn.insert(handle.id);
1206            let pane_id = match self.dyn_pane_ids.get(&handle.id) {
1207                Some(&id) => id,
1208                None => {
1209                    let factory = self.dynamic_registry.get(handle.kind).unwrap_or_else(|| {
1210                        panic!(
1211                            "tab kind '{}' has no registered content factory — \
1212                             add a `dynamic_tab::<S>(\"{}\", |handle, state| ...)` \
1213                             registration before connecting the model",
1214                            handle.kind, handle.kind,
1215                        )
1216                    });
1217                    let content = factory(handle, handle.payload.as_ref());
1218                    let id = ctx.add(TabPane::new(
1219                        handle.clone(),
1220                        content,
1221                        panel_ids.clone(),
1222                        header_ids.clone(),
1223                    ));
1224                    self.dyn_pane_ids.insert(handle.id, id);
1225                    id
1226                }
1227            };
1228            pane_ids.push(pane_id);
1229        }
1230        // Prune dynamic-pane memo entries for tabs the model no longer
1231        // carries. Their pane widgets are absent from the children this
1232        // rebuild returns, so the reconciling rebuild path (TabWidget is
1233        // `preserves_children_on_rebuild`) destroys them — they are not left
1234        // as stranded, still-active orphans.
1235        self.dyn_pane_ids.retain(|id, _| alive_dyn.contains(id));
1236
1237        pane_ids
1238    }
1239}
1240
1241impl Widget for TabWidget {
1242    fn build(&mut self, ctx: &mut BuildContext) -> Vec<WidgetId> {
1243        let self_id = ctx.self_id();
1244        ctx.enabled_when(self_id, self.enabled.clone());
1245
1246        // Bind orientation at Rebuild level — toggling the signal
1247        // (e.g. via a toolbar button) rebuilds TabWidget with the
1248        // new outer layout (HStack ↔ VStack) and a fresh TabBar in
1249        // the new orientation. Memoized panes survive the rebuild.
1250        self.orientation
1251            .bind_to(self_id, ctx.binding_registry(), BindingLevel::Rebuild);
1252        let orientation = self.orientation.get();
1253
1254        // Subscribe to dynamic-model mutations so add / remove /
1255        // reorder triggers a TabWidget rebuild that picks up the
1256        // new tab list.
1257        if let Some(model) = &self.dynamic_model {
1258            let version = ctx.signal(0_u64);
1259            version.bind_to(self_id, ctx.binding_registry(), BindingLevel::Rebuild);
1260            let observer = model.observe_changes({
1261                let v = version.clone();
1262                move |_change| v.set(v.get().wrapping_add(1))
1263            });
1264            ctx.own_handle(observer);
1265        }
1266
1267        // Snapshot static + dynamic into a single ordered handle
1268        // list. Static tabs come first, in declaration order.
1269        let static_count = self.static_tabs.len();
1270        let dyn_count = self.dynamic_model.as_ref().map(|m| m.len()).unwrap_or(0);
1271        let total = static_count + dyn_count;
1272
1273        let mut all_handles: Vec<TabHandle> = Vec::with_capacity(total);
1274        for slot in &self.static_tabs {
1275            all_handles.push(slot.handle.clone());
1276        }
1277        if let Some(model) = &self.dynamic_model {
1278            for i in 0..dyn_count {
1279                if let Some(h) = model.with_item(i, |h| h.clone()) {
1280                    all_handles.push(h);
1281                }
1282            }
1283        }
1284
1285        // Index → id lookup table. Used by the close / reorder /
1286        // pin callback wrappers below to translate the bar's
1287        // index-shaped events into id-shaped app callbacks. The
1288        // id ↔ selection bridge itself lives inside [`TabBar`] now;
1289        // TabWidget hands the bar `selected_id` and `id_of` directly.
1290        let index_to_id: Rc<Vec<TabId>> = Rc::new(all_handles.iter().map(|h| h.id).collect());
1291        let id_to_index: Rc<HashMap<TabId, usize>> = Rc::new(
1292            index_to_id
1293                .iter()
1294                .copied()
1295                .enumerate()
1296                .map(|(i, id)| (id, i))
1297                .collect(),
1298        );
1299
1300        // Drive `switcher_index` from `selected_id`. One-way only:
1301        // the inner `Switcher` reads the index to pick which pane is
1302        // visible, but never writes back — selection mutations all
1303        // flow through `selected_id` (the bar updates it on click,
1304        // app code may set it externally). Pre-sync handles the
1305        // initial state and stale-id cases without needing a
1306        // bidirectional effect.
1307        if total > 0 {
1308            let target_idx = self
1309                .selected_id
1310                .get()
1311                .and_then(|id| id_to_index.get(&id).copied())
1312                .unwrap_or_else(|| self.switcher_index.get().min(total - 1));
1313            if self.switcher_index.get() != target_idx {
1314                self.switcher_index.set(target_idx);
1315            }
1316        }
1317        let id_to_idx = id_to_index.clone();
1318        let switcher_idx = self.switcher_index.clone();
1319        ctx.effect(&self.selected_id, move |maybe_id| {
1320            if let Some(id) = maybe_id
1321                && let Some(&i) = id_to_idx.get(id)
1322                && switcher_idx.get() != i
1323            {
1324                switcher_idx.set(i);
1325            }
1326        });
1327
1328        // Internal model fed to the inner TabBar — a snapshot of
1329        // the unified handle list (built inside the `show_bar` block
1330        // below, since it is consumed only by the bar).
1331
1332        // Shared panel-id buffer: the Switcher writes panel widget
1333        // ids into it as panes are added; the bar's headers read
1334        // it to publish the Tab → TabPanel `controls()`
1335        // accessibility relation.
1336        let panel_ids = Rc::new(RefCell::new(Vec::with_capacity(total)));
1337
1338        // Shared header-id buffer: the bar populates this with each
1339        // tab header's WidgetId in tab order; each TabPane reads it
1340        // to publish the TabPanel → Tab `aria-labelledby` relation.
1341        let header_ids: Rc<RefCell<Vec<WidgetId>>> =
1342            Rc::new(RefCell::new(Vec::with_capacity(total)));
1343
1344        // Bind the visibility policy itself before reading it, so a bound
1345        // policy flipping (e.g. an app-level distraction-free mode swapping
1346        // `Always` for `Never`) rebuilds this widget and re-derives
1347        // `show_bar` below. Registered unconditionally — outside the
1348        // `show_bar` block, for the same reason as `sizing` / `tab_display`
1349        // further down: while the strip is hidden there is no bar widget to
1350        // carry the binding, so a hidden strip could never learn it should
1351        // come back.
1352        self.bar_visibility.register_if_bound(
1353            self_id,
1354            ctx.binding_registry(),
1355            BindingLevel::Rebuild,
1356        );
1357
1358        // Decide whether the tab strip is shown this build. Reactive
1359        // for `WhenMultiple`: a dynamic-model mutation rebuilds the
1360        // widget (the version observer above), so `total` is current.
1361        let show_bar = match self.bar_visibility.get() {
1362            TabBarVisibility::Always => true,
1363            TabBarVisibility::Never => false,
1364            TabBarVisibility::WhenMultiple => total >= 2,
1365        };
1366
1367        // Bind the sizing signal at the TabWidget level (not inside the
1368        // `show_bar` block) so a sizing change still rebuilds the widget even
1369        // while the strip is hidden (`WhenMultiple` with a single tab) — the
1370        // new mode is then applied the moment the bar reappears.
1371        if let Some(ref sizing) = self.sizing {
1372            sizing.bind_to(self_id, ctx.binding_registry(), BindingLevel::Rebuild);
1373        }
1374        // Same treatment for the display mode: a flip rebuilds the widget so the
1375        // bar re-derives its headers (icon ↔ text) even while the strip is
1376        // hidden, applying the moment it reappears.
1377        if let Some(ref display) = self.tab_display {
1378            display.bind_to(self_id, ctx.binding_registry(), BindingLevel::Rebuild);
1379        }
1380
1381        // Build + configure the inner TabBar — only when the strip is
1382        // shown (`bar_visibility`). Skipped entirely otherwise so the
1383        // bar's slot widgets aren't allocated as orphans. `internal_model`
1384        // and `delegate` are constructed here because they are consumed
1385        // only by the bar.
1386        let bar_id: Option<WidgetId> = if show_bar {
1387            let internal_model = ListModel::from_vec(all_handles.clone());
1388            let delegate = self.build_delegate();
1389
1390            // Selection is plumbed through as id-based — the bar
1391            // maintains its own private index-side signal and bridges
1392            // the two internally.
1393            let mut bar = match orientation {
1394                TabBarOrientation::Horizontal => TabBar::horizontal(
1395                    internal_model,
1396                    delegate,
1397                    self.selected_id.clone(),
1398                    |_, h: &TabHandle| h.id,
1399                ),
1400                TabBarOrientation::Vertical => TabBar::vertical(
1401                    internal_model,
1402                    delegate,
1403                    self.selected_id.clone(),
1404                    |_, h: &TabHandle| h.id,
1405                ),
1406            }
1407            .with_panel_ids(panel_ids.clone())
1408            .with_header_ids(header_ids.clone());
1409
1410            if let Some(ref sizing) = self.sizing {
1411                // The rebuild-triggering binding is installed above (outside
1412                // this block); here we just apply the current mode to the bar.
1413                bar = bar.tab_sizing(sizing.get());
1414            }
1415            if let Some(ref display) = self.tab_display {
1416                bar = bar.tab_display(display.get());
1417            }
1418            if let Some(ref bg) = self.tab_background {
1419                bar = bar.tab_background(bg.clone());
1420            }
1421            if let Some(ref bg) = self.selected_tab_background {
1422                bar = bar.selected_tab_background(bg.clone());
1423            }
1424            if let Some(ref bg) = self.hover_tab_background {
1425                bar = bar.hover_tab_background(bg.clone());
1426            }
1427            if let Some(ref bg) = self.idle_tab_background {
1428                bar = bar.idle_tab_background(bg.clone());
1429            }
1430            if let Some(ref bg) = self.bar_background {
1431                bar = bar.bar_background(bg.clone());
1432            }
1433            if self.tab_dividers {
1434                bar = match self.tab_divider_color {
1435                    Some(ref c) => bar.tab_divider_color(c.clone()),
1436                    None => bar.tab_dividers(),
1437                };
1438            }
1439            if let Some(pos) = self.active_indicator {
1440                bar = bar.active_indicator(pos);
1441            }
1442            if let Some(role) = self.selected_text_role {
1443                bar = bar.selected_text_role(role);
1444            }
1445            if let Some(role) = self.idle_text_role {
1446                bar = bar.idle_text_role(role);
1447            }
1448            if let Some(h) = self.tab_bar_height {
1449                bar = bar.tab_bar_height(h);
1450            }
1451            if let Some(w) = self.min_tab_width {
1452                bar = bar.min_tab_width(w);
1453            }
1454            if let Some(w) = self.max_tab_width {
1455                bar = bar.max_tab_width(w);
1456            }
1457            if let Some(w) = self.pinned_tab_width {
1458                bar = bar.pinned_tab_width(w);
1459            }
1460            if let Some(s) = self.show_scroll_arrows {
1461                bar = bar.show_scroll_arrows(s);
1462            }
1463            if let Some(mode) = self.overflow_button {
1464                bar = bar.overflow_button(mode);
1465            }
1466            if self.reorderable {
1467                bar = bar.reorderable(true);
1468            }
1469
1470            // Wrap the bar's index-shaped callbacks into the app's
1471            // id-shaped callbacks (close / reorder / pin / transfer / drop).
1472            bar = self.wire_bar_callbacks(bar, &index_to_id, static_count);
1473
1474            if let Some(ref mut slot) = self.bar_leading_slot {
1475                let id = slot.resolve(ctx);
1476                bar = bar.bar_leading_slot(id);
1477            }
1478            if let Some(ref mut slot) = self.bar_trailing_slot {
1479                let id = slot.resolve(ctx);
1480                bar = bar.bar_trailing_slot(id);
1481            }
1482            Some(ctx.add(bar))
1483        } else {
1484            None
1485        };
1486
1487        // Build (or reuse) the content panes — static + dynamic, both
1488        // memoized so internal state survives sibling rebuilds.
1489        let pane_ids = self.build_panes(
1490            ctx,
1491            &all_handles,
1492            static_count,
1493            dyn_count,
1494            &panel_ids,
1495            &header_ids,
1496        );
1497
1498        let mut switcher =
1499            Switcher::new(self.switcher_index.clone()).capture_child_ids_into(panel_ids);
1500        for &pane_id in &pane_ids {
1501            switcher = switcher.child(pane_id);
1502        }
1503        let switcher_id = ctx.add(switcher);
1504        // Tab content area must claim BOTH axes: full panel width
1505        // (so per-tab content fills the bounds, not just its natural
1506        // width) AND full panel height (slack below the tab bar).
1507        // `respect_intrinsic` makes the cross-axis fall back to the
1508        // switcher's intrinsic when a parent queries us with an
1509        // unspecified proposal, instead of reporting 0.
1510        let content_id = ctx.add(Expand::new().respect_intrinsic().child(switcher_id));
1511
1512        // When the strip is hidden (`bar_visibility`), the content
1513        // fills the whole area — no bar/content stack is needed.
1514        let root_id = match (bar_id, orientation) {
1515            (None, _) => content_id,
1516            (Some(bar_id), TabBarOrientation::Horizontal) => {
1517                ctx.add(VStack::new().child(bar_id).child(content_id))
1518            }
1519            (Some(bar_id), TabBarOrientation::Vertical) => ctx.add(
1520                crate::primitives::HStack::new()
1521                    .child(bar_id)
1522                    .child(content_id),
1523            ),
1524        };
1525        self.root_child_id = Some(root_id);
1526        vec![root_id]
1527    }
1528
1529    fn layout_response(&self, proposal: SizeProposal, ctx: &LayoutContext) -> LayoutResponse {
1530        self.root_child_id
1531            .and_then(|id| ctx.child_size(id, proposal))
1532            .unwrap_or_else(|| proposal.resolve(0.0, 0.0))
1533            .into()
1534    }
1535
1536    fn place_children(
1537        &self,
1538        bounds: Rect,
1539        _proposal: SizeProposal,
1540        children: &mut [WidgetPlacement],
1541        _ctx: &LayoutContext,
1542    ) {
1543        for child in children.iter_mut() {
1544            child.origin = bounds.origin();
1545            child.size = bounds.size();
1546        }
1547    }
1548
1549    fn accessibility(&self, builder: &mut AccessNodeBuilder) {
1550        builder.set_role(teksilo_core::accesskit::Role::GenericContainer);
1551    }
1552
1553    fn children(&self) -> Vec<WidgetId> {
1554        self.root_child_id.into_iter().collect()
1555    }
1556
1557    /// TabWidget memoizes the WidgetIds of its static-tab panes,
1558    /// dynamic-tab panes (keyed by [`TabId`]), and bar slots across
1559    /// rebuilds — internal mutable state (focus, scroll, animation,
1560    /// rich-text editor history, …) survives sibling mutations
1561    /// (dynamic-model push / remove / reorder, locale or theme
1562    /// changes that retitle live tabs). Without this opt-in, the
1563    /// framework's default `destroy_subtree` step on rebuild would
1564    /// reap those memoized panes and the user would see static-tab
1565    /// content vanish the first time they opened or closed a
1566    /// dynamic tab.
1567    fn preserves_children_on_rebuild(&self) -> bool {
1568        true
1569    }
1570}
1571
1572// ─── Once-per-process developer warning ─────────────────────────────
1573
1574/// Print a developer-aid warning the first time a cross-boundary
1575/// reorder is rejected by the default handler. Suppressed on
1576/// subsequent calls so high-frequency drag events don't spam stderr.
1577fn warn_cross_boundary_reorder_once(from: usize, to: usize, static_count: usize) {
1578    use std::sync::Once;
1579    static WARNED: Once = Once::new();
1580    WARNED.call_once(|| {
1581        eprintln!(
1582            "[teksilo-widgets::tab_widget] default on_reorder rejected a \
1583             cross-boundary move (from={from}, to={to}, \
1584             static_count={static_count}). Install an explicit \
1585             `on_reorder(...)` handler if you want to interleave \
1586             static and dynamic tabs."
1587        );
1588    });
1589}
1590
1591// ─── TabPane (internal content-pane wrapper) ────────────────────────
1592
1593/// Wraps each tab's content widget so the `Switcher` can attach a
1594/// stable accessibility name (the tab's title) and the framework's
1595/// dormancy bookkeeping (`controls` relation, `is_visible` flag)
1596/// has a consistent target.
1597#[derive(Debug)]
1598struct TabPane {
1599    handle: TabHandle,
1600    child_id: Option<WidgetId>,
1601    pending_child: Option<Box<dyn Widget>>,
1602    /// Captured during `build()` so `accessibility()` can find this
1603    /// pane's position in `panel_ids` (and thereby look up the
1604    /// corresponding tab header in `header_ids`) — surviving
1605    /// reorders without needing the parent to update memoized
1606    /// state.
1607    self_id: Option<WidgetId>,
1608    /// Shared buffer the parent `TabWidget` populates (via the
1609    /// inner `Switcher::capture_child_ids_into`) with each pane's
1610    /// `WidgetId` in tab order. The pane reads it to discover its
1611    /// own current index.
1612    panel_ids: Rc<RefCell<Vec<WidgetId>>>,
1613    /// Shared buffer the bar populates with each header's
1614    /// `WidgetId` in tab order. Read at `accessibility()` time to
1615    /// resolve the labelling tab.
1616    header_ids: Rc<RefCell<Vec<WidgetId>>>,
1617    /// When true, the pane attaches a `focusable(true)` handler to
1618    /// itself at build time AND advertises `Action::Focus` from
1619    /// `accessibility()`. Apps opt in via
1620    /// [`TabInfo::focusable_panel`] for panels containing no
1621    /// focusable descendants (an empty "About" tab, a chart-only
1622    /// metrics tab) so keyboard users can reach them.
1623    self_focusable: bool,
1624}
1625
1626impl TabPane {
1627    fn new(
1628        handle: TabHandle,
1629        content: Box<dyn Widget>,
1630        panel_ids: Rc<RefCell<Vec<WidgetId>>>,
1631        header_ids: Rc<RefCell<Vec<WidgetId>>>,
1632    ) -> Self {
1633        let self_focusable = handle.info.focusable_panel;
1634        Self {
1635            handle,
1636            child_id: None,
1637            pending_child: Some(content),
1638            self_id: None,
1639            panel_ids,
1640            header_ids,
1641            self_focusable,
1642        }
1643    }
1644}
1645
1646impl Widget for TabPane {
1647    fn build(&mut self, ctx: &mut BuildContext) -> Vec<WidgetId> {
1648        self.self_id = Some(ctx.self_id());
1649        if let Some(child) = self.pending_child.take() {
1650            self.child_id = Some(ctx.add_boxed(child));
1651        }
1652        if self.self_focusable {
1653            // Apply self-handlers so the framework treats this pane
1654            // as a Tab-key stop, allowing Tab from the selected tab
1655            // header to land inside an otherwise-empty panel.
1656            ctx.apply_self_handlers(
1657                teksilo_core::widget_builder::HandlerSet::new().focusable(true),
1658            );
1659        }
1660        self.child_id.into_iter().collect()
1661    }
1662
1663    fn layout_response(&self, proposal: SizeProposal, ctx: &LayoutContext) -> LayoutResponse {
1664        self.child_id
1665            .and_then(|id| ctx.child_size(id, proposal))
1666            .unwrap_or_else(|| proposal.resolve(0.0, 0.0))
1667            .into()
1668    }
1669
1670    fn place_children(
1671        &self,
1672        bounds: Rect,
1673        _proposal: SizeProposal,
1674        children: &mut [WidgetPlacement],
1675        _ctx: &LayoutContext,
1676    ) {
1677        for child in children.iter_mut() {
1678            child.origin = bounds.origin();
1679            child.size = bounds.size();
1680        }
1681    }
1682
1683    fn accessibility(&self, builder: &mut AccessNodeBuilder) {
1684        builder.set_role(teksilo_core::accesskit::Role::TabPanel);
1685        if let Some(ref title) = self.handle.info.title {
1686            let resolved: String = title.clone().into();
1687            builder.set_name(&resolved);
1688        }
1689        // ARIA aria-labelledby — point to the tab header that
1690        // controls this panel. Look up *current* index by finding
1691        // self_id in panel_ids (which the Switcher repopulates each
1692        // build, so this auto-corrects on reorder), then map that
1693        // to the header at the same position. Skip the relation —
1694        // no dangling — when self_id or the header for that index
1695        // isn't yet available (e.g. mid-rebuild after a model
1696        // mutation).
1697        if let Some(self_id) = self.self_id {
1698            let panel_ids = self.panel_ids.borrow();
1699            if let Some(pos) = panel_ids.iter().position(|&id| id == self_id) {
1700                if let Some(&header_id) = self.header_ids.borrow().get(pos) {
1701                    builder.push_labelled_by(teksilo_core::accessibility::widget_id_to_node_id(
1702                        header_id,
1703                    ));
1704                }
1705            }
1706        }
1707        // Opt-in panel focusability (TabInfo::focusable_panel).
1708        // AccessKit has no `tabindex` field; `Action::Focus` is the
1709        // canonical way to signal focusability to AT, matching how
1710        // TabHeader::accessibility advertises focusability.
1711        if self.self_focusable {
1712            builder.add_action(teksilo_core::accesskit::Action::Focus);
1713        }
1714    }
1715
1716    fn children(&self) -> Vec<WidgetId> {
1717        self.child_id.into_iter().collect()
1718    }
1719}
1720
1721// ─── AliasWidget: thin wrapper exposing a pre-registered widget id ──
1722
1723/// One-shot wrapper that "absorbs" a pre-registered `WidgetId` on
1724/// first build, returning it as the wrapper's only child. Used by
1725/// [`TabWidget::static_tab`] to bridge the
1726/// `teksu!` DSL's element-valued-slot pattern (which pre-registers
1727/// the inner widget and hands the parent its id) into the factory
1728/// shape `static_tab_factory` expects.
1729#[derive(Debug)]
1730struct AliasWidget {
1731    target: Option<WidgetId>,
1732    child_id: Option<WidgetId>,
1733}
1734
1735impl Widget for AliasWidget {
1736    fn build(&mut self, _ctx: &mut BuildContext) -> Vec<WidgetId> {
1737        if let Some(id) = self.target.take() {
1738            self.child_id = Some(id);
1739        }
1740        self.child_id.into_iter().collect()
1741    }
1742
1743    fn layout_response(&self, proposal: SizeProposal, ctx: &LayoutContext) -> LayoutResponse {
1744        self.child_id
1745            .and_then(|id| ctx.child_size(id, proposal))
1746            .unwrap_or_else(|| proposal.resolve(0.0, 0.0))
1747            .into()
1748    }
1749
1750    fn place_children(
1751        &self,
1752        bounds: Rect,
1753        _proposal: SizeProposal,
1754        children: &mut [WidgetPlacement],
1755        _ctx: &LayoutContext,
1756    ) {
1757        for child in children.iter_mut() {
1758            child.origin = bounds.origin();
1759            child.size = bounds.size();
1760        }
1761    }
1762
1763    fn children(&self) -> Vec<WidgetId> {
1764        self.child_id.into_iter().collect()
1765    }
1766}