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}