Skip to main content

frust_material/
tabs.rs

1// Ported from material_3_expressive v1.0.8 (MIT, © 2026 Paa Developments),
2// `lib/components/tabs/m3e_tabs.dart` + `enums/m3e_tabs_variant.dart` +
3// `models/m3e_tab.dart` + `styles/m3e_tab_theme.dart` (retrieved 2026-08-20).
4// Upstream: <https://github.com/paadevelopments/material_3_expressive>
5
6//! The Material 3 Expressive **tab bar**: [`TabsVariant::Primary`] (an
7//! indicator pill sized to each tab's own label/icon content, matching
8//! `m3e_tab_theme.dart`'s `indicatorFullWidth == false`) and
9//! [`TabsVariant::Secondary`] (a full-width underline spanning the whole tab
10//! slot). The active indicator **slides** between tabs on selection change —
11//! see the [Indicator slide](#indicator-slide) section — and each tab paints
12//! a `primary`-tinted state layer while pressed.
13//!
14//! [`tabs`] builds a [`TabsView`]; [`tab`] builds one [`Tab`] of it. It is a
15//! **controlled component** (see `docs/CODE_STANDARDS.md`'s Interaction
16//! Semantics): a tap fires `on_selected` with the *requested* index and
17//! leaves `selected` untouched — the app feeds the confirmed index back on
18//! the next rebuild, exactly like [`mod@crate::navbar`]'s
19//! `NavigationBarView` (the semantics precedent this module follows — see
20//! [Semantics](#semantics) below).
21//!
22//! # No scrollable variant — ported faithfully, not a gap
23//!
24//! The wider Material 3 spec (and this crate's own task brief) describes a
25//! "scrollable" tabs mode alongside a "fixed" one. **This reference family
26//! has no such mode.** `m3e_tabs.dart:154` lays every tab out as
27//! `Row(children: [Expanded(child: ...)])` unconditionally — every tab
28//! always gets an equal share of the available width, regardless of tab
29//! count or content, with no `SingleChildScrollView`/scroll-physics path
30//! anywhere in `lib/components/tabs/`. This port transcribes that rule
31//! exactly ([`TabsWidget::layout`] divides the available width evenly, the
32//! same shape [`mod@crate::navbar`]'s own fixed-slot layout uses) rather
33//! than inventing a scrollable mode the upstream component never ships —
34//! many/narrow tabs simply compress instead of scrolling, matching upstream
35//! pixel-for-pixel.
36//!
37//! # Indicator slide
38//!
39//! [`IndicatorMotion`] springs the indicator's `(left, width)` rect from the
40//! outgoing tab's geometry to the incoming one — the same `(from, to)` +
41//! one-progress-[`AnimationController`] shape [`mod@crate::button`]'s own
42//! `RadiusPaddingMotion` press morph uses
43//! (`plugins/material/src/button/motion.rs`), reimplemented locally over a
44//! position+width pair instead of a radius+padding one: an unseeded (mount
45//! frame) target *snaps*; a repeated target inside
46//! [`INDICATOR_RETARGET_TOLERANCE`] is a no-op (the dead band that keeps a
47//! re-resolving layout pass from restarting the spring every frame); any
48//! other retarget springs from **the value being painted right now** (never
49//! the stale `from` a naive implementation would jump from), so a selection
50//! change mid-flight morphs continuously.
51//!
52//! **Divergence from the reference.** `m3e_tabs.dart:156-162` drives this
53//! slide with `AnimatedPositioned(duration: M3EMotion.medium2, curve:
54//! M3EMotion.emphasized)` — a plain duration+curve tween, not a spring. But
55//! `m3e_motion.dart`'s own doc comment states the M3 Expressive motion
56//! vocabulary favours spring physics for spatial (position/size/shape)
57//! movement, and every *other* spatial morph this crate has ported so far —
58//! the button press morph, `button_group`'s squish — is spring-driven, never
59//! a duration+curve tween. This port follows that established crate
60//! convention over this one Dart file's own (arguably legacy) choice, using
61//! [`INDICATOR_SPRING`] (= [`crate::tokens::MaterialSpring::SPATIAL_DEFAULT`],
62//! `m3e_motion.dart:84-87`'s `spatialDefault` — the token that family names
63//! for exactly this motion class, "size, position, and shape morphs").
64//!
65//! **Layout independence.** The indicator's geometry is a pure paint-time
66//! overlay computed from already-laid-out tab rects — it never feeds back
67//! into the tab row's own layout (unlike an animated-height widget, see
68//! `docs/REVIEW_FOCUS.md`'s layout-skip hot spot). [`TabsWidget::paint`]
69//! therefore drives the spring with [`frust::authoring::PaintCtx::request_frame`],
70//! never `request_layout` — the same shape [`mod@crate::navbar`]'s own
71//! selection-indicator fade uses.
72//!
73//! **Reduced motion.** `Theme::motion.reduce_motion` snaps the indicator
74//! straight to its target rather than freezing wherever the spring happens
75//! to be — the same choice [`mod@crate::expandable_list`]'s reveal-fraction
76//! snap documents, not [`mod@crate::button_group`]'s squish-freeze (that
77//! spring never has a discrete "target" to jump to; this one always does).
78//! Since the snap is still a value change, [`TabsWidget::paint`] requests
79//! exactly one more frame when it actually moved the painted rect — a bare
80//! `request_frame`, never `request_layout`, since (as above) this motion
81//! never feeds layout.
82//!
83//! # Tab content: label / icon / both
84//!
85//! [`Tab::label`]/[`Tab::icon`] mirror `M3ETab`'s own two optional slots
86//! (`models/m3e_tab.dart:8-11` asserts at least one is present — transcribed
87//! as [`tabs`]'s own `debug_assert!`, since this crate's builder attaches
88//! `label`/`icon` *after* [`tab`] returns). Present content stacks icon over
89//! label, centered, with **no gap** between them — `_buildTabContent`
90//! (`m3e_tabs.dart:220-251`) is a plain `Column(mainAxisSize: min,
91//! mainAxisAlignment: center)` with no separator child. **Porting decision:**
92//! [`Tab::icon`] takes an [`frust::IconSource`] rather than the reference's
93//! arbitrary `Widget? icon` — the same closed-shape substitution
94//! [`mod@crate::segmented_button`]'s own "Checkmark" section documents for
95//! `Segment::icon`, applied here for the same reason (a fixed, resolvable
96//! icon geometry this widget can measure and recolor at paint time, rather
97//! than an opaque child view).
98//!
99//! # Selection semantics + disabled tabs
100//!
101//! [`tabs`] `debug_assert!`s [`MIN_TABS`] (`m3e_tabs.dart:27`'s own
102//! `tabs.length >= 2` constructor assert). A tap fires `on_selected(state,
103//! index)` on release inside an *enabled* tab — fire-on-up-inside, like
104//! every interactive widget in this crate
105//! (`docs/CODE_STANDARDS.md`'s Interaction Semantics).
106//!
107//! **Disabled tabs are this port's own addition, not an upstream concept**:
108//! `M3ETab`/`M3ETabs` carry no `enabled`/`disabled` field or branch anywhere
109//! in `lib/components/tabs/`. [`Tab::enabled`] (default `true`) mirrors
110//! [`mod@crate::button`]'s own `enabled` convention exactly: a disabled tab's
111//! content dims to [`crate::interaction::DISABLED_CONTENT_OPACITY`] over
112//! `on_surface` (`button/core.rs`'s `resolve_colors` is the precedent this
113//! reuses the same opacity constant from), its `event` arm swallows nothing
114//! (`Down`/`Up`/`Move` on it are simply ignored, arming no press), and its
115//! semantics node reports [`frust::authoring::Node::set_disabled`]. A
116//! disabled tab may still be the *selected* one — disabled and unselected
117//! are independent axes, the same separation `docs/CODE_STANDARDS.md`'s
118//! controlled-component convention assumes throughout this crate — so the
119//! indicator still targets a disabled-but-selected tab's geometry normally.
120//!
121//! # State layer: always `primary`, regardless of selection
122//!
123//! `_buildTab`'s `Positioned.fill(child: ColoredBox(color:
124//! scheme.primary.withValues(alpha: state.opacity)))` (`m3e_tabs.dart:200-207`)
125//! paints the whole tab slot's press overlay in the *indicator's* color
126//! (`primary`), never the tab's own (possibly `on_surface_variant`)
127//! foreground — transcribed exactly, not "fixed" to the unselected tint.
128//! Only press is tracked ([`crate::interaction::InteractionState::set_pressed`]),
129//! not hover/focus — the same established scope
130//! [`mod@crate::segmented_button`]'s own "State layer" section documents for
131//! a row-of-items widget in this catalog (`navbar`, `button_group`, `chips`
132//! all leave hover/focus unwired too); haptics fire
133//! [`crate::interaction::HapticSignal::None`] on every confirmed tap via
134//! [`crate::interaction::MaterialHaptics::fire`] — `M3ETappable`'s own
135//! unoverridden default (`m3e_tappable.dart:36`), the same documented no-op
136//! [`mod@crate::segmented_button`] fires.
137//!
138//! # Semantics
139//!
140//! The bar is one [`frust::authoring::Role::TabList`] container; each tab
141//! contributes a [`frust::authoring::Role::Tab`] node labelled with its text
142//! (absent for an icon-only tab, matching `semanticLabel: tab.label` passing
143//! a null label straight through when there is none) and carrying
144//! [`frust::authoring::Node::set_selected`] — [`mod@crate::navbar`]'s own
145//! `NavigationBarWidget::semantics` shape exactly (`TabList`/`Tab`, label +
146//! `set_selected`, no `Action::Click`), extended only with
147//! [`frust::authoring::Node::set_disabled`] for a disabled tab (see
148//! "Selection semantics + disabled tabs" above).
149
150use std::rc::Rc;
151use std::time::Duration;
152
153use frust::authoring::text::{FontWeight, LineHeight, TextContext, TextLayout, TextStyle};
154use frust::authoring::{
155    BoxConstraints, BuildCtx, ChangeFlags, CornerRadii, ErasedCallback, EventCtx, EventResult,
156    InputEvent, LayoutCtx, PaintCtx, PaintScene, PointerPhase, Role, SemanticsCtx, View, Widget,
157};
158use frust::{AnimationController, FrameTime, IconData, IconSource, SpringDesc, Theme};
159use kurbo::{Affine, BezPath, Point, Rect, Size, Vec2};
160use peniko::{Brush, Color};
161
162use crate::interaction::{
163    DISABLED_CONTENT_OPACITY, HapticSignal, InteractionState, MaterialHaptics,
164};
165use crate::press::presses;
166
167/// Fewest tabs a bar may carry (`m3e_tabs.dart:27`'s own `tabs.length >= 2`
168/// constructor assert).
169pub const MIN_TABS: usize = 2;
170
171/// Bar height, in logical px (`tabTheme.height`'s default,
172/// `m3e_tab_theme.dart:11`).
173const HEIGHT: f64 = 48.0;
174/// Leading-icon side length, in logical px (`iconSize`'s default,
175/// `m3e_tab_theme.dart:12`).
176const ICON_SIZE: f64 = 24.0;
177/// Indicator thickness, in logical px (`indicatorHeight`'s default,
178/// `m3e_tab_theme.dart:13`).
179const INDICATOR_HEIGHT: f64 = 3.0;
180/// Indicator top-corner radius, in logical px (`indicatorCornerRadius`'s
181/// default, `m3e_tab_theme.dart:15`) — bottom corners stay square (the
182/// indicator sits flush against the bar's own bottom edge).
183const INDICATOR_CORNER_RADIUS: f64 = 3.0;
184/// Bottom divider stroke width, in logical px — Flutter `BorderSide`'s own
185/// default width (`m3e_tabs.dart:146-147`'s `Border(bottom: BorderSide(color:
186/// ...)))`, which never overrides `width`).
187const DIVIDER_WIDTH: f64 = 1.0;
188
189/// The indicator slide's spring — see the [module docs](self)'s Indicator
190/// slide section for why this is a spring at all (a deliberate divergence
191/// from the reference's own duration+curve tween) and why this particular
192/// preset. Equal to [`crate::tokens::MaterialSpring::SPATIAL_DEFAULT`]
193/// (`spring_matches_material_spatial_default` is the tripwire).
194const INDICATOR_SPRING: SpringDesc = SpringDesc {
195    mass: 1.0,
196    stiffness: 700.0,
197    damping_ratio: 0.9,
198};
199/// Nominal period seeding the indicator's [`AnimationController`] clock. The
200/// motion is spring-driven ([`INDICATOR_SPRING`]) via `fling`, so this
201/// duration only backs the controller's construction and is not itself a
202/// timing (mirrors [`mod@crate::button`]'s own `PRESS_ANIM_PERIOD`).
203const INDICATOR_ANIM_PERIOD: Duration = Duration::from_millis(300);
204/// Launch velocity (px/sec) handed to each leg's [`AnimationController::fling`]
205/// — the same modest kick [`mod@crate::button`]'s press morph uses, so a
206/// selection change reads snappy rather than creeping off zero.
207const INDICATOR_FLING_VELOCITY: f64 = 4.0;
208/// The dead band (logical px) a new indicator target must exceed before it
209/// starts a new spring leg — mirrors [`mod@crate::button`]'s own
210/// `RETARGET_TOLERANCE`, preventing a re-resolving layout pass from
211/// restarting the spring every frame at the same value.
212const INDICATOR_RETARGET_TOLERANCE: f64 = 0.1;
213
214/// Unthemed-fallback `primary` (a theme resolves `colors.primary`).
215const PRIMARY: Color = Color::from_rgb8(0x67, 0x50, 0xA4);
216/// Unthemed-fallback `surface`.
217const SURFACE: Color = Color::from_rgb8(0xFE, 0xF7, 0xFF);
218/// Unthemed-fallback `on_surface`.
219const ON_SURFACE: Color = Color::from_rgb8(0x1D, 0x1B, 0x20);
220/// Unthemed-fallback `on_surface_variant`.
221const ON_SURFACE_VARIANT: Color = Color::from_rgb8(0x49, 0x45, 0x4F);
222/// Unthemed-fallback `surface_container_highest`.
223const SURFACE_CONTAINER_HIGHEST: Color = Color::from_rgb8(0xE6, 0xE0, 0xE9);
224
225/// The `titleSmall` type-scale token, `(size, line_height, letter_spacing,
226/// weight)` — the unthemed fallback for the tab label role
227/// (`m3e_tab_theme.dart:53-57`'s `type.titleSmall`).
228const TITLE_SMALL: (f32, f32, f32, FontWeight) = (14.0, 20.0, 0.1, FontWeight::MEDIUM);
229
230/// The ink every label run is *shaped* with; never painted — recolored at
231/// paint time instead of reshaped (see [`mod@crate::button`]'s `SHAPING_INK`
232/// for why).
233const SHAPING_INK: Color = Color::BLACK;
234
235/// The two tab emphasis levels Material 3 defines
236/// (`enums/m3e_tabs_variant.dart`). See the [module docs](self) for the
237/// indicator-geometry difference between them.
238#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
239pub enum TabsVariant {
240    /// Primary tabs sit at the top level of the hierarchy; the indicator
241    /// matches each tab's own label/icon content width.
242    #[default]
243    Primary,
244    /// Secondary tabs sit within a content area; the indicator spans the
245    /// whole tab slot.
246    Secondary,
247}
248
249// ---- Colors -----------------------------------------------------------------
250
251/// The resolved `(background, divider, primary, on_surface, on_surface_variant)`
252/// color table — `M3ETabTheme.backgroundColor`/`.dividerColor`/
253/// `.indicatorColor`/`.tabColor` (`styles/m3e_tab_theme.dart:38`-`:49`).
254/// `indicatorColor` and a selected tab's `tabColor` both resolve to
255/// `primary`, so this table names it once rather than duplicating the field.
256struct Colors {
257    background: Color,
258    divider: Color,
259    primary: Color,
260    on_surface: Color,
261    on_surface_variant: Color,
262}
263
264fn resolve_colors(theme: Option<&Theme>) -> Colors {
265    match theme {
266        Some(theme) => {
267            let s = theme.scheme();
268            Colors {
269                background: s.surface,
270                divider: s.surface_container_highest,
271                primary: s.primary,
272                on_surface: s.on_surface,
273                on_surface_variant: s.on_surface_variant,
274            }
275        }
276        None => Colors {
277            background: SURFACE,
278            divider: SURFACE_CONTAINER_HIGHEST,
279            primary: PRIMARY,
280            on_surface: ON_SURFACE,
281            on_surface_variant: ON_SURFACE_VARIANT,
282        },
283    }
284}
285
286/// `color` with its alpha channel replaced by `alpha`.
287fn with_alpha(color: Color, alpha: f32) -> Color {
288    let c = color.components;
289    Color::new([c[0], c[1], c[2], alpha])
290}
291
292/// A tab's foreground (icon/label) ink: disabled dims to
293/// [`DISABLED_CONTENT_OPACITY`] over `on_surface`
294/// (`docs/PLUGINS_CODE_STANDARDS.md`'s attribution note on this porting
295/// decision — see the [module docs](self)); otherwise `primary` while
296/// selected, `on_surface_variant` while not (`tabColor`,
297/// `m3e_tab_theme.dart:46-49`).
298fn tab_fg(colors: &Colors, selected: bool, enabled: bool) -> Color {
299    if !enabled {
300        return with_alpha(colors.on_surface, DISABLED_CONTENT_OPACITY);
301    }
302    if selected {
303        colors.primary
304    } else {
305        colors.on_surface_variant
306    }
307}
308
309/// The label's `titleSmall` type role (themed) or its unthemed [`TITLE_SMALL`]
310/// fallback. The returned style always carries [`SHAPING_INK`] (see that
311/// constant's doc) — selection/disabled tint is applied at paint time.
312fn label_style(theme: Option<&Theme>) -> TextStyle {
313    let mut style = match theme {
314        Some(theme) => theme.type_scale.title_small.clone(),
315        None => {
316            let (size, line_height, letter_spacing, weight) = TITLE_SMALL;
317            let mut style = TextStyle::new(size, SHAPING_INK);
318            style.line_height = LineHeight::Absolute(line_height);
319            style.letter_spacing = letter_spacing;
320            style.weight = weight;
321            style
322        }
323    };
324    style.color = SHAPING_INK;
325    style
326}
327
328/// Whether two optional icon sources name the same geometry (`d`/`design`
329/// equality) — lets `rebuild` skip re-resolving an unchanged icon, mirroring
330/// [`mod@crate::segmented_button`]'s own `same_icon_source`.
331fn same_icon_source(a: Option<IconSource>, b: Option<IconSource>) -> bool {
332    match (a, b) {
333        (Some(x), Some(y)) => x.d == y.d && x.design == y.design,
334        (None, None) => true,
335        _ => false,
336    }
337}
338
339// ---- The label run (sibling of segmented_button::LabelRun) ------------------
340
341/// A lazily-shaped, paint-time-rebrushed label run — a sibling copy of
342/// [`mod@crate::segmented_button`]'s own `LabelRun` (`pub(self)` to that
343/// module and out of scope to import).
344struct LabelRun {
345    content: String,
346    layout: Option<TextLayout>,
347    shaped_for: Option<TextStyle>,
348}
349
350impl LabelRun {
351    fn new(content: String) -> Self {
352        Self {
353            content,
354            layout: None,
355            shaped_for: None,
356        }
357    }
358
359    fn set_content(&mut self, content: &str) {
360        if self.content != content {
361            self.content = content.to_string();
362            self.layout = None;
363            self.shaped_for = None;
364        }
365    }
366
367    fn content(&self) -> &str {
368        &self.content
369    }
370
371    fn shape(&mut self, ctx: &mut LayoutCtx, style: &TextStyle) -> Size {
372        if let Some(cached) = &self.layout
373            && self.shaped_for.as_ref() == Some(style)
374        {
375            return cached.size();
376        }
377        let text_ctx = ctx.text_context::<TextContext>();
378        let laid = text_ctx.layout(&self.content, style, None);
379        let size = laid.size();
380        self.layout = Some(laid);
381        self.shaped_for = Some(style.clone());
382        size
383    }
384
385    fn size(&self) -> Size {
386        self.layout.as_ref().map_or(Size::ZERO, TextLayout::size)
387    }
388
389    fn paint(&self, origin: Point, color: Color, scene: &mut dyn PaintScene) {
390        let Some(layout) = &self.layout else {
391            return;
392        };
393        for mut run in layout.to_scene_runs(origin) {
394            run.brush = Brush::Solid(color);
395            scene.draw_glyph_run(run);
396        }
397    }
398}
399
400// ---- Indicator motion ---------------------------------------------------------
401
402/// Linear interpolation between `from` and `to` at `t` (unclamped — the
403/// spring's overshoot rides past `t = 1`, the "expressive" spatial-spring
404/// look; see the [module docs](self)' Indicator slide section).
405fn lerp(from: f64, to: f64, t: f64) -> f64 {
406    from + (to - from) * t
407}
408
409/// The indicator's spring-driven `(left, width)` slide between the outgoing
410/// and incoming tab's geometry. See the [module docs](self)' Indicator slide
411/// section for the shape and its precedent
412/// ([`mod@crate::button`]'s `RadiusPaddingMotion`).
413#[derive(Clone, Copy, Debug)]
414struct IndicatorMotion {
415    from_left: f64,
416    to_left: f64,
417    from_width: f64,
418    to_width: f64,
419    anim: AnimationController,
420    /// Whether a first target has been seeded — the mount frame snaps to
421    /// whatever it resolves rather than sliding in from nothing (mirrors the
422    /// reference's own `_indicatorLeft == null` no-paint-yet state).
423    seeded: bool,
424}
425
426impl IndicatorMotion {
427    /// An unseeded motion resting at `(0, 0)` — the first [`Self::retarget`]
428    /// snaps to its argument instead of springing to it, and nothing paints
429    /// until then (see [`TabsWidget::paint`]).
430    fn new() -> Self {
431        Self {
432            from_left: 0.0,
433            to_left: 0.0,
434            from_width: 0.0,
435            to_width: 0.0,
436            anim: AnimationController::new(INDICATOR_ANIM_PERIOD),
437            seeded: false,
438        }
439    }
440
441    /// Aim the indicator at `(left, width)`, returning whether this actually
442    /// started a new spring leg. Three outcomes, in order: an unseeded motion
443    /// **snaps** (mount frame); a target within
444    /// [`INDICATOR_RETARGET_TOLERANCE`] of the live one is **ignored**;
445    /// anything else **springs**, from the value being painted right now —
446    /// see the [module docs](self)' continuity note.
447    fn retarget(&mut self, left: f64, width: f64) -> bool {
448        if !self.seeded {
449            self.snap_to(left, width);
450            return false;
451        }
452        if (self.to_left - left).abs() <= INDICATOR_RETARGET_TOLERANCE
453            && (self.to_width - width).abs() <= INDICATOR_RETARGET_TOLERANCE
454        {
455            return false;
456        }
457        self.from_left = self.left();
458        self.from_width = self.width();
459        self.to_left = left;
460        self.to_width = width;
461        self.anim = AnimationController::new(INDICATOR_ANIM_PERIOD);
462        self.anim.fling(INDICATOR_FLING_VELOCITY, INDICATOR_SPRING);
463        true
464    }
465
466    /// Pin both channels to `(left, width)` with no motion at all, marking
467    /// the motion seeded.
468    fn snap_to(&mut self, left: f64, width: f64) {
469        self.from_left = left;
470        self.to_left = left;
471        self.from_width = width;
472        self.to_width = width;
473        self.anim = AnimationController::new(INDICATOR_ANIM_PERIOD);
474        self.seeded = true;
475    }
476
477    /// Fast-forward whatever leg is in flight straight to its target,
478    /// without changing the target itself — `Theme::motion.reduce_motion`'s
479    /// paint-time contract (see the [module docs](self)' Reduced motion
480    /// section). Collapses `from` onto `to` (mirrors
481    /// [`mod@crate::expandable_list`]'s own `Reveal::snap`) rather than
482    /// forcing the driver's internal progress value, so [`Self::left`]/
483    /// [`Self::width`] read the target regardless of the driver's own state.
484    fn snap(&mut self) {
485        self.from_left = self.to_left;
486        self.from_width = self.to_width;
487        self.anim.stop();
488    }
489
490    /// Advance the spring to frame time `now`, returning whether it is still
491    /// animating (in which case the caller must request another frame).
492    fn advance(&mut self, now: FrameTime) -> bool {
493        self.anim.advance(now)
494    }
495
496    /// Whether a first target has been seeded — i.e. whether
497    /// [`Self::left`]/[`Self::width`] mean anything yet.
498    fn is_seeded(&self) -> bool {
499        self.seeded
500    }
501
502    fn factor(&self) -> f64 {
503        let raw = self.anim.value();
504        if raw.is_finite() { raw } else { 0.0 }
505    }
506
507    /// The indicator's left edge to paint this frame.
508    fn left(&self) -> f64 {
509        lerp(self.from_left, self.to_left, self.factor())
510    }
511
512    /// The indicator's width to paint this frame (never negative — an
513    /// under-damped overshoot toward a narrower target could otherwise drive
514    /// it past zero, mirroring [`mod@crate::button`]'s own corner guard).
515    fn width(&self) -> f64 {
516        lerp(self.from_width, self.to_width, self.factor()).max(0.0)
517    }
518}
519
520// ---- View ---------------------------------------------------------------------
521
522/// One tab within a [`TabsView`] — the reference's `M3ETab`
523/// (`models/m3e_tab.dart`).
524pub struct Tab {
525    label: Option<String>,
526    icon: Option<IconSource>,
527    enabled: bool,
528}
529
530/// Create a tab with neither label nor icon set — attach at least one via
531/// [`Tab::label`]/[`Tab::icon`] (`debug_assert!`-checked by [`tabs`]; see the
532/// [module docs](self)' Tab content section).
533pub fn tab() -> Tab {
534    Tab {
535        label: None,
536        icon: None,
537        enabled: true,
538    }
539}
540
541impl Tab {
542    /// Attach a text label, shaped in the bar's `titleSmall` role.
543    pub fn label(mut self, label: impl Into<String>) -> Self {
544        self.label = Some(label.into());
545        self
546    }
547
548    /// Attach a leading icon, stacked above the label with no gap. See the
549    /// [module docs](self)' Tab content section for why this takes an
550    /// [`IconSource`] rather than an arbitrary child view.
551    pub fn icon(mut self, icon: IconSource) -> Self {
552        self.icon = Some(icon);
553        self
554    }
555
556    /// Whether this tab accepts a press (default `true`). See the [module
557    /// docs](self)' Selection semantics + disabled tabs section — this is
558    /// this port's own addition, not an upstream `M3ETab` field.
559    pub fn enabled(mut self, enabled: bool) -> Self {
560        self.enabled = enabled;
561        self
562    }
563}
564
565/// A view-held selection callback (erased per-tab on build/rebuild).
566type OnSelected<State> = Rc<dyn Fn(&mut State, usize)>;
567
568/// A declarative M3 Expressive tab bar. See the [module docs](self).
569pub struct TabsView<State: 'static> {
570    tabs: Vec<Tab>,
571    selected: usize,
572    variant: TabsVariant,
573    on_selected: OnSelected<State>,
574}
575
576/// Create a tab bar from `tabs` (2+, `debug_assert!`-checked — see
577/// [`MIN_TABS`]), reflecting `selected` (the currently-confirmed index).
578/// Primary variant by default; call
579/// [`TabsView::variant`]`(`[`TabsVariant::Secondary`]`)` for the full-width
580/// underline. `on_selected` fires with the *requested* index on release
581/// inside an enabled tab — see the [module docs](self)' Selection semantics
582/// section.
583pub fn tabs<State: 'static, F: Fn(&mut State, usize) + 'static>(
584    tabs: impl IntoIterator<Item = Tab>,
585    selected: usize,
586    on_selected: F,
587) -> TabsView<State> {
588    let tabs: Vec<Tab> = tabs.into_iter().collect();
589    debug_assert!(
590        tabs.len() >= MIN_TABS,
591        "a tab bar needs {MIN_TABS}+ tabs (m3e_tabs.dart:27), got {}",
592        tabs.len()
593    );
594    for t in &tabs {
595        debug_assert!(
596            t.label.is_some() || t.icon.is_some(),
597            "a tab needs a label or an icon (m3e_tab.dart:8-11)"
598        );
599    }
600    TabsView {
601        tabs,
602        selected,
603        variant: TabsVariant::Primary,
604        on_selected: Rc::new(on_selected),
605    }
606}
607
608/// PascalCase alias for [`tabs`], matching this catalog's view-fn vocabulary.
609#[allow(non_snake_case)]
610pub fn Tabs<State: 'static, F: Fn(&mut State, usize) + 'static>(
611    tabs_: impl IntoIterator<Item = Tab>,
612    selected: usize,
613    on_selected: F,
614) -> TabsView<State> {
615    tabs(tabs_, selected, on_selected)
616}
617
618impl<State: 'static> TabsView<State> {
619    /// Select [`TabsVariant::Secondary`] (full-width underline) over the
620    /// default [`TabsVariant::Primary`] (content-width pill).
621    pub fn variant(mut self, variant: TabsVariant) -> Self {
622        self.variant = variant;
623        self
624    }
625}
626
627// ---- Widget ---------------------------------------------------------------------
628
629/// Build one bound tap handler per tab, type-erasing the reported value
630/// (a plain `usize`, so no generic to erase — unlike
631/// [`mod@crate::segmented_button`]'s own `bind_on_tap`).
632fn bind_on_tap<State: 'static>(on_selected: &OnSelected<State>, idx: usize) -> ErasedCallback {
633    let callback = on_selected.clone();
634    Box::new(move |ctx: &mut EventCtx| {
635        MaterialHaptics::fire(HapticSignal::None);
636        let state = ctx.state_mut::<State>();
637        callback(state, idx);
638    })
639}
640
641fn bind_on_taps<State: 'static>(
642    tabs: &[Tab],
643    on_selected: &OnSelected<State>,
644) -> Vec<ErasedCallback> {
645    (0..tabs.len())
646        .map(|i| bind_on_tap::<State>(on_selected, i))
647        .collect()
648}
649
650/// The per-tab label-run/icon-geometry/enabled vectors [`build_tab_runtime`]
651/// returns.
652type TabRuntime = (
653    Vec<Option<LabelRun>>,
654    Vec<Option<(BezPath, f64)>>,
655    Vec<bool>,
656);
657
658/// Build the per-tab runtime vectors from `tabs` — shared by `build` and a
659/// structural (tab-count-changed) `rebuild`.
660fn build_tab_runtime(tabs: &[Tab]) -> TabRuntime {
661    let labels = tabs
662        .iter()
663        .map(|t| t.label.as_ref().map(|l| LabelRun::new(l.clone())))
664        .collect();
665    let icon_geoms = tabs
666        .iter()
667        .map(|t| t.icon.map(|src| IconData::from(src).resolve()))
668        .collect();
669    let enabled_flags = tabs.iter().map(|t| t.enabled).collect();
670    (labels, icon_geoms, enabled_flags)
671}
672
673/// The retained widget for a [`TabsView`].
674pub struct TabsWidget {
675    /// One label run per tab (`None` for an icon-only tab).
676    labels: Vec<Option<LabelRun>>,
677    /// Each tab's own resolved icon geometry (`None` for a label-only tab).
678    icon_geoms: Vec<Option<(BezPath, f64)>>,
679    /// Per-tab enabled flag — see the [module docs](self)' disabled-tabs
680    /// section.
681    enabled_flags: Vec<bool>,
682    selected: usize,
683    variant: TabsVariant,
684    /// Per-tab full slot rects in the widget's local space (equal-width
685    /// division — see the [module docs](self)' "No scrollable variant"
686    /// section), computed in [`Widget::layout`]; used for the secondary
687    /// indicator target, the state-layer fill, and hit-testing.
688    tab_rects: Vec<Rect>,
689    /// Per-tab content (icon+label) rects, centered within their own
690    /// [`Self::tab_rects`] slot; used for the primary indicator target and
691    /// content painting.
692    content_rects: Vec<Rect>,
693    /// Per-tab hover/focus/pressed/dragged tracking; only `pressed` is ever
694    /// set — see the [module docs](self)' State layer section.
695    interactions: Vec<InteractionState>,
696    /// The sliding indicator — see the [module docs](self)' Indicator slide
697    /// section.
698    indicator: IndicatorMotion,
699    /// Whether a press is currently armed (a `Down` landed on an enabled tab).
700    armed: bool,
701    /// The tab a press is currently tracking.
702    active_tab: Option<usize>,
703    /// Whether the armed pointer is currently inside [`Self::active_tab`].
704    pressed_inside: bool,
705    /// One bound tap handler per tab — see [`bind_on_tap`].
706    on_taps: Vec<ErasedCallback>,
707}
708
709impl TabsWidget {
710    /// Which tab (if any) contains local point `pos`.
711    fn tab_at(&self, pos: Point) -> Option<usize> {
712        self.tab_rects.iter().position(|r| r.contains(pos))
713    }
714
715    /// The `(left, width)` the indicator should target for tab `i`, per the
716    /// current [`Self::variant`] — [`crate::tabs::TabsVariant::Primary`]
717    /// reads [`Self::content_rects`], [`TabsVariant::Secondary`] reads
718    /// [`Self::tab_rects`] (see the [module docs](self)' variant-geometry
719    /// difference).
720    fn indicator_target(&self, i: usize) -> (f64, f64) {
721        let rect = match self.variant {
722            TabsVariant::Primary => self.content_rects.get(i).copied().unwrap_or(Rect::ZERO),
723            TabsVariant::Secondary => self.tab_rects.get(i).copied().unwrap_or(Rect::ZERO),
724        };
725        (rect.x0, rect.width())
726    }
727}
728
729impl<State: 'static> View<State> for TabsView<State> {
730    type Element = TabsWidget;
731
732    fn build(&self, _ctx: &mut BuildCtx<'_>) -> TabsWidget {
733        let (labels, icon_geoms, enabled_flags) = build_tab_runtime(&self.tabs);
734        let on_taps = bind_on_taps::<State>(&self.tabs, &self.on_selected);
735        let n = self.tabs.len();
736        TabsWidget {
737            labels,
738            icon_geoms,
739            enabled_flags,
740            selected: self.selected,
741            variant: self.variant,
742            tab_rects: vec![Rect::ZERO; n],
743            content_rects: vec![Rect::ZERO; n],
744            interactions: vec![InteractionState::new(); n],
745            indicator: IndicatorMotion::new(),
746            armed: false,
747            active_tab: None,
748            pressed_inside: false,
749            on_taps,
750        }
751    }
752
753    fn rebuild(
754        &self,
755        prev: &Self,
756        element: &mut TabsWidget,
757        _ctx: &mut BuildCtx<'_>,
758    ) -> ChangeFlags {
759        let mut flags = ChangeFlags::NONE;
760
761        if prev.tabs.len() != self.tabs.len() {
762            // Structural: rebuild every per-tab vector fresh. The indicator
763            // resets to unseeded so the upcoming layout snaps to the new
764            // geometry rather than sliding from stale positions — mirrors
765            // upstream's own `didUpdateWidget` clearing `_indicatorLeft`/
766            // `_indicatorWidth` on a tab-count change.
767            let (labels, icon_geoms, enabled_flags) = build_tab_runtime(&self.tabs);
768            element.labels = labels;
769            element.icon_geoms = icon_geoms;
770            element.enabled_flags = enabled_flags;
771            element.tab_rects = vec![Rect::ZERO; self.tabs.len()];
772            element.content_rects = vec![Rect::ZERO; self.tabs.len()];
773            element.interactions = vec![InteractionState::new(); self.tabs.len()];
774            element.indicator = IndicatorMotion::new();
775            element.armed = false;
776            element.active_tab = None;
777            element.pressed_inside = false;
778            flags |= ChangeFlags::LAYOUT | ChangeFlags::PAINT;
779        } else {
780            for (i, (prev_t, next_t)) in prev.tabs.iter().zip(self.tabs.iter()).enumerate() {
781                match (&prev_t.label, &next_t.label) {
782                    (None, None) => {}
783                    (Some(p), Some(n)) if p == n => {}
784                    (_, Some(n)) => {
785                        let run =
786                            element.labels[i].get_or_insert_with(|| LabelRun::new(String::new()));
787                        run.set_content(n);
788                        flags |= ChangeFlags::LAYOUT | ChangeFlags::PAINT;
789                    }
790                    (Some(_), None) => {
791                        element.labels[i] = None;
792                        flags |= ChangeFlags::LAYOUT | ChangeFlags::PAINT;
793                    }
794                }
795                if !same_icon_source(prev_t.icon, next_t.icon) {
796                    element.icon_geoms[i] = next_t.icon.map(|src| IconData::from(src).resolve());
797                    flags |= ChangeFlags::LAYOUT | ChangeFlags::PAINT;
798                }
799                if element.enabled_flags[i] != next_t.enabled {
800                    element.enabled_flags[i] = next_t.enabled;
801                    flags |= ChangeFlags::PAINT;
802                }
803            }
804        }
805
806        let n = self.tabs.len();
807        let next_selected = if n == 0 { 0 } else { self.selected.min(n - 1) };
808        let selection_or_variant_changed =
809            element.selected != next_selected || element.variant != self.variant;
810        element.selected = next_selected;
811        element.variant = self.variant;
812
813        if selection_or_variant_changed {
814            flags |= ChangeFlags::PAINT;
815            if !flags.needs_layout() {
816                // Non-structural: retarget using the geometry cached from the
817                // last layout — no relayout needed, since neither selection
818                // nor variant changes the tab row's own shape (mirrors
819                // `NavigationBar`'s own cheap indicator repaint). A
820                // structural change above already flagged LAYOUT, which will
821                // retarget with fresh geometry itself.
822                let (left, width) = element.indicator_target(element.selected);
823                element.indicator.retarget(left, width);
824            }
825        }
826
827        // Closures aren't comparable — always reinstall, mirroring
828        // `segmented_button`/`navbar`'s own callback rebinding.
829        element.on_taps = bind_on_taps::<State>(&self.tabs, &self.on_selected);
830
831        flags
832    }
833}
834
835impl Widget for TabsWidget {
836    fn layout(&mut self, ctx: &mut LayoutCtx, bc: &BoxConstraints) -> Size {
837        let width = if bc.max().width.is_finite() {
838            bc.max().width
839        } else {
840            0.0
841        };
842        let n = self.labels.len();
843        if n == 0 {
844            return bc.constrain(Size::new(width, HEIGHT));
845        }
846
847        let style = {
848            let theme = Theme::from_layout_ctx(ctx);
849            label_style(theme)
850        };
851        // Equal-width division regardless of tab count/content — see the
852        // [module docs](self)' "No scrollable variant" section.
853        let slot_w = width / n as f64;
854
855        self.tab_rects.clear();
856        self.content_rects.clear();
857        for i in 0..n {
858            let has_icon = self.icon_geoms[i].is_some();
859            let (label_w, label_h) = if let Some(run) = self.labels[i].as_mut() {
860                let size = run.shape(ctx, &style);
861                (size.width, size.height)
862            } else {
863                (0.0, 0.0)
864            };
865            let icon_w = if has_icon { ICON_SIZE } else { 0.0 };
866            let icon_h = if has_icon { ICON_SIZE } else { 0.0 };
867            // Column(mainAxisSize: min): width is the widest child, height is
868            // the sum (no gap — see the [module docs](self)' Tab content
869            // section).
870            let content_w = label_w.max(icon_w);
871            let content_h = icon_h + label_h;
872
873            let slot_x = i as f64 * slot_w;
874            self.tab_rects
875                .push(Rect::new(slot_x, 0.0, slot_x + slot_w, HEIGHT));
876
877            let content_x = slot_x + (slot_w - content_w) / 2.0;
878            let content_y = (HEIGHT - content_h) / 2.0;
879            self.content_rects.push(Rect::new(
880                content_x,
881                content_y,
882                content_x + content_w,
883                content_y + content_h,
884            ));
885        }
886
887        let selected = self.selected.min(n - 1);
888        let (left, target_width) = self.indicator_target(selected);
889        self.indicator.retarget(left, target_width);
890
891        bc.constrain(Size::new(width, HEIGHT))
892    }
893
894    fn paint(&mut self, ctx: &mut PaintCtx, scene: &mut dyn PaintScene) {
895        // Advance (or, under reduced motion, snap) the indicator before
896        // reading its geometry below — see the [module docs](self)'
897        // Indicator slide / Reduced motion sections. Paint-only: this motion
898        // never feeds layout, so only `request_frame`, never
899        // `request_layout`.
900        if self.indicator.is_seeded() {
901            let reduce_motion = Theme::from_paint_ctx(ctx).is_some_and(|t| t.motion.reduce_motion);
902            if reduce_motion {
903                let before = (self.indicator.left(), self.indicator.width());
904                self.indicator.snap();
905                let after = (self.indicator.left(), self.indicator.width());
906                if before != after {
907                    ctx.request_frame();
908                }
909            } else if self.indicator.advance(ctx.frame_time()) {
910                ctx.request_frame();
911            }
912        }
913
914        let origin = ctx.origin();
915        let size = ctx.size();
916        let n = self.tab_rects.len();
917
918        let colors = {
919            let theme = Theme::from_paint_ctx(ctx);
920            resolve_colors(theme)
921        };
922
923        scene.fill_rect(origin, size, colors.background);
924        scene.stroke_line(
925            Point::new(origin.x, origin.y + size.height),
926            Point::new(origin.x + size.width, origin.y + size.height),
927            DIVIDER_WIDTH,
928            colors.divider,
929        );
930
931        for i in 0..n {
932            let tab_rect = self.tab_rects[i];
933            let content_rect = self.content_rects[i];
934            let enabled = self.enabled_flags[i];
935            let selected = i == self.selected;
936
937            // The state layer is always `primary`-tinted, regardless of
938            // selection — see the [module docs](self)' State layer section.
939            let opacity = self.interactions[i].resolve_opacity();
940            if opacity > 0.0 {
941                let tab_origin = Point::new(origin.x + tab_rect.x0, origin.y + tab_rect.y0);
942                scene.fill_rect(
943                    tab_origin,
944                    tab_rect.size(),
945                    with_alpha(colors.primary, opacity),
946                );
947            }
948
949            let fg = tab_fg(&colors, selected, enabled);
950            let has_icon = self.icon_geoms[i].is_some();
951            let icon_w = if has_icon { ICON_SIZE } else { 0.0 };
952            let icon_h = if has_icon { ICON_SIZE } else { 0.0 };
953            let label_w = self.labels[i].as_ref().map_or(0.0, |run| run.size().width);
954
955            if let Some((path, design)) = &self.icon_geoms[i] {
956                let scale = if *design > 0.0 {
957                    ICON_SIZE / *design
958                } else {
959                    1.0
960                };
961                let icon_x = content_rect.x0 + (content_rect.width() - icon_w) / 2.0;
962                let icon_y = content_rect.y0;
963                let transform = Affine::translate(Vec2::new(icon_x, icon_y)) * Affine::scale(scale);
964                let scaled = transform * path.clone();
965                scene.fill_path(origin, &scaled, &Brush::Solid(fg));
966            }
967
968            if let Some(run) = &self.labels[i] {
969                let label_x = content_rect.x0 + (content_rect.width() - label_w) / 2.0;
970                let label_y = content_rect.y0 + icon_h;
971                run.paint(
972                    Point::new(origin.x + label_x, origin.y + label_y),
973                    fg,
974                    scene,
975                );
976            }
977        }
978
979        if self.indicator.is_seeded() {
980            let left = self.indicator.left();
981            let width = self.indicator.width();
982            let indicator_origin =
983                Point::new(origin.x + left, origin.y + HEIGHT - INDICATOR_HEIGHT);
984            scene.fill_rounded_rect_radii(
985                indicator_origin,
986                Size::new(width, INDICATOR_HEIGHT),
987                CornerRadii::new(INDICATOR_CORNER_RADIUS, INDICATOR_CORNER_RADIUS, 0.0, 0.0),
988                colors.primary,
989            );
990        }
991    }
992
993    fn event(&mut self, ctx: &mut EventCtx, event: &InputEvent) -> EventResult {
994        let InputEvent::Pointer(p) = event else {
995            return EventResult::Ignored;
996        };
997        match p.phase {
998            PointerPhase::Down => {
999                if !presses(p) {
1000                    return EventResult::Ignored;
1001                }
1002                let Some(i) = self.tab_at(p.position) else {
1003                    return EventResult::Ignored;
1004                };
1005                if !self.enabled_flags[i] {
1006                    // A disabled tab arms nothing — the same shape
1007                    // `button/core.rs`'s own disabled guard uses.
1008                    return EventResult::Ignored;
1009                }
1010                self.armed = true;
1011                self.active_tab = Some(i);
1012                self.pressed_inside = true;
1013                self.interactions[i].set_pressed(true);
1014                ctx.capture_pointer();
1015                ctx.request_redraw();
1016                EventResult::Handled
1017            }
1018            PointerPhase::Move => {
1019                if !self.armed {
1020                    return EventResult::Ignored;
1021                }
1022                let Some(i) = self.active_tab else {
1023                    return EventResult::Ignored;
1024                };
1025                let inside = self
1026                    .tab_rects
1027                    .get(i)
1028                    .is_some_and(|r| r.contains(p.position));
1029                if inside != self.pressed_inside {
1030                    self.pressed_inside = inside;
1031                    self.interactions[i].set_pressed(inside);
1032                    ctx.request_redraw();
1033                }
1034                EventResult::Handled
1035            }
1036            PointerPhase::Up => {
1037                if !self.armed {
1038                    return EventResult::Ignored;
1039                }
1040                if let Some(i) = self.active_tab {
1041                    self.interactions[i].set_pressed(false);
1042                    if self.pressed_inside {
1043                        (self.on_taps[i])(ctx);
1044                    }
1045                }
1046                self.armed = false;
1047                self.pressed_inside = false;
1048                self.active_tab = None;
1049                ctx.request_redraw();
1050                EventResult::Handled
1051            }
1052            PointerPhase::Cancel => {
1053                if !self.armed {
1054                    return EventResult::Ignored;
1055                }
1056                if let Some(i) = self.active_tab {
1057                    self.interactions[i].set_pressed(false);
1058                }
1059                self.armed = false;
1060                self.pressed_inside = false;
1061                self.active_tab = None;
1062                ctx.request_redraw();
1063                EventResult::Handled
1064            }
1065        }
1066    }
1067
1068    fn semantics(&self, ctx: &mut SemanticsCtx) {
1069        let labels = &self.labels;
1070        let enabled_flags = &self.enabled_flags;
1071        let selected = self.selected;
1072        ctx.push_container(
1073            Role::TabList,
1074            |_| {},
1075            |ctx| {
1076                for i in 0..labels.len() {
1077                    let label = labels[i].as_ref().map(LabelRun::content);
1078                    let enabled = enabled_flags[i];
1079                    ctx.push_node(Role::Tab, |node| {
1080                        if let Some(l) = label {
1081                            node.set_label(l);
1082                        }
1083                        node.set_selected(i == selected);
1084                        if !enabled {
1085                            node.set_disabled();
1086                        }
1087                    });
1088                }
1089            },
1090        );
1091    }
1092}
1093
1094#[cfg(test)]
1095mod tests {
1096    use super::*;
1097    use frust::authoring::{PointerButton, PointerEvent};
1098    use std::any::Any;
1099
1100    fn ft_secs(s: f64) -> FrameTime {
1101        FrameTime::from_nanos((s * 1_000_000_000.0) as u64)
1102    }
1103
1104    fn build_view(selected: usize) -> TabsView<Vec<usize>> {
1105        tabs(
1106            [tab().label("One"), tab().label("Two"), tab().label("Three")],
1107            selected,
1108            |s: &mut Vec<usize>, i| s.push(i),
1109        )
1110    }
1111
1112    fn build(view: &TabsView<Vec<usize>>) -> TabsWidget {
1113        let mut counter = 0u64;
1114        View::<Vec<usize>>::build(view, &mut BuildCtx::new(&mut counter))
1115    }
1116
1117    fn layout_themed(w: &mut TabsWidget, width: f64, theme: Option<&Theme>) -> Size {
1118        let bc = BoxConstraints::loose(Size::new(width, f64::INFINITY));
1119        let mut tcx = TextContext::new();
1120        let mut ctx =
1121            LayoutCtx::with_resources(Some(&mut tcx as &mut dyn Any), theme.map(|t| t as &dyn Any));
1122        w.layout(&mut ctx, &bc)
1123    }
1124
1125    fn layout(w: &mut TabsWidget, width: f64) -> Size {
1126        layout_themed(w, width, None)
1127    }
1128
1129    fn ev(phase: PointerPhase, x: f64, y: f64) -> InputEvent {
1130        InputEvent::Pointer(PointerEvent {
1131            phase,
1132            position: Point::new(x, y),
1133            button: PointerButton::Primary,
1134        })
1135    }
1136
1137    #[allow(clippy::ptr_arg)]
1138    fn dispatch(w: &mut TabsWidget, state: &mut Vec<usize>, event: &InputEvent) {
1139        let state_any: &mut dyn Any = state;
1140        let mut ctx = EventCtx::new(state_any, Point::ZERO, Size::new(300.0, HEIGHT));
1141        w.event(&mut ctx, event);
1142    }
1143
1144    // ---- IndicatorMotion: slide timeline (start/mid/end rects) -------------
1145
1146    #[test]
1147    fn indicator_spring_matches_material_spatial_default() {
1148        let token = crate::tokens::MaterialSpring::SPATIAL_DEFAULT;
1149        assert_eq!(INDICATOR_SPRING.stiffness, token.stiffness);
1150        assert_eq!(INDICATOR_SPRING.damping_ratio, token.damping_ratio);
1151        assert_eq!(INDICATOR_SPRING.mass, 1.0);
1152        assert_eq!(INDICATOR_SPRING.stiffness, 700.0);
1153        assert_eq!(INDICATOR_SPRING.damping_ratio, 0.9);
1154    }
1155
1156    #[test]
1157    fn indicator_slides_between_two_tabs_start_mid_end() {
1158        let mut motion = IndicatorMotion::new();
1159        assert!(
1160            !motion.retarget(0.0, 40.0),
1161            "the mount frame seeds, it does not animate"
1162        );
1163        assert_eq!(
1164            (motion.left(), motion.width()),
1165            (0.0, 40.0),
1166            "start: unseeded snap"
1167        );
1168
1169        assert!(
1170            motion.retarget(120.0, 60.0),
1171            "a real geometry change starts a spring leg"
1172        );
1173        assert_eq!(
1174            (motion.left(), motion.width()),
1175            (0.0, 40.0),
1176            "mid-flight starts exactly where the previous frame painted"
1177        );
1178
1179        motion.advance(ft_secs(0.0));
1180        motion.advance(ft_secs(0.05));
1181        let (mid_left, mid_width) = (motion.left(), motion.width());
1182        assert!(
1183            mid_left > 0.0 && mid_left < 120.0,
1184            "left sampled strictly between start and end, got {mid_left}"
1185        );
1186        assert!(
1187            mid_width > 40.0 && mid_width < 60.0,
1188            "width sampled strictly between start and end, got {mid_width}"
1189        );
1190
1191        let mut t = 0.05;
1192        for _ in 0..600 {
1193            t += 1.0 / 60.0;
1194            if !motion.advance(ft_secs(t)) {
1195                break;
1196            }
1197        }
1198        assert_eq!(
1199            (motion.left(), motion.width()),
1200            (120.0, 60.0),
1201            "end: settles exactly on the new tab's geometry"
1202        );
1203    }
1204
1205    #[test]
1206    fn a_target_inside_the_dead_band_is_ignored() {
1207        let mut motion = IndicatorMotion::new();
1208        motion.retarget(10.0, 40.0);
1209        assert!(
1210            !motion.retarget(
1211                10.0 + INDICATOR_RETARGET_TOLERANCE / 2.0,
1212                40.0 + INDICATOR_RETARGET_TOLERANCE / 2.0
1213            ),
1214            "a sub-tolerance change must not restart the spring"
1215        );
1216        assert!(motion.retarget(10.0 + INDICATOR_RETARGET_TOLERANCE * 2.0, 40.0));
1217    }
1218
1219    // ---- Bounds (2+ tabs, label-or-icon) -------------------------------------
1220
1221    #[test]
1222    #[should_panic(expected = "needs 2+ tabs")]
1223    fn fewer_than_two_tabs_panics() {
1224        let _ = tabs::<(), _>([tab().label("Only")], 0, |_s: &mut (), _i| {});
1225    }
1226
1227    #[test]
1228    #[should_panic(expected = "label or an icon")]
1229    fn a_tab_with_neither_label_nor_icon_panics() {
1230        let _ = tabs::<(), _>(
1231            [
1232                Tab {
1233                    label: None,
1234                    icon: None,
1235                    enabled: true,
1236                },
1237                tab().label("B"),
1238            ],
1239            0,
1240            |_s: &mut (), _i| {},
1241        );
1242    }
1243
1244    // ---- Layout: equal-width division, no scroll variant --------------------
1245
1246    #[test]
1247    fn tabs_always_divide_the_available_width_evenly_no_scroll_variant() {
1248        let many: Vec<Tab> = (0..6).map(|i| tab().label(format!("Tab {i}"))).collect();
1249        let view: TabsView<()> = tabs(many, 0, |_s: &mut (), _i: usize| {});
1250        let mut counter = 0u64;
1251        let mut w = View::<()>::build(&view, &mut BuildCtx::new(&mut counter));
1252        let size = layout(&mut w, 300.0);
1253        assert_eq!(size.width, 300.0);
1254        assert_eq!(size.height, HEIGHT);
1255        let slot_w = 300.0 / 6.0;
1256        for (i, rect) in w.tab_rects.iter().enumerate() {
1257            assert_eq!(rect.x0, i as f64 * slot_w);
1258            assert_eq!(rect.width(), slot_w);
1259        }
1260    }
1261
1262    // ---- Indicator geometry per variant ---------------------------------------
1263
1264    #[test]
1265    fn primary_indicator_matches_content_width_not_slot_width() {
1266        let view = build_view(0);
1267        let mut w = build(&view);
1268        layout(&mut w, 300.0);
1269        let (left, width) = w.indicator_target(0);
1270        let slot = w.tab_rects[0];
1271        assert!(
1272            width < slot.width(),
1273            "primary indicator narrower than the slot"
1274        );
1275        assert!(left > slot.x0, "content indented from the slot's left edge");
1276        assert!(
1277            left + width < slot.x1,
1278            "content indented from the slot's right edge too"
1279        );
1280    }
1281
1282    #[test]
1283    fn secondary_indicator_spans_the_whole_slot() {
1284        let view = build_view(0).variant(TabsVariant::Secondary);
1285        let mut w = build(&view);
1286        layout(&mut w, 300.0);
1287        let (left, width) = w.indicator_target(0);
1288        let slot = w.tab_rects[0];
1289        assert_eq!((left, width), (slot.x0, slot.width()));
1290    }
1291
1292    // ---- Selection: controlled, disabled tabs ---------------------------------
1293
1294    #[test]
1295    fn is_controlled_selection_only_moves_via_rebuild() {
1296        let mut w = build(&build_view(0));
1297        layout(&mut w, 300.0);
1298        let mut state = Vec::new();
1299        dispatch(&mut w, &mut state, &ev(PointerPhase::Down, 150.0, 20.0));
1300        dispatch(&mut w, &mut state, &ev(PointerPhase::Up, 150.0, 20.0));
1301        assert_eq!(w.selected, 0, "widget did not self-mutate");
1302        assert_eq!(state, vec![1]);
1303
1304        let next = build_view(1);
1305        let prev = build_view(0);
1306        let mut counter = 0u64;
1307        View::<Vec<usize>>::rebuild(&next, &prev, &mut w, &mut BuildCtx::new(&mut counter));
1308        assert_eq!(w.selected, 1);
1309    }
1310
1311    #[test]
1312    fn tap_reports_the_tapped_tabs_index() {
1313        let mut w = build(&build_view(0));
1314        layout(&mut w, 300.0);
1315        let mut state = Vec::new();
1316        // Tab 2 ("Three") occupies x in [200, 300).
1317        dispatch(&mut w, &mut state, &ev(PointerPhase::Down, 250.0, 20.0));
1318        dispatch(&mut w, &mut state, &ev(PointerPhase::Up, 250.0, 20.0));
1319        assert_eq!(state, vec![2]);
1320    }
1321
1322    #[test]
1323    fn up_outside_the_pressed_tab_does_not_fire() {
1324        let mut w = build(&build_view(0));
1325        layout(&mut w, 300.0);
1326        let mut state = Vec::new();
1327        dispatch(&mut w, &mut state, &ev(PointerPhase::Down, 10.0, 20.0));
1328        dispatch(&mut w, &mut state, &ev(PointerPhase::Move, 250.0, 20.0));
1329        dispatch(&mut w, &mut state, &ev(PointerPhase::Up, 250.0, 20.0));
1330        assert!(state.is_empty());
1331    }
1332
1333    #[test]
1334    fn cancel_clears_the_press_without_firing() {
1335        let mut w = build(&build_view(0));
1336        layout(&mut w, 300.0);
1337        let mut state = Vec::new();
1338        dispatch(&mut w, &mut state, &ev(PointerPhase::Down, 10.0, 20.0));
1339        assert!(w.armed);
1340        dispatch(&mut w, &mut state, &ev(PointerPhase::Cancel, 10.0, 20.0));
1341        assert!(!w.armed);
1342        assert!(state.is_empty());
1343    }
1344
1345    #[test]
1346    fn disabled_tab_swallows_the_press_and_does_not_fire() {
1347        let view: TabsView<Vec<usize>> = tabs(
1348            [tab().label("A"), tab().label("B").enabled(false)],
1349            0,
1350            |s: &mut Vec<usize>, i| s.push(i),
1351        );
1352        let mut counter = 0u64;
1353        let mut w = View::<Vec<usize>>::build(&view, &mut BuildCtx::new(&mut counter));
1354        layout(&mut w, 200.0);
1355        let mut state = Vec::new();
1356        // Tab 1 ("B", disabled) occupies x in [100, 200).
1357        dispatch(&mut w, &mut state, &ev(PointerPhase::Down, 150.0, 20.0));
1358        dispatch(&mut w, &mut state, &ev(PointerPhase::Up, 150.0, 20.0));
1359        assert!(
1360            state.is_empty(),
1361            "a disabled tab must not report a selection"
1362        );
1363        assert!(!w.armed, "a disabled tab must not arm a press at all");
1364    }
1365
1366    // ---- Paint: reduced motion, dividers -------------------------------------
1367
1368    #[test]
1369    fn reduce_motion_snaps_the_indicator_and_requests_one_frame_never_layout() {
1370        let mut theme = crate::baseline();
1371        theme.motion.reduce_motion = true;
1372
1373        let prev = build_view(0);
1374        let mut w = build(&prev);
1375        layout_themed(&mut w, 300.0, Some(&theme));
1376
1377        let next = build_view(2);
1378        let mut counter = 0u64;
1379        View::<Vec<usize>>::rebuild(&next, &prev, &mut w, &mut BuildCtx::new(&mut counter));
1380
1381        struct NullScene;
1382        impl PaintScene for NullScene {
1383            fn fill_rect(&mut self, _o: Point, _s: Size, _c: Color) {}
1384            fn draw_text(&mut self, _o: Point, _t: &str) {}
1385        }
1386        let mut scene = NullScene;
1387        let mut ctx = PaintCtx::new(Point::ZERO, Size::new(300.0, HEIGHT)).with_theme(&theme);
1388        w.paint(&mut ctx, &mut scene);
1389
1390        assert!(
1391            ctx.needs_frame(),
1392            "the snap moved the indicator, so one more frame must be requested"
1393        );
1394        assert!(
1395            !ctx.needs_layout(),
1396            "the indicator is paint-only — it must never feed layout"
1397        );
1398
1399        // A second paint at the now-settled position requests nothing more.
1400        let mut ctx2 = PaintCtx::new(Point::ZERO, Size::new(300.0, HEIGHT)).with_theme(&theme);
1401        w.paint(&mut ctx2, &mut scene);
1402        assert!(
1403            !ctx2.needs_frame(),
1404            "already at rest — no further frame needed"
1405        );
1406    }
1407
1408    #[test]
1409    fn animating_indicator_requests_a_frame_each_advance() {
1410        let prev = build_view(0);
1411        let mut w = build(&prev);
1412        layout(&mut w, 300.0);
1413
1414        let next = build_view(2);
1415        let mut counter = 0u64;
1416        View::<Vec<usize>>::rebuild(&next, &prev, &mut w, &mut BuildCtx::new(&mut counter));
1417
1418        struct NullScene;
1419        impl PaintScene for NullScene {
1420            fn fill_rect(&mut self, _o: Point, _s: Size, _c: Color) {}
1421            fn draw_text(&mut self, _o: Point, _t: &str) {}
1422        }
1423        let mut scene = NullScene;
1424        let mut ctx = PaintCtx::new(Point::ZERO, Size::new(300.0, HEIGHT));
1425        w.paint(&mut ctx, &mut scene);
1426        assert!(
1427            ctx.needs_frame(),
1428            "mid-flight spring keeps asking for frames"
1429        );
1430        assert!(!ctx.needs_layout(), "never feeds layout");
1431    }
1432
1433    #[test]
1434    fn divider_is_stroked_along_the_bottom_edge() {
1435        let mut w = build(&build_view(0));
1436        layout(&mut w, 300.0);
1437
1438        #[derive(Default)]
1439        struct StrokeRecorder {
1440            lines: Vec<(Point, Point, f64)>,
1441        }
1442        impl PaintScene for StrokeRecorder {
1443            fn fill_rect(&mut self, _o: Point, _s: Size, _c: Color) {}
1444            fn draw_text(&mut self, _o: Point, _t: &str) {}
1445            fn stroke_line(&mut self, p0: Point, p1: Point, width: f64, _c: Color) {
1446                self.lines.push((p0, p1, width));
1447            }
1448        }
1449        let mut rec = StrokeRecorder::default();
1450        let mut ctx = PaintCtx::new(Point::ZERO, Size::new(300.0, HEIGHT));
1451        w.paint(&mut ctx, &mut rec);
1452        assert_eq!(rec.lines.len(), 1);
1453        let (p0, p1, width) = rec.lines[0];
1454        assert_eq!(p0.y, HEIGHT);
1455        assert_eq!(p1.y, HEIGHT);
1456        assert_eq!(width, DIVIDER_WIDTH);
1457    }
1458
1459    // ---- Semantics --------------------------------------------------------------
1460
1461    #[test]
1462    fn semantics_yields_a_tablist_of_tabs_with_selection_and_disabled() {
1463        fn logic(_s: &mut ()) -> TabsView<()> {
1464            tabs::<(), _>(
1465                [tab().label("A"), tab().label("B").enabled(false)],
1466                1,
1467                |_s: &mut (), _i| {},
1468            )
1469        }
1470        let mut root: frust_core::RenderRoot<(), TabsView<()>> = frust_core::RenderRoot::new();
1471        let mut state = ();
1472        root.rebuild(&mut logic, &mut state);
1473        let mut tcx = TextContext::new();
1474        root.layout_with_text(Size::new(300.0, HEIGHT), &mut tcx as &mut dyn Any);
1475        let update = root.semantics();
1476
1477        let tablist = update
1478            .nodes
1479            .iter()
1480            .find(|(_, n)| n.role() == Role::TabList)
1481            .expect("a TabList container node is contributed");
1482        assert_eq!(tablist.1.children().len(), 2);
1483
1484        let tabs_found: Vec<_> = update
1485            .nodes
1486            .iter()
1487            .filter(|(_, n)| n.role() == Role::Tab)
1488            .collect();
1489        assert_eq!(tabs_found.len(), 2);
1490
1491        let a = tabs_found
1492            .iter()
1493            .find(|(_, n)| n.label() == Some("A"))
1494            .expect("tab A is present");
1495        assert_eq!(a.1.is_selected(), Some(false));
1496        assert!(!a.1.is_disabled());
1497
1498        let b = tabs_found
1499            .iter()
1500            .find(|(_, n)| n.label() == Some("B"))
1501            .expect("tab B is present");
1502        assert_eq!(b.1.is_selected(), Some(true));
1503        assert!(b.1.is_disabled());
1504    }
1505}