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}