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