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_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_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 //
1543 // The active `TabStyle`: per-call override > theme slot >
1544 // built-in `RecipeTabStyle`. Resolved here, once, because the
1545 // strip height below, the header row's per-tab extent and the
1546 // bar chrome further down all come from it; each header resolves
1547 // the same style from the same override and theme.
1548 let tab_style =
1549 crate::styles::recipe_tab_style::resolve_tab_style(self.style_override.as_ref(), ctx);
1550 let (header_min_height, motion_duration_normal, motion_easing_standard) = {
1551 let theme = ctx.theme();
1552 // The style's `editor_height` is the outer bounds height of
1553 // a tab header (the focus-ring envelope is reserved inside,
1554 // see `TabHeader::intrinsic_height`), so the bar's preferred
1555 // row height is exactly the style's (or the `tab_bar_height`
1556 // override for a compact strip).
1557 (
1558 self.tab_height
1559 .unwrap_or_else(|| tab_style.editor_height(&theme.input)),
1560 theme.motion.duration_normal,
1561 theme.motion.easing_standard,
1562 )
1563 };
1564
1565 // Custom row widget: lays out the headers side-by-side with
1566 // shared-or-independent width semantics. The bounds buffers
1567 // are shared with the bar's drag-target handlers below so we
1568 // can map a drop-hover pointer position onto the right tab
1569 // boundary even when the row scrolls.
1570 let header_bounds_buf: Rc<RefCell<Vec<Rect>>> =
1571 Rc::new(RefCell::new(Vec::with_capacity(unpinned_header_ids.len())));
1572 let row_bounds_buf: Rc<std::cell::Cell<Rect>> =
1573 Rc::new(std::cell::Cell::new(Rect::new(0.0, 0.0, 0.0, 0.0)));
1574 // Resolved inter-tab divider colour (used by both the scrollable
1575 // row's overlay and the pinned strip), or `None` when off.
1576 let divider_prop: Option<teksilo_core::color_prop::ColorProp> =
1577 self.tab_dividers.then(|| {
1578 self.tab_divider_color
1579 .clone()
1580 .unwrap_or_else(|| BorderRole::Divider.into())
1581 });
1582 let row = TabHeaderRow {
1583 header_ids: unpinned_header_ids.clone(),
1584 axis: self.orientation,
1585 sizing: self.sizing,
1586 min_extent: self.min_tab_width,
1587 max_extent: self.max_tab_width,
1588 spacing: self.spacing,
1589 tab_height: self.tab_height,
1590 style: RowTabStyle(tab_style.clone()),
1591 header_bounds_buf: header_bounds_buf.clone(),
1592 row_bounds_buf: row_bounds_buf.clone(),
1593 divider: divider_prop.clone().map(|c| (c, self.spacing)),
1594 overlay_id: None,
1595 reveal: self.reveal.clone(),
1596 };
1597 let row_id = ctx.add(row);
1598 self.header_row_id = Some(row_id);
1599
1600 let scroll = match self.orientation {
1601 TabBarOrientation::Horizontal => ScrollArea::from_id(row_id)
1602 .scroll_bar_style(ScrollBarMode::Thin)
1603 .vertical_scroll_bar_policy(ScrollBarPolicy::AlwaysOff)
1604 .horizontal_scroll_bar_policy(ScrollBarPolicy::AsNeeded)
1605 .widget_resizable(true)
1606 .preferred_size(0.0, header_min_height),
1607 TabBarOrientation::Vertical => ScrollArea::from_id(row_id)
1608 .scroll_bar_style(ScrollBarMode::Overlay)
1609 .horizontal_scroll_bar_policy(ScrollBarPolicy::AlwaysOff)
1610 .vertical_scroll_bar_policy(ScrollBarPolicy::AsNeeded)
1611 .widget_resizable(true),
1612 };
1613 // Capture scroll signals BEFORE moving the ScrollArea into
1614 // the arena — drives arrow visibility and the wheel-mapping
1615 // handler. `scroll_x` / `max_scroll_x` for horizontal,
1616 // `scroll_y` / `max_scroll_y` for vertical.
1617 let scroll_x = scroll.scroll_x_signal().clone();
1618 let max_scroll_x = scroll.max_scroll_x_signal().clone();
1619 let scroll_y = scroll.scroll_y_signal().clone();
1620 let max_scroll_y = scroll.max_scroll_y_signal().clone();
1621 let scroll_viewport = scroll.viewport_size_cell();
1622 let scroll_id = ctx.add(scroll);
1623
1624 // Wrap the scroll area in a stack so the bar slots have a
1625 // place to sit. The scroll area takes all the slack along
1626 // the layout axis. Outer container axis matches the bar's
1627 // orientation: HStack for horizontal, VStack for vertical
1628 // (slot → pinned strip → leading arrow → scroll area →
1629 // trailing arrow → dropdown → trailing slot).
1630 let scroll_main = match self.orientation {
1631 TabBarOrientation::Horizontal => scroll_x.clone(),
1632 TabBarOrientation::Vertical => scroll_y.clone(),
1633 };
1634 let max_scroll_main = match self.orientation {
1635 TabBarOrientation::Horizontal => max_scroll_x.clone(),
1636 TabBarOrientation::Vertical => max_scroll_y.clone(),
1637 };
1638 // Hand the header row the two things it can only get from the
1639 // area — which is built *from* the row's id, so this is the
1640 // earliest it can be done. Still long before any layout runs.
1641 *self.reveal.area.borrow_mut() = Some(RevealArea {
1642 scroll_main: scroll_main.clone(),
1643 viewport: scroll_viewport,
1644 });
1645 // Accumulate the outer-stack children into a Vec, then
1646 // construct the actual HStack / VStack at the end based on
1647 // orientation. Keeps the body axis-agnostic.
1648 let mut outer_children: Vec<WidgetId> = Vec::new();
1649
1650 if let Some(slot) = self.bar_leading_slot.take() {
1651 let id = match slot {
1652 PendingChild::Id(id) => id,
1653 PendingChild::Deferred(w) => ctx.add_boxed(w),
1654 };
1655 self.bar_leading_slot_id = Some(id);
1656 outer_children.push(id);
1657 }
1658
1659 // Pinned strip — non-scrolling, fixed-width icons. Lives at
1660 // the leading edge so pinned tabs are always visible
1661 // regardless of how far the unpinned tabs scroll. Strip
1662 // orientation matches the bar.
1663 if !pinned_header_ids.is_empty() {
1664 // A 1 dp divider widget between consecutive pinned headers when
1665 // dividers are enabled. The pinned strip is a plain stack (it
1666 // does not use `header_bounds_buf`), so we interleave real
1667 // `Divider` widgets rather than an overlay — they're inert and
1668 // don't affect pinned drag/reorder.
1669 let make_divider = |ctx: &mut BuildContext| -> Option<WidgetId> {
1670 divider_prop.clone().map(|c| {
1671 let d = match self.orientation {
1672 TabBarOrientation::Horizontal => crate::primitives::Divider::vertical(),
1673 TabBarOrientation::Vertical => crate::primitives::Divider::horizontal(),
1674 };
1675 ctx.add(d.color(c))
1676 })
1677 };
1678 let pinned_id = match self.orientation {
1679 TabBarOrientation::Horizontal => {
1680 let mut pinned = HStack::new().spacing(self.spacing);
1681 for (i, id) in pinned_header_ids.iter().enumerate() {
1682 if i > 0
1683 && let Some(div) = make_divider(ctx)
1684 {
1685 pinned = pinned.child(div);
1686 }
1687 pinned = pinned.child(*id);
1688 }
1689 ctx.add(pinned)
1690 }
1691 TabBarOrientation::Vertical => {
1692 let mut pinned = crate::VStack::new().spacing(self.spacing);
1693 for (i, id) in pinned_header_ids.iter().enumerate() {
1694 if i > 0
1695 && let Some(div) = make_divider(ctx)
1696 {
1697 pinned = pinned.child(div);
1698 }
1699 pinned = pinned.child(*id);
1700 }
1701 ctx.add(pinned)
1702 }
1703 };
1704 self.pinned_strip_id = Some(pinned_id);
1705 outer_children.push(pinned_id);
1706 }
1707
1708 // Leading scroll arrow.
1709 if self.show_scroll_arrows {
1710 let arrow_id = build_scroll_arrow(
1711 ctx,
1712 ScrollArrowKind::Leading,
1713 self.orientation,
1714 scroll_main.clone(),
1715 max_scroll_main.clone(),
1716 motion_duration_normal,
1717 motion_easing_standard,
1718 self.idle_text_role,
1719 );
1720 // Visibility: only when there's something to scroll back.
1721 let visible = scroll_main.clone().map(|x| *x > 0.5);
1722 ctx.visible_when(arrow_id, visible);
1723 outer_children.push(arrow_id);
1724 }
1725
1726 // The scroll area takes all the slack along the layout axis.
1727 let scroll_slot = match self.orientation {
1728 TabBarOrientation::Horizontal => ctx.add(Expand::horizontal().child(scroll_id)),
1729 TabBarOrientation::Vertical => ctx.add(Expand::vertical().child(scroll_id)),
1730 };
1731 outer_children.push(scroll_slot);
1732
1733 // Trailing scroll arrow.
1734 if self.show_scroll_arrows {
1735 let arrow_id = build_scroll_arrow(
1736 ctx,
1737 ScrollArrowKind::Trailing,
1738 self.orientation,
1739 scroll_main.clone(),
1740 max_scroll_main.clone(),
1741 motion_duration_normal,
1742 motion_easing_standard,
1743 self.idle_text_role,
1744 );
1745 // Visibility: only when there's more to scroll forward.
1746 let visible = scroll_main
1747 .clone()
1748 .zip(&max_scroll_main)
1749 .map(|(x, max)| *x + 0.5 < *max);
1750 ctx.visible_when(arrow_id, visible);
1751 outer_children.push(arrow_id);
1752 }
1753
1754 // Overflow dropdown — a chevron-down `PopoverIconButton` whose
1755 // popover content is a `ListView` mirroring the full tab
1756 // list. Activating an item sets `selected_id` and dismisses
1757 // the popover. `Auto` (default) reveals it only when the headers
1758 // overflow the viewport (`max_scroll_main > 0`), mirroring the
1759 // scroll-arrow auto-show; `Always` keeps it pinned; `Never` omits it.
1760 if self.overflow_button != TabOverflowButton::Never && !header_labels.is_empty() {
1761 // Build (id, label, enabled) entries so the dropdown can
1762 // route activation by stable TabId rather than by index.
1763 let entries: Vec<DropdownEntry> = header_labels
1764 .iter()
1765 .zip(index_to_id.iter().copied())
1766 .zip(enabled_tabs.iter().copied())
1767 .map(|((label, id), enabled)| DropdownEntry {
1768 id,
1769 label: label.clone(),
1770 enabled,
1771 })
1772 .collect();
1773 let dropdown_id = build_overflow_dropdown(
1774 ctx,
1775 self.selected_id.clone(),
1776 entries,
1777 self.idle_text_role,
1778 );
1779 if self.overflow_button == TabOverflowButton::Auto {
1780 // Reveal only when there is something scrolled out of view.
1781 let overflowing = max_scroll_main.clone().map(|m| *m > 0.5);
1782 ctx.visible_when(dropdown_id, overflowing);
1783 }
1784 outer_children.push(dropdown_id);
1785 }
1786
1787 if let Some(slot) = self.bar_trailing_slot.take() {
1788 let id = match slot {
1789 PendingChild::Id(id) => id,
1790 PendingChild::Deferred(w) => ctx.add_boxed(w),
1791 };
1792 self.bar_trailing_slot_id = Some(id);
1793 outer_children.push(id);
1794 }
1795
1796 let root_id = match self.orientation {
1797 TabBarOrientation::Horizontal => {
1798 let mut row = HStack::new().spacing(DEFAULT_BAR_SLOT_SPACING);
1799 for id in &outer_children {
1800 row = row.child(*id);
1801 }
1802 ctx.add(row)
1803 }
1804 TabBarOrientation::Vertical => {
1805 let mut col = crate::VStack::new().spacing(DEFAULT_BAR_SLOT_SPACING);
1806 for id in &outer_children {
1807 col = col.child(*id);
1808 }
1809 ctx.add(col)
1810 }
1811 };
1812 self.outer_stack_id = Some(root_id);
1813 // Let the active `TabStyle` (resolved above) wrap the bar
1814 // content with the strip chrome — backdrop fill, content-pane
1815 // separator, drag-reorder drop indicator. This replaces the
1816 // old `TabBar::paint`: the bar is now pure composition.
1817 let style = tab_style;
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 bar's resolved `TabStyle`, held by [`TabHeaderRow`]. A newtype only
2228/// so the row can keep deriving `Debug`, which `dyn TabStyle` does not
2229/// implement.
2230struct RowTabStyle(teksilo_core::styles::SharedTabStyle);
2231
2232impl std::fmt::Debug for RowTabStyle {
2233 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
2234 f.write_str("RowTabStyle(..)")
2235 }
2236}
2237
2238/// The headers run — a horizontal row in [`TabBarOrientation::Horizontal`]
2239/// mode, a vertical column in [`TabBarOrientation::Vertical`] mode.
2240/// Owns the `Shared`/`Independent` sizing math and exposes per-tab
2241/// world bounds back to the bar's DnD handlers.
2242#[derive(Debug)]
2243struct TabHeaderRow {
2244 header_ids: Vec<WidgetId>,
2245 axis: TabBarOrientation,
2246 sizing: TabSizing,
2247 /// Min extent, in whichever sense the axis gives it: the per-tab
2248 /// width for a horizontal bar, and the *bar's own* minimum width
2249 /// for a vertical one (vertical pill extents come from the style's
2250 /// `editor_height`, never from this knob). Reuses the bar's
2251 /// `min_tab_width` in both cases.
2252 min_extent: f32,
2253 max_extent: f32,
2254 spacing: f32,
2255 /// Optional per-tab extent override along the bar's cross axis (the tab
2256 /// strip height for a horizontal bar; the per-tab pill height for a
2257 /// vertical one). `None` → the style's `editor_height`.
2258 tab_height: Option<f32>,
2259 /// The bar's resolved `TabStyle`, asked for the per-tab extent when
2260 /// `tab_height` is `None`.
2261 style: RowTabStyle,
2262 /// Per-tab bounds in world coords, populated by `place_children`.
2263 /// Shared with the bar's drop handlers via `Rc<RefCell<...>>`;
2264 /// the bar reads this to compute drop-insertion position for an
2265 /// in-progress drag.
2266 header_bounds_buf: Rc<RefCell<Vec<Rect>>>,
2267 /// Cached row-level world bounds — used to map bar-local
2268 /// coordinates onto header bounds.
2269 row_bounds_buf: Rc<std::cell::Cell<Rect>>,
2270 /// `(color, spacing)` for an inter-tab divider overlay, or `None`
2271 /// when dividers are off. When `Some`, `build` appends a single
2272 /// `TabRowDividers` leaf as the last child (painted on top of the
2273 /// headers, reading `header_bounds_buf`).
2274 divider: Option<(teksilo_core::color_prop::ColorProp, f32)>,
2275 /// The appended divider-overlay child id, set in `build` when
2276 /// `divider` is `Some`. Kept so `children()` reports it too.
2277 overlay_id: Option<WidgetId>,
2278 /// Shared "scroll the active tab into view" request. Armed by the
2279 /// bar, consumed here — see [`Self::apply_pending_reveal`].
2280 reveal: RevealState,
2281}
2282
2283impl TabHeaderRow {
2284 /// The per-tab cross-axis extent: the explicit override (compact bars) or
2285 /// the style's `editor_height`.
2286 fn tab_extent(&self, ctx: &LayoutContext) -> f32 {
2287 self.tab_height
2288 .unwrap_or_else(|| self.style.0.editor_height(&ctx.theme.input))
2289 }
2290
2291 fn compute_extents(&self, viewport_main: Option<f32>, ctx: &LayoutContext) -> Vec<f32> {
2292 let n = self.header_ids.len();
2293 if n == 0 {
2294 return Vec::new();
2295 }
2296 match self.sizing {
2297 TabSizing::Shared | TabSizing::Fill => {
2298 let target = match self.axis {
2299 TabBarOrientation::Horizontal => {
2300 // Divide the viewport width across tabs
2301 // (Firefox / Chrome convention) and clamp by
2302 // the layout-axis [min, max] knobs. `Fill`
2303 // drops the max cap: its whole point is to
2304 // consume the strip edge to edge rather than
2305 // leave trailing slack past `max_tab_width`.
2306 // The min still holds — below it the headers
2307 // overflow into scroll.
2308 let total_spacing = self.spacing * (n.saturating_sub(1)) as f32;
2309 let avail = viewport_main.unwrap_or(0.0).max(0.0);
2310 let ideal = ((avail - total_spacing).max(0.0) / n as f32).max(0.0);
2311 if self.sizing == TabSizing::Fill {
2312 ideal.max(self.min_extent)
2313 } else {
2314 ideal.clamp(self.min_extent, self.max_extent)
2315 }
2316 }
2317 TabBarOrientation::Vertical => {
2318 // Vertical sidebar pills are NOT viewport-
2319 // divided — that turns a tall bar into ~200 dp
2320 // tab bands, which neither Firefox / Chrome
2321 // (no native vertical mode) nor VS Code /
2322 // IntelliJ do. Use the intrinsic per-tab
2323 // height (`editor_height`) so vertical
2324 // tabs match horizontal tabs in size. `Fill`
2325 // is no different here: in a vertical bar it
2326 // stretches the pill *width* (see
2327 // `layout_response`), never the height.
2328 self.tab_extent(ctx)
2329 }
2330 };
2331 vec![target; n]
2332 }
2333 TabSizing::Independent => self
2334 .header_ids
2335 .iter()
2336 .map(|&id| {
2337 let s = ctx.child_size(id, SizeProposal::unspecified());
2338 let raw = match self.axis {
2339 TabBarOrientation::Horizontal => s.map(|s| s.width),
2340 TabBarOrientation::Vertical => s.map(|s| s.height),
2341 };
2342 let fallback = match self.axis {
2343 TabBarOrientation::Horizontal => self.min_extent,
2344 TabBarOrientation::Vertical => self.tab_extent(ctx),
2345 };
2346 let raw = raw.unwrap_or(fallback);
2347 // [min, max] are width-defaulted (96 / 240) and
2348 // axis-mismatched in vertical mode where they'd
2349 // force tab heights to ≥96 dp. Skip the clamp on
2350 // the height axis; the intrinsic per-tab height
2351 // is already the right answer.
2352 match self.axis {
2353 TabBarOrientation::Horizontal => {
2354 raw.clamp(self.min_extent, self.max_extent)
2355 }
2356 TabBarOrientation::Vertical => raw,
2357 }
2358 })
2359 .collect(),
2360 }
2361 }
2362}
2363
2364impl TabHeaderRow {
2365 /// Consume a pending "scroll the active tab into view" request,
2366 /// given this pass's per-tab extents and the viewport's extent along
2367 /// the layout axis.
2368 ///
2369 /// Called from `layout_response`, deliberately, and not from
2370 /// `place_children`: the enclosing `ScrollArea` measures its content
2371 /// (this row) *before* it clamps and reads `scroll_x` to position
2372 /// that content, so an offset written here lands in the very same
2373 /// layout pass. Written from `place_children` — which runs after the
2374 /// area has already placed the row — it would be a frame late, and
2375 /// the strip would visibly lurch one frame after the tab activated.
2376 ///
2377 /// The move is minimal, matching the `ScrollIntoView` convention:
2378 /// only the edge the tab fell off is chased, so revealing a tab
2379 /// that is already visible is a no-op rather than a recentring.
2380 fn apply_pending_reveal(&self, extents: &[f32], viewport_main: f32) {
2381 // Not yet measurable — keep the request rather than resolve it
2382 // against a viewport we don't have.
2383 if viewport_main <= 0.0 {
2384 return;
2385 }
2386 let Some(target) = self.reveal.pending.get() else {
2387 return;
2388 };
2389 let Some(&extent) = extents.get(target) else {
2390 // The row no longer has that header — it was closed or
2391 // pinned between the arm and this pass. Drop the request
2392 // rather than scroll to whatever now sits at that position.
2393 self.reveal.pending.set(None);
2394 return;
2395 };
2396 let area_guard = self.reveal.area.borrow();
2397 let Some(area) = area_guard.as_ref() else {
2398 return;
2399 };
2400 self.reveal.pending.set(None);
2401
2402 let content =
2403 extents.iter().sum::<f32>() + self.spacing * extents.len().saturating_sub(1) as f32;
2404 let max_scroll = (content - viewport_main).max(0.0);
2405 if max_scroll <= 0.0 {
2406 // Everything fits; there is nothing to reveal.
2407 return;
2408 }
2409 let lead = extents[..target].iter().sum::<f32>() + self.spacing * target as f32;
2410 let current = area.scroll_main.get();
2411 let next = if lead < current {
2412 lead
2413 } else if lead + extent > current + viewport_main {
2414 lead + extent - viewport_main
2415 } else {
2416 current
2417 }
2418 .clamp(0.0, max_scroll);
2419 if (next - current).abs() > REVEAL_EPSILON {
2420 area.scroll_main.set(next);
2421 }
2422 }
2423
2424 /// The full child list: the pre-registered headers plus the optional
2425 /// divider overlay appended last.
2426 fn child_ids(&self) -> Vec<WidgetId> {
2427 let mut ids = self.header_ids.clone();
2428 ids.extend(self.overlay_id);
2429 ids
2430 }
2431}
2432
2433impl Widget for TabHeaderRow {
2434 fn build(&mut self, ctx: &mut BuildContext) -> Vec<WidgetId> {
2435 // Arming a reveal has to schedule the layout pass that consumes
2436 // it: activating a tab changes no size, so on its own it would
2437 // only repaint and the request would sit unread.
2438 self.reveal.generation.bind_to(
2439 ctx.self_id(),
2440 ctx.binding_registry(),
2441 BindingLevel::Relayout,
2442 );
2443 // Headers are pre-registered with the bar's BuildContext; the row
2444 // just exposes them. When dividers are on, append a single overlay
2445 // leaf (last child → painted on top of the headers) that reads the
2446 // shared `header_bounds_buf` to draw a line at each boundary.
2447 if let Some((color, spacing)) = self.divider.clone() {
2448 let overlay = ctx.add_boxed(Box::new(TabRowDividers {
2449 header_bounds_buf: self.header_bounds_buf.clone(),
2450 axis: self.axis,
2451 color,
2452 spacing,
2453 }));
2454 self.overlay_id = Some(overlay);
2455 }
2456 self.child_ids()
2457 }
2458
2459 fn layout_response(&self, proposal: SizeProposal, ctx: &LayoutContext) -> LayoutResponse {
2460 let n = self.header_ids.len();
2461 if n == 0 {
2462 return Size::new(0.0, 0.0).into();
2463 }
2464 let total_spacing = self.spacing * (n - 1) as f32;
2465 match self.axis {
2466 TabBarOrientation::Horizontal => {
2467 // Cap the row's height at one tab header's intrinsic
2468 // height (= `editor_height`). If the surrounding
2469 // outer HStack proposes a taller height because a
2470 // sibling (toolbar button, dropdown trigger) wants
2471 // more room, the row should NOT stretch — it would
2472 // turn the strip into a tall band with the pills
2473 // floating in the middle. Clamping here keeps the
2474 // tab strip exactly token-sized.
2475 let intrinsic = self.tab_extent(ctx);
2476 let height = proposal
2477 .height
2478 .map(|h| h.min(intrinsic))
2479 .unwrap_or(intrinsic);
2480 let extents = self.compute_extents(proposal.width, ctx);
2481 let total = extents.iter().sum::<f32>() + total_spacing;
2482 // Resolve any pending reveal now that both halves of the
2483 // arithmetic are known. Only against a real width
2484 // proposal: an unbounded probe (the vertical bar's
2485 // natural-size measurement, a11y sizing) makes
2486 // `compute_extents` fall back to `min_extent` for every
2487 // tab, which would place the target at the wrong offset.
2488 // The area's own measurement always supplies a width.
2489 if let Some(viewport_main) = proposal.width {
2490 self.apply_pending_reveal(&extents, viewport_main);
2491 }
2492 Size::new(total, height).into()
2493 }
2494 TabBarOrientation::Vertical => {
2495 // Adapt to the longest tab label, clamped to
2496 // [min_extent, max_extent]. Without this, the row
2497 // would echo `proposal.width` and let the bar swallow
2498 // whatever cross-axis space the parent gave it.
2499 //
2500 // `Fill` wants exactly that echo, though: the pills
2501 // span the width the bar is offered. Only when the
2502 // proposal is unbounded (nothing to fill) does it fall
2503 // back to the fit-to-widest-label width.
2504 let width = match (self.sizing, proposal.width) {
2505 (TabSizing::Fill, Some(proposed)) => proposed.max(0.0),
2506 _ => {
2507 let intrinsic = self
2508 .header_ids
2509 .iter()
2510 .filter_map(|&id| ctx.child_size(id, SizeProposal::unspecified()))
2511 .map(|s| s.width)
2512 .fold(0.0_f32, f32::max);
2513 let mut w = intrinsic.clamp(self.min_extent, self.max_extent);
2514 if let Some(proposed) = proposal.width {
2515 w = w.min(proposed).max(self.min_extent);
2516 }
2517 w
2518 }
2519 };
2520 let extents = self.compute_extents(proposal.height, ctx);
2521 let total = extents.iter().sum::<f32>() + total_spacing;
2522 // Vertical extents are the intrinsic per-tab height and
2523 // don't depend on the proposal (see `compute_extents`),
2524 // so a probe can't skew them — but the viewport height
2525 // *is* missing here: the `ScrollArea` measures its
2526 // content with `height: None`. Read the viewport it last
2527 // placed instead; it only goes stale on the frame the
2528 // bar is resized, which is not a frame a reveal is in
2529 // flight on.
2530 let viewport_main = self
2531 .reveal
2532 .area
2533 .borrow()
2534 .as_ref()
2535 .map_or(0.0, |a| a.viewport.get().height);
2536 self.apply_pending_reveal(&extents, viewport_main);
2537 Size::new(width, total).into()
2538 }
2539 }
2540 }
2541
2542 fn place_children(
2543 &self,
2544 bounds: Rect,
2545 proposal: SizeProposal,
2546 children: &mut [WidgetPlacement],
2547 ctx: &LayoutContext,
2548 ) {
2549 // For Shared sizing, divide the *viewport* main extent (the
2550 // proposal main axis) — NOT the bounds main extent, which is
2551 // the content size returned by `layout_response`. ScrollArea
2552 // computes content size from `layout_response` and then calls
2553 // `place_children` with bounds = content_size, so using the
2554 // bounds main here would feedback-loop the layout pass.
2555 let viewport_main = match self.axis {
2556 TabBarOrientation::Horizontal => proposal.width,
2557 TabBarOrientation::Vertical => proposal.height,
2558 };
2559 let extents = self.compute_extents(viewport_main, ctx);
2560 let mut buf = self.header_bounds_buf.borrow_mut();
2561 buf.clear();
2562 match self.axis {
2563 TabBarOrientation::Horizontal => {
2564 let mut x = bounds.x;
2565 for (i, child) in children.iter_mut().enumerate() {
2566 if i >= extents.len() {
2567 break;
2568 }
2569 child.origin = Point::new(x, bounds.y);
2570 child.size = Size::new(extents[i], bounds.height);
2571 buf.push(Rect::new(x, bounds.y, extents[i], bounds.height));
2572 x += extents[i] + self.spacing;
2573 }
2574 }
2575 TabBarOrientation::Vertical => {
2576 let mut y = bounds.y;
2577 for (i, child) in children.iter_mut().enumerate() {
2578 if i >= extents.len() {
2579 break;
2580 }
2581 child.origin = Point::new(bounds.x, y);
2582 child.size = Size::new(bounds.width, extents[i]);
2583 buf.push(Rect::new(bounds.x, y, bounds.width, extents[i]));
2584 y += extents[i] + self.spacing;
2585 }
2586 }
2587 }
2588 drop(buf);
2589 // The divider overlay (appended last) is not a header — the loop
2590 // above broke before it (i >= extents.len()) so it never reached
2591 // `header_bounds_buf`. Place it spanning the whole row so it can
2592 // paint the inter-tab lines on top.
2593 if self.overlay_id.is_some()
2594 && let Some(last) = children.last_mut()
2595 {
2596 last.origin = bounds.origin();
2597 last.size = bounds.size();
2598 }
2599 self.row_bounds_buf.set(bounds);
2600 }
2601
2602 fn children(&self) -> Vec<WidgetId> {
2603 self.child_ids()
2604 }
2605}
2606
2607/// Pure-decoration overlay (the last child of [`TabHeaderRow`]) that paints
2608/// a 1 dp line at each boundary between consecutive tab headers, reading the
2609/// row's shared `header_bounds_buf` (world coords). Painted on top of the
2610/// headers so it shows over any per-tab background; pointer events pass
2611/// straight through.
2612struct TabRowDividers {
2613 header_bounds_buf: Rc<RefCell<Vec<Rect>>>,
2614 axis: TabBarOrientation,
2615 color: teksilo_core::color_prop::ColorProp,
2616 spacing: f32,
2617}
2618
2619impl std::fmt::Debug for TabRowDividers {
2620 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
2621 f.debug_struct("TabRowDividers")
2622 .field("axis", &self.axis)
2623 .finish()
2624 }
2625}
2626
2627impl Widget for TabRowDividers {
2628 fn build(&mut self, ctx: &mut BuildContext) -> Vec<WidgetId> {
2629 // Repaint when the (possibly bound) divider colour changes.
2630 self.color.register_if_bound(
2631 ctx.self_id(),
2632 ctx.binding_registry(),
2633 BindingLevel::RepaintOnly,
2634 );
2635 ctx.apply_self_handlers(HandlerSet::new().event_pass_through(true));
2636 vec![]
2637 }
2638
2639 fn layout_response(&self, proposal: SizeProposal, _ctx: &LayoutContext) -> LayoutResponse {
2640 // Leaf overlay — fill whatever bounds the row places it at.
2641 proposal.resolve(0.0, 0.0).into()
2642 }
2643
2644 fn paint(&self, _bounds: Rect, canvas: &mut Canvas, ctx: &PaintContext) {
2645 let headers = self.header_bounds_buf.borrow();
2646 if headers.len() < 2 {
2647 return;
2648 }
2649 let color = self.color.resolve(ctx.theme, true);
2650 let t = ctx.theme.shape.border_width.max(1.0);
2651 // Draw between consecutive headers. When `spacing > 0` the line is
2652 // centred in the gap; with flush tabs it sits on the shared edge.
2653 for pair in headers.windows(2) {
2654 let (a, b) = (pair[0], pair[1]);
2655 let line = match self.axis {
2656 TabBarOrientation::Horizontal => {
2657 let mid = (a.right() + b.x) * 0.5;
2658 Rect::new(mid - t * 0.5, a.y, t, a.height)
2659 }
2660 TabBarOrientation::Vertical => {
2661 let mid = (a.bottom() + b.y) * 0.5;
2662 Rect::new(a.x, mid - t * 0.5, a.width, t)
2663 }
2664 };
2665 canvas.fill_rect(line, color);
2666 }
2667 }
2668
2669 fn accessibility(&self, builder: &mut AccessNodeBuilder) {
2670 builder.set_hidden();
2671 }
2672}
2673
2674// ─── Scroll arrow + overflow dropdown construction ───────────────────
2675
2676/// Apply a bar-level [`TabDisplayMode`] to one tab's resolved label / icon /
2677/// tooltip. Icon-only modes blank the displayed label (the header then sizes to
2678/// the icon) and promote the title to the hover tooltip; with no icon they fall
2679/// back to the title's initial letter so the tab is never blank.
2680fn apply_tab_display(
2681 mode: TabDisplayMode,
2682 label: LocalizedString,
2683 icon: Option<IconWidget>,
2684 tooltip: Option<LocalizedString>,
2685) -> (LocalizedString, Option<IconWidget>, Option<LocalizedString>) {
2686 match mode {
2687 // Render as declared (Auto) or both when available (IconText) — there
2688 // is nothing to force-add, so these are identical transforms.
2689 TabDisplayMode::Auto | TabDisplayMode::IconText => (label, icon, tooltip),
2690 // Title only — drop the icon.
2691 TabDisplayMode::Text => (label, None, tooltip),
2692 // Icon only — blank the displayed label, promote the title to the
2693 // tooltip, and fall back to the initial letter when there is no icon.
2694 TabDisplayMode::Icon => {
2695 let resolved = label.clone().resolve_now();
2696 let tip = tooltip.or_else(|| (!resolved.trim().is_empty()).then(|| label.clone()));
2697 if icon.is_some() {
2698 (lit!(""), icon, tip)
2699 } else {
2700 let initial: String = resolved.chars().take(1).collect();
2701 (lit!(initial), None, tip)
2702 }
2703 }
2704 }
2705}
2706
2707#[derive(Debug, Clone, Copy)]
2708enum ScrollArrowKind {
2709 Leading,
2710 Trailing,
2711}
2712
2713fn build_scroll_arrow(
2714 ctx: &mut BuildContext,
2715 kind: ScrollArrowKind,
2716 orientation: TabBarOrientation,
2717 scroll_main: Signal<f32>,
2718 max_scroll_main: Signal<f32>,
2719 duration: std::time::Duration,
2720 easing: Easing,
2721 icon_role: TextRole,
2722) -> WidgetId {
2723 let _ = ctx;
2724 let icon_size = crate::styles::recipe_button_style::BUTTON_ICON_SIZE;
2725 let icon = match (orientation, kind) {
2726 (TabBarOrientation::Horizontal, ScrollArrowKind::Leading) => {
2727 IconWidget::chevron_left(icon_size)
2728 }
2729 (TabBarOrientation::Horizontal, ScrollArrowKind::Trailing) => {
2730 IconWidget::chevron_right(icon_size)
2731 }
2732 (TabBarOrientation::Vertical, ScrollArrowKind::Leading) => {
2733 IconWidget::chevron_up(icon_size)
2734 }
2735 (TabBarOrientation::Vertical, ScrollArrowKind::Trailing) => {
2736 IconWidget::chevron_down(icon_size)
2737 }
2738 };
2739 let tooltip = match (orientation, kind) {
2740 (TabBarOrientation::Horizontal, ScrollArrowKind::Leading) => {
2741 lit!("Scroll tabs left")
2742 }
2743 (TabBarOrientation::Horizontal, ScrollArrowKind::Trailing) => {
2744 lit!("Scroll tabs right")
2745 }
2746 (TabBarOrientation::Vertical, ScrollArrowKind::Leading) => {
2747 lit!("Scroll tabs up")
2748 }
2749 (TabBarOrientation::Vertical, ScrollArrowKind::Trailing) => {
2750 lit!("Scroll tabs down")
2751 }
2752 };
2753 let button = IconButton::new(icon)
2754 .embedded()
2755 .size(IconButtonSize::Compact)
2756 .icon_role(icon_role)
2757 .tooltip(tooltip)
2758 .on_activate_fn(move |_ctx| {
2759 let cur = scroll_main.get();
2760 let target = match kind {
2761 ScrollArrowKind::Leading => (cur - SCROLL_ARROW_STEP).max(0.0),
2762 ScrollArrowKind::Trailing => (cur + SCROLL_ARROW_STEP).min(max_scroll_main.get()),
2763 };
2764 // The main-axis scroll signal is created via
2765 // `Signal::new_animated` inside ScrollArea, so
2766 // `animate_to` is supported.
2767 scroll_main.animate_to(target, duration, easing);
2768 });
2769 ctx.add(button)
2770}
2771
2772/// One entry in the overflow dropdown — a stable [`TabId`], the
2773/// resolved label, and whether the tab is enabled. Built fresh per
2774/// bar build pass; cloned into the `ListView`'s underlying
2775/// `ListModel`.
2776#[derive(Clone)]
2777struct DropdownEntry {
2778 id: TabId,
2779 label: LocalizedString,
2780 enabled: bool,
2781}
2782
2783impl std::fmt::Debug for DropdownEntry {
2784 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
2785 f.debug_struct("DropdownEntry")
2786 .field("id", &self.id)
2787 .field("enabled", &self.enabled)
2788 .finish()
2789 }
2790}
2791
2792/// Width of the overflow popover. Roughly two tab-widths so the
2793/// labels read at the same density as the bar itself.
2794const DROPDOWN_WIDTH: f32 = 240.0;
2795/// Cap on the popover height — beyond this many items the ListView
2796/// scrolls internally. Roughly ten rows of `DROPDOWN_ROW_HEIGHT`.
2797const DROPDOWN_MAX_HEIGHT: f32 = 320.0;
2798/// Per-row height. Smaller than a tab header so the dropdown reads
2799/// as a menu rather than a strip preview.
2800const DROPDOWN_ROW_HEIGHT: f32 = 28.0;
2801
2802/// [`DROPDOWN_ROW_HEIGHT`] raised to the density's `target_size`
2803/// (24 / 32 / 44 dp). The identity at Compact.
2804fn dropdown_row_height(tokens: &InputTokens) -> f32 {
2805 dp(DROPDOWN_ROW_HEIGHT, TargetRole::Target, tokens)
2806}
2807/// Padding inside the dropdown surface.
2808const DROPDOWN_PADDING: f32 = 4.0;
2809
2810/// [`DROPDOWN_PADDING`] scaled by the density's `spacing_factor`
2811/// (1.00 / 1.15 / 1.30).
2812fn dropdown_padding(tokens: &InputTokens) -> f32 {
2813 spacing(DROPDOWN_PADDING, tokens)
2814}
2815
2816fn build_overflow_dropdown(
2817 ctx: &mut BuildContext,
2818 selected_id: Signal<Option<TabId>>,
2819 entries: Vec<DropdownEntry>,
2820 icon_role: TextRole,
2821) -> WidgetId {
2822 let _ = ctx;
2823 let icon_size = crate::styles::recipe_button_style::BUTTON_ICON_SIZE;
2824 // Same square, icon-sized control as the scroll arrows (an `IconButton`, not
2825 // a label-less `Button` that pads out around the glyph) so it stays adapted
2826 // to its icon and consistent in both bar orientations.
2827 let trigger = IconButton::new(IconWidget::chevron_down(icon_size))
2828 .embedded()
2829 .size(IconButtonSize::Compact)
2830 .icon_role(icon_role)
2831 .tooltip(lit!("Show all tabs"));
2832
2833 // Cap each row at the dropdown height so a click still hits a
2834 // sensible-sized button regardless of `entries.len()`.
2835 let row_count = entries.len();
2836 let model = ListModel::from_vec(entries);
2837 let selected_for_delegate = selected_id.clone();
2838 let list = ListView::new(model, move |_i, entry: &DropdownEntry, _selected| {
2839 let entry_id = entry.id;
2840 let label = entry.label.clone();
2841 let enabled = entry.enabled;
2842 let sel = selected_for_delegate.clone();
2843 Box::new(
2844 Button::new(label)
2845 .variant(ButtonVariant::Ghost)
2846 .enabled(enabled)
2847 .on_activate_fn(move |ctx: &mut EventContext| {
2848 sel.set(Some(entry_id));
2849 ctx.dismiss_self_overlay_chain();
2850 }),
2851 ) as Box<dyn Widget>
2852 })
2853 .item_height(dropdown_row_height(&ctx.theme().input));
2854
2855 // Compute a shrink-to-content height for short tab lists; cap
2856 // at `DROPDOWN_MAX_HEIGHT` for long ones (the ListView's
2857 // internal scroll bar takes over past the cap).
2858 let pad = dropdown_padding(&ctx.theme().input);
2859 let natural_h = (row_count as f32 * dropdown_row_height(&ctx.theme().input)) + (pad * 2.0);
2860 let content_h = natural_h.min(DROPDOWN_MAX_HEIGHT);
2861
2862 // Sized container. `FixedSize` forces both axes (content_h
2863 // shrinks on a short list; the constant width keeps the popover
2864 // from stretching to fit a long label).
2865 let sized = FixedSize::new()
2866 .width(DROPDOWN_WIDTH - pad * 2.0)
2867 .height(content_h - pad * 2.0)
2868 .child(list);
2869
2870 // Raised surface — `SurfaceRole::Raised` is the popup-fill
2871 // token; the `BorderRole::Default` 1 dp border gives the
2872 // popover a clean edge over arbitrary backgrounds.
2873 let surface = Panel::new()
2874 .background(SurfaceRole::Raised)
2875 .border_color(BorderRole::Default)
2876 .border_width(1.0)
2877 .padding(pad)
2878 .child(sized);
2879
2880 ctx.add(
2881 PopoverIconButton::new(trigger)
2882 // `surface` is already a chromed `Panel` (Raised) — opt out
2883 // of the auto popover surface to avoid double-chroming.
2884 .content(surface)
2885 .bare()
2886 .placement(OverlayPlacement::BelowPreferred)
2887 .has_popup_kind(HasPopup::Menu),
2888 )
2889}
2890
2891// ─── Helper math: drop-insertion index + selection adjust ───────────
2892
2893/// Pull the layout-axis range `(start, end)` out of a header's world
2894/// bounds. Horizontal bars use `(x, right)`; vertical bars use
2895/// `(y, bottom)`.
2896fn axis_range(rect: &Rect, axis: TabBarOrientation) -> (f32, f32) {
2897 match axis {
2898 TabBarOrientation::Horizontal => (rect.x, rect.right()),
2899 TabBarOrientation::Vertical => (rect.y, rect.bottom()),
2900 }
2901}
2902
2903/// Find the world-coord (along the layout axis) of the insertion-line
2904/// position closest to `pointer_main`, given each header's world
2905/// bounds. The returned coordinate is a tab boundary — the leading
2906/// edge of a header, or the trailing edge of the last header.
2907fn insertion_world_main_for(bounds: &[Rect], pointer_main: f32, axis: TabBarOrientation) -> f32 {
2908 let n = bounds.len();
2909 debug_assert!(n > 0);
2910 let (_, last_end) = axis_range(&bounds[n - 1], axis);
2911 if pointer_main >= last_end {
2912 return last_end;
2913 }
2914 let (first_start, _) = axis_range(&bounds[0], axis);
2915 if pointer_main <= first_start {
2916 return first_start;
2917 }
2918 for header in bounds {
2919 let (start, end) = axis_range(header, axis);
2920 let mid = (start + end) * 0.5;
2921 if pointer_main < mid {
2922 return start;
2923 }
2924 }
2925 last_end
2926}
2927
2928/// Find the model index where the dragged tab should be inserted.
2929/// `n` items → `n+1` valid insertion indices: 0 means "before the
2930/// first", `n` means "after the last".
2931fn insertion_index_for(bounds: &[Rect], pointer_main: f32, axis: TabBarOrientation) -> usize {
2932 let n = bounds.len();
2933 if n == 0 {
2934 return 0;
2935 }
2936 let (_, last_end) = axis_range(&bounds[n - 1], axis);
2937 if pointer_main >= last_end {
2938 return n;
2939 }
2940 let (first_start, _) = axis_range(&bounds[0], axis);
2941 if pointer_main <= first_start {
2942 return 0;
2943 }
2944 for (i, header) in bounds.iter().enumerate() {
2945 let (start, end) = axis_range(header, axis);
2946 let mid = (start + end) * 0.5;
2947 if pointer_main < mid {
2948 return i;
2949 }
2950 }
2951 n
2952}
2953
2954// Selection adjustment after move/remove is unnecessary now: the
2955// public selection signal is `Signal<Option<TabId>>`, which is
2956// stable across reorders by definition (the moved tab keeps its
2957// id) and across removals it goes stale and the bar's pre-build
2958// sync routes the id-not-found case to the next-neighbor fallback
2959// (browser convention).
2960
2961#[cfg(test)]
2962mod drop_math_tests {
2963 use super::*;
2964
2965 fn three_tabs() -> Vec<Rect> {
2966 vec![
2967 Rect::new(0.0, 0.0, 100.0, 30.0), // x ∈ [0..100)
2968 Rect::new(100.0, 0.0, 100.0, 30.0), // x ∈ [100..200)
2969 Rect::new(200.0, 0.0, 100.0, 30.0), // x ∈ [200..300)
2970 ]
2971 }
2972
2973 fn three_tabs_vertical() -> Vec<Rect> {
2974 vec![
2975 Rect::new(0.0, 0.0, 200.0, 50.0), // y ∈ [0..50)
2976 Rect::new(0.0, 50.0, 200.0, 50.0), // y ∈ [50..100)
2977 Rect::new(0.0, 100.0, 200.0, 50.0), // y ∈ [100..150)
2978 ]
2979 }
2980
2981 #[test]
2982 fn pointer_before_first_tab_inserts_at_zero() {
2983 let bounds = three_tabs();
2984 let axis = TabBarOrientation::Horizontal;
2985 assert_eq!(insertion_index_for(&bounds, -10.0, axis), 0);
2986 assert_eq!(insertion_world_main_for(&bounds, -10.0, axis), 0.0);
2987 }
2988
2989 #[test]
2990 fn pointer_past_last_tab_appends() {
2991 let bounds = three_tabs();
2992 let axis = TabBarOrientation::Horizontal;
2993 assert_eq!(insertion_index_for(&bounds, 999.0, axis), 3);
2994 assert_eq!(insertion_world_main_for(&bounds, 999.0, axis), 300.0);
2995 }
2996
2997 #[test]
2998 fn pointer_in_left_half_of_a_tab_inserts_before_it() {
2999 let bounds = three_tabs();
3000 let axis = TabBarOrientation::Horizontal;
3001 // Tab 1 spans 100..200; pointer at x=120 is in its left half.
3002 assert_eq!(insertion_index_for(&bounds, 120.0, axis), 1);
3003 assert_eq!(insertion_world_main_for(&bounds, 120.0, axis), 100.0);
3004 }
3005
3006 #[test]
3007 fn pointer_in_right_half_of_a_tab_inserts_after_it() {
3008 let bounds = three_tabs();
3009 let axis = TabBarOrientation::Horizontal;
3010 // Tab 1's right half is 150..200 → insertion at index 2.
3011 assert_eq!(insertion_index_for(&bounds, 175.0, axis), 2);
3012 assert_eq!(insertion_world_main_for(&bounds, 175.0, axis), 200.0);
3013 }
3014
3015 #[test]
3016 fn vertical_pointer_above_first_tab_inserts_at_zero() {
3017 let bounds = three_tabs_vertical();
3018 let axis = TabBarOrientation::Vertical;
3019 assert_eq!(insertion_index_for(&bounds, -10.0, axis), 0);
3020 assert_eq!(insertion_world_main_for(&bounds, -10.0, axis), 0.0);
3021 }
3022
3023 #[test]
3024 fn vertical_pointer_past_last_tab_appends() {
3025 let bounds = three_tabs_vertical();
3026 let axis = TabBarOrientation::Vertical;
3027 assert_eq!(insertion_index_for(&bounds, 999.0, axis), 3);
3028 assert_eq!(insertion_world_main_for(&bounds, 999.0, axis), 150.0);
3029 }
3030
3031 #[test]
3032 fn vertical_pointer_in_top_half_of_a_tab_inserts_before_it() {
3033 let bounds = three_tabs_vertical();
3034 let axis = TabBarOrientation::Vertical;
3035 // Tab 1 spans y=50..100; pointer at y=60 is in its top half.
3036 assert_eq!(insertion_index_for(&bounds, 60.0, axis), 1);
3037 assert_eq!(insertion_world_main_for(&bounds, 60.0, axis), 50.0);
3038 }
3039
3040 #[test]
3041 fn vertical_pointer_in_bottom_half_of_a_tab_inserts_after_it() {
3042 let bounds = three_tabs_vertical();
3043 let axis = TabBarOrientation::Vertical;
3044 // Tab 1's bottom half is y=75..100 → insertion at index 2.
3045 assert_eq!(insertion_index_for(&bounds, 88.0, axis), 2);
3046 assert_eq!(insertion_world_main_for(&bounds, 88.0, axis), 100.0);
3047 }
3048}
3049
3050// ─── Helper: a 0×0 widget used as a throwaway return value when we
3051// only need the side-effect of `ListSource::with_item_fn` (its
3052// closure access to `&T`), not an actual widget. The probe is
3053// constructed, returned to `with_item_fn`, and dropped immediately.
3054// ────────────────────────────────────────────────────────────────────
3055
3056#[derive(Debug)]
3057struct EnabledProbe;
3058
3059impl Widget for EnabledProbe {
3060 fn layout_response(&self, _proposal: SizeProposal, _ctx: &LayoutContext) -> LayoutResponse {
3061 Size::new(0.0, 0.0).into()
3062 }
3063}