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_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 Widget + 'static) -> Self {
494        let handle = TabHandle::static_handle(TabId::fresh(), info);
495        self.static_tabs.push(StaticTabSlot {
496            handle,
497            source: StaticContentSource::Owned(Some(Box::new(content))),
498            pane_id: None,
499        });
500        self
501    }
502
503    /// Ergonomic shorthand for a title-only static tab:
504    /// `tab(label, content)` is `static_tab(TabInfo::new().title(label),
505    /// content)`. `label` accepts `tr!(...)` (translated) or `lit!(...)`.
506    /// This is the method the `teksu!` `tab:` slot lowers to
507    /// (`tab: lit!("Overview"), Card { … }`).
508    pub fn tab(self, label: impl Into<LocalizedString>, content: impl Widget + 'static) -> Self {
509        self.static_tab(TabInfo::new().title(label), content)
510    }
511
512    /// `WidgetId` twin of [`tab`](Self::tab) — `tab_id(label, id)` is
513    /// `static_tab_id(TabInfo::new().title(label), id)`. This is what the
514    /// `teksu!` `tab:` slot lowers to when its content is an id binding
515    /// (`#{…}` / `name = Element`).
516    pub fn tab_id(self, label: impl Into<LocalizedString>, id: WidgetId) -> Self {
517        self.static_tab_id(TabInfo::new().title(label), id)
518    }
519
520    /// Add a static tab whose content is constructed by a factory
521    /// closure. The factory is called once — on the slot's first
522    /// build — and the resulting pane is memoized just like
523    /// [`static_tab`](Self::static_tab).
524    pub fn static_tab_factory(
525        mut self,
526        info: TabInfo,
527        factory: impl Fn(&TabHandle) -> Box<dyn Widget> + 'static,
528    ) -> Self {
529        let handle = TabHandle::static_handle(TabId::fresh(), info);
530        self.static_tabs.push(StaticTabSlot {
531            handle,
532            source: StaticContentSource::Factory(Rc::new(factory)),
533            pane_id: None,
534        });
535        self
536    }
537
538    /// Element-valued slot variant for the `teksu!` DSL — accepts a
539    /// pre-registered widget id rather than a `Box<dyn Widget>`.
540    /// Equivalent to [`static_tab`](Self::static_tab) with an
541    /// already-built child; the id is wrapped in a tab pane on
542    /// first build and the pane id is memoized thereafter.
543    pub fn static_tab_id(mut self, info: TabInfo, content_id: WidgetId) -> Self {
544        let handle = TabHandle::static_handle(TabId::fresh(), info);
545        self.static_tabs.push(StaticTabSlot {
546            handle,
547            source: StaticContentSource::PreId(Some(content_id)),
548            pane_id: None,
549        });
550        self
551    }
552
553    /// Add a static tab with a caller-provided [`TabId`] — useful
554    /// when external code (an app-event handler, a session-restore
555    /// path, a deep link) needs to flip selection to this tab by id.
556    /// The pane is memoized like [`static_tab`](Self::static_tab).
557    pub fn static_tab_with_id(
558        mut self,
559        id: TabId,
560        info: TabInfo,
561        content: impl Widget + 'static,
562    ) -> Self {
563        let handle = TabHandle::static_handle(id, info);
564        self.static_tabs.push(StaticTabSlot {
565            handle,
566            source: StaticContentSource::Owned(Some(Box::new(content))),
567            pane_id: None,
568        });
569        self
570    }
571
572    /// Factory variant of [`static_tab_with_id`](Self::static_tab_with_id).
573    pub fn static_tab_factory_with_id(
574        mut self,
575        id: TabId,
576        info: TabInfo,
577        factory: impl Fn(&TabHandle) -> Box<dyn Widget> + 'static,
578    ) -> Self {
579        let handle = TabHandle::static_handle(id, info);
580        self.static_tabs.push(StaticTabSlot {
581            handle,
582            source: StaticContentSource::Factory(Rc::new(factory)),
583            pane_id: None,
584        });
585        self
586    }
587
588    /// Register a dynamic-tab content factory keyed by `kind`. The
589    /// `<S>` type parameter pins the payload type — the framework
590    /// downcasts `handle.payload` to `S` before calling the
591    /// factory and panics with a clear message on kind/payload
592    /// mismatch, so `Any` never leaks into app code.
593    pub fn dynamic_tab<S: Any + 'static>(
594        mut self,
595        kind: &'static str,
596        factory: impl Fn(&TabHandle, &S) -> Box<dyn Widget> + 'static,
597    ) -> Self {
598        assert!(
599            kind != STATIC_KIND,
600            "tab kind '{}' is reserved by the framework for static tabs",
601            STATIC_KIND
602        );
603        debug_assert!(
604            !self.dynamic_registry.contains_key(kind),
605            "dynamic_tab kind '{kind}' is already registered — duplicate registration"
606        );
607        let kind_for_panic = kind;
608        let typed_factory: DynamicContentFactory = Rc::new(move |handle, payload| {
609            let typed = payload.downcast_ref::<S>().unwrap_or_else(|| {
610                panic!(
611                    "tab kind '{}' was registered for {} but the handle's \
612                     payload has a different type",
613                    kind_for_panic,
614                    std::any::type_name::<S>(),
615                )
616            });
617            factory(handle, typed)
618        });
619        self.dynamic_registry.insert(kind, typed_factory);
620        self
621    }
622
623    /// Connect the dynamic-tab data source. Mutations rebuild the
624    /// dynamic-tab subtree; static tabs are unaffected.
625    pub fn dynamic_model(mut self, model: ListModel<TabHandle>) -> Self {
626        self.dynamic_model = Some(model);
627        self
628    }
629
630    // ── Bar configuration (forwarded to inner TabBar) ──────────────
631
632    /// Set the per-tab sizing strategy as a static value. Internally
633    /// stores it as a `Signal<TabSizing>` so the widget can be
634    /// retrofitted to reactive control via [`Self::sizing`]
635    /// without breaking existing call sites.
636    pub fn tab_sizing(mut self, mode: TabSizing) -> Self {
637        self.sizing = Some(Signal::new(mode));
638        self
639    }
640
641    /// Bind the per-tab sizing strategy, statically or reactively —
642    /// flipping a bound signal swaps between Shared / Independent / Fill
643    /// live, with no rebuild on the parent's part. The signal is bound at
644    /// `BindingLevel::Rebuild` inside [`build`](Widget::build);
645    /// memoized panes survive the rebuild so per-tab state is
646    /// preserved.
647    pub fn sizing(mut self, sizing: impl Into<Prop<TabSizing>>) -> Self {
648        self.sizing = Some(sizing.into().as_signal());
649        self
650    }
651
652    /// Choose what every tab shows — icon, label, or both
653    /// ([`TabDisplayMode`]), statically or reactively. A bound signal can be
654    /// flipped to swap icon / text / icon+text live (the bar rebuilds,
655    /// memoized panes survive), with no rebuild on the parent's part. Bound
656    /// at `BindingLevel::Rebuild`.
657    pub fn tab_display(mut self, mode: impl Into<Prop<TabDisplayMode>>) -> Self {
658        self.tab_display = Some(mode.into().as_signal());
659        self
660    }
661
662    /// All-states shorthand for the per-tab background — every tab
663    /// (selected, idle, hovered) paints this unless a per-state override
664    /// is set. Accepts any `Color`, `SurfaceRole`, or `Signal<Color>` (via
665    /// [`ColorProp`](teksilo_core::color_prop::ColorProp)). Default is
666    /// transparent. To tint the bar's backdrop instead, use
667    /// [`bar_background`](Self::bar_background).
668    pub fn tab_background(mut self, color: impl Into<teksilo_core::color_prop::ColorProp>) -> Self {
669        self.tab_background = Some(color.into());
670        self
671    }
672
673    /// Background for the **selected** tab. Falls back to
674    /// [`tab_background`](Self::tab_background), then transparent.
675    pub fn selected_tab_background(
676        mut self,
677        color: impl Into<teksilo_core::color_prop::ColorProp>,
678    ) -> Self {
679        self.selected_tab_background = Some(color.into());
680        self
681    }
682
683    /// Background for the **hovered** (non-selected) tab. Falls back to
684    /// [`tab_background`](Self::tab_background), then transparent.
685    pub fn hover_tab_background(
686        mut self,
687        color: impl Into<teksilo_core::color_prop::ColorProp>,
688    ) -> Self {
689        self.hover_tab_background = Some(color.into());
690        self
691    }
692
693    /// Background for **idle** tabs (not selected, not hovered). Falls back
694    /// to [`tab_background`](Self::tab_background), then transparent.
695    pub fn idle_tab_background(
696        mut self,
697        color: impl Into<teksilo_core::color_prop::ColorProp>,
698    ) -> Self {
699        self.idle_tab_background = Some(color.into());
700        self
701    }
702
703    /// Set the bar-strip backdrop fill (behind headers, slots, arrows),
704    /// independent of the per-tab backgrounds. Default transparent.
705    pub fn bar_background(mut self, color: impl Into<teksilo_core::color_prop::ColorProp>) -> Self {
706        self.bar_background = Some(color.into());
707        self
708    }
709
710    /// Draw a 1 dp divider between consecutive tabs. Off by default.
711    pub fn tab_dividers(mut self) -> Self {
712        self.tab_dividers = true;
713        self
714    }
715
716    /// Like [`tab_dividers`](Self::tab_dividers) with an explicit colour
717    /// (`Color`, [`BorderRole`](teksilo_tokens::BorderRole), or
718    /// `Signal<Color>`). Implies `tab_dividers()`.
719    pub fn tab_divider_color(
720        mut self,
721        color: impl Into<teksilo_core::color_prop::ColorProp>,
722    ) -> Self {
723        self.tab_dividers = true;
724        self.tab_divider_color = Some(color.into());
725        self
726    }
727
728    /// Choose which edge the active-tab highlight indicator hugs. Default
729    /// [`TabIndicatorPosition::OuterEdge`](teksilo_core::styles::TabIndicatorPosition);
730    /// [`InnerEdge`](teksilo_core::styles::TabIndicatorPosition::InnerEdge)
731    /// puts it below the label (horizontal) / trailing edge (vertical).
732    pub fn active_indicator(
733        mut self,
734        position: teksilo_core::styles::TabIndicatorPosition,
735    ) -> Self {
736        self.active_indicator = Some(position);
737        self
738    }
739
740    /// Set the text role used for the label (and matching icon tint)
741    /// on the **selected** tab. Default: [`teksilo_tokens::TextRole::Primary`]
742    /// — the Int UI editor-strip convention. Override to e.g.
743    /// [`teksilo_tokens::TextRole::Accent`] when the strip sits over a
744    /// tinted surface.
745    pub fn selected_text_role(mut self, role: teksilo_tokens::TextRole) -> Self {
746        self.selected_text_role = Some(role);
747        self
748    }
749
750    /// Set the text role used for the label (and matching icon tint)
751    /// on **idle** tabs (not selected, not disabled). Default:
752    /// [`teksilo_tokens::TextRole::Secondary`]. Disabled tabs always read
753    /// as [`teksilo_tokens::TextRole::Disabled`] regardless of this
754    /// setting.
755    pub fn idle_text_role(mut self, role: teksilo_tokens::TextRole) -> Self {
756        self.idle_text_role = Some(role);
757        self
758    }
759    /// Minimum scrollable-tab width in logical pixels. Default
760    /// [`DEFAULT_MIN_TAB_WIDTH`].
761    pub fn min_tab_width(mut self, dp: f32) -> Self {
762        self.min_tab_width = Some(dp);
763        self
764    }
765    /// Maximum scrollable-tab width in logical pixels. Default
766    /// [`DEFAULT_MAX_TAB_WIDTH`].
767    pub fn max_tab_width(mut self, dp: f32) -> Self {
768        self.max_tab_width = Some(dp);
769        self
770    }
771    /// Fixed width for pinned (icon-only) tabs in logical pixels. Default
772    /// [`DEFAULT_PINNED_TAB_WIDTH`].
773    pub fn pinned_tab_width(mut self, dp: f32) -> Self {
774        self.pinned_tab_width = Some(dp);
775        self
776    }
777    /// Show or hide the leading/trailing scroll-arrow buttons when tabs overflow.
778    /// Default (unset) uses the style's preference.
779    pub fn show_scroll_arrows(mut self, on: bool) -> Self {
780        self.show_scroll_arrows = Some(on);
781        self
782    }
783    /// When the trailing "show all tabs" overflow dropdown appears. Default
784    /// (unset) is [`TabOverflowButton::Auto`] — shown only when the tab headers
785    /// overflow the bar's viewport. See [`TabOverflowButton`] for
786    /// `Always` / `Never`.
787    pub fn overflow_button(mut self, mode: TabOverflowButton) -> Self {
788        self.overflow_button = Some(mode);
789        self
790    }
791    /// Convenience over [`overflow_button`](Self::overflow_button): `true` maps
792    /// to [`TabOverflowButton::Always`], `false` to [`TabOverflowButton::Never`].
793    pub fn show_overflow_dropdown(mut self, on: bool) -> Self {
794        self.overflow_button = Some(if on {
795            TabOverflowButton::Always
796        } else {
797            TabOverflowButton::Never
798        });
799        self
800    }
801    /// Allow drag-to-reorder of tabs within the bar. Default `false`.
802    /// Setting [`on_reorder`](Self::on_reorder) implies `reorderable(true)`.
803    pub fn reorderable(mut self, on: bool) -> Self {
804        self.reorderable = on;
805        self
806    }
807
808    /// Install a close-tab handler. Receives the [`TabId`] of the
809    /// closed tab (not its index — indices are presentation-only)
810    /// and the firing [`EventContext`]. The latter lets the handler
811    /// open a confirmation dialog
812    /// (`ctx.present_modal(MessageBox::confirm(...))`), dispatch an
813    /// intent, or otherwise route the close request before mutating
814    /// the underlying model. To veto, do nothing in the handler; to
815    /// confirm-then-close, only call the model mutator on accept.
816    ///
817    /// If unset, the default behavior is to remove the tab from
818    /// [`dynamic_model`](Self::dynamic_model) without a prompt
819    /// (static tabs cannot be closed by default).
820    pub fn on_close(mut self, f: impl Fn(TabId, &mut EventContext) + 'static) -> Self {
821        self.on_close = Some(Rc::new(f));
822        self
823    }
824
825    /// Install a reorder handler. Receives `(moved_tab_id,
826    /// destination_index, ctx)` in the unified static-then-dynamic
827    /// ordering. The firing [`EventContext`] lets the handler
828    /// confirm or dispatch the reorder via a dialog / intent
829    /// before mutating the model. If unset, the default behavior
830    /// is to reorder within the dynamic region of
831    /// [`dynamic_model`](Self::dynamic_model). Implies
832    /// [`reorderable(true)`](Self::reorderable).
833    pub fn on_reorder(mut self, f: impl Fn(TabId, usize, &mut EventContext) + 'static) -> Self {
834        self.on_reorder = Some(Rc::new(f));
835        self.reorderable = true;
836        self
837    }
838
839    /// Install a pin-toggle handler — receives `(tab_id,
840    /// new_pinned_flag, ctx)` when the user drags a tab across the
841    /// pinned ↔ unpinned boundary. The firing [`EventContext`]
842    /// lets the handler confirm or dispatch the transition via a
843    /// dialog / intent. Apps decide whether to actually mutate the
844    /// tab's `info.pinned`.
845    pub fn on_pin_toggle(mut self, f: impl Fn(TabId, bool, &mut EventContext) + 'static) -> Self {
846        self.on_pin_toggle = Some(Rc::new(f));
847        self
848    }
849
850    /// Opt into cross-`TabWidget` tab transfer (app-internal
851    /// drag-and-drop between two tabbed containers). When enabled,
852    /// this widget's **dynamic** tabs can be dragged out to any other
853    /// accepting `TabWidget`, and it accepts tabs dragged in from one,
854    /// painting an insertion-line indicator between its tabs.
855    ///
856    /// The dragged [`TabHandle`] moves intact — its `Rc<dyn Any>`
857    /// payload (the heavy per-tab state) is preserved, not rebuilt —
858    /// so the receiving widget must register a content factory for the
859    /// tab's `kind` via [`dynamic_tab`](Self::dynamic_tab).
860    ///
861    /// **Static tabs are excluded**: they have no factory on a
862    /// receiving widget, so they can never be transferred out (they
863    /// still reorder in place if [`reorderable`](Self::reorderable)).
864    ///
865    /// By default, accepting a tab inserts it into this widget's
866    /// [`dynamic_model`](Self::dynamic_model) and transferring one out
867    /// removes it from this widget's model. Override either side with
868    /// [`on_tab_received`](Self::on_tab_received) /
869    /// [`on_transfer_out`](Self::on_transfer_out). Default: off.
870    pub fn accept_external_tabs(mut self, on: bool) -> Self {
871        self.accept_external_tabs = on;
872        self
873    }
874
875    /// Override the target-side behaviour when a foreign tab is
876    /// dropped onto this widget. Receives `(handle, insertion_index,
877    /// ctx)` where `insertion_index` is within the **dynamic** tab
878    /// region. The app inserts the handle into its own model. Implies
879    /// [`accept_external_tabs(true)`](Self::accept_external_tabs).
880    ///
881    /// If unset, the default inserts the handle into
882    /// [`dynamic_model`](Self::dynamic_model) at the drop position.
883    pub fn on_tab_received(
884        mut self,
885        f: impl Fn(TabHandle, usize, &mut EventContext) + 'static,
886    ) -> Self {
887        self.on_tab_received = Some(Rc::new(f));
888        self.accept_external_tabs = true;
889        self
890    }
891
892    /// Override the source-side behaviour after one of this widget's
893    /// tabs has been accepted by another `TabWidget`. Receives the
894    /// transferred [`TabId`]; the app removes it from its own model.
895    /// Implies [`accept_external_tabs(true)`](Self::accept_external_tabs).
896    ///
897    /// If unset, the default removes the tab from
898    /// [`dynamic_model`](Self::dynamic_model).
899    pub fn on_transfer_out(mut self, f: impl Fn(TabId, &mut EventContext) + 'static) -> Self {
900        self.on_transfer_out = Some(Rc::new(f));
901        self.accept_external_tabs = true;
902        self
903    }
904
905    /// Accept **non-tab** drops onto the tab bar — an in-app foreign
906    /// drag (e.g. a file dragged from a `TreeView`, carrying app data)
907    /// or an OS file/text/URL drop. The bar shows an insertion-line
908    /// indicator while such a payload hovers; on drop, `f` runs with
909    /// the raw [`DragPayload`], the insertion index *within the dynamic
910    /// region*, and the firing context. Inspect the payload
911    /// (`get_typed::<T>()` / `files()` / `text()` / `uris()`) and, e.g.,
912    /// push a new `TabHandle` into your [`dynamic_model`](Self::dynamic_model);
913    /// return `true` if accepted.
914    ///
915    /// This is the "open a dropped file as a tab" hook (VS Code style).
916    /// Independent of [`accept_external_tabs`](Self::accept_external_tabs).
917    /// OS drops also require `TeksiloAppBuilder::install_external_dnd()`.
918    pub fn on_external_drop(
919        mut self,
920        f: impl Fn(&DragPayload, usize, &mut EventContext) -> bool + 'static,
921    ) -> Self {
922        self.on_external_drop = Some(Rc::new(f));
923        self
924    }
925
926    /// Place a widget on the leading edge of the tab strip (before the first
927    /// tab). Memoized: registered once on first build, reused on rebuilds.
928    pub fn bar_leading_slot(mut self, w: impl Widget + 'static) -> Self {
929        self.bar_leading_slot = Some(BarSlot::new(PendingChild::Deferred(Box::new(w))));
930        self
931    }
932    /// Place a widget on the trailing edge of the tab strip (after the last
933    /// tab and overflow button). Memoized like
934    /// [`bar_leading_slot`](Self::bar_leading_slot).
935    pub fn bar_trailing_slot(mut self, w: impl Widget + 'static) -> Self {
936        self.bar_trailing_slot = Some(BarSlot::new(PendingChild::Deferred(Box::new(w))));
937        self
938    }
939
940    /// Element-valued variant of
941    /// [`bar_leading_slot`](Self::bar_leading_slot) accepting a
942    /// pre-registered `WidgetId` (for the `teksu!` DSL).
943    pub fn bar_leading_slot_id(mut self, id: WidgetId) -> Self {
944        self.bar_leading_slot = Some(BarSlot::new(PendingChild::Id(id)));
945        self
946    }
947    /// Element-valued variant of
948    /// [`bar_trailing_slot`](Self::bar_trailing_slot).
949    pub fn bar_trailing_slot_id(mut self, id: WidgetId) -> Self {
950        self.bar_trailing_slot = Some(BarSlot::new(PendingChild::Id(id)));
951        self
952    }
953}
954
955impl TabWidget {
956    // ── build() helpers ────────────────────────────────────────────
957    //
958    // `build()` is decomposed into three self-contained steps so the
959    // method body reads as orchestration rather than implementation.
960    // Each helper captures only `&self` (plus the build-local lookup
961    // tables it needs) and has no side effects beyond the arena
962    // registrations it performs through `ctx`.
963
964    /// Translate `TabInfo` fields into the [`TabDelegate`]'s
965    /// closure-shaped accessors. Pure — captures nothing from the
966    /// surrounding `build()`.
967    fn build_delegate(&self) -> TabDelegate<TabHandle> {
968        let mut delegate =
969            TabDelegate::new(|_, h: &TabHandle| h.info.title.clone().unwrap_or_else(|| lit!("")))
970                .icon(|_, h: &TabHandle| h.info.icon.as_ref().map(|f| f()))
971                .closable(|_, h: &TabHandle| h.info.closable)
972                .pinned(|_, h: &TabHandle| h.info.pinned)
973                .enabled(|_, h: &TabHandle| h.info.initial_enabled.get())
974                .tooltip(|_, h: &TabHandle| {
975                    // Pinned tabs render icon-only; promote `title` to the
976                    // tooltip if the caller didn't set one explicitly so
977                    // the user can still identify the tab on hover.
978                    if h.info.pinned
979                        && h.info.tooltip.is_none()
980                        && h.info.rich_tooltip.is_none()
981                        && h.info.composite_tooltip.is_none()
982                    {
983                        h.info.title.clone()
984                    } else {
985                        h.info.tooltip.clone()
986                    }
987                });
988        // Bypass the tooltip-clearing setters here: TabInfo already
989        // enforces mutual exclusion across plain / rich / composite,
990        // so each closure returns `Some` only for its flavor.
991        delegate.rich_tooltip_key = Some(Box::new(|_, h: &TabHandle| match &h.info.rich_tooltip {
992            Some(crate::tooltip::RichTooltipSource::Key(k)) => Some(k.clone()),
993            _ => None,
994        }));
995        delegate.rich_tooltip_content =
996            Some(Box::new(|_, h: &TabHandle| match &h.info.rich_tooltip {
997                Some(crate::tooltip::RichTooltipSource::Content(c)) => Some(c.clone()),
998                _ => None,
999            }));
1000        delegate.composite_tooltip = Some(Box::new(|_, h: &TabHandle| {
1001            h.info.composite_tooltip.as_ref().map(|factory| factory())
1002        }));
1003        delegate = delegate.context_menu(|_, h: &TabHandle| h.info.context_menu.clone());
1004        delegate
1005    }
1006
1007    /// Wrap the bar's index-shaped callbacks (close / reorder / pin /
1008    /// cross-bar transfer / non-tab drop) into the app's id-shaped
1009    /// callbacks, translating at the boundary via `index_to_id` and
1010    /// `saturating_sub(static_count)` for the unified→dynamic index map.
1011    fn wire_bar_callbacks(
1012        &self,
1013        mut bar: TabBar<TabHandle>,
1014        index_to_id: &Rc<Vec<TabId>>,
1015        static_count: usize,
1016    ) -> TabBar<TabHandle> {
1017        // Wrap callbacks: bar speaks in indices, app speaks in
1018        // TabIds. We translate at the boundary using the
1019        // `index_to_id` lookup captured at build time.
1020        let close_cb = self.on_close.clone();
1021        let dyn_model_for_close = self.dynamic_model.clone();
1022        let idx_to_id_for_close = index_to_id.clone();
1023        bar = bar.on_close(move |i: usize, ctx: &mut EventContext| {
1024            if let Some(&id) = idx_to_id_for_close.get(i) {
1025                if let Some(ref f) = close_cb {
1026                    f(id, ctx);
1027                } else if i >= static_count {
1028                    // Default: remove from dynamic_model. Static
1029                    // tabs are not auto-closable.
1030                    if let Some(ref model) = dyn_model_for_close {
1031                        let dyn_idx = i - static_count;
1032                        if dyn_idx < model.len() {
1033                            let _ = model.remove(dyn_idx);
1034                        }
1035                    }
1036                }
1037            }
1038        });
1039
1040        // `on_reorder(...)` setter sets `reorderable = true`, so the
1041        // single `self.reorderable` flag is the only gate we need.
1042        let reorder_cb = self.on_reorder.clone();
1043        let dyn_model_for_reorder = self.dynamic_model.clone();
1044        let idx_to_id_for_reorder = index_to_id.clone();
1045        if self.reorderable {
1046            bar = bar.on_reorder(move |from: usize, to: usize, ctx: &mut EventContext| {
1047                if let Some(&id) = idx_to_id_for_reorder.get(from) {
1048                    if let Some(ref f) = reorder_cb {
1049                        f(id, to, ctx);
1050                    } else if from >= static_count && to >= static_count {
1051                        // Default: reorder within the dynamic region
1052                        // only. Static tabs are pinned in place.
1053                        if let Some(ref model) = dyn_model_for_reorder {
1054                            let from_dyn = from - static_count;
1055                            let to_dyn = to - static_count;
1056                            if from_dyn < model.len() && to_dyn < model.len() {
1057                                model.move_item(from_dyn, to_dyn);
1058                            }
1059                        }
1060                    } else {
1061                        // Cross-boundary reorder: silently rejected
1062                        // by the default handler. Surface it once
1063                        // per process so developers don't chase a
1064                        // ghost — install an explicit `on_reorder`
1065                        // to interleave static and dynamic tabs.
1066                        warn_cross_boundary_reorder_once(from, to, static_count);
1067                    }
1068                }
1069            });
1070        }
1071
1072        if let Some(f) = self.on_pin_toggle.clone() {
1073            let idx_to_id = index_to_id.clone();
1074            bar = bar.on_pin_toggle(move |i: usize, pinned: bool, ctx: &mut EventContext| {
1075                if let Some(&id) = idx_to_id.get(i) {
1076                    f(id, pinned, ctx);
1077                }
1078            });
1079        }
1080
1081        // Cross-bar transfer wiring. The bar speaks in unified model
1082        // indices (static tabs first, then dynamic); the app speaks in
1083        // dynamic-region indices and TabIds. Static tabs are excluded
1084        // from transfer — they have no factory on a receiving widget.
1085        if self.accept_external_tabs {
1086            bar = bar
1087                .accept_external_tabs(true)
1088                .with_transferable_predicate(|_, h: &TabHandle| h.kind != STATIC_KIND);
1089
1090            // Target side: insert the received handle. The bar's
1091            // insertion index is in unified model space; translate to
1092            // a dynamic-region index for the app / default model.
1093            let received_cb = self.on_tab_received.clone();
1094            let dyn_model_for_recv = self.dynamic_model.clone();
1095            bar = bar.on_tab_received_rc(Rc::new(
1096                move |handle: TabHandle, to_model: usize, ctx: &mut EventContext| {
1097                    let dyn_index = to_model.saturating_sub(static_count);
1098                    if let Some(ref f) = received_cb {
1099                        f(handle, dyn_index, ctx);
1100                    } else if let Some(ref model) = dyn_model_for_recv {
1101                        let idx = dyn_index.min(model.len());
1102                        model.insert(idx, handle);
1103                    }
1104                },
1105            ));
1106
1107            // Source side: remove the transferred tab by id.
1108            let transfer_out_cb = self.on_transfer_out.clone();
1109            let dyn_model_for_out = self.dynamic_model.clone();
1110            bar = bar.on_transfer_out_rc(Rc::new(move |tab_id: TabId, ctx: &mut EventContext| {
1111                if let Some(ref f) = transfer_out_cb {
1112                    f(tab_id, ctx);
1113                } else if let Some(ref model) = dyn_model_for_out {
1114                    let pos =
1115                        (0..model.len()).find(|&i| model.with_item(i, |h| h.id) == Some(tab_id));
1116                    if let Some(pos) = pos {
1117                        let _ = model.remove(pos);
1118                    }
1119                }
1120            }));
1121        }
1122
1123        // Non-tab drops (foreign in-app drag / OS file drop). Translate
1124        // the bar's unified model index to a dynamic-region index for
1125        // the app callback. Independent of `accept_external_tabs`.
1126        if let Some(external_cb) = self.on_external_drop.clone() {
1127            bar = bar.on_external_drop_rc(Rc::new(
1128                move |payload: &DragPayload, to_model: usize, ctx: &mut EventContext| {
1129                    let dyn_index = to_model.saturating_sub(static_count);
1130                    (external_cb)(payload, dyn_index, ctx)
1131                },
1132            ));
1133        }
1134
1135        bar
1136    }
1137
1138    /// Build (or reuse) the content panes. Static and dynamic panes
1139    /// both memoize their pane `WidgetId` — once registered, the pane
1140    /// outlives sibling rebuilds (caused by dynamic-model mutations) so
1141    /// internal state survives. Static panes cache in
1142    /// [`StaticTabSlot::pane_id`]; dynamic panes cache in
1143    /// [`Self::dyn_pane_ids`] keyed by [`TabId`], pruned at the end to
1144    /// drop tabs no longer in the model.
1145    fn build_panes(
1146        &mut self,
1147        ctx: &mut BuildContext,
1148        all_handles: &[TabHandle],
1149        static_count: usize,
1150        dyn_count: usize,
1151        panel_ids: &Rc<RefCell<Vec<WidgetId>>>,
1152        header_ids: &Rc<RefCell<Vec<WidgetId>>>,
1153    ) -> Vec<WidgetId> {
1154        let mut pane_ids: Vec<WidgetId> = Vec::with_capacity(static_count + dyn_count);
1155
1156        for slot in self.static_tabs.iter_mut() {
1157            let pane_id = match slot.pane_id {
1158                Some(id) => id,
1159                None => {
1160                    let content = slot.source.into_widget(&slot.handle);
1161                    let id = ctx.add(TabPane::new(
1162                        slot.handle.clone(),
1163                        content,
1164                        panel_ids.clone(),
1165                        header_ids.clone(),
1166                    ));
1167                    slot.pane_id = Some(id);
1168                    id
1169                }
1170            };
1171            pane_ids.push(pane_id);
1172        }
1173
1174        let mut alive_dyn: HashSet<TabId> = HashSet::with_capacity(dyn_count);
1175        for handle in all_handles.iter().skip(static_count) {
1176            alive_dyn.insert(handle.id);
1177            let pane_id = match self.dyn_pane_ids.get(&handle.id) {
1178                Some(&id) => id,
1179                None => {
1180                    let factory = self.dynamic_registry.get(handle.kind).unwrap_or_else(|| {
1181                        panic!(
1182                            "tab kind '{}' has no registered content factory — \
1183                             add a `dynamic_tab::<S>(\"{}\", |handle, state| ...)` \
1184                             registration before connecting the model",
1185                            handle.kind, handle.kind,
1186                        )
1187                    });
1188                    let content = factory(handle, handle.payload.as_ref());
1189                    let id = ctx.add(TabPane::new(
1190                        handle.clone(),
1191                        content,
1192                        panel_ids.clone(),
1193                        header_ids.clone(),
1194                    ));
1195                    self.dyn_pane_ids.insert(handle.id, id);
1196                    id
1197                }
1198            };
1199            pane_ids.push(pane_id);
1200        }
1201        // Prune dynamic-pane memo entries for tabs the model no longer
1202        // carries. Their pane widgets are absent from the children this
1203        // rebuild returns, so the reconciling rebuild path (TabWidget is
1204        // `preserves_children_on_rebuild`) destroys them — they are not left
1205        // as stranded, still-active orphans.
1206        self.dyn_pane_ids.retain(|id, _| alive_dyn.contains(id));
1207
1208        pane_ids
1209    }
1210}
1211
1212impl Widget for TabWidget {
1213    fn build(&mut self, ctx: &mut BuildContext) -> Vec<WidgetId> {
1214        let self_id = ctx.self_id();
1215        ctx.enabled_when(self_id, self.enabled.clone());
1216
1217        // Bind orientation at Rebuild level — toggling the signal
1218        // (e.g. via a toolbar button) rebuilds TabWidget with the
1219        // new outer layout (HStack ↔ VStack) and a fresh TabBar in
1220        // the new orientation. Memoized panes survive the rebuild.
1221        self.orientation
1222            .bind_to(self_id, ctx.binding_registry(), BindingLevel::Rebuild);
1223        let orientation = self.orientation.get();
1224
1225        // Subscribe to dynamic-model mutations so add / remove /
1226        // reorder triggers a TabWidget rebuild that picks up the
1227        // new tab list.
1228        if let Some(model) = &self.dynamic_model {
1229            let version = ctx.signal(0_u64);
1230            version.bind_to(self_id, ctx.binding_registry(), BindingLevel::Rebuild);
1231            let observer = model.observe_changes({
1232                let v = version.clone();
1233                move |_change| v.set(v.get().wrapping_add(1))
1234            });
1235            ctx.own_handle(observer);
1236        }
1237
1238        // Snapshot static + dynamic into a single ordered handle
1239        // list. Static tabs come first, in declaration order.
1240        let static_count = self.static_tabs.len();
1241        let dyn_count = self.dynamic_model.as_ref().map(|m| m.len()).unwrap_or(0);
1242        let total = static_count + dyn_count;
1243
1244        let mut all_handles: Vec<TabHandle> = Vec::with_capacity(total);
1245        for slot in &self.static_tabs {
1246            all_handles.push(slot.handle.clone());
1247        }
1248        if let Some(model) = &self.dynamic_model {
1249            for i in 0..dyn_count {
1250                if let Some(h) = model.with_item(i, |h| h.clone()) {
1251                    all_handles.push(h);
1252                }
1253            }
1254        }
1255
1256        // Index → id lookup table. Used by the close / reorder /
1257        // pin callback wrappers below to translate the bar's
1258        // index-shaped events into id-shaped app callbacks. The
1259        // id ↔ selection bridge itself lives inside [`TabBar`] now;
1260        // TabWidget hands the bar `selected_id` and `id_of` directly.
1261        let index_to_id: Rc<Vec<TabId>> = Rc::new(all_handles.iter().map(|h| h.id).collect());
1262        let id_to_index: Rc<HashMap<TabId, usize>> = Rc::new(
1263            index_to_id
1264                .iter()
1265                .copied()
1266                .enumerate()
1267                .map(|(i, id)| (id, i))
1268                .collect(),
1269        );
1270
1271        // Drive `switcher_index` from `selected_id`. One-way only:
1272        // the inner `Switcher` reads the index to pick which pane is
1273        // visible, but never writes back — selection mutations all
1274        // flow through `selected_id` (the bar updates it on click,
1275        // app code may set it externally). Pre-sync handles the
1276        // initial state and stale-id cases without needing a
1277        // bidirectional effect.
1278        if total > 0 {
1279            let target_idx = self
1280                .selected_id
1281                .get()
1282                .and_then(|id| id_to_index.get(&id).copied())
1283                .unwrap_or_else(|| self.switcher_index.get().min(total - 1));
1284            if self.switcher_index.get() != target_idx {
1285                self.switcher_index.set(target_idx);
1286            }
1287        }
1288        let id_to_idx = id_to_index.clone();
1289        let switcher_idx = self.switcher_index.clone();
1290        ctx.effect(&self.selected_id, move |maybe_id| {
1291            if let Some(id) = maybe_id
1292                && let Some(&i) = id_to_idx.get(id)
1293                && switcher_idx.get() != i
1294            {
1295                switcher_idx.set(i);
1296            }
1297        });
1298
1299        // Internal model fed to the inner TabBar — a snapshot of
1300        // the unified handle list (built inside the `show_bar` block
1301        // below, since it is consumed only by the bar).
1302
1303        // Shared panel-id buffer: the Switcher writes panel widget
1304        // ids into it as panes are added; the bar's headers read
1305        // it to publish the Tab → TabPanel `controls()`
1306        // accessibility relation.
1307        let panel_ids = Rc::new(RefCell::new(Vec::with_capacity(total)));
1308
1309        // Shared header-id buffer: the bar populates this with each
1310        // tab header's WidgetId in tab order; each TabPane reads it
1311        // to publish the TabPanel → Tab `aria-labelledby` relation.
1312        let header_ids: Rc<RefCell<Vec<WidgetId>>> =
1313            Rc::new(RefCell::new(Vec::with_capacity(total)));
1314
1315        // Bind the visibility policy itself before reading it, so a bound
1316        // policy flipping (e.g. an app-level distraction-free mode swapping
1317        // `Always` for `Never`) rebuilds this widget and re-derives
1318        // `show_bar` below. Registered unconditionally — outside the
1319        // `show_bar` block, for the same reason as `sizing` / `tab_display`
1320        // further down: while the strip is hidden there is no bar widget to
1321        // carry the binding, so a hidden strip could never learn it should
1322        // come back.
1323        self.bar_visibility.register_if_bound(
1324            self_id,
1325            ctx.binding_registry(),
1326            BindingLevel::Rebuild,
1327        );
1328
1329        // Decide whether the tab strip is shown this build. Reactive
1330        // for `WhenMultiple`: a dynamic-model mutation rebuilds the
1331        // widget (the version observer above), so `total` is current.
1332        let show_bar = match self.bar_visibility.get() {
1333            TabBarVisibility::Always => true,
1334            TabBarVisibility::Never => false,
1335            TabBarVisibility::WhenMultiple => total >= 2,
1336        };
1337
1338        // Bind the sizing signal at the TabWidget level (not inside the
1339        // `show_bar` block) so a sizing change still rebuilds the widget even
1340        // while the strip is hidden (`WhenMultiple` with a single tab) — the
1341        // new mode is then applied the moment the bar reappears.
1342        if let Some(ref sizing) = self.sizing {
1343            sizing.bind_to(self_id, ctx.binding_registry(), BindingLevel::Rebuild);
1344        }
1345        // Same treatment for the display mode: a flip rebuilds the widget so the
1346        // bar re-derives its headers (icon ↔ text) even while the strip is
1347        // hidden, applying the moment it reappears.
1348        if let Some(ref display) = self.tab_display {
1349            display.bind_to(self_id, ctx.binding_registry(), BindingLevel::Rebuild);
1350        }
1351
1352        // Build + configure the inner TabBar — only when the strip is
1353        // shown (`bar_visibility`). Skipped entirely otherwise so the
1354        // bar's slot widgets aren't allocated as orphans. `internal_model`
1355        // and `delegate` are constructed here because they are consumed
1356        // only by the bar.
1357        let bar_id: Option<WidgetId> = if show_bar {
1358            let internal_model = ListModel::from_vec(all_handles.clone());
1359            let delegate = self.build_delegate();
1360
1361            // Selection is plumbed through as id-based — the bar
1362            // maintains its own private index-side signal and bridges
1363            // the two internally.
1364            let mut bar = match orientation {
1365                TabBarOrientation::Horizontal => TabBar::horizontal(
1366                    internal_model,
1367                    delegate,
1368                    self.selected_id.clone(),
1369                    |_, h: &TabHandle| h.id,
1370                ),
1371                TabBarOrientation::Vertical => TabBar::vertical(
1372                    internal_model,
1373                    delegate,
1374                    self.selected_id.clone(),
1375                    |_, h: &TabHandle| h.id,
1376                ),
1377            }
1378            .with_panel_ids(panel_ids.clone())
1379            .with_header_ids(header_ids.clone());
1380
1381            if let Some(ref sizing) = self.sizing {
1382                // The rebuild-triggering binding is installed above (outside
1383                // this block); here we just apply the current mode to the bar.
1384                bar = bar.tab_sizing(sizing.get());
1385            }
1386            if let Some(ref display) = self.tab_display {
1387                bar = bar.tab_display(display.get());
1388            }
1389            if let Some(ref bg) = self.tab_background {
1390                bar = bar.tab_background(bg.clone());
1391            }
1392            if let Some(ref bg) = self.selected_tab_background {
1393                bar = bar.selected_tab_background(bg.clone());
1394            }
1395            if let Some(ref bg) = self.hover_tab_background {
1396                bar = bar.hover_tab_background(bg.clone());
1397            }
1398            if let Some(ref bg) = self.idle_tab_background {
1399                bar = bar.idle_tab_background(bg.clone());
1400            }
1401            if let Some(ref bg) = self.bar_background {
1402                bar = bar.bar_background(bg.clone());
1403            }
1404            if self.tab_dividers {
1405                bar = match self.tab_divider_color {
1406                    Some(ref c) => bar.tab_divider_color(c.clone()),
1407                    None => bar.tab_dividers(),
1408                };
1409            }
1410            if let Some(pos) = self.active_indicator {
1411                bar = bar.active_indicator(pos);
1412            }
1413            if let Some(role) = self.selected_text_role {
1414                bar = bar.selected_text_role(role);
1415            }
1416            if let Some(role) = self.idle_text_role {
1417                bar = bar.idle_text_role(role);
1418            }
1419            if let Some(h) = self.tab_bar_height {
1420                bar = bar.tab_bar_height(h);
1421            }
1422            if let Some(w) = self.min_tab_width {
1423                bar = bar.min_tab_width(w);
1424            }
1425            if let Some(w) = self.max_tab_width {
1426                bar = bar.max_tab_width(w);
1427            }
1428            if let Some(w) = self.pinned_tab_width {
1429                bar = bar.pinned_tab_width(w);
1430            }
1431            if let Some(s) = self.show_scroll_arrows {
1432                bar = bar.show_scroll_arrows(s);
1433            }
1434            if let Some(mode) = self.overflow_button {
1435                bar = bar.overflow_button(mode);
1436            }
1437            if self.reorderable {
1438                bar = bar.reorderable(true);
1439            }
1440
1441            // Wrap the bar's index-shaped callbacks into the app's
1442            // id-shaped callbacks (close / reorder / pin / transfer / drop).
1443            bar = self.wire_bar_callbacks(bar, &index_to_id, static_count);
1444
1445            if let Some(ref mut slot) = self.bar_leading_slot {
1446                let id = slot.resolve(ctx);
1447                bar = bar.bar_leading_slot_id(id);
1448            }
1449            if let Some(ref mut slot) = self.bar_trailing_slot {
1450                let id = slot.resolve(ctx);
1451                bar = bar.bar_trailing_slot_id(id);
1452            }
1453            Some(ctx.add(bar))
1454        } else {
1455            None
1456        };
1457
1458        // Build (or reuse) the content panes — static + dynamic, both
1459        // memoized so internal state survives sibling rebuilds.
1460        let pane_ids = self.build_panes(
1461            ctx,
1462            &all_handles,
1463            static_count,
1464            dyn_count,
1465            &panel_ids,
1466            &header_ids,
1467        );
1468
1469        let mut switcher =
1470            Switcher::new(self.switcher_index.clone()).capture_child_ids_into(panel_ids);
1471        for &pane_id in &pane_ids {
1472            switcher = switcher.child_id(pane_id);
1473        }
1474        let switcher_id = ctx.add(switcher);
1475        // Tab content area must claim BOTH axes: full panel width
1476        // (so per-tab content fills the bounds, not just its natural
1477        // width) AND full panel height (slack below the tab bar).
1478        // `respect_intrinsic` makes the cross-axis fall back to the
1479        // switcher's intrinsic when a parent queries us with an
1480        // unspecified proposal, instead of reporting 0.
1481        let content_id = ctx.add(Expand::new().respect_intrinsic().child_id(switcher_id));
1482
1483        // When the strip is hidden (`bar_visibility`), the content
1484        // fills the whole area — no bar/content stack is needed.
1485        let root_id = match (bar_id, orientation) {
1486            (None, _) => content_id,
1487            (Some(bar_id), TabBarOrientation::Horizontal) => {
1488                ctx.add(VStack::new().add_child(bar_id).add_child(content_id))
1489            }
1490            (Some(bar_id), TabBarOrientation::Vertical) => ctx.add(
1491                crate::primitives::HStack::new()
1492                    .add_child(bar_id)
1493                    .add_child(content_id),
1494            ),
1495        };
1496        self.root_child_id = Some(root_id);
1497        vec![root_id]
1498    }
1499
1500    fn layout_response(&self, proposal: SizeProposal, ctx: &LayoutContext) -> LayoutResponse {
1501        self.root_child_id
1502            .and_then(|id| ctx.child_size(id, proposal))
1503            .unwrap_or_else(|| proposal.resolve(0.0, 0.0))
1504            .into()
1505    }
1506
1507    fn place_children(
1508        &self,
1509        bounds: Rect,
1510        _proposal: SizeProposal,
1511        children: &mut [WidgetPlacement],
1512        _ctx: &LayoutContext,
1513    ) {
1514        for child in children.iter_mut() {
1515            child.origin = bounds.origin();
1516            child.size = bounds.size();
1517        }
1518    }
1519
1520    fn accessibility(&self, builder: &mut AccessNodeBuilder) {
1521        builder.set_role(teksilo_core::accesskit::Role::GenericContainer);
1522    }
1523
1524    fn children(&self) -> Vec<WidgetId> {
1525        self.root_child_id.into_iter().collect()
1526    }
1527
1528    /// TabWidget memoizes the WidgetIds of its static-tab panes,
1529    /// dynamic-tab panes (keyed by [`TabId`]), and bar slots across
1530    /// rebuilds — internal mutable state (focus, scroll, animation,
1531    /// rich-text editor history, …) survives sibling mutations
1532    /// (dynamic-model push / remove / reorder, locale or theme
1533    /// changes that retitle live tabs). Without this opt-in, the
1534    /// framework's default `destroy_subtree` step on rebuild would
1535    /// reap those memoized panes and the user would see static-tab
1536    /// content vanish the first time they opened or closed a
1537    /// dynamic tab.
1538    fn preserves_children_on_rebuild(&self) -> bool {
1539        true
1540    }
1541}
1542
1543// ─── Once-per-process developer warning ─────────────────────────────
1544
1545/// Print a developer-aid warning the first time a cross-boundary
1546/// reorder is rejected by the default handler. Suppressed on
1547/// subsequent calls so high-frequency drag events don't spam stderr.
1548fn warn_cross_boundary_reorder_once(from: usize, to: usize, static_count: usize) {
1549    use std::sync::Once;
1550    static WARNED: Once = Once::new();
1551    WARNED.call_once(|| {
1552        eprintln!(
1553            "[teksilo-widgets::tab_widget] default on_reorder rejected a \
1554             cross-boundary move (from={from}, to={to}, \
1555             static_count={static_count}). Install an explicit \
1556             `on_reorder(...)` handler if you want to interleave \
1557             static and dynamic tabs."
1558        );
1559    });
1560}
1561
1562// ─── TabPane (internal content-pane wrapper) ────────────────────────
1563
1564/// Wraps each tab's content widget so the `Switcher` can attach a
1565/// stable accessibility name (the tab's title) and the framework's
1566/// dormancy bookkeeping (`controls` relation, `is_visible` flag)
1567/// has a consistent target.
1568#[derive(Debug)]
1569struct TabPane {
1570    handle: TabHandle,
1571    child_id: Option<WidgetId>,
1572    pending_child: Option<Box<dyn Widget>>,
1573    /// Captured during `build()` so `accessibility()` can find this
1574    /// pane's position in `panel_ids` (and thereby look up the
1575    /// corresponding tab header in `header_ids`) — surviving
1576    /// reorders without needing the parent to update memoized
1577    /// state.
1578    self_id: Option<WidgetId>,
1579    /// Shared buffer the parent `TabWidget` populates (via the
1580    /// inner `Switcher::capture_child_ids_into`) with each pane's
1581    /// `WidgetId` in tab order. The pane reads it to discover its
1582    /// own current index.
1583    panel_ids: Rc<RefCell<Vec<WidgetId>>>,
1584    /// Shared buffer the bar populates with each header's
1585    /// `WidgetId` in tab order. Read at `accessibility()` time to
1586    /// resolve the labelling tab.
1587    header_ids: Rc<RefCell<Vec<WidgetId>>>,
1588    /// When true, the pane attaches a `focusable(true)` handler to
1589    /// itself at build time AND advertises `Action::Focus` from
1590    /// `accessibility()`. Apps opt in via
1591    /// [`TabInfo::focusable_panel`] for panels containing no
1592    /// focusable descendants (an empty "About" tab, a chart-only
1593    /// metrics tab) so keyboard users can reach them.
1594    self_focusable: bool,
1595}
1596
1597impl TabPane {
1598    fn new(
1599        handle: TabHandle,
1600        content: Box<dyn Widget>,
1601        panel_ids: Rc<RefCell<Vec<WidgetId>>>,
1602        header_ids: Rc<RefCell<Vec<WidgetId>>>,
1603    ) -> Self {
1604        let self_focusable = handle.info.focusable_panel;
1605        Self {
1606            handle,
1607            child_id: None,
1608            pending_child: Some(content),
1609            self_id: None,
1610            panel_ids,
1611            header_ids,
1612            self_focusable,
1613        }
1614    }
1615}
1616
1617impl Widget for TabPane {
1618    fn build(&mut self, ctx: &mut BuildContext) -> Vec<WidgetId> {
1619        self.self_id = Some(ctx.self_id());
1620        if let Some(child) = self.pending_child.take() {
1621            self.child_id = Some(ctx.add_boxed(child));
1622        }
1623        if self.self_focusable {
1624            // Apply self-handlers so the framework treats this pane
1625            // as a Tab-key stop, allowing Tab from the selected tab
1626            // header to land inside an otherwise-empty panel.
1627            ctx.apply_self_handlers(
1628                teksilo_core::widget_builder::HandlerSet::new().focusable(true),
1629            );
1630        }
1631        self.child_id.into_iter().collect()
1632    }
1633
1634    fn layout_response(&self, proposal: SizeProposal, ctx: &LayoutContext) -> LayoutResponse {
1635        self.child_id
1636            .and_then(|id| ctx.child_size(id, proposal))
1637            .unwrap_or_else(|| proposal.resolve(0.0, 0.0))
1638            .into()
1639    }
1640
1641    fn place_children(
1642        &self,
1643        bounds: Rect,
1644        _proposal: SizeProposal,
1645        children: &mut [WidgetPlacement],
1646        _ctx: &LayoutContext,
1647    ) {
1648        for child in children.iter_mut() {
1649            child.origin = bounds.origin();
1650            child.size = bounds.size();
1651        }
1652    }
1653
1654    fn accessibility(&self, builder: &mut AccessNodeBuilder) {
1655        builder.set_role(teksilo_core::accesskit::Role::TabPanel);
1656        if let Some(ref title) = self.handle.info.title {
1657            let resolved: String = title.clone().into();
1658            builder.set_name(&resolved);
1659        }
1660        // ARIA aria-labelledby — point to the tab header that
1661        // controls this panel. Look up *current* index by finding
1662        // self_id in panel_ids (which the Switcher repopulates each
1663        // build, so this auto-corrects on reorder), then map that
1664        // to the header at the same position. Skip the relation —
1665        // no dangling — when self_id or the header for that index
1666        // isn't yet available (e.g. mid-rebuild after a model
1667        // mutation).
1668        if let Some(self_id) = self.self_id {
1669            let panel_ids = self.panel_ids.borrow();
1670            if let Some(pos) = panel_ids.iter().position(|&id| id == self_id) {
1671                if let Some(&header_id) = self.header_ids.borrow().get(pos) {
1672                    builder.push_labelled_by(teksilo_core::accessibility::widget_id_to_node_id(
1673                        header_id,
1674                    ));
1675                }
1676            }
1677        }
1678        // Opt-in panel focusability (TabInfo::focusable_panel).
1679        // AccessKit has no `tabindex` field; `Action::Focus` is the
1680        // canonical way to signal focusability to AT, matching how
1681        // TabHeader::accessibility advertises focusability.
1682        if self.self_focusable {
1683            builder.add_action(teksilo_core::accesskit::Action::Focus);
1684        }
1685    }
1686
1687    fn children(&self) -> Vec<WidgetId> {
1688        self.child_id.into_iter().collect()
1689    }
1690}
1691
1692// ─── AliasWidget: thin wrapper exposing a pre-registered widget id ──
1693
1694/// One-shot wrapper that "absorbs" a pre-registered `WidgetId` on
1695/// first build, returning it as the wrapper's only child. Used by
1696/// [`TabWidget::static_tab_id`] to bridge the
1697/// `teksu!` DSL's element-valued-slot pattern (which pre-registers
1698/// the inner widget and hands the parent its id) into the factory
1699/// shape `static_tab_factory` expects.
1700#[derive(Debug)]
1701struct AliasWidget {
1702    target: Option<WidgetId>,
1703    child_id: Option<WidgetId>,
1704}
1705
1706impl Widget for AliasWidget {
1707    fn build(&mut self, _ctx: &mut BuildContext) -> Vec<WidgetId> {
1708        if let Some(id) = self.target.take() {
1709            self.child_id = Some(id);
1710        }
1711        self.child_id.into_iter().collect()
1712    }
1713
1714    fn layout_response(&self, proposal: SizeProposal, ctx: &LayoutContext) -> LayoutResponse {
1715        self.child_id
1716            .and_then(|id| ctx.child_size(id, proposal))
1717            .unwrap_or_else(|| proposal.resolve(0.0, 0.0))
1718            .into()
1719    }
1720
1721    fn place_children(
1722        &self,
1723        bounds: Rect,
1724        _proposal: SizeProposal,
1725        children: &mut [WidgetPlacement],
1726        _ctx: &LayoutContext,
1727    ) {
1728        for child in children.iter_mut() {
1729            child.origin = bounds.origin();
1730            child.size = bounds.size();
1731        }
1732    }
1733
1734    fn children(&self) -> Vec<WidgetId> {
1735        self.child_id.into_iter().collect()
1736    }
1737}