Skip to main content

teksilo_widgets/tab_widget/
bar.rs

1// SPDX-License-Identifier: MPL-2.0
2// SPDX-FileCopyrightText: 2026 FernTech
3
4//! `TabBar<T>` — header strip driven by a data source.
5//!
6//! Horizontal and vertical orientations, with shared / independent
7//! sizing. Bar-leading and bar-trailing slots are wired. Overflow is
8//! handled by a `ScrollArea` around the headers row, plus optional
9//! scroll arrows and a "show all tabs" overflow dropdown (both on by
10//! default); whichever tab is activated is scrolled back into view (see
11//! [`RevealState`]). Closable tabs (with middle-click close),
12//! drag-to-reorder with edge auto-scroll, and a leading icon-only
13//! pinned-tab strip are all supported. Multi-line (multi-row) wrapping
14//! is the one layout mode not yet implemented.
15//!
16//! The data source is consumed via the `pub(crate)` [`ListSource`]
17//! abstraction so callers can pass either a `ListModel<T>` (clonable,
18//! mutable) or any external `ListDataSource<Item = T>` (a database
19//! cursor, a virtual list, …) without TabBar having to carry a generic
20//! source parameter.
21//!
22//! ## Accessibility
23//!
24//! The bar emits `Role::TabList` with an `aria-orientation`
25//! reflecting whether it was built with [`TabBar::horizontal`] or
26//! [`TabBar::vertical`]. When a page hosts more than one tab list,
27//! give each one an accessible name via
28//! [`.access_label(tr!(tab_list_name()))`](teksilo_core::widget_builder::WidgetBuilder::access_label)
29//! so screen readers can distinguish them (ARIA APG recommendation).
30//!
31//! ```ignore
32//! use teksilo_widgets::tab_widget::{TabBar, TabDelegate, TabId};
33//! use teksilo_data::ListModel;
34//! use teksilo_core::signal::Signal;
35//!
36//! #[derive(Clone)]
37//! struct Tab { id: TabId, title: String }
38//!
39//! let model: ListModel<Tab> = ListModel::new();
40//! let selected: Signal<Option<TabId>> = Signal::new(None);
41//! let delegate = TabDelegate::new(|_i, t: &Tab| teksilo_i18n::lit!(t.title.clone()));
42//! let _bar = TabBar::horizontal(model, delegate, selected, |_i, t| t.id)
43//!     .reorderable(true)
44//!     .tab_dividers();
45//! ```
46
47use std::cell::RefCell;
48use std::rc::Rc;
49use teksilo_i18n::lit;
50
51use teksilo_canvas::{Canvas, Point, Rect, Size, SizeProposal};
52use teksilo_core::DropFeedback;
53use teksilo_core::accessibility::AccessNodeBuilder;
54use teksilo_core::binding::BindingLevel;
55use teksilo_core::build_context::BuildContext;
56use teksilo_core::drag_payload::{DragPayload, DropOutcome};
57use teksilo_core::event::{EventResponse, ScrollDelta, WidgetEvent};
58use teksilo_core::overlay::OverlayPlacement;
59use teksilo_core::signal::Signal;
60use teksilo_core::widget::{
61    EventContext, LayoutContext, LayoutResponse, PaintContext, PendingChild, Widget,
62    WidgetPlacement,
63};
64use teksilo_core::widget_builder::HandlerSet;
65use teksilo_core::widget_id::WidgetId;
66use teksilo_data::{ListDataSource, ListModel};
67use teksilo_i18n::LocalizedString;
68use teksilo_tokens::Easing;
69
70use crate::list_source::ListSource;
71use crate::primitives::FixedSize;
72use crate::scroll_area::{ScrollArea, ScrollBarMode, ScrollBarPolicy};
73use crate::tab_widget::delegate::{
74    TabBarOrientation, TabDelegate, TabDisplayMode, TabOverflowButton, TabSizing,
75};
76use crate::tab_widget::header::{HeaderShared, TabHeader, TabHeaderConfig};
77use crate::tab_widget::id::TabId;
78use crate::{
79    Button, ButtonVariant, Expand, HStack, IconButton, IconButtonSize, IconWidget, ListView, Panel,
80    PopoverIconButton,
81};
82use teksilo_core::accesskit::HasPopup;
83use teksilo_tokens::{BorderRole, SurfaceRole, TextRole};
84
85use std::collections::HashMap;
86
87/// Default min width for an unpinned tab.
88pub const DEFAULT_MIN_TAB_WIDTH: f32 = 96.0;
89/// Default max width for an unpinned tab.
90pub const DEFAULT_MAX_TAB_WIDTH: f32 = 240.0;
91/// Default spacing between tab headers in the row. `0.0` so tabs sit
92/// flush against each other (Firefox / Chrome convention) — adjacent
93/// tab boundaries are visually separated by the per-tab borders, not
94/// by an empty gap.
95pub const DEFAULT_TAB_SPACING: f32 = 0.0;
96/// Default spacing between the bar's leading slot, scroll area, and
97/// trailing slot.
98pub const DEFAULT_BAR_SLOT_SPACING: f32 = 8.0;
99/// Default width (in dp) of a pinned tab — icon-only squares.
100pub const DEFAULT_PINNED_TAB_WIDTH: f32 = 32.0;
101/// Distance (in dp) one click of a scroll arrow advances the
102/// horizontal scroll position. Roughly one tab's worth.
103const SCROLL_ARROW_STEP: f32 = 120.0;
104/// Pixels-per-line conversion for `ScrollDelta::Lines`. Mouse wheels
105/// send their deltas in units of "lines"; the bar treats one line as
106/// roughly one tab-width's worth of scrolling so a single notch
107/// scrolls one full tab into view.
108const WHEEL_LINE_PIXELS: f32 = 64.0;
109/// Edge-zone width inside which `on_drag_tick` ramps the auto-scroll
110/// velocity up to [`DRAG_MAX_VELOCITY`].
111const DRAG_EDGE_ZONE: f32 = 32.0;
112/// Cap on per-frame auto-scroll velocity during a drag at the bar
113/// edges.
114const DRAG_MAX_VELOCITY: f32 = 12.0;
115
116/// Drag payload published by a tab header when the user starts
117/// dragging it.
118///
119/// Generic over the bar's item type `T` so a `TabBar<T>` only ever
120/// downcasts (`get_typed::<TabBarDragData<T>>()`) a drag started by
121/// another `TabBar<T>` — a drag from a `TabBar<OtherT>` simply never
122/// matches, giving cross-bar transfer type-safety for free.
123///
124/// Two consumers:
125/// - **Intra-bar reorder**: the bar's own `on_drop` matches
126///   `source_bar_id == self_id` and uses `source_index` to drive
127///   `move_item`. `item` is unused on this path (and may be `None`).
128/// - **Cross-bar transfer**: a *different* bar that opted in via
129///   [`accept_external_tabs`](TabBar::accept_external_tabs) takes
130///   `item` by value and hands it to its
131///   [`on_tab_received`](TabBar::on_tab_received) callback. `item` is
132///   `Some` only when the source bar opted in *and* the per-tab
133///   transferable predicate allows it (static tabs are excluded).
134pub struct TabBarDragData<T: 'static> {
135    /// Model index of the dragged tab in the *source* bar.
136    pub source_index: usize,
137    /// Widget id of the source `TabBar`. The receiving bar compares
138    /// it to its own id to tell an intra-bar reorder from a
139    /// cross-bar transfer.
140    pub source_bar_id: WidgetId,
141    /// Stable id of the dragged tab — handed to the source bar's
142    /// `on_transfer_out` so the app can remove it by id.
143    pub source_id: TabId,
144    /// A clone of the dragged item, carried for cross-bar transfer.
145    /// `None` when the source bar didn't opt into transfer or the tab
146    /// is non-transferable (e.g. a static tab).
147    pub item: Option<T>,
148}
149
150/// A reactive header strip that pulls its tab list from a data source
151/// and writes the active tab into a shared `Signal<Option<TabId>>`.
152///
153/// Selection is **id-based**: the bar holds a stable [`TabId`] per
154/// item (extracted via the `id_of` closure passed to the constructor)
155/// and the public `selected_id` signal is the source of truth across
156/// reorders / removals / locale changes. Internal index-based work
157/// (keyboard nav, scroll-to-active, click activation) reads a
158/// **private** `selected_index` signal that the bar keeps in
159/// bidirectional sync with `selected_id` at build time.
160pub struct TabBar<T: 'static> {
161    source: ListSource<T>,
162    delegate: TabDelegate<T>,
163    /// Public selection signal — id-based, stable across reorders.
164    selected_id: Signal<Option<TabId>>,
165    /// Closure that extracts a stable [`TabId`] from each model item.
166    /// Called per-item at every build.
167    id_of: Rc<dyn Fn(usize, &T) -> TabId>,
168    /// Private index signal used by internal index-based code
169    /// (keyboard nav, scroll, click). Synced with `selected_id` at
170    /// build time via two `ctx.effect`s installed in [`Widget::build`].
171    selected: Signal<usize>,
172
173    orientation: TabBarOrientation,
174    sizing: TabSizing,
175    tab_display: TabDisplayMode,
176    min_tab_width: f32,
177    max_tab_width: f32,
178    pinned_tab_width: f32,
179    spacing: f32,
180    /// Optional tab-strip cross-axis extent override (compact bars).
181    tab_height: Option<f32>,
182
183    /// All-states surface color/role shorthand applied to every tab
184    /// header — the per-state overrides below fall back to this, which
185    /// itself falls back to transparent. Default `None`.
186    tab_background: Option<teksilo_core::color_prop::ColorProp>,
187    /// Background for the **selected** tab (falls back to
188    /// `tab_background`, then transparent).
189    selected_tab_background: Option<teksilo_core::color_prop::ColorProp>,
190    /// Background for the **hovered** (non-selected) tab (falls back to
191    /// `tab_background`, then transparent).
192    hover_tab_background: Option<teksilo_core::color_prop::ColorProp>,
193    /// Background for **idle** tabs (falls back to `tab_background`, then
194    /// transparent).
195    idle_tab_background: Option<teksilo_core::color_prop::ColorProp>,
196    /// Backdrop fill spanning the whole bar strip, painted behind the
197    /// headers / slots / arrows. Independent of the per-tab backgrounds.
198    /// Default `None` = transparent.
199    bar_background: Option<teksilo_core::color_prop::ColorProp>,
200    /// Text role used for the label (and matching icon tint) on the
201    /// selected tab. Default: `TextRole::Primary`.
202    selected_text_role: TextRole,
203    /// Text role used for the label (and matching icon tint) on idle
204    /// tabs (not selected, not disabled). Default: `TextRole::Secondary`.
205    idle_text_role: TextRole,
206    /// When `true`, draw a 1 dp divider between consecutive tabs (in both
207    /// the scrollable and the pinned strip).
208    tab_dividers: bool,
209    /// Color of the inter-tab dividers. `None` ⇒ `BorderRole::Divider`.
210    tab_divider_color: Option<teksilo_core::color_prop::ColorProp>,
211    /// Which edge the active-tab highlight indicator hugs. Default
212    /// [`TabIndicatorPosition::OuterEdge`](teksilo_core::styles::TabIndicatorPosition).
213    active_indicator: teksilo_core::styles::TabIndicatorPosition,
214    /// Per-call style override propagated to every header in the bar.
215    /// `None` means "use the theme slot or the bundled `RecipeTabStyle`".
216    style_override: Option<teksilo_core::styles::SharedTabStyle>,
217
218    bar_leading_slot: Option<PendingChild>,
219    bar_trailing_slot: Option<PendingChild>,
220
221    show_separator: bool,
222    show_scroll_arrows: bool,
223    overflow_button: TabOverflowButton,
224    vertical_wheel_scrolls_horizontally: bool,
225    shift_wheel_scrolls_horizontally: bool,
226
227    on_close: Option<Rc<dyn Fn(usize, &mut EventContext)>>,
228    reorderable: bool,
229    on_reorder: Option<Rc<dyn Fn(usize, usize, &mut EventContext)>>,
230    on_pin_toggle: Option<Rc<dyn Fn(usize, bool, &mut EventContext)>>,
231
232    /// Cross-bar transfer opt-in. When `true`, headers publish an
233    /// item-carrying [`TabBarDragData`] (a drag source) AND the bar
234    /// accepts foreign tabs as a drop target. Set via
235    /// [`accept_external_tabs`](Self::accept_external_tabs).
236    accept_external_tabs: bool,
237    /// Item-clone closure, installed by
238    /// [`accept_external_tabs`](Self::accept_external_tabs) where
239    /// `T: Clone`. Captures the `Clone` capability so `build()` (which
240    /// is not `T: Clone`-bounded) can produce the carried item clone.
241    /// `None` ⇒ payloads carry `item: None` (reorder-only).
242    clone_item: Option<Rc<dyn Fn(&T) -> T>>,
243    /// Target-side callback: a foreign tab was dropped here. Receives
244    /// the moved item, the model insertion index in *this* bar, and
245    /// the firing context. The app inserts into its own model.
246    on_tab_received: Option<Rc<dyn Fn(T, usize, &mut EventContext)>>,
247    /// Source-side callback: one of this bar's tabs was accepted by a
248    /// *different* bar. Receives the transferred tab's id; the app
249    /// removes it from its own model.
250    on_transfer_out: Option<Rc<dyn Fn(TabId, &mut EventContext)>>,
251    /// Drop handler for **non-tab** payloads — an in-app foreign drag
252    /// (a tree/list row carrying app data) or an OS file/text/URL
253    /// drop. Receives the raw payload, the model insertion index, and
254    /// the firing context; returns `true` if accepted. Distinct from
255    /// [`on_tab_received`](Self::on_tab_received), which only handles
256    /// tabs dragged from a peer `TabBar<T>`.
257    on_external_drop: Option<Rc<dyn Fn(&DragPayload, usize, &mut EventContext) -> bool>>,
258    /// Per-tab transferable predicate. `None` ⇒ all tabs transferable.
259    /// `TabWidget` installs one that excludes static tabs.
260    transferable_fn: Option<Rc<dyn Fn(usize, &T) -> bool>>,
261    /// Set `true` by the bar's own `on_drop` when it consumes a drag
262    /// as an intra-bar reorder; read-and-reset by the source header's
263    /// `on_drag_ended` to suppress a spurious `on_transfer_out` (which
264    /// would otherwise remove the just-reordered tab). `on_drop` runs
265    /// before `on_drag_ended` in the same dispatch, so no reset-at-
266    /// drag-start is needed.
267    self_reorder_flag: Rc<std::cell::Cell<bool>>,
268
269    /// Optional shared buffer the parent `TabWidget<T>` populates with
270    /// its content panel ids so the headers can publish the
271    /// `controls()` accessibility relation. `None` for stand-alone
272    /// `TabBar` use — the headers simply omit the relation in that
273    /// case (which is the right semantics: there is no panel to
274    /// control).
275    panel_ids_buffer: Option<Rc<RefCell<Vec<WidgetId>>>>,
276
277    /// Optional shared buffer the parent `TabWidget<T>` reads after
278    /// the bar builds to obtain each header's `WidgetId` (in tab
279    /// order). Used to wire the `TabPanel → aria-labelledby → Tab`
280    /// accessibility relation on the TabPane side. `None` for
281    /// stand-alone `TabBar` use.
282    header_ids_buffer: Option<Rc<RefCell<Vec<WidgetId>>>>,
283
284    /// Drop indicator x position in bar-local coords, painted by
285    /// `paint()`. `None` means no drag in progress / not dropping
286    /// here. Cloned into the on_drag_hover / on_drag_leave handlers
287    /// at build time and into the bar's paint via `paint_state`.
288    paint_state: PaintState,
289
290    /// "Scroll the active tab into view" plumbing, shared with the
291    /// header row. Lives on the bar (not on the row, which is rebuilt
292    /// from scratch every pass) so `revealed` remembers across rebuilds
293    /// what the strip was last scrolled to.
294    reveal: RevealState,
295
296    root_child_id: Option<WidgetId>,
297
298    /// Direct widget-id handles to the bar's natural-width
299    /// contributors. In vertical orientation, `layout_response`
300    /// probes each at unspecified width to compute the bar's
301    /// intrinsic width (max across them), then clamps to
302    /// `[min_tab_width, max_tab_width]`. Bypasses the inner
303    /// `ScrollArea` whose own `layout_response` echoes its
304    /// proposal, which would otherwise let the bar swallow
305    /// whatever cross-axis space the parent gave it.
306    header_row_id: Option<WidgetId>,
307    pinned_strip_id: Option<WidgetId>,
308    bar_leading_slot_id: Option<WidgetId>,
309    bar_trailing_slot_id: Option<WidgetId>,
310    /// The bar's outer stack (slots + arrows + the scroll slot +
311    /// dropdown), *inside* the style chrome. A vertical bar measures
312    /// this at an unbounded height to recover its natural height —
313    /// the scroll slot is an `Expand::vertical`, which reports 0 and
314    /// takes its size from surplus, so the stack alone would say the
315    /// bar is 0 dp tall. See `natural_height_vertical`.
316    outer_stack_id: Option<WidgetId>,
317}
318
319#[derive(Clone)]
320struct PaintState {
321    /// Drop-indicator x in bar-local coords (`Some(x)`) or `None`
322    /// when no drag is in progress over the bar. A `Signal` (not a
323    /// bare `Cell`) so the `TabStyle`-built chrome painter can bind
324    /// to it and repaint when a drag updates the insertion point.
325    drop_indicator_x: Signal<Option<f32>>,
326    /// Cached bar world bounds recorded by `place_children`. Drop
327    /// handlers use the origin to translate world-coords header
328    /// bounds into bar-local space, and the size to detect when the
329    /// pointer is in the edge auto-scroll zone.
330    last_bar_bounds: Rc<std::cell::Cell<Rect>>,
331}
332
333impl Default for PaintState {
334    fn default() -> Self {
335        Self {
336            drop_indicator_x: Signal::new(None),
337            last_bar_bounds: Rc::new(std::cell::Cell::new(Rect::new(0.0, 0.0, 0.0, 0.0))),
338        }
339    }
340}
341
342impl std::fmt::Debug for PaintState {
343    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
344        f.debug_struct("PaintState")
345            .field("drop_indicator_x", &self.drop_indicator_x.get())
346            .field("last_bar_bounds", &self.last_bar_bounds.get())
347            .finish()
348    }
349}
350
351/// Below this many logical pixels a reveal is not worth a scroll write —
352/// the tab is already flush with the edge it was chasing.
353const REVEAL_EPSILON: f32 = 0.5;
354
355/// The enclosing `ScrollArea`'s handles, resolved once it exists.
356///
357/// The area is built *from* the header row's id, so the row cannot be
358/// handed these at construction — [`RevealState::area`] is filled in
359/// immediately afterwards, which is still long before any layout runs.
360#[derive(Clone)]
361struct RevealArea {
362    /// Offset along the bar's layout axis: `scroll_x` for a horizontal
363    /// bar, `scroll_y` for a vertical one.
364    scroll_main: Signal<f32>,
365    /// The viewport the area last placed its content into.
366    viewport: Rc<std::cell::Cell<Size>>,
367}
368
369/// "Scroll the active tab back into view", as an edge-triggered request
370/// shared between the bar and its header row.
371///
372/// A tab activated by pointer or keyboard is revealed for free: both move
373/// focus, and the framework's focus follow dispatches `ScrollIntoView` up
374/// the ancestor chain. Selection written *programmatically* — an app
375/// setting `selected_id`, the overflow dropdown, the AT click path — moves
376/// no focus, so without this the active tab can sit outside the strip's
377/// viewport indefinitely.
378///
379/// The bar arms; [`TabHeaderRow`] consumes, because that is where the
380/// per-tab extents live.
381#[derive(Clone)]
382struct RevealState {
383    /// Position of the tab to reveal **in unpinned-header space** (the
384    /// space the row's extents are indexed by), or `None` when nothing is
385    /// pending. Taken by the row's next real measurement.
386    pending: Rc<std::cell::Cell<Option<usize>>>,
387    /// Bumped on every arm, and bound to the header row at
388    /// [`BindingLevel::Relayout`] so arming schedules the layout pass
389    /// that consumes it. Without it, a selection change that resizes
390    /// nothing would only repaint and the request would sit unread until
391    /// some unrelated relayout happened by.
392    generation: Signal<u64>,
393    /// The tab the strip was last scrolled to. Guards the build-time arm:
394    /// a rebuild for an unrelated reason — a locale flip, a retitled tab,
395    /// a tab added elsewhere in the strip — must not yank the viewport
396    /// back to the active tab after the user scrolled away from it by
397    /// hand.
398    revealed: Rc<std::cell::Cell<Option<TabId>>>,
399    /// Set once the enclosing `ScrollArea` is built. See [`RevealArea`].
400    area: Rc<RefCell<Option<RevealArea>>>,
401}
402
403impl Default for RevealState {
404    fn default() -> Self {
405        Self {
406            pending: Rc::new(std::cell::Cell::new(None)),
407            generation: Signal::new(0),
408            revealed: Rc::new(std::cell::Cell::new(None)),
409            area: Rc::new(RefCell::new(None)),
410        }
411    }
412}
413
414impl RevealState {
415    /// Request that unpinned header `position` be scrolled into view on
416    /// the next layout pass, and schedule that pass.
417    fn arm(&self, position: usize) {
418        self.pending.set(Some(position));
419        self.generation.set(self.generation.get().wrapping_add(1));
420    }
421}
422
423impl std::fmt::Debug for RevealState {
424    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
425        f.debug_struct("RevealState")
426            .field("pending", &self.pending.get())
427            .field("generation", &self.generation.get())
428            .field("revealed", &self.revealed.get())
429            .field("area", &self.area.borrow().is_some())
430            .finish()
431    }
432}
433
434impl<T: 'static> std::fmt::Debug for TabBar<T> {
435    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
436        f.debug_struct("TabBar")
437            .field("len", &self.source.len())
438            .field("selected", &self.selected.get())
439            .field("sizing", &self.sizing)
440            .field("min_tab_width", &self.min_tab_width)
441            .field("max_tab_width", &self.max_tab_width)
442            .finish()
443    }
444}
445
446impl<T: 'static> TabBar<T> {
447    /// Construct a horizontal tab bar from a [`ListModel<T>`].
448    /// Default sizing is [`TabSizing::Shared`].
449    ///
450    /// `selected_id` is the id-based selection signal — written by
451    /// the bar on click / keyboard / drag-drop and observable by
452    /// callers. `id_of(index, &item)` extracts the stable [`TabId`]
453    /// from each model item.
454    pub fn horizontal(
455        model: ListModel<T>,
456        delegate: TabDelegate<T>,
457        selected_id: Signal<Option<TabId>>,
458        id_of: impl Fn(usize, &T) -> TabId + 'static,
459    ) -> Self {
460        Self::from_list_source(
461            ListSource::from_model(model),
462            delegate,
463            selected_id,
464            Rc::new(id_of),
465            TabBarOrientation::Horizontal,
466        )
467    }
468
469    /// Construct a horizontal tab bar from any [`ListDataSource`].
470    /// Default sizing is [`TabSizing::Shared`].
471    pub fn horizontal_from_source<S: ListDataSource<Item = T>>(
472        source: S,
473        delegate: TabDelegate<T>,
474        selected_id: Signal<Option<TabId>>,
475        id_of: impl Fn(usize, &T) -> TabId + 'static,
476    ) -> Self {
477        Self::from_list_source(
478            ListSource::from_data_source(source),
479            delegate,
480            selected_id,
481            Rc::new(id_of),
482            TabBarOrientation::Horizontal,
483        )
484    }
485
486    /// Construct a vertical tab bar from a [`ListModel<T>`]. Tabs
487    /// stack top-to-bottom as horizontal pills (icon + label + close
488    /// button arranged left-to-right within each pill). Default
489    /// sizing is [`TabSizing::Shared`] — uniform pill heights.
490    pub fn vertical(
491        model: ListModel<T>,
492        delegate: TabDelegate<T>,
493        selected_id: Signal<Option<TabId>>,
494        id_of: impl Fn(usize, &T) -> TabId + 'static,
495    ) -> Self {
496        Self::from_list_source(
497            ListSource::from_model(model),
498            delegate,
499            selected_id,
500            Rc::new(id_of),
501            TabBarOrientation::Vertical,
502        )
503    }
504
505    /// Construct a vertical tab bar from any [`ListDataSource`].
506    pub fn vertical_from_source<S: ListDataSource<Item = T>>(
507        source: S,
508        delegate: TabDelegate<T>,
509        selected_id: Signal<Option<TabId>>,
510        id_of: impl Fn(usize, &T) -> TabId + 'static,
511    ) -> Self {
512        Self::from_list_source(
513            ListSource::from_data_source(source),
514            delegate,
515            selected_id,
516            Rc::new(id_of),
517            TabBarOrientation::Vertical,
518        )
519    }
520
521    pub(crate) fn from_list_source(
522        source: ListSource<T>,
523        delegate: TabDelegate<T>,
524        selected_id: Signal<Option<TabId>>,
525        id_of: Rc<dyn Fn(usize, &T) -> TabId>,
526        orientation: TabBarOrientation,
527    ) -> Self {
528        Self {
529            source,
530            delegate,
531            selected_id,
532            id_of,
533            selected: Signal::new(0_usize),
534            orientation,
535            sizing: TabSizing::Shared,
536            tab_display: TabDisplayMode::Auto,
537            min_tab_width: DEFAULT_MIN_TAB_WIDTH,
538            max_tab_width: DEFAULT_MAX_TAB_WIDTH,
539            pinned_tab_width: DEFAULT_PINNED_TAB_WIDTH,
540            spacing: DEFAULT_TAB_SPACING,
541            tab_height: None,
542            tab_background: None,
543            selected_tab_background: None,
544            hover_tab_background: None,
545            idle_tab_background: None,
546            bar_background: None,
547            selected_text_role: TextRole::Primary,
548            idle_text_role: TextRole::Secondary,
549            tab_dividers: false,
550            tab_divider_color: None,
551            active_indicator: teksilo_core::styles::TabIndicatorPosition::OuterEdge,
552            style_override: None,
553            bar_leading_slot: None,
554            bar_trailing_slot: None,
555            show_separator: true,
556            show_scroll_arrows: true,
557            overflow_button: TabOverflowButton::Auto,
558            vertical_wheel_scrolls_horizontally: true,
559            shift_wheel_scrolls_horizontally: true,
560            on_close: None,
561            reorderable: false,
562            on_reorder: None,
563            on_pin_toggle: None,
564            accept_external_tabs: false,
565            clone_item: None,
566            on_tab_received: None,
567            on_transfer_out: None,
568            on_external_drop: None,
569            transferable_fn: None,
570            self_reorder_flag: Rc::new(std::cell::Cell::new(false)),
571            panel_ids_buffer: None,
572            header_ids_buffer: None,
573            paint_state: PaintState::default(),
574            reveal: RevealState::default(),
575            root_child_id: None,
576            header_row_id: None,
577            pinned_strip_id: None,
578            bar_leading_slot_id: None,
579            bar_trailing_slot_id: None,
580            outer_stack_id: None,
581        }
582    }
583
584    /// Override the per-tab sizing strategy. See [`TabSizing`].
585    pub fn tab_sizing(mut self, mode: TabSizing) -> Self {
586        self.sizing = mode;
587        self
588    }
589
590    /// The natural height of a **vertical** bar: the headers' own extent
591    /// plus whatever the non-scrolling slots (pinned strip, scroll
592    /// arrows, overflow dropdown, leading / trailing slot widgets) and
593    /// their spacings contribute.
594    ///
595    /// The outer stack can't answer this on its own: the scroll slot is
596    /// an `Expand::vertical`, which reports 0 at its natural size and
597    /// grows from surplus, so measuring the stack at an unbounded height
598    /// yields "everything except the tabs". Adding the header column's
599    /// own unbounded height back gives the whole bar — no duplicate
600    /// spacing arithmetic (the stack already counted it).
601    ///
602    /// Without this a vertical bar next to a flexible sibling (the
603    /// `Spacer` that pins a nav to the bottom of a sidebar) collapses to
604    /// 0 dp and its pills spill out of it.
605    fn natural_height_vertical(&self, width: Option<f32>, ctx: &LayoutContext) -> f32 {
606        let probe = SizeProposal {
607            width,
608            height: None,
609        };
610        let slots_h = self
611            .outer_stack_id
612            .and_then(|id| ctx.child_size(id, probe))
613            .map(|s| s.height)
614            .unwrap_or(0.0);
615        let headers_h = self
616            .header_row_id
617            .and_then(|id| ctx.child_size(id, probe))
618            .map(|s| s.height)
619            .unwrap_or(0.0);
620        slots_h + headers_h
621    }
622
623    /// Choose what every tab shows — icon, label, or both. See
624    /// [`TabDisplayMode`]. Default [`TabDisplayMode::Auto`] (render each tab as
625    /// its `TabInfo` declares).
626    pub fn tab_display(mut self, mode: TabDisplayMode) -> Self {
627        self.tab_display = mode;
628        self
629    }
630
631    /// Minimum width (in dp) any unpinned tab will be drawn at.
632    /// Default: [`DEFAULT_MIN_TAB_WIDTH`].
633    ///
634    /// In **horizontal** orientation this clamps the **per-tab** width.
635    /// In **vertical** orientation every tab is forced to the bar's
636    /// cross-axis width, so the same knob defines the bar's minimum
637    /// width — the sidebar adapts to the widest piece of bar content
638    /// (tab labels or a slot widget) and never shrinks below this floor.
639    /// Vertical pill heights stay at `theme.components.tab.editor_tab_height`
640    /// regardless of this knob.
641    ///
642    /// Under [`TabSizing::Fill`] a **vertical** bar takes the width it is
643    /// offered outright, so this floor no longer applies to it; in a
644    /// **horizontal** `Fill` bar it still does (the tabs overflow into
645    /// scroll rather than squeeze below it).
646    pub fn min_tab_width(mut self, dp: f32) -> Self {
647        self.min_tab_width = dp.max(0.0);
648        self
649    }
650
651    /// Override the tab-strip cross-axis extent (the strip height for a
652    /// horizontal bar; the per-tab pill height for a vertical one). `None`
653    /// keeps the style's `editor_tab_height`. Use for a compact bar.
654    pub fn tab_bar_height(mut self, dp: f32) -> Self {
655        self.tab_height = Some(dp.max(0.0));
656        self
657    }
658
659    /// Maximum width (in dp) any unpinned tab will be drawn at — long
660    /// labels truncate with an ellipsis at this width.
661    /// Default: [`DEFAULT_MAX_TAB_WIDTH`].
662    ///
663    /// In **horizontal** orientation this clamps the **per-tab** width.
664    /// In **vertical** orientation it caps the whole sidebar's width —
665    /// see [`min_tab_width`](Self::min_tab_width) for the symmetric
666    /// adapt-to-content rule.
667    ///
668    /// [`TabSizing::Fill`] ignores this cap in both orientations — filling
669    /// the bar is the point, and a cap would leave exactly the slack the
670    /// mode exists to remove.
671    pub fn max_tab_width(mut self, dp: f32) -> Self {
672        self.max_tab_width = dp.max(0.0);
673        self
674    }
675
676    /// Override the spacing (in dp) between adjacent tab headers in
677    /// the row. Default: [`DEFAULT_TAB_SPACING`].
678    pub fn tab_spacing(mut self, dp: f32) -> Self {
679        self.spacing = dp.max(0.0);
680        self
681    }
682
683    /// Width (in dp) of an icon-only pinned tab.
684    /// Default: [`DEFAULT_PINNED_TAB_WIDTH`].
685    pub fn pinned_tab_width(mut self, dp: f32) -> Self {
686        self.pinned_tab_width = dp.max(0.0);
687        self
688    }
689
690    /// All-states shorthand for the per-tab background — every tab
691    /// (selected, idle, hovered) paints this unless a per-state override
692    /// below is set. Accepts any `Color`, `SurfaceRole`, or `Signal<Color>`
693    /// (via [`ColorProp`](teksilo_core::color_prop::ColorProp)).
694    /// Default `None` = transparent. To tint the bar's backdrop instead,
695    /// use [`bar_background`](Self::bar_background).
696    pub fn tab_background(mut self, color: impl Into<teksilo_core::color_prop::ColorProp>) -> Self {
697        self.tab_background = Some(color.into());
698        self
699    }
700
701    /// Background for the **selected** tab. Falls back to
702    /// [`tab_background`](Self::tab_background), then transparent.
703    pub fn selected_tab_background(
704        mut self,
705        color: impl Into<teksilo_core::color_prop::ColorProp>,
706    ) -> Self {
707        self.selected_tab_background = Some(color.into());
708        self
709    }
710
711    /// Background for the **hovered** (non-selected) tab. Falls back to
712    /// [`tab_background`](Self::tab_background), then transparent.
713    pub fn hover_tab_background(
714        mut self,
715        color: impl Into<teksilo_core::color_prop::ColorProp>,
716    ) -> Self {
717        self.hover_tab_background = Some(color.into());
718        self
719    }
720
721    /// Background for **idle** tabs (not selected, not hovered). Falls back
722    /// to [`tab_background`](Self::tab_background), then transparent.
723    pub fn idle_tab_background(
724        mut self,
725        color: impl Into<teksilo_core::color_prop::ColorProp>,
726    ) -> Self {
727        self.idle_tab_background = Some(color.into());
728        self
729    }
730
731    /// Set the backdrop fill spanning the whole bar strip (behind the
732    /// headers, slots, and scroll arrows). Independent of the per-tab
733    /// backgrounds. Accepts any `Color`, `SurfaceRole`, or `Signal<Color>`.
734    /// Default `None` = transparent.
735    pub fn bar_background(mut self, color: impl Into<teksilo_core::color_prop::ColorProp>) -> Self {
736        self.bar_background = Some(color.into());
737        self
738    }
739
740    /// Draw a 1 dp divider between consecutive tabs (scrollable and pinned
741    /// strips). Off by default. See [`tab_divider_color`](Self::tab_divider_color).
742    pub fn tab_dividers(mut self) -> Self {
743        self.tab_dividers = true;
744        self
745    }
746
747    /// Like [`tab_dividers`](Self::tab_dividers), but with an explicit
748    /// colour. Accepts any `Color`, [`BorderRole`],
749    /// or `Signal<Color>`. Implies `tab_dividers()`.
750    pub fn tab_divider_color(
751        mut self,
752        color: impl Into<teksilo_core::color_prop::ColorProp>,
753    ) -> Self {
754        self.tab_dividers = true;
755        self.tab_divider_color = Some(color.into());
756        self
757    }
758
759    /// Choose which edge the active-tab highlight indicator hugs. Default
760    /// [`TabIndicatorPosition::OuterEdge`](teksilo_core::styles::TabIndicatorPosition)
761    /// (top for horizontal / leading for vertical);
762    /// [`InnerEdge`](teksilo_core::styles::TabIndicatorPosition::InnerEdge)
763    /// puts it below the label (horizontal) / on the trailing edge (vertical).
764    /// Honoured by the default `RecipeTabStyle`; a custom
765    /// [`TabStyle`](teksilo_core::styles::TabStyle) may interpret it freely.
766    pub fn active_indicator(
767        mut self,
768        position: teksilo_core::styles::TabIndicatorPosition,
769    ) -> Self {
770        self.active_indicator = position;
771        self
772    }
773
774    /// Set the text role used for the label (and matching icon tint)
775    /// on the **selected** tab. Default: [`TextRole::Primary`] — the
776    /// Int UI editor-strip convention. Override to e.g.
777    /// [`TextRole::Accent`] when the strip sits over a tinted surface.
778    pub fn selected_text_role(mut self, role: TextRole) -> Self {
779        self.selected_text_role = role;
780        self
781    }
782
783    /// Set the text role used for the label (and matching icon tint)
784    /// on **idle** tabs (not selected, not disabled). Default:
785    /// [`TextRole::Secondary`]. Disabled tabs always read as
786    /// [`TextRole::Disabled`] regardless of this setting.
787    pub fn idle_text_role(mut self, role: TextRole) -> Self {
788        self.idle_text_role = role;
789        self
790    }
791
792    /// Override the active [`TabStyle`](teksilo_core::styles::TabStyle)
793    /// for every header in this bar. The widget keeps responsibility
794    /// for the label / icon / close button composition, the
795    /// optional per-state tab backgrounds, and all input handling;
796    /// the style only paints the accent indicator and focus ring
797    /// chrome via `make_body`. Per-call override > theme slot >
798    /// built-in `RecipeTabStyle` default.
799    pub fn style(mut self, style: impl teksilo_core::styles::TabStyle) -> Self {
800        self.style_override = Some(std::rc::Rc::new(style));
801        self
802    }
803
804    /// Install a pin-toggle handler called whenever the user crosses
805    /// a pinned tab over the unpinned region or vice-versa during a
806    /// drag. Receives `(model_index, new_pinned_flag, ctx)`. The
807    /// firing [`EventContext`] lets the handler confirm the
808    /// transition via a dialog or route it through an intent before
809    /// mutating the item; apps decide whether to actually flip the
810    /// pinned state.
811    pub fn on_pin_toggle(mut self, f: impl Fn(usize, bool, &mut EventContext) + 'static) -> Self {
812        self.on_pin_toggle = Some(Rc::new(f));
813        self
814    }
815
816    /// Bar-level leading slot — a widget rendered before the headers
817    /// row (and before any pinned region in later phases).
818    pub fn bar_leading_slot(mut self, w: impl Widget + 'static) -> Self {
819        self.bar_leading_slot = Some(PendingChild::Deferred(Box::new(w)));
820        self
821    }
822
823    /// Bar-level leading slot accepting a pre-registered widget id.
824    pub fn bar_leading_slot_id(mut self, id: WidgetId) -> Self {
825        self.bar_leading_slot = Some(PendingChild::Id(id));
826        self
827    }
828
829    /// Bar-level trailing slot — a widget rendered after the headers
830    /// row (and after any overflow dropdown in later phases).
831    pub fn bar_trailing_slot(mut self, w: impl Widget + 'static) -> Self {
832        self.bar_trailing_slot = Some(PendingChild::Deferred(Box::new(w)));
833        self
834    }
835
836    /// Bar-level trailing slot accepting a pre-registered widget id.
837    pub fn bar_trailing_slot_id(mut self, id: WidgetId) -> Self {
838        self.bar_trailing_slot = Some(PendingChild::Id(id));
839        self
840    }
841
842    /// Toggle the 1 dp bottom separator the bar paints under the
843    /// headers. Default: on.
844    pub fn separator(mut self, on: bool) -> Self {
845        self.show_separator = on;
846        self
847    }
848
849    /// Toggle the leading + trailing scroll-arrow buttons. They
850    /// auto-show when the headers row overflows the bar's viewport,
851    /// and click animates the scroll position by one tab-width.
852    /// Default: on.
853    pub fn show_scroll_arrows(mut self, on: bool) -> Self {
854        self.show_scroll_arrows = on;
855        self
856    }
857
858    /// When the trailing "show all tabs" overflow dropdown appears — a
859    /// `Popover` with a `MenuList` of every tab. Default:
860    /// [`TabOverflowButton::Auto`] (shown only when the headers overflow the
861    /// viewport). See [`TabOverflowButton`] for `Always` / `Never`.
862    pub fn overflow_button(mut self, mode: TabOverflowButton) -> Self {
863        self.overflow_button = mode;
864        self
865    }
866
867    /// Convenience over [`overflow_button`](Self::overflow_button): `true` maps
868    /// to [`TabOverflowButton::Always`], `false` to [`TabOverflowButton::Never`].
869    /// Prefer `overflow_button(TabOverflowButton::Auto)` for the default
870    /// "only when overflowing" behaviour.
871    pub fn show_overflow_dropdown(mut self, on: bool) -> Self {
872        self.overflow_button = if on {
873            TabOverflowButton::Always
874        } else {
875            TabOverflowButton::Never
876        };
877        self
878    }
879
880    /// On a horizontal bar, treat a plain vertical-wheel event as a
881    /// horizontal scroll (Firefox / Chrome convention). Has no
882    /// effect on vertical or multi-line bars (those still scroll
883    /// vertically). Default: on.
884    pub fn vertical_wheel_scrolls_horizontally(mut self, on: bool) -> Self {
885        self.vertical_wheel_scrolls_horizontally = on;
886        self
887    }
888
889    /// `Shift` + vertical wheel forces a horizontal scroll regardless
890    /// of orientation. Default: on.
891    pub fn shift_wheel_scrolls_horizontally(mut self, on: bool) -> Self {
892        self.shift_wheel_scrolls_horizontally = on;
893        self
894    }
895
896    /// Install a close-tab handler called whenever the user clicks a
897    /// closable tab's close button, middle-clicks the tab header, or
898    /// presses `Delete` on a focused tab. The handler receives the
899    /// firing [`EventContext`] so it can open a confirmation dialog
900    /// (`ctx.present_modal(MessageBox::confirm(...))`), dispatch an
901    /// intent, or otherwise route the close request through the
902    /// framework. To veto the close, do nothing in the handler; to
903    /// confirm-then-close, run the confirmation flow and only mutate
904    /// the underlying model on accept.
905    ///
906    /// If unset and the bar is backed by a [`ListModel<T>`], the
907    /// default behavior is to remove the item at the given index
908    /// from the model (no confirmation, no ctx needed for that path).
909    pub fn on_close(mut self, f: impl Fn(usize, &mut EventContext) + 'static) -> Self {
910        self.on_close = Some(Rc::new(f));
911        self
912    }
913
914    /// Enable drag-to-reorder. Each tab header becomes a drag source
915    /// and the bar accepts drops anywhere along the headers row,
916    /// painting an insertion-line indicator at the would-be
917    /// position. On drop the bar calls [`on_reorder`](Self::on_reorder)
918    /// — falling back to `ListModel::move_item` when the bar is
919    /// backed by a `ListModel<T>` and no explicit handler is set.
920    /// Default: off.
921    pub fn reorderable(mut self, on: bool) -> Self {
922        self.reorderable = on;
923        self
924    }
925
926    /// Install a reorder handler called whenever the user drag-drops
927    /// a tab to a new position. Receives `(from, to, ctx)` —
928    /// `from`/`to` are model indices and `ctx` is the firing
929    /// [`EventContext`] so the handler can open a confirmation
930    /// dialog or dispatch an intent before persisting the move.
931    /// Implies [`reorderable(true)`](Self::reorderable).
932    pub fn on_reorder(mut self, f: impl Fn(usize, usize, &mut EventContext) + 'static) -> Self {
933        self.on_reorder = Some(Rc::new(f));
934        self.reorderable = true;
935        self
936    }
937
938    /// Opt into cross-bar tab transfer. When enabled, this bar's
939    /// headers become transfer drag sources (their drag payload
940    /// carries a clone of the dragged item) **and** the bar accepts
941    /// tabs dragged from *other* `TabBar<T>`s, painting the same
942    /// insertion-line indicator as an intra-bar reorder.
943    ///
944    /// Requires `T: Clone` — the dragged item is cloned into the
945    /// payload (cheap for handle-like `T` whose heavy state lives
946    /// behind an `Rc`). Default: off.
947    ///
948    /// Pair with [`on_tab_received`](Self::on_tab_received) (this bar,
949    /// as a drop target — insert the item into your model) and
950    /// [`on_transfer_out`](Self::on_transfer_out) (the source bar —
951    /// remove the tab from your model).
952    pub fn accept_external_tabs(mut self, on: bool) -> Self
953    where
954        T: Clone,
955    {
956        self.accept_external_tabs = on;
957        self.clone_item = if on {
958            Some(Rc::new(|t: &T| t.clone()))
959        } else {
960            None
961        };
962        self
963    }
964
965    /// Install the target-side callback fired when a foreign tab is
966    /// dropped onto this bar. Receives `(item, insertion_index, ctx)`
967    /// — the moved item (taken by value from the drag payload), the
968    /// model index in *this* bar where it should land, and the firing
969    /// context. The app inserts the item into its own model. Implies
970    /// [`accept_external_tabs(true)`](Self::accept_external_tabs).
971    pub fn on_tab_received(mut self, f: impl Fn(T, usize, &mut EventContext) + 'static) -> Self
972    where
973        T: Clone,
974    {
975        self.on_tab_received = Some(Rc::new(f));
976        if !self.accept_external_tabs {
977            self = self.accept_external_tabs(true);
978        }
979        self
980    }
981
982    /// Install the source-side callback fired after one of this bar's
983    /// tabs has been accepted by a *different* bar. Receives the
984    /// transferred tab's [`TabId`]; the app removes it from its own
985    /// model. Not fired for intra-bar reorders (those go through
986    /// [`on_reorder`](Self::on_reorder)) or rejected / cancelled
987    /// drags. Implies [`accept_external_tabs(true)`](Self::accept_external_tabs).
988    pub fn on_transfer_out(mut self, f: impl Fn(TabId, &mut EventContext) + 'static) -> Self
989    where
990        T: Clone,
991    {
992        self.on_transfer_out = Some(Rc::new(f));
993        if !self.accept_external_tabs {
994            self = self.accept_external_tabs(true);
995        }
996        self
997    }
998
999    /// Accept **non-tab** drops onto the bar — an in-app foreign drag
1000    /// (e.g. a file dragged from a `TreeView`, carrying app data) or an
1001    /// OS file/text/URL drop. The bar paints the same insertion-line
1002    /// indicator while such a payload hovers, and on drop calls `f`
1003    /// with the raw [`DragPayload`], the model insertion index, and the
1004    /// firing context. Return `true` if accepted — the app inspects the
1005    /// payload (`get_typed::<T>()` / `files()` / `text()` / `uris()`)
1006    /// and mints whatever it needs (e.g. opens a tab).
1007    ///
1008    /// Independent of [`accept_external_tabs`](Self::accept_external_tabs):
1009    /// a bar can accept foreign tabs, non-tab payloads, both, or
1010    /// neither. OS drops additionally require the app to have called
1011    /// `TeksiloAppBuilder::install_external_dnd()`.
1012    ///
1013    /// Note: the hover indicator is *optimistic* — it shows for any
1014    /// non-tab payload while this handler is installed; `f`'s return
1015    /// value is authoritative at drop time.
1016    pub fn on_external_drop(
1017        mut self,
1018        f: impl Fn(&DragPayload, usize, &mut EventContext) -> bool + 'static,
1019    ) -> Self {
1020        self.on_external_drop = Some(Rc::new(f));
1021        self
1022    }
1023
1024    /// Internal hook: install the non-tab drop handler. `pub(crate)`
1025    /// because `TabWidget` wires its own index-translation layer.
1026    pub(crate) fn on_external_drop_rc(
1027        mut self,
1028        f: Rc<dyn Fn(&DragPayload, usize, &mut EventContext) -> bool>,
1029    ) -> Self {
1030        self.on_external_drop = Some(f);
1031        self
1032    }
1033
1034    /// Internal hook: install a per-tab transferable predicate.
1035    /// `TabWidget` uses it to exclude static tabs (whose content has
1036    /// no factory on a receiving bar) from cross-bar transfer. When
1037    /// the predicate returns `false`, the tab's drag payload carries
1038    /// `item: None` and a foreign bar rejects the drop.
1039    pub(crate) fn with_transferable_predicate(
1040        mut self,
1041        f: impl Fn(usize, &T) -> bool + 'static,
1042    ) -> Self {
1043        self.transferable_fn = Some(Rc::new(f));
1044        self
1045    }
1046
1047    /// Internal hook: install the source-side transfer-out callback.
1048    /// `pub(crate)` because `TabWidget` wires its own translation
1049    /// layer; the public entry point is on `TabWidget`.
1050    pub(crate) fn on_transfer_out_rc(mut self, f: Rc<dyn Fn(TabId, &mut EventContext)>) -> Self {
1051        self.on_transfer_out = Some(f);
1052        self
1053    }
1054
1055    /// Internal hook: install the target-side received callback.
1056    /// `pub(crate)` because `TabWidget` wires its own translation
1057    /// layer; the public entry point is on `TabWidget`.
1058    pub(crate) fn on_tab_received_rc(mut self, f: Rc<dyn Fn(T, usize, &mut EventContext)>) -> Self {
1059        self.on_tab_received = Some(f);
1060        self
1061    }
1062
1063    /// Internal hook used by `TabWidget<T>` to share a panel-ids
1064    /// buffer with this bar. The wrapping widget passes its
1065    /// `Switcher`'s captured panel ids in; the headers read them in
1066    /// `accessibility()` to publish the Tab → TabPanel `controls()`
1067    /// relation.
1068    pub(crate) fn with_panel_ids(mut self, buffer: Rc<RefCell<Vec<WidgetId>>>) -> Self {
1069        self.panel_ids_buffer = Some(buffer);
1070        self
1071    }
1072
1073    /// Share the bar's header-ids buffer with the parent so each
1074    /// `TabPane` can wire its `aria-labelledby` relation to the
1075    /// header at the matching index. Populated by `build()` once
1076    /// every header has been added to the arena; readers must
1077    /// `borrow()` after the bar's build pass.
1078    pub(crate) fn with_header_ids(mut self, buffer: Rc<RefCell<Vec<WidgetId>>>) -> Self {
1079        self.header_ids_buffer = Some(buffer);
1080        self
1081    }
1082}
1083
1084impl<T: 'static> Widget for TabBar<T> {
1085    fn build(&mut self, ctx: &mut BuildContext) -> Vec<WidgetId> {
1086        // Rebuild on data-source changes. We store a `version: Signal<u64>`
1087        // bound at `BindingLevel::Rebuild`; the observer increments it
1088        // for every `DataChange`. Lifetime of the observer is tied to
1089        // this build pass via `ctx.own_handle(...)`.
1090        let self_id = ctx.self_id();
1091        let version = ctx.signal(0u64);
1092        version.bind_to(self_id, ctx.binding_registry(), BindingLevel::Rebuild);
1093
1094        let data_ver = Rc::new(std::cell::Cell::new(0_u64));
1095        let observer_handle = (self.source.observe_fn)(Box::new({
1096            let version = version.clone();
1097            let dv = data_ver.clone();
1098            move |_change| {
1099                let next = dv.get().wrapping_add(1);
1100                dv.set(next);
1101                version.set(next);
1102            }
1103        }));
1104        ctx.own_handle(observer_handle);
1105
1106        // Snapshot enabled + pinned flags up front. Headers need
1107        // the full enabled vector (for arrow-key skip-over) and we
1108        // need pinned[i] to partition the layout into pinned strip
1109        // vs scrollable region. The `ListSource::with_item_fn` API
1110        // is widget-shaped, so we side-channel the booleans through
1111        // a `Cell` and discard the throwaway widget it produces.
1112        let n = self.source.len();
1113        let mut enabled_tabs = Vec::with_capacity(n);
1114        let mut pinned_tabs: Vec<bool> = Vec::with_capacity(n);
1115        for i in 0..n {
1116            let cell = std::cell::Cell::new((true, false));
1117            (self.source.with_item_fn)(i, &|item| {
1118                cell.set((
1119                    self.delegate.resolve_enabled(i, item),
1120                    self.delegate.resolve_pinned(i, item),
1121                ));
1122                Box::new(EnabledProbe) as Box<dyn Widget>
1123            });
1124            let (e, p) = cell.get();
1125            enabled_tabs.push(e);
1126            pinned_tabs.push(p);
1127        }
1128        let enabled_tabs = Rc::new(enabled_tabs);
1129
1130        // ── Bidirectional id ↔ index selection sync ────────────────
1131        //
1132        // The bar's PUBLIC API is id-based (`selected_id`); its
1133        // internal index-based code (keyboard, scroll, click) reads
1134        // `selected` (the private index signal). At build time we:
1135        //
1136        //   1. Compute id↔index lookup tables from the live model
1137        //      via `id_of`.
1138        //   2. Pre-build sync: bring the two signals into agreement —
1139        //      valid id wins, stale id falls back to the
1140        //      previously-selected index clamped into range
1141        //      (positional fallback = next neighbor of the closed
1142        //      tab; browser convention).
1143        //   3. Install two `ctx.effect`s for steady-state propagation:
1144        //      external id changes → index, internal index changes
1145        //      (from header click / keyboard) → id. No-op guards
1146        //      prevent ping-pong.
1147        let mut id_to_index: HashMap<TabId, usize> = HashMap::with_capacity(n);
1148        let mut index_to_id: Vec<TabId> = Vec::with_capacity(n);
1149        for i in 0..n {
1150            let cell: std::cell::Cell<Option<TabId>> = std::cell::Cell::new(None);
1151            (self.source.with_item_fn)(i, &|item| {
1152                cell.set(Some((self.id_of)(i, item)));
1153                Box::new(EnabledProbe) as Box<dyn Widget>
1154            });
1155            if let Some(id) = cell.get() {
1156                id_to_index.insert(id, i);
1157                index_to_id.push(id);
1158            }
1159        }
1160        let id_to_index = Rc::new(id_to_index);
1161        let index_to_id = Rc::new(index_to_id);
1162
1163        if n > 0 {
1164            let valid = self
1165                .selected_id
1166                .get()
1167                .and_then(|id| id_to_index.get(&id).copied());
1168            if let Some(target_idx) = valid {
1169                if self.selected.get() != target_idx {
1170                    self.selected.set(target_idx);
1171                }
1172            } else {
1173                let clamped = self.selected.get().min(n - 1);
1174                if self.selected.get() != clamped {
1175                    self.selected.set(clamped);
1176                }
1177                let new_id = index_to_id[clamped];
1178                if self.selected_id.get() != Some(new_id) {
1179                    self.selected_id.set(Some(new_id));
1180                }
1181            }
1182        } else if self.selected_id.get().is_some() {
1183            self.selected_id.set(None);
1184        }
1185
1186        let id_to_idx_for_eff = id_to_index.clone();
1187        let idx_for_id_eff = self.selected.clone();
1188        ctx.effect(&self.selected_id, move |maybe_id| {
1189            if let Some(id) = maybe_id
1190                && let Some(&i) = id_to_idx_for_eff.get(id)
1191                && idx_for_id_eff.get() != i
1192            {
1193                idx_for_id_eff.set(i);
1194            }
1195        });
1196        let idx_to_id_for_eff = index_to_id.clone();
1197        let id_for_idx_eff = self.selected_id.clone();
1198        ctx.effect(&self.selected, move |i| {
1199            let new_id = idx_to_id_for_eff.get(*i).copied();
1200            if id_for_idx_eff.get() != new_id {
1201                id_for_idx_eff.set(new_id);
1202            }
1203        });
1204
1205        let header_ids_buf = self
1206            .header_ids_buffer
1207            .clone()
1208            .unwrap_or_else(|| Rc::new(RefCell::new(Vec::with_capacity(n))));
1209        // If a parent provided a pre-allocated buffer (e.g.
1210        // `TabWidget` rebuilding after a dynamic-model mutation),
1211        // clear stale entries so the new tab order replaces — never
1212        // appends to — the prior pass.
1213        header_ids_buf.borrow_mut().clear();
1214        let panel_ids_buf = self
1215            .panel_ids_buffer
1216            .clone()
1217            .unwrap_or_else(|| Rc::new(RefCell::new(Vec::new())));
1218        let shared = Rc::new(HeaderShared {
1219            header_ids: header_ids_buf.clone(),
1220            panel_ids: panel_ids_buf,
1221            enabled_tabs: enabled_tabs.clone(),
1222        });
1223
1224        // Pinned tabs render in a leading non-scrolling strip;
1225        // unpinned tabs go inside the scrollable TabHeaderRow.
1226        // We accumulate both lists here, then compose the row_outer
1227        // with the strips in the right order below.
1228        let mut pinned_header_ids: Vec<WidgetId> = Vec::new();
1229        let mut unpinned_header_ids: Vec<WidgetId> = Vec::with_capacity(n);
1230        // Maps each unpinned-region position to its index in the
1231        // **model**. Used by the drop handler to translate the
1232        // `insertion_index_for(...)` result (which is in unpinned
1233        // space — `header_bounds_buf` only contains the unpinned
1234        // row's bounds) to a model index that `move_item` can
1235        // consume directly.
1236        let mut unpinned_to_model: Vec<usize> = Vec::with_capacity(n);
1237        // Collected per-tab labels are reused by the overflow
1238        // dropdown's MenuList. Resolved at build time → re-resolved on
1239        // every data-source change (the bar rebuilds via `version`)
1240        // and on every locale change (because the dropdown's
1241        // MenuItems consume `LocalizedString` directly, which carries
1242        // its own reactive resolver).
1243        let mut header_labels: Vec<LocalizedString> = Vec::with_capacity(n);
1244
1245        // Reorder handler. Explicit `on_reorder` wins; otherwise
1246        // fall back to the source's `move_item_fn` (populated for
1247        // ListModel-backed bars).
1248        //
1249        // No pre-emptive `selected.set(...)` here: selection is
1250        // **id-based**. The id stored in `selected_id` is unchanged
1251        // by a reorder (the same tab is just at a different index),
1252        // and the bar's pre-build sync re-resolves the id → index
1253        // mapping during the rebuild that the model mutation
1254        // triggers. Writing the bar's private `selected` index
1255        // signal *before* the move would fire the index → id
1256        // effect against the pre-move `index_to_id` map and stamp
1257        // the wrong id into `selected_id`, which the post-rebuild
1258        // sync would then promote — causing the active tab to
1259        // change visually (and the content pane to fall out of
1260        // sync) on every drag.
1261        let reorder_handler: Option<Rc<dyn Fn(usize, usize, &mut EventContext)>> =
1262            if self.reorderable {
1263                if let Some(explicit) = self.on_reorder.clone() {
1264                    Some(explicit)
1265                } else {
1266                    self.source.move_item_fn.clone().map(|move_fn| {
1267                        Rc::new(move |from: usize, to: usize, _ctx: &mut EventContext| {
1268                            (move_fn)(from, to);
1269                        }) as Rc<dyn Fn(usize, usize, &mut EventContext)>
1270                    })
1271                }
1272            } else {
1273                None
1274            };
1275
1276        // Close handler. The explicit `on_close` overrides everything;
1277        // otherwise we fall back to the source's `remove_item_fn`
1278        // (populated when backed by a `ListModel`) and lift it into
1279        // the ctx-accepting shape by ignoring ctx. Same id-based
1280        // discipline as reorder: don't pre-empt the index signal.
1281        // After model.remove the rebuild's pre-build sync handles
1282        // both the "selected id still valid" case (re-indexes to
1283        // the survivor) and the "selected id stale" case (stale-id
1284        // fallback picks the next neighbor, browser convention).
1285        let close_handler: Option<Rc<dyn Fn(usize, &mut EventContext)>> =
1286            if let Some(explicit) = self.on_close.clone() {
1287                Some(explicit)
1288            } else {
1289                self.source.remove_item_fn.clone().map(|remove| {
1290                    Rc::new(move |i: usize, _ctx: &mut EventContext| {
1291                        (remove)(i);
1292                    }) as Rc<dyn Fn(usize, &mut EventContext)>
1293                })
1294            };
1295        for i in 0..n {
1296            // Build the TabHeader for index i. The data-source
1297            // `with_item_fn` requires a `Fn(&T) -> Box<dyn Widget>`
1298            // closure; we use it as the bridge to construct a
1299            // `Box<TabHeader>` from the resolved delegate fields.
1300            let is_pinned = pinned_tabs[i];
1301            let selected = self.selected.clone();
1302            let shared_for_header = shared.clone();
1303            // Pinned tabs use the fixed pinned width; non-pinned use
1304            // the bar's `[min, max]` clamp.
1305            let (min_w, max_w) = if is_pinned {
1306                (self.pinned_tab_width, self.pinned_tab_width)
1307            } else {
1308                (self.min_tab_width, self.max_tab_width)
1309            };
1310            let label_capture: Rc<RefCell<Option<LocalizedString>>> = Rc::new(RefCell::new(None));
1311            let label_capture_clone = label_capture.clone();
1312            let close_handler_for_tab = close_handler.clone();
1313            let header = (self.source.with_item_fn)(i, &|item| -> Box<dyn Widget> {
1314                let label = self.delegate.resolve_label(i, item);
1315                // Capture the *original* title (pre display-mode transform) so
1316                // the overflow dropdown / a11y always read the real name even in
1317                // icon-only mode.
1318                *label_capture_clone.borrow_mut() = Some(label.clone());
1319                let icon = self.delegate.resolve_icon(i, item);
1320                let leading_slot = self.delegate.resolve_leading(i, item);
1321                let trailing_slot = self.delegate.resolve_trailing(i, item);
1322                let tooltip = self.delegate.resolve_tooltip(i, item);
1323                // Preserve the original title as the accessible name before the
1324                // display mode may blank the visible label (icon-only tabs).
1325                let at_name = label.clone();
1326                // Apply the bar-level display mode (icon / text / icon+text).
1327                let (label, icon, tooltip) =
1328                    apply_tab_display(self.tab_display, label, icon, tooltip);
1329                let rich_tooltip = self.delegate.resolve_rich_tooltip(i, item);
1330                let composite_tooltip = self.delegate.resolve_composite_tooltip(i, item);
1331                let context_menu_factory = self.delegate.resolve_context_menu(i, item);
1332                let enabled = self.delegate.resolve_enabled(i, item);
1333                let closable = self.delegate.resolve_closable(i, item);
1334                let on_close: Option<Rc<dyn Fn(&mut EventContext)>> = if closable {
1335                    close_handler_for_tab.clone().map(|f| {
1336                        Rc::new(move |ctx: &mut EventContext| (f)(i, ctx))
1337                            as Rc<dyn Fn(&mut EventContext)>
1338                    })
1339                } else {
1340                    None
1341                };
1342
1343                let on_reorder_to: Option<Rc<dyn Fn(usize, &mut EventContext)>> = if !is_pinned {
1344                    reorder_handler.clone().map(|reorder| {
1345                        Rc::new(move |to: usize, ctx: &mut EventContext| (reorder)(i, to, ctx))
1346                            as Rc<dyn Fn(usize, &mut EventContext)>
1347                    })
1348                } else {
1349                    None
1350                };
1351
1352                // A header is a drag source when reordering is on OR
1353                // cross-bar transfer is enabled. Build the payload
1354                // factory here (we have `&item` in scope): it carries
1355                // the source identity always, and a clone of the item
1356                // when transfer is enabled and the tab is transferable
1357                // (the `clone_item` closure encapsulates `T: Clone` so
1358                // `build()` need not be bounded on it).
1359                let is_drag_source = reorder_handler.is_some() || self.accept_external_tabs;
1360                let make_drag_payload: Option<Rc<dyn Fn() -> DragPayload>> = if is_drag_source {
1361                    let tab_id = (self.id_of)(i, item);
1362                    let transferable = self.transferable_fn.as_ref().is_none_or(|f| f(i, item));
1363                    let item_payload: Option<(T, Rc<dyn Fn(&T) -> T>)> = if transferable {
1364                        self.clone_item.as_ref().map(|cf| ((cf)(item), cf.clone()))
1365                    } else {
1366                        None
1367                    };
1368                    let src_index = i;
1369                    let bar_id = self_id;
1370                    Some(Rc::new(move || {
1371                        let item = item_payload.as_ref().map(|(it, cf)| (cf)(it));
1372                        DragPayload::typed(TabBarDragData {
1373                            source_index: src_index,
1374                            source_bar_id: bar_id,
1375                            source_id: tab_id,
1376                            item,
1377                        })
1378                    }) as Rc<dyn Fn() -> DragPayload>)
1379                } else {
1380                    None
1381                };
1382
1383                // Source-side completion: when one of our tabs is
1384                // accepted by a *different* bar, fire on_transfer_out.
1385                // Suppressed for intra-bar reorders via the shared
1386                // self-reorder flag (set by our own on_drop, which
1387                // runs before on_drag_ended in the same dispatch).
1388                let on_drag_ended: Option<Rc<dyn Fn(DropOutcome, &mut EventContext)>> =
1389                    match (self.accept_external_tabs, self.on_transfer_out.clone()) {
1390                        (true, Some(transfer_out)) => {
1391                            let tab_id = (self.id_of)(i, item);
1392                            let self_reorder = self.self_reorder_flag.clone();
1393                            Some(
1394                                Rc::new(move |outcome: DropOutcome, ctx: &mut EventContext| {
1395                                    if matches!(outcome, DropOutcome::InApp { accepted: true })
1396                                        && !self_reorder.replace(false)
1397                                    {
1398                                        (transfer_out)(tab_id, ctx);
1399                                    }
1400                                })
1401                                    as Rc<dyn Fn(DropOutcome, &mut EventContext)>,
1402                            )
1403                        }
1404                        _ => None,
1405                    };
1406
1407                Box::new(TabHeader::new(TabHeaderConfig {
1408                    label,
1409                    at_name,
1410                    icon,
1411                    leading_slot,
1412                    trailing_slot,
1413                    tooltip,
1414                    rich_tooltip,
1415                    composite_tooltip,
1416                    context_menu_factory,
1417                    // Pinned tabs suppress the close button —
1418                    // Firefox / Chrome convention. They're closed
1419                    // via the context menu only.
1420                    on_close: if is_pinned { None } else { on_close },
1421                    on_reorder_to,
1422                    make_drag_payload,
1423                    on_drag_ended,
1424                    index: i,
1425                    initial_enabled: enabled,
1426                    selected: selected.clone(),
1427                    shared: shared_for_header.clone(),
1428                    min_width: min_w,
1429                    max_width: max_w,
1430                    pinned: is_pinned,
1431                    orientation: self.orientation,
1432                    tab_background: self.tab_background.clone(),
1433                    selected_tab_background: self.selected_tab_background.clone(),
1434                    hover_tab_background: self.hover_tab_background.clone(),
1435                    idle_tab_background: self.idle_tab_background.clone(),
1436                    selected_text_role: self.selected_text_role,
1437                    idle_text_role: self.idle_text_role,
1438                    active_indicator: self.active_indicator,
1439                    style_override: self.style_override.clone(),
1440                }))
1441            });
1442            // Should never be `None` for `i < len()`, but defend:
1443            // skipping this index keeps the bar coherent if the source
1444            // mutated mid-build (e.g., another thread — though the
1445            // tree is single-threaded today).
1446            if let Some(header) = header {
1447                let id = ctx.add_boxed(header);
1448                if is_pinned {
1449                    pinned_header_ids.push(id);
1450                } else {
1451                    unpinned_header_ids.push(id);
1452                    unpinned_to_model.push(i);
1453                }
1454                header_ids_buf.borrow_mut().push(id);
1455                if let Some(lbl) = label_capture.borrow_mut().take() {
1456                    header_labels.push(lbl);
1457                } else {
1458                    header_labels.push(lit!(String::new()));
1459                }
1460            }
1461        }
1462        let unpinned_to_model = Rc::new(unpinned_to_model);
1463        let model_len = n;
1464
1465        // ── Scroll-the-active-tab-into-view ───────────────────────────
1466        //
1467        // Model index → position in the *unpinned* row, which is the
1468        // space `TabHeaderRow`'s extents are indexed by. A pinned tab
1469        // maps to `None`: it lives in the leading strip, which never
1470        // scrolls, so it is visible by construction.
1471        let mut model_to_unpinned: Vec<Option<usize>> = vec![None; n];
1472        for (position, &model_index) in unpinned_to_model.iter().enumerate() {
1473            model_to_unpinned[model_index] = Some(position);
1474        }
1475        let model_to_unpinned = Rc::new(model_to_unpinned);
1476
1477        // Arm on the way out of a build. Two things reach this point: a
1478        // selection that changed while the bar was rebuilding anyway (a
1479        // tab was opened, or closed and its neighbour promoted), and a
1480        // request armed by the effect below just before the rebuild —
1481        // whose position was resolved against the *old* tab order, so it
1482        // is re-resolved here against the new one.
1483        let selected_target = self.selected_id.get();
1484        if selected_target.is_some()
1485            && (self.reveal.revealed.get() != selected_target
1486                || self.reveal.pending.get().is_some())
1487        {
1488            self.reveal.revealed.set(selected_target);
1489            match model_to_unpinned
1490                .get(self.selected.get())
1491                .copied()
1492                .flatten()
1493            {
1494                Some(position) => self.reveal.arm(position),
1495                None => self.reveal.pending.set(None),
1496            }
1497        }
1498
1499        // Steady state: selection written from outside (an app setting
1500        // `selected_id`, the overflow dropdown below, the AT click path)
1501        // moves the index without rebuilding the bar, so the arm above
1502        // never runs. Neither does the framework's focus follow, which is
1503        // what reveals a pointer- or keyboard-activated tab for free.
1504        // This is the case the bar used to have no answer for.
1505        {
1506            let reveal = self.reveal.clone();
1507            let positions = model_to_unpinned.clone();
1508            let ids = index_to_id.clone();
1509            ctx.effect(&self.selected, move |index| {
1510                let target = ids.get(*index).copied();
1511                if target.is_none() || reveal.revealed.get() == target {
1512                    return;
1513                }
1514                reveal.revealed.set(target);
1515                match positions.get(*index).copied().flatten() {
1516                    Some(position) => reveal.arm(position),
1517                    None => reveal.pending.set(None),
1518                }
1519            });
1520        }
1521
1522        // ScrollArea wants a fixed `preferred_size.height` so the
1523        // viewport doesn't get squashed by the focus-ring envelope
1524        // headers reserve. Snapshot the theme values up front — we
1525        // don't want to hold a borrow on `ctx` while later code
1526        // mutates the arena.
1527        let (header_min_height, motion_duration_normal, motion_easing_standard) = {
1528            let theme = ctx.theme();
1529            // `editor_tab_height` is the outer bounds height of a
1530            // tab header — the focus-ring envelope is reserved
1531            // inside (see `TabHeader::intrinsic_height`), so the
1532            // bar's preferred row height is exactly the token (or the
1533            // `tab_bar_height` override for a compact strip).
1534            (
1535                self.tab_height
1536                    .unwrap_or(crate::styles::recipe_tab_style::TAB_EDITOR_HEIGHT),
1537                theme.motion.duration_normal,
1538                theme.motion.easing_standard,
1539            )
1540        };
1541
1542        // Custom row widget: lays out the headers side-by-side with
1543        // shared-or-independent width semantics. The bounds buffers
1544        // are shared with the bar's drag-target handlers below so we
1545        // can map a drop-hover pointer position onto the right tab
1546        // boundary even when the row scrolls.
1547        let header_bounds_buf: Rc<RefCell<Vec<Rect>>> =
1548            Rc::new(RefCell::new(Vec::with_capacity(unpinned_header_ids.len())));
1549        let row_bounds_buf: Rc<std::cell::Cell<Rect>> =
1550            Rc::new(std::cell::Cell::new(Rect::new(0.0, 0.0, 0.0, 0.0)));
1551        // Resolved inter-tab divider colour (used by both the scrollable
1552        // row's overlay and the pinned strip), or `None` when off.
1553        let divider_prop: Option<teksilo_core::color_prop::ColorProp> =
1554            self.tab_dividers.then(|| {
1555                self.tab_divider_color
1556                    .clone()
1557                    .unwrap_or_else(|| BorderRole::Divider.into())
1558            });
1559        let row = TabHeaderRow {
1560            header_ids: unpinned_header_ids.clone(),
1561            axis: self.orientation,
1562            sizing: self.sizing,
1563            min_extent: self.min_tab_width,
1564            max_extent: self.max_tab_width,
1565            spacing: self.spacing,
1566            tab_height: self.tab_height,
1567            header_bounds_buf: header_bounds_buf.clone(),
1568            row_bounds_buf: row_bounds_buf.clone(),
1569            divider: divider_prop.clone().map(|c| (c, self.spacing)),
1570            overlay_id: None,
1571            reveal: self.reveal.clone(),
1572        };
1573        let row_id = ctx.add(row);
1574        self.header_row_id = Some(row_id);
1575
1576        let scroll = match self.orientation {
1577            TabBarOrientation::Horizontal => ScrollArea::from_id(row_id)
1578                .scroll_bar_style(ScrollBarMode::Thin)
1579                .vertical_scroll_bar_policy(ScrollBarPolicy::AlwaysOff)
1580                .horizontal_scroll_bar_policy(ScrollBarPolicy::AsNeeded)
1581                .widget_resizable(true)
1582                .preferred_size(0.0, header_min_height),
1583            TabBarOrientation::Vertical => ScrollArea::from_id(row_id)
1584                .scroll_bar_style(ScrollBarMode::Overlay)
1585                .horizontal_scroll_bar_policy(ScrollBarPolicy::AlwaysOff)
1586                .vertical_scroll_bar_policy(ScrollBarPolicy::AsNeeded)
1587                .widget_resizable(true),
1588        };
1589        // Capture scroll signals BEFORE moving the ScrollArea into
1590        // the arena — drives arrow visibility and the wheel-mapping
1591        // handler. `scroll_x` / `max_scroll_x` for horizontal,
1592        // `scroll_y` / `max_scroll_y` for vertical.
1593        let scroll_x = scroll.scroll_x_signal().clone();
1594        let max_scroll_x = scroll.max_scroll_x_signal().clone();
1595        let scroll_y = scroll.scroll_y_signal().clone();
1596        let max_scroll_y = scroll.max_scroll_y_signal().clone();
1597        let scroll_viewport = scroll.viewport_size_cell();
1598        let scroll_id = ctx.add(scroll);
1599
1600        // Wrap the scroll area in a stack so the bar slots have a
1601        // place to sit. The scroll area takes all the slack along
1602        // the layout axis. Outer container axis matches the bar's
1603        // orientation: HStack for horizontal, VStack for vertical
1604        // (slot → pinned strip → leading arrow → scroll area →
1605        // trailing arrow → dropdown → trailing slot).
1606        let scroll_main = match self.orientation {
1607            TabBarOrientation::Horizontal => scroll_x.clone(),
1608            TabBarOrientation::Vertical => scroll_y.clone(),
1609        };
1610        let max_scroll_main = match self.orientation {
1611            TabBarOrientation::Horizontal => max_scroll_x.clone(),
1612            TabBarOrientation::Vertical => max_scroll_y.clone(),
1613        };
1614        // Hand the header row the two things it can only get from the
1615        // area — which is built *from* the row's id, so this is the
1616        // earliest it can be done. Still long before any layout runs.
1617        *self.reveal.area.borrow_mut() = Some(RevealArea {
1618            scroll_main: scroll_main.clone(),
1619            viewport: scroll_viewport,
1620        });
1621        // Accumulate the outer-stack children into a Vec, then
1622        // construct the actual HStack / VStack at the end based on
1623        // orientation. Keeps the body axis-agnostic.
1624        let mut outer_children: Vec<WidgetId> = Vec::new();
1625
1626        if let Some(slot) = self.bar_leading_slot.take() {
1627            let id = match slot {
1628                PendingChild::Id(id) => id,
1629                PendingChild::Deferred(w) => ctx.add_boxed(w),
1630            };
1631            self.bar_leading_slot_id = Some(id);
1632            outer_children.push(id);
1633        }
1634
1635        // Pinned strip — non-scrolling, fixed-width icons. Lives at
1636        // the leading edge so pinned tabs are always visible
1637        // regardless of how far the unpinned tabs scroll. Strip
1638        // orientation matches the bar.
1639        if !pinned_header_ids.is_empty() {
1640            // A 1 dp divider widget between consecutive pinned headers when
1641            // dividers are enabled. The pinned strip is a plain stack (it
1642            // does not use `header_bounds_buf`), so we interleave real
1643            // `Divider` widgets rather than an overlay — they're inert and
1644            // don't affect pinned drag/reorder.
1645            let make_divider = |ctx: &mut BuildContext| -> Option<WidgetId> {
1646                divider_prop.clone().map(|c| {
1647                    let d = match self.orientation {
1648                        TabBarOrientation::Horizontal => crate::primitives::Divider::vertical(),
1649                        TabBarOrientation::Vertical => crate::primitives::Divider::horizontal(),
1650                    };
1651                    ctx.add(d.color(c))
1652                })
1653            };
1654            let pinned_id = match self.orientation {
1655                TabBarOrientation::Horizontal => {
1656                    let mut pinned = HStack::new().spacing(self.spacing);
1657                    for (i, id) in pinned_header_ids.iter().enumerate() {
1658                        if i > 0
1659                            && let Some(div) = make_divider(ctx)
1660                        {
1661                            pinned = pinned.add_child(div);
1662                        }
1663                        pinned = pinned.add_child(*id);
1664                    }
1665                    ctx.add(pinned)
1666                }
1667                TabBarOrientation::Vertical => {
1668                    let mut pinned = crate::VStack::new().spacing(self.spacing);
1669                    for (i, id) in pinned_header_ids.iter().enumerate() {
1670                        if i > 0
1671                            && let Some(div) = make_divider(ctx)
1672                        {
1673                            pinned = pinned.add_child(div);
1674                        }
1675                        pinned = pinned.add_child(*id);
1676                    }
1677                    ctx.add(pinned)
1678                }
1679            };
1680            self.pinned_strip_id = Some(pinned_id);
1681            outer_children.push(pinned_id);
1682        }
1683
1684        // Leading scroll arrow.
1685        if self.show_scroll_arrows {
1686            let arrow_id = build_scroll_arrow(
1687                ctx,
1688                ScrollArrowKind::Leading,
1689                self.orientation,
1690                scroll_main.clone(),
1691                max_scroll_main.clone(),
1692                motion_duration_normal,
1693                motion_easing_standard,
1694                self.idle_text_role,
1695            );
1696            // Visibility: only when there's something to scroll back.
1697            let visible = scroll_main.clone().map(|x| *x > 0.5);
1698            ctx.visible_when(arrow_id, visible);
1699            outer_children.push(arrow_id);
1700        }
1701
1702        // The scroll area takes all the slack along the layout axis.
1703        let scroll_slot = match self.orientation {
1704            TabBarOrientation::Horizontal => ctx.add(Expand::horizontal().child_id(scroll_id)),
1705            TabBarOrientation::Vertical => ctx.add(Expand::vertical().child_id(scroll_id)),
1706        };
1707        outer_children.push(scroll_slot);
1708
1709        // Trailing scroll arrow.
1710        if self.show_scroll_arrows {
1711            let arrow_id = build_scroll_arrow(
1712                ctx,
1713                ScrollArrowKind::Trailing,
1714                self.orientation,
1715                scroll_main.clone(),
1716                max_scroll_main.clone(),
1717                motion_duration_normal,
1718                motion_easing_standard,
1719                self.idle_text_role,
1720            );
1721            // Visibility: only when there's more to scroll forward.
1722            let visible = scroll_main
1723                .clone()
1724                .zip(&max_scroll_main)
1725                .map(|(x, max)| *x + 0.5 < *max);
1726            ctx.visible_when(arrow_id, visible);
1727            outer_children.push(arrow_id);
1728        }
1729
1730        // Overflow dropdown — a chevron-down `PopoverIconButton` whose
1731        // popover content is a `ListView` mirroring the full tab
1732        // list. Activating an item sets `selected_id` and dismisses
1733        // the popover. `Auto` (default) reveals it only when the headers
1734        // overflow the viewport (`max_scroll_main > 0`), mirroring the
1735        // scroll-arrow auto-show; `Always` keeps it pinned; `Never` omits it.
1736        if self.overflow_button != TabOverflowButton::Never && !header_labels.is_empty() {
1737            // Build (id, label, enabled) entries so the dropdown can
1738            // route activation by stable TabId rather than by index.
1739            let entries: Vec<DropdownEntry> = header_labels
1740                .iter()
1741                .zip(index_to_id.iter().copied())
1742                .zip(enabled_tabs.iter().copied())
1743                .map(|((label, id), enabled)| DropdownEntry {
1744                    id,
1745                    label: label.clone(),
1746                    enabled,
1747                })
1748                .collect();
1749            let dropdown_id = build_overflow_dropdown(
1750                ctx,
1751                self.selected_id.clone(),
1752                entries,
1753                self.idle_text_role,
1754            );
1755            if self.overflow_button == TabOverflowButton::Auto {
1756                // Reveal only when there is something scrolled out of view.
1757                let overflowing = max_scroll_main.clone().map(|m| *m > 0.5);
1758                ctx.visible_when(dropdown_id, overflowing);
1759            }
1760            outer_children.push(dropdown_id);
1761        }
1762
1763        if let Some(slot) = self.bar_trailing_slot.take() {
1764            let id = match slot {
1765                PendingChild::Id(id) => id,
1766                PendingChild::Deferred(w) => ctx.add_boxed(w),
1767            };
1768            self.bar_trailing_slot_id = Some(id);
1769            outer_children.push(id);
1770        }
1771
1772        let root_id = match self.orientation {
1773            TabBarOrientation::Horizontal => {
1774                let mut row = HStack::new().spacing(DEFAULT_BAR_SLOT_SPACING);
1775                for id in &outer_children {
1776                    row = row.add_child(*id);
1777                }
1778                ctx.add(row)
1779            }
1780            TabBarOrientation::Vertical => {
1781                let mut col = crate::VStack::new().spacing(DEFAULT_BAR_SLOT_SPACING);
1782                for id in &outer_children {
1783                    col = col.add_child(*id);
1784                }
1785                ctx.add(col)
1786            }
1787        };
1788        self.outer_stack_id = Some(root_id);
1789        // Resolve the active `TabStyle` and let it wrap the bar
1790        // content with the strip chrome — backdrop fill, content-pane
1791        // separator, drag-reorder drop indicator. Per-call override >
1792        // theme slot > built-in `RecipeTabStyle`. This replaces the
1793        // old `TabBar::paint`: the bar is now pure composition.
1794        let style: teksilo_core::styles::SharedTabStyle = self
1795            .style_override
1796            .clone()
1797            .or_else(|| ctx.theme().style_slots.tab.clone())
1798            .unwrap_or_else(|| Rc::new(crate::styles::RecipeTabStyle::default()));
1799        let chrome_cfg = teksilo_core::styles::TabBarChromeConfig {
1800            content: root_id,
1801            orientation: self.orientation.into(),
1802            show_separator: self.show_separator,
1803            surface_role: self.bar_background.clone(),
1804            drop_indicator: self.paint_state.drop_indicator_x.clone(),
1805        };
1806        let bar_root = style.make_bar(&chrome_cfg, ctx);
1807        self.root_child_id = Some(bar_root);
1808
1809        // Wheel-mapping handler. Attached via `on_pointer_event`
1810        // (not `on_scroll`) so the framework fires it in the
1811        // *preview pass* on each strict ancestor of the pointer
1812        // target — i.e. before the descendant ScrollArea has a
1813        // chance to consume the event. That's what lets us
1814        // remap "wheel down" → "scroll right" on a horizontal-only
1815        // bar; if we ran in bubble, ScrollArea would have already
1816        // handled the event and stopped propagation.
1817        //
1818        // We only consume events we're actively remapping; genuine
1819        // horizontal-wheel deltas (trackpad two-finger pan) pass
1820        // through to ScrollArea unchanged.
1821        let vert_to_horiz = self.vertical_wheel_scrolls_horizontally;
1822        let shift_to_horiz = self.shift_wheel_scrolls_horizontally;
1823        let scroll_x_for_wheel = scroll_x.clone();
1824        let max_scroll_x_for_wheel = max_scroll_x.clone();
1825        let orientation_for_wheel = self.orientation;
1826        let handler = HandlerSet::new().on_pointer_event(
1827            move |event: &WidgetEvent, _ctx: &mut EventContext| -> EventResponse {
1828                // Vertical bars scroll vertically — ScrollArea handles
1829                // wheel events natively; nothing to remap here.
1830                if orientation_for_wheel == TabBarOrientation::Vertical {
1831                    return EventResponse::Ignored;
1832                }
1833                let WidgetEvent::Scroll { delta, modifiers } = event else {
1834                    return EventResponse::Ignored;
1835                };
1836                let (dx, dy) = match delta {
1837                    ScrollDelta::Lines { x, y } => (x * WHEEL_LINE_PIXELS, y * WHEEL_LINE_PIXELS),
1838                    ScrollDelta::Pixels { x, y } => (*x, *y),
1839                };
1840                let shift = modifiers.shift();
1841                // Decide whether *this* event is one we want to
1842                // remap. Shift always remaps; otherwise we only
1843                // remap a vertical-only wheel on a horizontal bar.
1844                let should_remap = if shift && shift_to_horiz {
1845                    true
1846                } else {
1847                    vert_to_horiz && dx.abs() < f32::EPSILON && dy.abs() > 0.0
1848                };
1849                if !should_remap {
1850                    return EventResponse::Ignored;
1851                }
1852                let mapped_dx = if dx.abs() > 0.0 { dx } else { dy };
1853                if mapped_dx.abs() < f32::EPSILON {
1854                    return EventResponse::Ignored;
1855                }
1856                // Sign convention matches ScrollArea: positive delta
1857                // moves the content (so positive `y` from a wheel-down
1858                // event scrolls right when remapped to horizontal).
1859                let new_x =
1860                    (scroll_x_for_wheel.get() + mapped_dx).clamp(0.0, max_scroll_x_for_wheel.get());
1861                scroll_x_for_wheel.set(new_x);
1862                EventResponse::Handled
1863            },
1864        );
1865        ctx.apply_self_handlers(handler);
1866
1867        // Drag-target handlers: attached when reordering is on OR the
1868        // bar accepts cross-bar transfers. The bar acts as the single
1869        // drop target — we convert pointer position (delivered in
1870        // bar-local coords) into a tab boundary by walking
1871        // `header_bounds_buf` (world coords) translated into bar-local
1872        // space via `last_bar_bounds.origin` cached by `place_children`.
1873        //
1874        // Two payload consumers share this target:
1875        //   - intra-bar reorder: `data.source_bar_id == self_id` →
1876        //     `reorder(from, to)` (only when `reorder` is set).
1877        //   - cross-bar transfer: a foreign bar's payload carrying
1878        //     `item: Some(_)` → `on_tab_received(item, to_model)`
1879        //     (only when `accept_external` is on).
1880        if reorder_handler.is_some() || self.accept_external_tabs || self.on_external_drop.is_some()
1881        {
1882            let bar_id_for_drop = self_id;
1883            let axis = self.orientation;
1884            let accept_external = self.accept_external_tabs;
1885            let has_external_drop = self.on_external_drop.is_some();
1886            // Insertion line cross-extent used when the target bar has
1887            // no unpinned headers yet (empty bar): span the bar's
1888            // cross axis so the indicator is still visible.
1889            let drop_handler = HandlerSet::new()
1890                .on_drag_hover({
1891                    let header_bounds = header_bounds_buf.clone();
1892                    let drop_indicator = self.paint_state.drop_indicator_x.clone();
1893                    let bar_bounds = self.paint_state.last_bar_bounds.clone();
1894                    move |payload: &DragPayload,
1895                          position: Point,
1896                          _ctx: &mut EventContext|
1897                          -> DropFeedback {
1898                        match payload.get_typed::<TabBarDragData<T>>() {
1899                            Some(data) => {
1900                                // Accept an intra-bar reorder, or a
1901                                // foreign tab when this bar opted into
1902                                // transfer and the payload carries a
1903                                // transferable item.
1904                                let is_intra = data.source_bar_id == bar_id_for_drop;
1905                                let is_foreign_ok = accept_external && data.item.is_some();
1906                                if !is_intra && !is_foreign_ok {
1907                                    drop_indicator.set(None);
1908                                    return DropFeedback::NoFeedback;
1909                                }
1910                            }
1911                            None => {
1912                                // Non-tab payload (foreign in-app drag
1913                                // or OS drop): accepted only if a
1914                                // non-tab drop handler is installed.
1915                                // The indicator is optimistic — the
1916                                // handler decides for real at drop.
1917                                if !has_external_drop {
1918                                    drop_indicator.set(None);
1919                                    return DropFeedback::NoFeedback;
1920                                }
1921                            }
1922                        }
1923                        let bar = bar_bounds.get();
1924                        let bounds = header_bounds.borrow();
1925                        // Empty target bar (no unpinned headers): drop
1926                        // at the leading edge, indicator spans the
1927                        // bar's cross axis.
1928                        if bounds.is_empty() {
1929                            let cross = match axis {
1930                                TabBarOrientation::Horizontal => bar.height,
1931                                TabBarOrientation::Vertical => bar.width,
1932                            };
1933                            drop_indicator.set(Some(0.0));
1934                            return DropFeedback::InsertionLine {
1935                                y: 0.0,
1936                                width: cross,
1937                            };
1938                        }
1939                        // Layout-axis pointer position in world coords:
1940                        // x for horizontal bars, y for vertical bars.
1941                        let (pointer_world_main, bar_origin_main) = match axis {
1942                            TabBarOrientation::Horizontal => (position.x + bar.x, bar.x),
1943                            TabBarOrientation::Vertical => (position.y + bar.y, bar.y),
1944                        };
1945                        let insertion_world_main =
1946                            insertion_world_main_for(&bounds, pointer_world_main, axis);
1947                        let insertion_local_main = insertion_world_main - bar_origin_main;
1948                        drop_indicator.set(Some(insertion_local_main));
1949                        DropFeedback::InsertionLine {
1950                            y: 0.0,
1951                            width: bounds[0].height,
1952                        }
1953                    }
1954                })
1955                .on_drag_leave({
1956                    let drop_indicator = self.paint_state.drop_indicator_x.clone();
1957                    move |_ctx: &mut EventContext| {
1958                        drop_indicator.set(None);
1959                    }
1960                })
1961                .on_drop({
1962                    let header_bounds = header_bounds_buf.clone();
1963                    let bar_bounds = self.paint_state.last_bar_bounds.clone();
1964                    let drop_indicator = self.paint_state.drop_indicator_x.clone();
1965                    let reorder = reorder_handler.clone();
1966                    let on_received = self.on_tab_received.clone();
1967                    let on_external_drop = self.on_external_drop.clone();
1968                    let self_reorder = self.self_reorder_flag.clone();
1969                    let unpinned_to_model = unpinned_to_model.clone();
1970                    let bar_id = bar_id_for_drop;
1971                    move |mut payload: DragPayload,
1972                          position: Point,
1973                          ctx: &mut EventContext|
1974                          -> bool {
1975                        drop_indicator.set(None);
1976                        // Extract the tab payload if this is one; a
1977                        // failed downcast leaves `payload` intact for
1978                        // the non-tab branch below.
1979                        let mut data = payload.take_typed::<TabBarDragData<T>>();
1980                        let bar = bar_bounds.get();
1981                        let bounds = header_bounds.borrow();
1982                        // Resolve the model insertion index from the
1983                        // pointer. `insertion_index_for` works in
1984                        // **unpinned** space (the bounds buffer only
1985                        // holds unpinned headers); map it to a model
1986                        // index. An empty target bar inserts at 0.
1987                        let to_model = if bounds.is_empty() {
1988                            0
1989                        } else {
1990                            let pointer_world_main = match axis {
1991                                TabBarOrientation::Horizontal => position.x + bar.x,
1992                                TabBarOrientation::Vertical => position.y + bar.y,
1993                            };
1994                            let to_unpinned =
1995                                insertion_index_for(&bounds, pointer_world_main, axis);
1996                            if to_unpinned < unpinned_to_model.len() {
1997                                unpinned_to_model[to_unpinned]
1998                            } else {
1999                                // Past the trailing edge of the
2000                                // unpinned region — insert just after
2001                                // the last unpinned tab.
2002                                unpinned_to_model
2003                                    .last()
2004                                    .map(|&last| last + 1)
2005                                    .unwrap_or(model_len)
2006                            }
2007                        };
2008
2009                        let Some(data) = data.as_mut() else {
2010                            // ── Non-tab payload (foreign drag / OS) ─
2011                            // `payload` is intact (downcast missed).
2012                            drop(bounds);
2013                            return match on_external_drop.as_ref() {
2014                                Some(cb) => (cb)(&payload, to_model, ctx),
2015                                None => false,
2016                            };
2017                        };
2018
2019                        if data.source_bar_id == bar_id {
2020                            // ── Intra-bar reorder ──────────────────
2021                            // Mark the drag as a self-reorder so the
2022                            // source header's on_drag_ended suppresses
2023                            // on_transfer_out (which would otherwise
2024                            // remove the just-reordered tab).
2025                            self_reorder.set(true);
2026                            let Some(reorder) = reorder.as_ref() else {
2027                                return true;
2028                            };
2029                            let from = data.source_index;
2030                            // `move_item(from, to)` interprets `to` as
2031                            // the **post-removal** insertion position,
2032                            // so a forward drag adjusts by -1.
2033                            let adjusted_to = if from < to_model {
2034                                to_model.saturating_sub(1)
2035                            } else {
2036                                to_model
2037                            };
2038                            if from != adjusted_to {
2039                                (reorder)(from, adjusted_to, ctx);
2040                            }
2041                            true
2042                        } else if accept_external {
2043                            // ── Cross-bar transfer ─────────────────
2044                            // No `-1` correction: there is no source
2045                            // slot inside *this* model to compensate
2046                            // for. The app inserts the moved item at
2047                            // exactly `to_model`.
2048                            let Some(item) = data.item.take() else {
2049                                return false;
2050                            };
2051                            if let Some(cb) = on_received.as_ref() {
2052                                (cb)(item, to_model, ctx);
2053                            }
2054                            true
2055                        } else {
2056                            false
2057                        }
2058                    }
2059                })
2060                .on_drag_tick({
2061                    // Edge auto-scroll while a drag is in progress.
2062                    // Ramp the scroll velocity linearly inside the
2063                    // edge zones; cap at `DRAG_MAX_VELOCITY` so fast
2064                    // drags don't rocket past the content. Axis-aware:
2065                    // horizontal bars scroll by x, vertical by y.
2066                    let scroll_main = scroll_main.clone();
2067                    let max_scroll_main = max_scroll_main.clone();
2068                    let bar_bounds = self.paint_state.last_bar_bounds.clone();
2069                    move |position: Point, _ctx: &mut EventContext| {
2070                        let bar = bar_bounds.get();
2071                        let (pointer_main, bar_extent) = match axis {
2072                            TabBarOrientation::Horizontal => (position.x, bar.width),
2073                            TabBarOrientation::Vertical => (position.y, bar.height),
2074                        };
2075                        let max = max_scroll_main.get();
2076                        let cur = scroll_main.get();
2077                        let leading_in = (DRAG_EDGE_ZONE - pointer_main).max(0.0);
2078                        let trailing_in = (pointer_main - (bar_extent - DRAG_EDGE_ZONE)).max(0.0);
2079                        let delta = if leading_in > 0.0 {
2080                            -(leading_in / DRAG_EDGE_ZONE) * DRAG_MAX_VELOCITY
2081                        } else if trailing_in > 0.0 {
2082                            (trailing_in / DRAG_EDGE_ZONE) * DRAG_MAX_VELOCITY
2083                        } else {
2084                            0.0
2085                        };
2086                        if delta.abs() > 0.001 {
2087                            scroll_main.set((cur + delta).clamp(0.0, max));
2088                        }
2089                    }
2090                });
2091            ctx.apply_self_handlers(drop_handler);
2092        }
2093
2094        vec![bar_root]
2095    }
2096
2097    fn layout_response(&self, proposal: SizeProposal, ctx: &LayoutContext) -> LayoutResponse {
2098        let Some(root_id) = self.root_child_id else {
2099            return proposal.resolve(0.0, 0.0).into();
2100        };
2101        let final_proposal = match self.orientation {
2102            TabBarOrientation::Vertical => {
2103                // Adapt the bar's cross-axis (width) to whichever piece
2104                // of bar content is widest — tab labels, the pinned
2105                // strip, or a leading / trailing slot widget — clamped
2106                // to [min_tab_width, max_tab_width]. Probing the inner
2107                // ScrollArea would just echo our own proposal back, so
2108                // we measure the row directly.
2109                //
2110                // Under `TabSizing::Fill` the bar instead takes the
2111                // width it is offered (the sidebar's full width) and
2112                // hands it down to the header column, which stretches
2113                // every pill to it. An unbounded proposal has no width
2114                // to fill, so it falls back to the intrinsic path.
2115                let target = match (self.sizing, proposal.width) {
2116                    (TabSizing::Fill, Some(p)) => p.max(0.0),
2117                    _ => {
2118                        let mut intrinsic_w = 0.0_f32;
2119                        for opt in [
2120                            self.header_row_id,
2121                            self.pinned_strip_id,
2122                            self.bar_leading_slot_id,
2123                            self.bar_trailing_slot_id,
2124                        ] {
2125                            if let Some(id) = opt
2126                                && let Some(s) = ctx.child_size(id, SizeProposal::unspecified())
2127                            {
2128                                intrinsic_w = intrinsic_w.max(s.width);
2129                            }
2130                        }
2131                        let mut t = intrinsic_w.clamp(self.min_tab_width, self.max_tab_width);
2132                        if let Some(p) = proposal.width {
2133                            t = t.min(p).max(self.min_tab_width);
2134                        }
2135                        t
2136                    }
2137                };
2138                SizeProposal {
2139                    width: Some(target),
2140                    height: proposal.height,
2141                }
2142            }
2143            TabBarOrientation::Horizontal => proposal,
2144        };
2145        let mut size = ctx
2146            .child_size(root_id, final_proposal)
2147            .unwrap_or_else(|| final_proposal.resolve(0.0, 0.0));
2148        // Unbounded height + vertical: the outer stack reports 0 (its
2149        // scroll slot is an `Expand::vertical`, which is 0-natural and
2150        // sizes from surplus), which would collapse the bar to nothing
2151        // beside a flexible sibling — a `Spacer` in a sidebar column.
2152        // Report the tabs' own extent instead, so a vertical bar has a
2153        // natural height like any other content widget.
2154        if self.orientation == TabBarOrientation::Vertical && proposal.height.is_none() {
2155            size.height = self.natural_height_vertical(final_proposal.width, ctx);
2156        }
2157        size.into()
2158    }
2159
2160    fn place_children(
2161        &self,
2162        bounds: Rect,
2163        _proposal: SizeProposal,
2164        children: &mut [WidgetPlacement],
2165        _ctx: &LayoutContext,
2166    ) {
2167        // Record the bar's world bounds so the drag handlers can
2168        // translate bar-local pointer positions back to world coords
2169        // (matching the world-coords header bounds populated by
2170        // TabHeaderRow).
2171        self.paint_state.last_bar_bounds.set(bounds);
2172        for child in children.iter_mut() {
2173            child.origin = bounds.origin();
2174            child.size = bounds.size();
2175        }
2176    }
2177
2178    // No `paint()`: the bar is pure composition. Backdrop fill,
2179    // content-pane separator, and the drag-reorder drop indicator are
2180    // all drawn by the active `TabStyle`'s `make_bar` chrome (see
2181    // `RecipeTabStyle` / `TabBarChromePainter`).
2182
2183    fn accessibility(&self, builder: &mut AccessNodeBuilder) {
2184        builder.set_role(teksilo_core::accesskit::Role::TabList);
2185        builder.set_orientation(match self.orientation {
2186            TabBarOrientation::Horizontal => teksilo_core::accesskit::Orientation::Horizontal,
2187            TabBarOrientation::Vertical => teksilo_core::accesskit::Orientation::Vertical,
2188        });
2189    }
2190
2191    fn children(&self) -> Vec<WidgetId> {
2192        self.root_child_id.into_iter().collect()
2193    }
2194}
2195
2196// ─── Internal: the headers row / column ─────────────────────────────
2197
2198/// The headers run — a horizontal row in [`TabBarOrientation::Horizontal`]
2199/// mode, a vertical column in [`TabBarOrientation::Vertical`] mode.
2200/// Owns the `Shared`/`Independent` sizing math and exposes per-tab
2201/// world bounds back to the bar's DnD handlers.
2202#[derive(Debug)]
2203struct TabHeaderRow {
2204    header_ids: Vec<WidgetId>,
2205    axis: TabBarOrientation,
2206    sizing: TabSizing,
2207    /// Min extent on the *layout axis* — width for horizontal,
2208    /// height for vertical. Reuses the same `min_tab_width` knob for
2209    /// the vertical case (it's about per-tab pill extent, not the
2210    /// width of the bar).
2211    min_extent: f32,
2212    max_extent: f32,
2213    spacing: f32,
2214    /// Optional per-tab extent override along the bar's cross axis (the tab
2215    /// strip height for a horizontal bar; the per-tab pill height for a
2216    /// vertical one). `None` → the style's `editor_tab_height`.
2217    tab_height: Option<f32>,
2218    /// Per-tab bounds in world coords, populated by `place_children`.
2219    /// Shared with the bar's drop handlers via `Rc<RefCell<...>>`;
2220    /// the bar reads this to compute drop-insertion position for an
2221    /// in-progress drag.
2222    header_bounds_buf: Rc<RefCell<Vec<Rect>>>,
2223    /// Cached row-level world bounds — used to map bar-local
2224    /// coordinates onto header bounds.
2225    row_bounds_buf: Rc<std::cell::Cell<Rect>>,
2226    /// `(color, spacing)` for an inter-tab divider overlay, or `None`
2227    /// when dividers are off. When `Some`, `build` appends a single
2228    /// `TabRowDividers` leaf as the last child (painted on top of the
2229    /// headers, reading `header_bounds_buf`).
2230    divider: Option<(teksilo_core::color_prop::ColorProp, f32)>,
2231    /// The appended divider-overlay child id, set in `build` when
2232    /// `divider` is `Some`. Kept so `children()` reports it too.
2233    overlay_id: Option<WidgetId>,
2234    /// Shared "scroll the active tab into view" request. Armed by the
2235    /// bar, consumed here — see [`Self::apply_pending_reveal`].
2236    reveal: RevealState,
2237}
2238
2239impl TabHeaderRow {
2240    /// The per-tab cross-axis extent: the explicit override (compact bars) or
2241    /// the style's `editor_tab_height`.
2242    fn tab_extent(&self, ctx: &LayoutContext) -> f32 {
2243        self.tab_height
2244            .unwrap_or_else(|| TabHeader::intrinsic_height(ctx))
2245    }
2246
2247    fn compute_extents(&self, viewport_main: Option<f32>, ctx: &LayoutContext) -> Vec<f32> {
2248        let n = self.header_ids.len();
2249        if n == 0 {
2250            return Vec::new();
2251        }
2252        match self.sizing {
2253            TabSizing::Shared | TabSizing::Fill => {
2254                let target = match self.axis {
2255                    TabBarOrientation::Horizontal => {
2256                        // Divide the viewport width across tabs
2257                        // (Firefox / Chrome convention) and clamp by
2258                        // the layout-axis [min, max] knobs. `Fill`
2259                        // drops the max cap: its whole point is to
2260                        // consume the strip edge to edge rather than
2261                        // leave trailing slack past `max_tab_width`.
2262                        // The min still holds — below it the headers
2263                        // overflow into scroll.
2264                        let total_spacing = self.spacing * (n.saturating_sub(1)) as f32;
2265                        let avail = viewport_main.unwrap_or(0.0).max(0.0);
2266                        let ideal = ((avail - total_spacing).max(0.0) / n as f32).max(0.0);
2267                        if self.sizing == TabSizing::Fill {
2268                            ideal.max(self.min_extent)
2269                        } else {
2270                            ideal.clamp(self.min_extent, self.max_extent)
2271                        }
2272                    }
2273                    TabBarOrientation::Vertical => {
2274                        // Vertical sidebar pills are NOT viewport-
2275                        // divided — that turns a tall bar into ~200 dp
2276                        // tab bands, which neither Firefox / Chrome
2277                        // (no native vertical mode) nor VS Code /
2278                        // IntelliJ do. Use the intrinsic per-tab
2279                        // height (`editor_tab_height`) so vertical
2280                        // tabs match horizontal tabs in size. `Fill`
2281                        // is no different here: in a vertical bar it
2282                        // stretches the pill *width* (see
2283                        // `layout_response`), never the height.
2284                        self.tab_extent(ctx)
2285                    }
2286                };
2287                vec![target; n]
2288            }
2289            TabSizing::Independent => self
2290                .header_ids
2291                .iter()
2292                .map(|&id| {
2293                    let s = ctx.child_size(id, SizeProposal::unspecified());
2294                    let raw = match self.axis {
2295                        TabBarOrientation::Horizontal => s.map(|s| s.width),
2296                        TabBarOrientation::Vertical => s.map(|s| s.height),
2297                    };
2298                    let fallback = match self.axis {
2299                        TabBarOrientation::Horizontal => self.min_extent,
2300                        TabBarOrientation::Vertical => self.tab_extent(ctx),
2301                    };
2302                    let raw = raw.unwrap_or(fallback);
2303                    // [min, max] are width-defaulted (96 / 240) and
2304                    // axis-mismatched in vertical mode where they'd
2305                    // force tab heights to ≥96 dp. Skip the clamp on
2306                    // the height axis; the intrinsic per-tab height
2307                    // is already the right answer.
2308                    match self.axis {
2309                        TabBarOrientation::Horizontal => {
2310                            raw.clamp(self.min_extent, self.max_extent)
2311                        }
2312                        TabBarOrientation::Vertical => raw,
2313                    }
2314                })
2315                .collect(),
2316        }
2317    }
2318}
2319
2320impl TabHeaderRow {
2321    /// Consume a pending "scroll the active tab into view" request,
2322    /// given this pass's per-tab extents and the viewport's extent along
2323    /// the layout axis.
2324    ///
2325    /// Called from `layout_response`, deliberately, and not from
2326    /// `place_children`: the enclosing `ScrollArea` measures its content
2327    /// (this row) *before* it clamps and reads `scroll_x` to position
2328    /// that content, so an offset written here lands in the very same
2329    /// layout pass. Written from `place_children` — which runs after the
2330    /// area has already placed the row — it would be a frame late, and
2331    /// the strip would visibly lurch one frame after the tab activated.
2332    ///
2333    /// The move is minimal, matching the `ScrollIntoView` convention:
2334    /// only the edge the tab fell off is chased, so revealing a tab
2335    /// that is already visible is a no-op rather than a recentring.
2336    fn apply_pending_reveal(&self, extents: &[f32], viewport_main: f32) {
2337        // Not yet measurable — keep the request rather than resolve it
2338        // against a viewport we don't have.
2339        if viewport_main <= 0.0 {
2340            return;
2341        }
2342        let Some(target) = self.reveal.pending.get() else {
2343            return;
2344        };
2345        let Some(&extent) = extents.get(target) else {
2346            // The row no longer has that header — it was closed or
2347            // pinned between the arm and this pass. Drop the request
2348            // rather than scroll to whatever now sits at that position.
2349            self.reveal.pending.set(None);
2350            return;
2351        };
2352        let area_guard = self.reveal.area.borrow();
2353        let Some(area) = area_guard.as_ref() else {
2354            return;
2355        };
2356        self.reveal.pending.set(None);
2357
2358        let content =
2359            extents.iter().sum::<f32>() + self.spacing * extents.len().saturating_sub(1) as f32;
2360        let max_scroll = (content - viewport_main).max(0.0);
2361        if max_scroll <= 0.0 {
2362            // Everything fits; there is nothing to reveal.
2363            return;
2364        }
2365        let lead = extents[..target].iter().sum::<f32>() + self.spacing * target as f32;
2366        let current = area.scroll_main.get();
2367        let next = if lead < current {
2368            lead
2369        } else if lead + extent > current + viewport_main {
2370            lead + extent - viewport_main
2371        } else {
2372            current
2373        }
2374        .clamp(0.0, max_scroll);
2375        if (next - current).abs() > REVEAL_EPSILON {
2376            area.scroll_main.set(next);
2377        }
2378    }
2379
2380    /// The full child list: the pre-registered headers plus the optional
2381    /// divider overlay appended last.
2382    fn child_ids(&self) -> Vec<WidgetId> {
2383        let mut ids = self.header_ids.clone();
2384        ids.extend(self.overlay_id);
2385        ids
2386    }
2387}
2388
2389impl Widget for TabHeaderRow {
2390    fn build(&mut self, ctx: &mut BuildContext) -> Vec<WidgetId> {
2391        // Arming a reveal has to schedule the layout pass that consumes
2392        // it: activating a tab changes no size, so on its own it would
2393        // only repaint and the request would sit unread.
2394        self.reveal.generation.bind_to(
2395            ctx.self_id(),
2396            ctx.binding_registry(),
2397            BindingLevel::Relayout,
2398        );
2399        // Headers are pre-registered with the bar's BuildContext; the row
2400        // just exposes them. When dividers are on, append a single overlay
2401        // leaf (last child → painted on top of the headers) that reads the
2402        // shared `header_bounds_buf` to draw a line at each boundary.
2403        if let Some((color, spacing)) = self.divider.clone() {
2404            let overlay = ctx.add_boxed(Box::new(TabRowDividers {
2405                header_bounds_buf: self.header_bounds_buf.clone(),
2406                axis: self.axis,
2407                color,
2408                spacing,
2409            }));
2410            self.overlay_id = Some(overlay);
2411        }
2412        self.child_ids()
2413    }
2414
2415    fn layout_response(&self, proposal: SizeProposal, ctx: &LayoutContext) -> LayoutResponse {
2416        let n = self.header_ids.len();
2417        if n == 0 {
2418            return Size::new(0.0, 0.0).into();
2419        }
2420        let total_spacing = self.spacing * (n - 1) as f32;
2421        match self.axis {
2422            TabBarOrientation::Horizontal => {
2423                // Cap the row's height at one tab header's intrinsic
2424                // height (= `editor_tab_height`). If the surrounding
2425                // outer HStack proposes a taller height because a
2426                // sibling (toolbar button, dropdown trigger) wants
2427                // more room, the row should NOT stretch — it would
2428                // turn the strip into a tall band with the pills
2429                // floating in the middle. Clamping here keeps the
2430                // tab strip exactly token-sized.
2431                let intrinsic = self.tab_extent(ctx);
2432                let height = proposal
2433                    .height
2434                    .map(|h| h.min(intrinsic))
2435                    .unwrap_or(intrinsic);
2436                let extents = self.compute_extents(proposal.width, ctx);
2437                let total = extents.iter().sum::<f32>() + total_spacing;
2438                // Resolve any pending reveal now that both halves of the
2439                // arithmetic are known. Only against a real width
2440                // proposal: an unbounded probe (the vertical bar's
2441                // natural-size measurement, a11y sizing) makes
2442                // `compute_extents` fall back to `min_extent` for every
2443                // tab, which would place the target at the wrong offset.
2444                // The area's own measurement always supplies a width.
2445                if let Some(viewport_main) = proposal.width {
2446                    self.apply_pending_reveal(&extents, viewport_main);
2447                }
2448                Size::new(total, height).into()
2449            }
2450            TabBarOrientation::Vertical => {
2451                // Adapt to the longest tab label, clamped to
2452                // [min_extent, max_extent]. Without this, the row
2453                // would echo `proposal.width` and let the bar swallow
2454                // whatever cross-axis space the parent gave it.
2455                //
2456                // `Fill` wants exactly that echo, though: the pills
2457                // span the width the bar is offered. Only when the
2458                // proposal is unbounded (nothing to fill) does it fall
2459                // back to the fit-to-widest-label width.
2460                let width = match (self.sizing, proposal.width) {
2461                    (TabSizing::Fill, Some(proposed)) => proposed.max(0.0),
2462                    _ => {
2463                        let intrinsic = self
2464                            .header_ids
2465                            .iter()
2466                            .filter_map(|&id| ctx.child_size(id, SizeProposal::unspecified()))
2467                            .map(|s| s.width)
2468                            .fold(0.0_f32, f32::max);
2469                        let mut w = intrinsic.clamp(self.min_extent, self.max_extent);
2470                        if let Some(proposed) = proposal.width {
2471                            w = w.min(proposed).max(self.min_extent);
2472                        }
2473                        w
2474                    }
2475                };
2476                let extents = self.compute_extents(proposal.height, ctx);
2477                let total = extents.iter().sum::<f32>() + total_spacing;
2478                // Vertical extents are the intrinsic per-tab height and
2479                // don't depend on the proposal (see `compute_extents`),
2480                // so a probe can't skew them — but the viewport height
2481                // *is* missing here: the `ScrollArea` measures its
2482                // content with `height: None`. Read the viewport it last
2483                // placed instead; it only goes stale on the frame the
2484                // bar is resized, which is not a frame a reveal is in
2485                // flight on.
2486                let viewport_main = self
2487                    .reveal
2488                    .area
2489                    .borrow()
2490                    .as_ref()
2491                    .map_or(0.0, |a| a.viewport.get().height);
2492                self.apply_pending_reveal(&extents, viewport_main);
2493                Size::new(width, total).into()
2494            }
2495        }
2496    }
2497
2498    fn place_children(
2499        &self,
2500        bounds: Rect,
2501        proposal: SizeProposal,
2502        children: &mut [WidgetPlacement],
2503        ctx: &LayoutContext,
2504    ) {
2505        // For Shared sizing, divide the *viewport* main extent (the
2506        // proposal main axis) — NOT the bounds main extent, which is
2507        // the content size returned by `layout_response`. ScrollArea
2508        // computes content size from `layout_response` and then calls
2509        // `place_children` with bounds = content_size, so using the
2510        // bounds main here would feedback-loop the layout pass.
2511        let viewport_main = match self.axis {
2512            TabBarOrientation::Horizontal => proposal.width,
2513            TabBarOrientation::Vertical => proposal.height,
2514        };
2515        let extents = self.compute_extents(viewport_main, ctx);
2516        let mut buf = self.header_bounds_buf.borrow_mut();
2517        buf.clear();
2518        match self.axis {
2519            TabBarOrientation::Horizontal => {
2520                let mut x = bounds.x;
2521                for (i, child) in children.iter_mut().enumerate() {
2522                    if i >= extents.len() {
2523                        break;
2524                    }
2525                    child.origin = Point::new(x, bounds.y);
2526                    child.size = Size::new(extents[i], bounds.height);
2527                    buf.push(Rect::new(x, bounds.y, extents[i], bounds.height));
2528                    x += extents[i] + self.spacing;
2529                }
2530            }
2531            TabBarOrientation::Vertical => {
2532                let mut y = bounds.y;
2533                for (i, child) in children.iter_mut().enumerate() {
2534                    if i >= extents.len() {
2535                        break;
2536                    }
2537                    child.origin = Point::new(bounds.x, y);
2538                    child.size = Size::new(bounds.width, extents[i]);
2539                    buf.push(Rect::new(bounds.x, y, bounds.width, extents[i]));
2540                    y += extents[i] + self.spacing;
2541                }
2542            }
2543        }
2544        drop(buf);
2545        // The divider overlay (appended last) is not a header — the loop
2546        // above broke before it (i >= extents.len()) so it never reached
2547        // `header_bounds_buf`. Place it spanning the whole row so it can
2548        // paint the inter-tab lines on top.
2549        if self.overlay_id.is_some()
2550            && let Some(last) = children.last_mut()
2551        {
2552            last.origin = bounds.origin();
2553            last.size = bounds.size();
2554        }
2555        self.row_bounds_buf.set(bounds);
2556    }
2557
2558    fn children(&self) -> Vec<WidgetId> {
2559        self.child_ids()
2560    }
2561}
2562
2563/// Pure-decoration overlay (the last child of [`TabHeaderRow`]) that paints
2564/// a 1 dp line at each boundary between consecutive tab headers, reading the
2565/// row's shared `header_bounds_buf` (world coords). Painted on top of the
2566/// headers so it shows over any per-tab background; pointer events pass
2567/// straight through.
2568struct TabRowDividers {
2569    header_bounds_buf: Rc<RefCell<Vec<Rect>>>,
2570    axis: TabBarOrientation,
2571    color: teksilo_core::color_prop::ColorProp,
2572    spacing: f32,
2573}
2574
2575impl std::fmt::Debug for TabRowDividers {
2576    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
2577        f.debug_struct("TabRowDividers")
2578            .field("axis", &self.axis)
2579            .finish()
2580    }
2581}
2582
2583impl Widget for TabRowDividers {
2584    fn build(&mut self, ctx: &mut BuildContext) -> Vec<WidgetId> {
2585        // Repaint when the (possibly bound) divider colour changes.
2586        self.color.register_if_bound(
2587            ctx.self_id(),
2588            ctx.binding_registry(),
2589            BindingLevel::RepaintOnly,
2590        );
2591        ctx.apply_self_handlers(HandlerSet::new().event_pass_through(true));
2592        vec![]
2593    }
2594
2595    fn layout_response(&self, proposal: SizeProposal, _ctx: &LayoutContext) -> LayoutResponse {
2596        // Leaf overlay — fill whatever bounds the row places it at.
2597        proposal.resolve(0.0, 0.0).into()
2598    }
2599
2600    fn paint(&self, _bounds: Rect, canvas: &mut Canvas, ctx: &PaintContext) {
2601        let headers = self.header_bounds_buf.borrow();
2602        if headers.len() < 2 {
2603            return;
2604        }
2605        let color = self.color.resolve(ctx.theme, true);
2606        let t = ctx.theme.shape.border_width.max(1.0);
2607        // Draw between consecutive headers. When `spacing > 0` the line is
2608        // centred in the gap; with flush tabs it sits on the shared edge.
2609        for pair in headers.windows(2) {
2610            let (a, b) = (pair[0], pair[1]);
2611            let line = match self.axis {
2612                TabBarOrientation::Horizontal => {
2613                    let mid = (a.right() + b.x) * 0.5;
2614                    Rect::new(mid - t * 0.5, a.y, t, a.height)
2615                }
2616                TabBarOrientation::Vertical => {
2617                    let mid = (a.bottom() + b.y) * 0.5;
2618                    Rect::new(a.x, mid - t * 0.5, a.width, t)
2619                }
2620            };
2621            canvas.fill_rect(line, color);
2622        }
2623    }
2624
2625    fn accessibility(&self, builder: &mut AccessNodeBuilder) {
2626        builder.set_hidden();
2627    }
2628}
2629
2630// ─── Scroll arrow + overflow dropdown construction ───────────────────
2631
2632/// Apply a bar-level [`TabDisplayMode`] to one tab's resolved label / icon /
2633/// tooltip. Icon-only modes blank the displayed label (the header then sizes to
2634/// the icon) and promote the title to the hover tooltip; with no icon they fall
2635/// back to the title's initial letter so the tab is never blank.
2636fn apply_tab_display(
2637    mode: TabDisplayMode,
2638    label: LocalizedString,
2639    icon: Option<IconWidget>,
2640    tooltip: Option<LocalizedString>,
2641) -> (LocalizedString, Option<IconWidget>, Option<LocalizedString>) {
2642    match mode {
2643        // Render as declared (Auto) or both when available (IconText) — there
2644        // is nothing to force-add, so these are identical transforms.
2645        TabDisplayMode::Auto | TabDisplayMode::IconText => (label, icon, tooltip),
2646        // Title only — drop the icon.
2647        TabDisplayMode::Text => (label, None, tooltip),
2648        // Icon only — blank the displayed label, promote the title to the
2649        // tooltip, and fall back to the initial letter when there is no icon.
2650        TabDisplayMode::Icon => {
2651            let resolved = label.clone().resolve_now();
2652            let tip = tooltip.or_else(|| (!resolved.trim().is_empty()).then(|| label.clone()));
2653            if icon.is_some() {
2654                (lit!(""), icon, tip)
2655            } else {
2656                let initial: String = resolved.chars().take(1).collect();
2657                (lit!(initial), None, tip)
2658            }
2659        }
2660    }
2661}
2662
2663#[derive(Debug, Clone, Copy)]
2664enum ScrollArrowKind {
2665    Leading,
2666    Trailing,
2667}
2668
2669fn build_scroll_arrow(
2670    ctx: &mut BuildContext,
2671    kind: ScrollArrowKind,
2672    orientation: TabBarOrientation,
2673    scroll_main: Signal<f32>,
2674    max_scroll_main: Signal<f32>,
2675    duration: std::time::Duration,
2676    easing: Easing,
2677    icon_role: TextRole,
2678) -> WidgetId {
2679    let _ = ctx;
2680    let icon_size = crate::styles::recipe_button_style::BUTTON_ICON_SIZE;
2681    let icon = match (orientation, kind) {
2682        (TabBarOrientation::Horizontal, ScrollArrowKind::Leading) => {
2683            IconWidget::chevron_left(icon_size)
2684        }
2685        (TabBarOrientation::Horizontal, ScrollArrowKind::Trailing) => {
2686            IconWidget::chevron_right(icon_size)
2687        }
2688        (TabBarOrientation::Vertical, ScrollArrowKind::Leading) => {
2689            IconWidget::chevron_up(icon_size)
2690        }
2691        (TabBarOrientation::Vertical, ScrollArrowKind::Trailing) => {
2692            IconWidget::chevron_down(icon_size)
2693        }
2694    };
2695    let tooltip = match (orientation, kind) {
2696        (TabBarOrientation::Horizontal, ScrollArrowKind::Leading) => {
2697            lit!("Scroll tabs left")
2698        }
2699        (TabBarOrientation::Horizontal, ScrollArrowKind::Trailing) => {
2700            lit!("Scroll tabs right")
2701        }
2702        (TabBarOrientation::Vertical, ScrollArrowKind::Leading) => {
2703            lit!("Scroll tabs up")
2704        }
2705        (TabBarOrientation::Vertical, ScrollArrowKind::Trailing) => {
2706            lit!("Scroll tabs down")
2707        }
2708    };
2709    let button = IconButton::new(icon)
2710        .embedded()
2711        .size(IconButtonSize::Compact)
2712        .icon_role(icon_role)
2713        .tooltip(tooltip)
2714        .on_activate_fn(move |_ctx| {
2715            let cur = scroll_main.get();
2716            let target = match kind {
2717                ScrollArrowKind::Leading => (cur - SCROLL_ARROW_STEP).max(0.0),
2718                ScrollArrowKind::Trailing => (cur + SCROLL_ARROW_STEP).min(max_scroll_main.get()),
2719            };
2720            // The main-axis scroll signal is created via
2721            // `Signal::new_animated` inside ScrollArea, so
2722            // `animate_to` is supported.
2723            scroll_main.animate_to(target, duration, easing);
2724        });
2725    ctx.add(button)
2726}
2727
2728/// One entry in the overflow dropdown — a stable [`TabId`], the
2729/// resolved label, and whether the tab is enabled. Built fresh per
2730/// bar build pass; cloned into the `ListView`'s underlying
2731/// `ListModel`.
2732#[derive(Clone)]
2733struct DropdownEntry {
2734    id: TabId,
2735    label: LocalizedString,
2736    enabled: bool,
2737}
2738
2739impl std::fmt::Debug for DropdownEntry {
2740    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
2741        f.debug_struct("DropdownEntry")
2742            .field("id", &self.id)
2743            .field("enabled", &self.enabled)
2744            .finish()
2745    }
2746}
2747
2748/// Width of the overflow popover. Roughly two tab-widths so the
2749/// labels read at the same density as the bar itself.
2750const DROPDOWN_WIDTH: f32 = 240.0;
2751/// Cap on the popover height — beyond this many items the ListView
2752/// scrolls internally. Roughly ten rows of `DROPDOWN_ROW_HEIGHT`.
2753const DROPDOWN_MAX_HEIGHT: f32 = 320.0;
2754/// Per-row height. Smaller than a tab header so the dropdown reads
2755/// as a menu rather than a strip preview.
2756const DROPDOWN_ROW_HEIGHT: f32 = 28.0;
2757/// Padding inside the dropdown surface.
2758const DROPDOWN_PADDING: f32 = 4.0;
2759
2760fn build_overflow_dropdown(
2761    ctx: &mut BuildContext,
2762    selected_id: Signal<Option<TabId>>,
2763    entries: Vec<DropdownEntry>,
2764    icon_role: TextRole,
2765) -> WidgetId {
2766    let _ = ctx;
2767    let icon_size = crate::styles::recipe_button_style::BUTTON_ICON_SIZE;
2768    // Same square, icon-sized control as the scroll arrows (an `IconButton`, not
2769    // a label-less `Button` that pads out around the glyph) so it stays adapted
2770    // to its icon and consistent in both bar orientations.
2771    let trigger = IconButton::new(IconWidget::chevron_down(icon_size))
2772        .embedded()
2773        .size(IconButtonSize::Compact)
2774        .icon_role(icon_role)
2775        .tooltip(lit!("Show all tabs"));
2776
2777    // Cap each row at the dropdown height so a click still hits a
2778    // sensible-sized button regardless of `entries.len()`.
2779    let row_count = entries.len();
2780    let model = ListModel::from_vec(entries);
2781    let selected_for_delegate = selected_id.clone();
2782    let list = ListView::new(model, move |_i, entry: &DropdownEntry, _selected| {
2783        let entry_id = entry.id;
2784        let label = entry.label.clone();
2785        let enabled = entry.enabled;
2786        let sel = selected_for_delegate.clone();
2787        Box::new(
2788            Button::new(label)
2789                .variant(ButtonVariant::Ghost)
2790                .enabled(enabled)
2791                .on_activate_fn(move |ctx: &mut EventContext| {
2792                    sel.set(Some(entry_id));
2793                    ctx.dismiss_self_overlay_chain();
2794                }),
2795        ) as Box<dyn Widget>
2796    })
2797    .item_height(DROPDOWN_ROW_HEIGHT);
2798
2799    // Compute a shrink-to-content height for short tab lists; cap
2800    // at `DROPDOWN_MAX_HEIGHT` for long ones (the ListView's
2801    // internal scroll bar takes over past the cap).
2802    let natural_h = (row_count as f32 * DROPDOWN_ROW_HEIGHT) + (DROPDOWN_PADDING * 2.0);
2803    let content_h = natural_h.min(DROPDOWN_MAX_HEIGHT);
2804
2805    // Sized container. `FixedSize` forces both axes (content_h
2806    // shrinks on a short list; the constant width keeps the popover
2807    // from stretching to fit a long label).
2808    let sized = FixedSize::new()
2809        .width(DROPDOWN_WIDTH - DROPDOWN_PADDING * 2.0)
2810        .height(content_h - DROPDOWN_PADDING * 2.0)
2811        .child(list);
2812
2813    // Raised surface — `SurfaceRole::Raised` is the popup-fill
2814    // token; the `BorderRole::Default` 1 dp border gives the
2815    // popover a clean edge over arbitrary backgrounds.
2816    let surface = Panel::new()
2817        .background(SurfaceRole::Raised)
2818        .border_color(BorderRole::Default)
2819        .border_width(1.0)
2820        .padding(DROPDOWN_PADDING)
2821        .child(sized);
2822
2823    ctx.add(
2824        PopoverIconButton::new(trigger)
2825            // `surface` is already a chromed `Panel` (Raised) — opt out
2826            // of the auto popover surface to avoid double-chroming.
2827            .content(surface)
2828            .bare()
2829            .placement(OverlayPlacement::BelowPreferred)
2830            .has_popup_kind(HasPopup::Menu),
2831    )
2832}
2833
2834// ─── Helper math: drop-insertion index + selection adjust ───────────
2835
2836/// Pull the layout-axis range `(start, end)` out of a header's world
2837/// bounds. Horizontal bars use `(x, right)`; vertical bars use
2838/// `(y, bottom)`.
2839fn axis_range(rect: &Rect, axis: TabBarOrientation) -> (f32, f32) {
2840    match axis {
2841        TabBarOrientation::Horizontal => (rect.x, rect.right()),
2842        TabBarOrientation::Vertical => (rect.y, rect.bottom()),
2843    }
2844}
2845
2846/// Find the world-coord (along the layout axis) of the insertion-line
2847/// position closest to `pointer_main`, given each header's world
2848/// bounds. The returned coordinate is a tab boundary — the leading
2849/// edge of a header, or the trailing edge of the last header.
2850fn insertion_world_main_for(bounds: &[Rect], pointer_main: f32, axis: TabBarOrientation) -> f32 {
2851    let n = bounds.len();
2852    debug_assert!(n > 0);
2853    let (_, last_end) = axis_range(&bounds[n - 1], axis);
2854    if pointer_main >= last_end {
2855        return last_end;
2856    }
2857    let (first_start, _) = axis_range(&bounds[0], axis);
2858    if pointer_main <= first_start {
2859        return first_start;
2860    }
2861    for header in bounds {
2862        let (start, end) = axis_range(header, axis);
2863        let mid = (start + end) * 0.5;
2864        if pointer_main < mid {
2865            return start;
2866        }
2867    }
2868    last_end
2869}
2870
2871/// Find the model index where the dragged tab should be inserted.
2872/// `n` items → `n+1` valid insertion indices: 0 means "before the
2873/// first", `n` means "after the last".
2874fn insertion_index_for(bounds: &[Rect], pointer_main: f32, axis: TabBarOrientation) -> usize {
2875    let n = bounds.len();
2876    if n == 0 {
2877        return 0;
2878    }
2879    let (_, last_end) = axis_range(&bounds[n - 1], axis);
2880    if pointer_main >= last_end {
2881        return n;
2882    }
2883    let (first_start, _) = axis_range(&bounds[0], axis);
2884    if pointer_main <= first_start {
2885        return 0;
2886    }
2887    for (i, header) in bounds.iter().enumerate() {
2888        let (start, end) = axis_range(header, axis);
2889        let mid = (start + end) * 0.5;
2890        if pointer_main < mid {
2891            return i;
2892        }
2893    }
2894    n
2895}
2896
2897// Selection adjustment after move/remove is unnecessary now: the
2898// public selection signal is `Signal<Option<TabId>>`, which is
2899// stable across reorders by definition (the moved tab keeps its
2900// id) and across removals it goes stale and the bar's pre-build
2901// sync routes the id-not-found case to the next-neighbor fallback
2902// (browser convention).
2903
2904#[cfg(test)]
2905mod drop_math_tests {
2906    use super::*;
2907
2908    fn three_tabs() -> Vec<Rect> {
2909        vec![
2910            Rect::new(0.0, 0.0, 100.0, 30.0),   // x ∈ [0..100)
2911            Rect::new(100.0, 0.0, 100.0, 30.0), // x ∈ [100..200)
2912            Rect::new(200.0, 0.0, 100.0, 30.0), // x ∈ [200..300)
2913        ]
2914    }
2915
2916    fn three_tabs_vertical() -> Vec<Rect> {
2917        vec![
2918            Rect::new(0.0, 0.0, 200.0, 50.0),   // y ∈ [0..50)
2919            Rect::new(0.0, 50.0, 200.0, 50.0),  // y ∈ [50..100)
2920            Rect::new(0.0, 100.0, 200.0, 50.0), // y ∈ [100..150)
2921        ]
2922    }
2923
2924    #[test]
2925    fn pointer_before_first_tab_inserts_at_zero() {
2926        let bounds = three_tabs();
2927        let axis = TabBarOrientation::Horizontal;
2928        assert_eq!(insertion_index_for(&bounds, -10.0, axis), 0);
2929        assert_eq!(insertion_world_main_for(&bounds, -10.0, axis), 0.0);
2930    }
2931
2932    #[test]
2933    fn pointer_past_last_tab_appends() {
2934        let bounds = three_tabs();
2935        let axis = TabBarOrientation::Horizontal;
2936        assert_eq!(insertion_index_for(&bounds, 999.0, axis), 3);
2937        assert_eq!(insertion_world_main_for(&bounds, 999.0, axis), 300.0);
2938    }
2939
2940    #[test]
2941    fn pointer_in_left_half_of_a_tab_inserts_before_it() {
2942        let bounds = three_tabs();
2943        let axis = TabBarOrientation::Horizontal;
2944        // Tab 1 spans 100..200; pointer at x=120 is in its left half.
2945        assert_eq!(insertion_index_for(&bounds, 120.0, axis), 1);
2946        assert_eq!(insertion_world_main_for(&bounds, 120.0, axis), 100.0);
2947    }
2948
2949    #[test]
2950    fn pointer_in_right_half_of_a_tab_inserts_after_it() {
2951        let bounds = three_tabs();
2952        let axis = TabBarOrientation::Horizontal;
2953        // Tab 1's right half is 150..200 → insertion at index 2.
2954        assert_eq!(insertion_index_for(&bounds, 175.0, axis), 2);
2955        assert_eq!(insertion_world_main_for(&bounds, 175.0, axis), 200.0);
2956    }
2957
2958    #[test]
2959    fn vertical_pointer_above_first_tab_inserts_at_zero() {
2960        let bounds = three_tabs_vertical();
2961        let axis = TabBarOrientation::Vertical;
2962        assert_eq!(insertion_index_for(&bounds, -10.0, axis), 0);
2963        assert_eq!(insertion_world_main_for(&bounds, -10.0, axis), 0.0);
2964    }
2965
2966    #[test]
2967    fn vertical_pointer_past_last_tab_appends() {
2968        let bounds = three_tabs_vertical();
2969        let axis = TabBarOrientation::Vertical;
2970        assert_eq!(insertion_index_for(&bounds, 999.0, axis), 3);
2971        assert_eq!(insertion_world_main_for(&bounds, 999.0, axis), 150.0);
2972    }
2973
2974    #[test]
2975    fn vertical_pointer_in_top_half_of_a_tab_inserts_before_it() {
2976        let bounds = three_tabs_vertical();
2977        let axis = TabBarOrientation::Vertical;
2978        // Tab 1 spans y=50..100; pointer at y=60 is in its top half.
2979        assert_eq!(insertion_index_for(&bounds, 60.0, axis), 1);
2980        assert_eq!(insertion_world_main_for(&bounds, 60.0, axis), 50.0);
2981    }
2982
2983    #[test]
2984    fn vertical_pointer_in_bottom_half_of_a_tab_inserts_after_it() {
2985        let bounds = three_tabs_vertical();
2986        let axis = TabBarOrientation::Vertical;
2987        // Tab 1's bottom half is y=75..100 → insertion at index 2.
2988        assert_eq!(insertion_index_for(&bounds, 88.0, axis), 2);
2989        assert_eq!(insertion_world_main_for(&bounds, 88.0, axis), 100.0);
2990    }
2991}
2992
2993// ─── Helper: a 0×0 widget used as a throwaway return value when we
2994// only need the side-effect of `ListSource::with_item_fn` (its
2995// closure access to `&T`), not an actual widget. The probe is
2996// constructed, returned to `with_item_fn`, and dropped immediately.
2997// ────────────────────────────────────────────────────────────────────
2998
2999#[derive(Debug)]
3000struct EnabledProbe;
3001
3002impl Widget for EnabledProbe {
3003    fn layout_response(&self, _proposal: SizeProposal, _ctx: &LayoutContext) -> LayoutResponse {
3004        Size::new(0.0, 0.0).into()
3005    }
3006}