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