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