Skip to main content

cranpose_liquid/widgets/
tab_bar.rs

1//! The floating glass tab bar: a capsule of tabs over live content with a
2//! liquid selection blob (dual-spring stretch), plus an optional detached
3//! circular accessory (the iOS 26 search button).
4
5use crate::material::{
6    neutral_surface_lift, neutral_surface_tint, Glass, GlassDynamics, GlassMorph, GlassShadow,
7    LiquidModifierExt,
8};
9use crate::motion::LiquidMotion;
10use crate::theme::{liquid_colors, liquid_typography, LiquidTypography};
11use crate::widgets::content_scope::ScopeContent;
12use cranpose_macros::composable;
13use cranpose_ui::text::{FontWeight, SpanStyle, TextStyle};
14use cranpose_ui::widgets::{
15    Box, BoxSpec, BoxWithConstraints, BoxWithConstraintsScope, Column, ColumnSpec, Row, RowSpec,
16    Text,
17};
18use cranpose_ui::{
19    Brush, Color, CornerRadii, Modifier, PointerInputScope, Rect, SemanticsWidgetRole, Size,
20};
21use cranpose_ui_layout::{Alignment, HorizontalAlignment, VerticalAlignment};
22use std::cell::RefCell;
23use std::rc::Rc;
24
25/// Visual treatment for a tab icon.
26#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
27pub enum LiquidTabIconStyle {
28    #[default]
29    Plain,
30    AppBadge,
31}
32
33/// One tab: icon path data (24×24 viewBox), label, and icon treatment.
34#[derive(Clone, Debug, PartialEq)]
35pub struct LiquidTab {
36    pub icon: &'static str,
37    pub label: &'static str,
38    pub icon_style: LiquidTabIconStyle,
39    /// Optical correction for symbols whose path bounds do not fill the
40    /// shared icon frame uniformly.
41    pub icon_scale: f32,
42}
43
44impl LiquidTab {
45    pub fn new(icon: &'static str, label: &'static str) -> Self {
46        Self {
47            icon,
48            label,
49            icon_style: LiquidTabIconStyle::Plain,
50            icon_scale: 1.0,
51        }
52    }
53
54    pub fn app_badge(icon: &'static str, label: &'static str) -> Self {
55        Self {
56            icon,
57            label,
58            icon_style: LiquidTabIconStyle::AppBadge,
59            icon_scale: 1.0,
60        }
61    }
62
63    pub fn with_icon_scale(mut self, scale: f32) -> Self {
64        self.icon_scale = normalize_icon_scale(scale);
65        self
66    }
67}
68
69fn normalize_icon_scale(scale: f32) -> f32 {
70    if scale.is_finite() {
71        scale.clamp(0.5, 1.5)
72    } else {
73        1.0
74    }
75}
76
77fn tab_base_content_color(colors: crate::theme::LiquidColors) -> Color {
78    colors.label
79}
80
81fn tab_selection_content_color(colors: crate::theme::LiquidColors) -> Color {
82    colors.accent
83}
84
85const BAR_HEIGHT: f32 = 64.0;
86/// Resting bubble height: the reference bubble fills the bar to ~4dp
87/// insets (56/64 — a 52dp blob read as a floating pill with odd gaps,
88/// user screenshots on both bars).
89const BLOB_HEIGHT: f32 = 56.0;
90const BLOB_MARGIN: f32 = 4.0;
91/// Raised bubble growth over the resting blob — VERTICAL ONLY. Measured
92/// on the raw hold recording (bottom_bar_click_to_change_then_hold_a_
93/// little.mov, held frames f_0260/f_0300): the held bubble keeps its rest
94/// width (348px over a 319px cell pitch = the 1.09 rest factor) while its
95/// height grows 56dp -> ~82dp (238px against the 185px bar), poking ~9dp
96/// past BOTH bar edges. A uniform 1.375x projection made a 1.5-pitch-wide
97/// capsule that sat over the neighbor cell and garbled its glyphs for the
98/// whole hold.
99const FLIGHT_LENS_HEIGHT_PROJECTION: f32 = 68.0 / BLOB_HEIGHT;
100/// A whisper of ellipse bow on the raised capsule — enough to soften the
101/// corners off a flat-topped rectangle (feedback item 7) without bowing
102/// it into a circle/oval. The reference raised bubble is a CURVED ROUNDED
103/// RECTANGLE: wider than tall, mostly-straight top and bottom edges,
104/// rounded corners (tab-swipe f_028 over "Account"). A 0.55 blend rounded
105/// it into a circle (user: "should not be this circle-like round, and not
106/// oval, more like curved rounded rectangle").
107const FLIGHT_ELLIPSE_BLEND: f32 = 0.25;
108/// Resting bubble width over the cell pitch. Measured on the reference
109/// (bottom-bar-click f_0000: bubble 96 over pitch 87.5): the bubble is
110/// barely wider than its cell, so a CELL-CENTERED rest keeps its edge
111/// flush inside the pill even at the end cells. The overhang this factor
112/// asks for (tab·0.05) is what [`tab_lens_rest_width`] caps at
113/// [`BLOB_MARGIN`], which is the whole reason an end cell is legal.
114const TAB_LENS_REST_WIDTH_FACTOR: f32 = 1.10;
115/// Fraction of the shared droplet stretch the tab bubble surface carries:
116/// the reference mid-swipe bubble elongates to ~1.15x its rest width
117/// (tab-swipe T400), never a multi-cell worm.
118const TAB_STRAIN_RESPONSE: f32 = 0.30;
119/// Width allotted to each tab inside the pill.
120const TAB_WIDTH: f32 = 78.0;
121/// Plain icon frame size (its path occupies about 25dp over 11dp labels).
122const TAB_ICON_SIZE: f32 = 32.0;
123const TAB_LABEL_SIZE: f32 = 11.0;
124/// The drag lens overflows the pill vertically like the reference bubble.
125const TAP_SLOP: f32 = 6.0;
126const ACCESSORY_GAP: f32 = 10.0;
127
128/// Layout parameters for a [`LiquidTabBar`].
129#[derive(Clone, Copy, Debug, PartialEq)]
130pub struct LiquidTabBarSpec {
131    max_tab_width: f32,
132}
133
134impl LiquidTabBarSpec {
135    pub fn new(max_tab_width: f32) -> Self {
136        Self {
137            max_tab_width: if max_tab_width.is_finite() {
138                max_tab_width.max(1.0)
139            } else {
140                TAB_WIDTH
141            },
142        }
143    }
144}
145
146impl Default for LiquidTabBarSpec {
147    fn default() -> Self {
148        Self::new(TAB_WIDTH)
149    }
150}
151
152fn tab_flight_lens_material(foreground: cranpose_ui_graphics::Color, accent: Color) -> Glass {
153    Glass::lens()
154        .no_clip()
155        .tint(neutral_surface_tint(foreground, 0.06, 0.05))
156        // The color-mask act is the LENS's own optic: dark ink transmitted
157        // through the bubble takes the accent (reference behavior); the
158        // cells beneath keep their honest colors.
159        .ink_recolor(accent, 0.85)
160        .blur_radius(0.0)
161        // The bubble MAGNIFIES its content (light-mode recording): the icon
162        // and label under the bubble read visibly bigger than the same
163        // glyphs outside it, while the tab spacing compresses inward — a
164        // true lens, not a 1:1 pass-through. The etalon's full-face field
165        // (depth 1.0) pulls the neighbours in; the modest optical zoom
166        // scales the ridden cell up so the covered icon is "a little
167        // bigger" like the reference.
168        .refraction_depth(1.0)
169        .refraction_curve(0.25)
170        .optical_zoom(1.22)
171        // The raised bubble's rim is a FOLD band like the toggle's: the
172        // reference hold frames (raw recording f_0260) read a thin darker
173        // ring re-imaging the content just outside the silhouette, with
174        // chromatic micro-fringes — without it the bubble fades out as a
175        // soft white sticker over the white bar. Kept shallow: 4dp reached
176        // the covered cell's teal label and threw vivid cyan streaks along
177        // the lip where the reference shows a faint washed ghost.
178        .fold_depth(2.5)
179        // The ride's rainbow (user feedback item 3): the riding bubble's
180        // caps carry the toggle-class chromatic split; the REST bubble
181        // stays the verified subtle look because dispersion and the fold
182        // ride press_depth (shallow floor at rest, deep on the ride).
183        .dispersion(0.9)
184        // Raised milk: the reference's held bubble face lifts modestly
185        // toward white as it rises (on-white-click-hold sheet, held rows
186        // f_0240+); lift scales by activity, so the verified resting look
187        // is untouched. Kept subtle — the fully clear face read flat, the
188        // heavy wash of earlier rounds read foggy.
189        .lift(0.05)
190        .highlight(0.18)
191        // Bottom-biased contact shadow (user feedback item 3: the ride
192        // bubble casts a visible soft shadow on the white bar; the default
193        // lens spread erased it on white-on-white).
194        .shadow_style(GlassShadow::new(
195            cranpose_ui_graphics::Color::BLACK.with_alpha(0.14),
196            12.0,
197            4.0,
198            -2.0,
199        ))
200}
201
202fn tab_bar_surface_material(foreground: cranpose_ui_graphics::Color) -> Glass {
203    Glass::regular()
204        .tint(neutral_surface_tint(foreground, 0.0, 0.04))
205        // 4dp let bold section headers read through the face as strong
206        // dark smears; the reference face (bar_over_headers) drowns them
207        // to a faint ghost while the color wash survives. The flat-tile
208        // composite solve below is blur-invariant, so the measured
209        // tint/saturation/lift stay pinned.
210        .blur_radius(9.0)
211        // Measured on bar_over_orange_purple: tile (242,150,77) reads
212        // (253,210,168) through the bar. Saturation deepens the channels
213        // before the screen lift, so the lift knob runs higher than the
214        // per-channel solve (~0.52); this pair lands the measured composite
215        // and drowns the fold ghosts the way the reference does.
216        .saturation(1.15)
217        .lift(neutral_surface_lift(foreground, 0.60, -0.24))
218        .highlight(0.20)
219        // The reference bar folds nearby content inside its long edges:
220        // section headers under the top edge render mirrored upside-down
221        // (bar_headers_folded) — the same pure-displacement fold as the
222        // toggle, shallower.
223        .fold_depth(8.0)
224        .adaptive_frost(foreground, 0.28)
225}
226
227fn tab_flight_tint_multiplier(activity: f32) -> f32 {
228    1.0 - 0.25 * activity.clamp(0.0, 1.0)
229}
230
231fn tab_lens_activity_motion(raised: bool) -> cranpose_animation::AnimationType {
232    if raised {
233        // Continuous contact rise on the toggle's calibrated spring (~10
234        // frames to full, the reference on-white gesture rows).
235        cranpose_animation::spring(0.9, 1400.0)
236    } else {
237        // The return into the bar keeps its slower material drain.
238        cranpose_animation::spring(1.0, 900.0)
239    }
240}
241
242fn tab_lens_left(pointer_x: f32, tab_width: f32, count: usize, has_accessory: bool) -> f32 {
243    let last_tab = tab_width * count.saturating_sub(1) as f32;
244    let min = if has_accessory { -tab_width * 0.2 } else { 0.0 };
245    let max = if has_accessory {
246        tab_width * (count as f32 - 0.45)
247    } else {
248        last_tab
249    };
250    (pointer_x - tab_width * 0.5).clamp(min, max)
251}
252
253/// The settled lens position for a selected cell: CELL-CENTERED at every
254/// index, exactly like the reference (bottom-bar-click f_0000 measures the
255/// end bubble's center on its cell center, its edge flush with the pill's
256/// rounded end). The rest width ([`TAB_LENS_REST_WIDTH_FACTOR`]) is what
257/// keeps the end cells legal — its overhang stays within [`BLOB_MARGIN`].
258/// Public so alignment tests assert the same rule the widget settles to.
259pub fn tab_lens_resting_left(selected: usize, tab_width: f32, count: usize) -> f32 {
260    tab_width * selected.min(count.saturating_sub(1)) as f32
261}
262
263/// The resting bubble's width for a cell pitch — the second half of the
264/// public resting contract ([`tab_lens_resting_left`] gives the position).
265///
266/// A cell-centered bubble on an end cell overhangs the cell strip by half its
267/// excess, and the pill only extends [`BLOB_MARGIN`] past that strip, so the
268/// overhang is what decides whether the end bubble lands flush inside the
269/// pill's rounded end or crosses it. [`TAB_LENS_REST_WIDTH_FACTOR`] sets the
270/// reference proportion; the margin caps it, so a bar built with wide cells
271/// keeps its ends legal instead of poking the bubble outside the pill.
272pub fn tab_lens_rest_width(tab_width: f32) -> f32 {
273    let overhang = (tab_width * (TAB_LENS_REST_WIDTH_FACTOR - 1.0) * 0.5).min(BLOB_MARGIN);
274    tab_width + 2.0 * overhang
275}
276
277#[derive(Clone, Copy, Debug, PartialEq)]
278struct AppBadgeGeometry {
279    size: Size,
280    corner_radius: f32,
281    stripe: Rect,
282    glyph: Rect,
283}
284
285fn app_badge_geometry(optical_scale: f32) -> AppBadgeGeometry {
286    let scale = normalize_icon_scale(optical_scale);
287    AppBadgeGeometry {
288        size: Size::new(20.0 * scale, 32.0 * scale),
289        corner_radius: 5.0 * scale,
290        stripe: Rect {
291            x: 7.0 * scale,
292            y: 4.5 * scale,
293            width: 6.0 * scale,
294            height: 1.5 * scale,
295        },
296        glyph: Rect {
297            x: 3.0 * scale,
298            y: 10.0 * scale,
299            width: 14.0 * scale,
300            height: 14.0 * scale,
301        },
302    }
303}
304
305#[composable]
306#[allow(non_snake_case)]
307fn TabIcon(icon: &'static str, style: LiquidTabIconStyle, color: Color, optical_scale: f32) {
308    const FRAME_HEIGHT: f32 = 32.0;
309    Box(
310        Modifier::empty().size(Size::new(TAB_ICON_SIZE, FRAME_HEIGHT)),
311        BoxSpec::default().content_alignment(Alignment::CENTER),
312        move || match style {
313            LiquidTabIconStyle::Plain => {
314                crate::icons::Icon(icon, TAB_ICON_SIZE * optical_scale, color)
315            }
316            LiquidTabIconStyle::AppBadge => {
317                let geometry = app_badge_geometry(optical_scale);
318                Box(
319                    Modifier::empty()
320                        .size(geometry.size)
321                        .draw_behind(move |scope| {
322                            scope.draw_round_rect(
323                                Brush::solid(color),
324                                CornerRadii::uniform(geometry.corner_radius),
325                            );
326                            scope.draw_rect_at(geometry.stripe, Brush::solid(Color::WHITE));
327                        }),
328                    BoxSpec::default(),
329                    move || {
330                        Box(
331                            Modifier::empty()
332                                .offset(geometry.glyph.x, geometry.glyph.y)
333                                .size(Size::new(geometry.glyph.width, geometry.glyph.height)),
334                            BoxSpec::default(),
335                            move || crate::icons::Icon(icon, geometry.glyph.width, Color::WHITE),
336                        );
337                    },
338                );
339            }
340        },
341    );
342}
343
344#[derive(Clone, Copy, PartialEq)]
345struct TabCellsSpec {
346    base_color: Color,
347    selected: Option<usize>,
348    selected_color: Color,
349    interactive: bool,
350    selection_only: bool,
351}
352
353fn tab_cell_is_visible(index: usize, selected: Option<usize>, selection_only: bool) -> bool {
354    !selection_only || selected == Some(index)
355}
356
357#[composable]
358#[allow(non_snake_case)]
359fn TabCells(
360    modifier: Modifier,
361    tabs: Rc<Vec<LiquidTab>>,
362    typography: LiquidTypography,
363    tab_width: f32,
364    spec: TabCellsSpec,
365) {
366    Row(modifier, RowSpec::default(), move || {
367        for (index, tab) in tabs.iter().enumerate() {
368            let visible = tab_cell_is_visible(index, spec.selected, spec.selection_only);
369            let color = if spec.selected == Some(index) {
370                spec.selected_color
371            } else {
372                spec.base_color
373            };
374            let label_for_semantics = tab.label;
375            let mut cell = Modifier::empty().size(Size::new(tab_width, BLOB_HEIGHT));
376            if spec.interactive {
377                cell = cell.semantics(move |config| {
378                    config.role = Some(SemanticsWidgetRole::Button);
379                    config.is_clickable = true;
380                    config.content_description = Some(label_for_semantics.to_string());
381                });
382            }
383            let icon = tab.icon;
384            let icon_style = tab.icon_style;
385            let icon_scale = tab.icon_scale;
386            let label = tab.label;
387            let label_style = TextStyle {
388                span_style: SpanStyle {
389                    color: Some(color),
390                    font_size: cranpose_ui::text::TextUnit::Sp(TAB_LABEL_SIZE),
391                    font_weight: Some(FontWeight::MEDIUM),
392                    ..typography.caption1.span_style.clone()
393                },
394                ..typography.caption1.clone()
395            };
396            Box(
397                cell,
398                BoxSpec::default().content_alignment(Alignment::CENTER),
399                move || {
400                    if !visible {
401                        return;
402                    }
403                    let label_style = label_style.clone();
404                    Column(
405                        Modifier::empty(),
406                        ColumnSpec::default()
407                            .horizontal_alignment(HorizontalAlignment::CenterHorizontally),
408                        move || {
409                            TabIcon(icon, icon_style, color, icon_scale);
410                            Text(label, Modifier::empty(), label_style.clone());
411                        },
412                    );
413                },
414            );
415        }
416    });
417}
418
419fn tab_lens_node_top(node_height: f32) -> f32 {
420    (BAR_HEIGHT - node_height) * 0.5
421}
422
423fn tab_bar_accessory_gap(has_accessory: bool) -> f32 {
424    if has_accessory {
425        ACCESSORY_GAP
426    } else {
427        0.0
428    }
429}
430
431fn accessory_surfaces_touch(edge_gap: f32) -> bool {
432    edge_gap <= 0.0
433}
434
435fn tab_lens_base_size(tab_width: f32, activity: f32) -> (f32, f32) {
436    let activity = activity.clamp(0.0, 1.0);
437    let ease = activity * activity * (3.0 - 2.0 * activity);
438    let rest_width = tab_lens_rest_width(tab_width);
439    let projection = 1.0 + (FLIGHT_LENS_HEIGHT_PROJECTION - 1.0) * ease;
440    (rest_width, BLOB_HEIGHT * projection)
441}
442
443#[derive(Clone, Copy, Debug, PartialEq)]
444struct TabFlightGeometry {
445    center: (f32, f32),
446    base_size: Size,
447    pose: crate::dynamics::LiquidPose,
448    lens_position: f32,
449    lens_activity: f32,
450    resting_tint: Color,
451    accessory_center: Option<(f32, f32)>,
452}
453
454#[derive(Clone, Copy, Debug, PartialEq)]
455struct TabFlightNode {
456    origin: (f32, f32),
457    size: Size,
458}
459
460fn tab_flight_dynamics(geometry: TabFlightGeometry, node: TabFlightNode) -> GlassDynamics {
461    let activity = geometry.lens_activity.clamp(0.0, 1.0);
462    let energy = geometry.pose.energy() * activity;
463    // The rest bubble is a capsule (r = h/2); the raised bubble keeps that
464    // full corner and additionally blends its SDF toward an ellipse
465    // (activity-scaled at the material layer), so the tall held body reads
466    // as the reference's continuously curved oval — a reduced corner
467    // radius squares the silhouette into a rounded rectangle instead.
468    let radius = geometry.base_size.height * (0.48 + 0.02 * energy);
469    let glue = 20.0;
470    let shapes = geometry
471        .accessory_center
472        .filter(|(x, _)| {
473            let effective_stretch =
474                1.0 + (geometry.pose.stretch.max(geometry.pose.ortho) - 1.0) * TAB_STRAIN_RESPONSE;
475            let edge_gap = (*x - geometry.center.0).abs()
476                - geometry.base_size.width * effective_stretch * 0.5
477                - BAR_HEIGHT * 0.5;
478            accessory_surfaces_touch(edge_gap)
479        })
480        .map(|(x, y)| {
481            vec![(
482                x - node.origin.0,
483                y - node.origin.1,
484                BAR_HEIGHT,
485                BAR_HEIGHT,
486                -1.0,
487            )]
488        })
489        .unwrap_or_default();
490    GlassDynamics {
491        morph: Some(GlassMorph {
492            node_size: (node.size.width, node.size.height),
493            primary: (
494                geometry.center.0 - node.origin.0,
495                geometry.center.1 - node.origin.1,
496                geometry.base_size.width,
497                geometry.base_size.height,
498                radius,
499            ),
500            shapes,
501            glue,
502            // 1.1 was calibrated for the old 1.5-pitch-wide bubble; on the
503            // cell-width body the same amplitude curls the rim into marble
504            // swirls mid-travel where the reference keeps clean arc smears.
505            wobble_amplitude: 0.5 * energy,
506            wobble_phase: geometry.lens_position * 0.045,
507            bulge_amplitude: geometry.pose.bulge_amplitude.min(8.0) * activity,
508            bulge_direction: geometry.pose.bulge_direction,
509            ellipse_blend: FLIGHT_ELLIPSE_BLEND,
510            deformation: Some(crate::material::GlassDeformation::incompressible(
511                geometry.pose.axis,
512                // Reference mid-swipe bubble elongates to ~1.15x its rest
513                // width (tab-swipe T400) — the raw droplet stretch (up to
514                // 1.5) read as a two-cell worm.
515                1.0 + (geometry.pose.stretch - 1.0) * activity * TAB_STRAIN_RESPONSE,
516            )),
517            zoom_anchor: (0.0, 0.0),
518        }),
519        activity: Some(activity),
520        // Ride depth: the raised bubble runs the full vivid rim (fold +
521        // dispersion at strength); the resting bubble keeps a shallow
522        // floor so its verified look is untouched.
523        press_depth: Some(0.3 + 0.7 * activity),
524        resting_tint: Some(geometry.resting_tint),
525        tint_alpha_multiplier: Some(tab_flight_tint_multiplier(geometry.lens_activity)),
526        ..Default::default()
527    }
528}
529
530/// The scope a tab bar's destinations are declared in.
531///
532/// Each call adds one destination, in the order it is made, which is also the
533/// order the indices passed to `on_select` count in.
534pub struct LiquidTabBarScope {
535    tabs: ScopeContent<LiquidTab>,
536}
537
538impl LiquidTabBarScope {
539    /// A destination showing `icon` above `label`.
540    pub fn tab(&self, icon: &'static str, label: &'static str) {
541        self.push(LiquidTab::new(icon, label));
542    }
543
544    /// A destination whose icon is drawn as an application badge.
545    pub fn app_badge(&self, icon: &'static str, label: &'static str) {
546        self.push(LiquidTab::app_badge(icon, label));
547    }
548
549    /// A destination built out, for the optical corrections a particular
550    /// symbol needs — see [`LiquidTab`].
551    pub fn push(&self, tab: LiquidTab) {
552        self.tabs.push(tab);
553    }
554}
555
556fn collect_tabs(content: impl FnOnce(&LiquidTabBarScope)) -> Vec<LiquidTab> {
557    ScopeContent::collect(|tabs| LiquidTabBarScope { tabs }, content)
558}
559
560/// A unified floating glass tab bar with every destination inside one pill.
561///
562/// ```rust,ignore
563/// LiquidTabBar(Modifier::empty(), LiquidTabBarSpec::default(), selected, on_select, |scope| {
564///     scope.tab(icons::DOCUMENT, "Today");
565///     scope.tab(icons::SEARCH, "Search");
566/// });
567/// ```
568#[composable]
569#[allow(non_snake_case)]
570pub fn LiquidTabBar(
571    modifier: Modifier,
572    spec: LiquidTabBarSpec,
573    selected: usize,
574    on_select: impl Fn(usize) + 'static,
575    content: impl FnOnce(&LiquidTabBarScope),
576) {
577    LiquidTabBarLayout(
578        modifier,
579        spec,
580        collect_tabs(content),
581        selected,
582        on_select,
583        false,
584        || {},
585    );
586}
587
588/// A floating glass tab bar with a detached accessory to its right.
589#[composable]
590#[allow(non_snake_case)]
591pub fn LiquidTabBarWithAccessory(
592    modifier: Modifier,
593    spec: LiquidTabBarSpec,
594    selected: usize,
595    on_select: impl Fn(usize) + 'static,
596    content: impl FnOnce(&LiquidTabBarScope),
597    accessory: impl FnMut() + 'static,
598) {
599    LiquidTabBarLayout(
600        modifier,
601        spec,
602        collect_tabs(content),
603        selected,
604        on_select,
605        true,
606        accessory,
607    );
608}
609
610#[composable]
611#[allow(non_snake_case)]
612fn LiquidTabBarLayout(
613    modifier: Modifier,
614    spec: LiquidTabBarSpec,
615    tabs: Vec<LiquidTab>,
616    selected: usize,
617    on_select: impl Fn(usize) + 'static,
618    has_accessory: bool,
619    accessory: impl FnMut() + 'static,
620) {
621    let colors = liquid_colors();
622    let typography = liquid_typography();
623    let count = tabs.len().max(1);
624    let selected = selected.min(count - 1);
625    let on_select: Rc<dyn Fn(usize)> = Rc::new(on_select);
626    let tabs = Rc::new(tabs);
627    let accessory = Rc::new(RefCell::new(accessory));
628
629    Row(
630        modifier,
631        RowSpec::default().vertical_alignment(VerticalAlignment::CenterVertically),
632        move || {
633            let tabs = Rc::clone(&tabs);
634            let typography = typography.clone();
635            let on_select = Rc::clone(&on_select);
636            let accessory = Rc::clone(&accessory);
637
638            // The main pill (wrapped in a stack so the drag lens can float
639            // ABOVE the finished bar and magnify icons + glass together).
640            let lens_x_outer = cranpose_core::remember(|| {
641                cranpose_core::mutableStateOf((
642                    0.0f32,
643                    0.0f32,
644                    0.0f32,
645                    crate::dynamics::LiquidPose::default(),
646                ))
647            })
648            .with(|state| *state);
649            // Held glass comes CLOSER and lights up: while a finger rests
650            // on the bar the whole surface scales up a touch and the shader
651            // concentrates saturation + a gradient highlight under the
652            // finger (user-observed reference behavior of every touched
653            // glass surface).
654            let bar_touch =
655                cranpose_core::remember(|| cranpose_core::mutableStateOf((0.0f32, 0.0f32)))
656                    .with(|state| *state);
657            let bar_held = cranpose_core::remember(|| cranpose_core::mutableStateOf(false))
658                .with(|state| *state);
659            let bar_press = cranpose_animation::animateFloatAsState(
660                if bar_held.get() { 1.0 } else { 0.0 },
661                cranpose_animation::spring(1.0, 600.0),
662                "tabbar-hold-press",
663            );
664            // The rise transforms the WHOLE stack — pill, cells, and the
665            // floating lens are one optical body. Lifting only the pill
666            // scaled the cells away from the unscaled lens, drifting the
667            // bubble off its cell in proportion to the cell's distance
668            // from the pill center.
669            let bar_lift = Modifier::empty().graphics_layer(move || {
670                let press = bar_press.get().clamp(0.0, 1.0);
671                let rise = 1.0 + 0.03 * press;
672                cranpose_ui_graphics::GraphicsLayer {
673                    scale_x: rise,
674                    scale_y: rise,
675                    translation_y: -2.5 * press,
676                    ..Default::default()
677                }
678            });
679            // The stack is pinned to the pill height so the mounting lens
680            // (taller than the bar) can never inflate it — an unpinned stack
681            // grew on press and the centering Row shifted the whole bar down.
682            Box(
683                bar_lift.then(Modifier::empty().height(BAR_HEIGHT)),
684                BoxSpec::default(),
685                move || {
686                    let tabs = Rc::clone(&tabs);
687                    let typography = typography.clone();
688                    let on_select = Rc::clone(&on_select);
689                    // The bar's edge is defined by shadow and contrast, not a
690                    // bright rim stroke.
691                    let pill = Modifier::empty()
692                        .glass_effect_with(
693                            // Dark labels must never sink into dark content
694                            // scrolling beneath: the glass lifts adaptively over
695                            // dark backdrops (inert over the light ones the
696                            // pinned captures use).
697                            tab_bar_surface_material(colors.label),
698                            move || {
699                                let press = bar_press.get().clamp(0.0, 1.0);
700                                let (touch_x, touch_y) = bar_touch.get();
701                                GlassDynamics {
702                                    highlight_boost: 0.45 * press,
703                                    saturation_boost: 0.12 * press,
704                                    touch: (press > 0.01).then_some((touch_x, touch_y, press)),
705                                    ..Default::default()
706                                }
707                            },
708                        )
709                        .height(BAR_HEIGHT);
710                    Box(pill, BoxSpec::default(), move || {
711                        let tabs = Rc::clone(&tabs);
712                        let typography = typography.clone();
713                        let on_select = Rc::clone(&on_select);
714                        BoxWithConstraints(Modifier::empty().padding(BLOB_MARGIN), move |scope| {
715                            let tabs = Rc::clone(&tabs);
716                            let typography = typography.clone();
717                            let on_select = Rc::clone(&on_select);
718                            let constrained = scope.constraints().max_width;
719                            let tab_width = if constrained.is_finite() && constrained > 1.0 {
720                                (constrained / count as f32).min(spec.max_tab_width)
721                            } else {
722                                spec.max_tab_width
723                            };
724
725                            // One optical body owns selection at rest, under direct
726                            // manipulation, and throughout release settle.
727                            let lens_pressed =
728                                cranpose_core::remember(|| cranpose_core::mutableStateOf(false))
729                                    .with(|state| *state);
730                            let resting_lens_x = tab_lens_resting_left(selected, tab_width, count);
731                            let lens_axis =
732                                crate::motion::remember_liquid_drag_axis(resting_lens_x);
733                            // Controlled-state restore only: while a finger
734                            // holds the bar the axis belongs to the gesture
735                            // (an unconditional settle re-targeted the lens
736                            // back every frame and cancelled the touch-down
737                            // attract — live report).
738                            if !lens_pressed.get() {
739                                lens_axis.settle_to(resting_lens_x, LiquidMotion::glide());
740                            }
741                            let lens_x = lens_axis.value();
742                            let lens_pose = lens_axis.liquid_pose();
743                            let lens_in_flight = !lens_axis.is_dragging()
744                                && (lens_x - resting_lens_x).abs() > tab_width * 0.15;
745                            let lens_raised = lens_pressed.get() || lens_in_flight;
746                            let lens_activity_target = if lens_raised { 1.0 } else { 0.0 };
747                            let lens_activity_anim = cranpose_animation::animateFloatAsState(
748                                lens_activity_target,
749                                tab_lens_activity_motion(lens_raised),
750                                "tabbar-lens-activity",
751                            );
752                            // The reference raises the surface CONTINUOUSLY:
753                            // depth, chroma and scale rise together over ~10
754                            // frames (on-white gesture rows) — contact and
755                            // return ride the same animated channel.
756                            let lens_activity = lens_activity_anim.get();
757                            // The color-mask act under the LIVE bubble is the
758                            // LENS MATERIAL's own optic — the shader recolors
759                            // the ink it transmits — never a recolor of the
760                            // cells themselves (an element recolor gets
761                            // refracted into accent smears around the bubble
762                            // rim). The accented CELL follows the lens center
763                            // crossing, not the committed model: a click
764                            // promotes the destination the instant `selected`
765                            // snaps while the bubble is still at the origin
766                            // (on-white-click 0ms: Conversation teal, bubble
767                            // parked at Translate — the reference hands the
768                            // accent off mid-flight).
769                            let visual_index = crate::motion::liquid_visual_index(
770                                selected,
771                                lens_x,
772                                tab_width,
773                                count,
774                                crate::motion::liquid_axis_owns_visual_selection(
775                                    lens_pressed.get(),
776                                    lens_x,
777                                    resting_lens_x,
778                                    tab_width,
779                                ),
780                            );
781                            TabCells(
782                                Modifier::empty(),
783                                Rc::clone(&tabs),
784                                typography.clone(),
785                                tab_width,
786                                TabCellsSpec {
787                                    base_color: tab_base_content_color(colors),
788                                    selected: Some(visual_index),
789                                    selected_color: tab_selection_content_color(colors),
790                                    interactive: true,
791                                    selection_only: false,
792                                },
793                            );
794
795                            // Swipe/tap surface across the whole pill interior:
796                            // the shared lens gesture (crate::motion) with the
797                            // bar's clamp rules and hold feedback.
798                            let row_width = tab_width * count as f32;
799                            let gesture = Modifier::empty()
800                                .size(Size::new(row_width, BLOB_HEIGHT))
801                                .pointer_input(selected, {
802                                    let on_select = Rc::clone(&on_select);
803                                    let lens_axis = Rc::clone(&lens_axis);
804                                    move |scope: PointerInputScope| {
805                                        let on_select = Rc::clone(&on_select);
806                                        let lens_axis = Rc::clone(&lens_axis);
807                                        crate::motion::liquid_lens_gesture(
808                                            scope,
809                                            crate::motion::LiquidLensGesture {
810                                                axis: lens_axis,
811                                                cell_width: tab_width,
812                                                count,
813                                                tap_slop: TAP_SLOP,
814                                                drag_left: Rc::new(move |x| {
815                                                    tab_lens_left(
816                                                        x,
817                                                        tab_width,
818                                                        count,
819                                                        has_accessory,
820                                                    )
821                                                }),
822                                                rest_left: Rc::new(move |index| {
823                                                    tab_lens_resting_left(index, tab_width, count)
824                                                }),
825                                                selected,
826                                                on_pressed: Rc::new(move |down| {
827                                                    lens_pressed.set(down);
828                                                    bar_held.set(down);
829                                                }),
830                                                on_touch: Rc::new(move |x, y| {
831                                                    bar_touch
832                                                        .set((x + BLOB_MARGIN, y + BLOB_MARGIN));
833                                                }),
834                                                on_select,
835                                            },
836                                        )
837                                    }
838                                });
839                            Box(gesture, BoxSpec::default(), || {});
840
841                            // Publish the lens springs for the overlay rendered
842                            // ABOVE the finished bar (outside this glass layer, so
843                            // the lens magnifies icons and glass together).
844                            let published = (lens_x, lens_activity, tab_width, lens_pose);
845                            if lens_x_outer.get() != published {
846                                lens_x_outer.set(published);
847                            }
848                        });
849                    });
850
851                    // The lens bubble, floating above the whole pill. Its shape
852                    // follows the droplet law (crate::dynamics): cruising speed
853                    // stretches it along the travel axis, launch compresses it,
854                    // braking swells the leading edge — orthogonal axis inverse,
855                    // area conserved — and it magnifies harder in motion. The
856                    // search accessory's circle joins its liquid field: drag the
857                    // lens to the bar's end and the two glue through a
858                    // smooth-union neck.
859                    let (lens_px, lens_activity, lens_tab_w, pose) = lens_x_outer.get();
860                    let lens_w = tab_lens_rest_width(lens_tab_w);
861                    let lens_h = BLOB_HEIGHT * FLIGHT_LENS_HEIGHT_PROJECTION;
862                    // Node headroom for the deformation extremes (max axis
863                    // stretch + leading bulge, max ortho swell) and rim glow.
864                    let deformation_headroom =
865                        crate::dynamics::STRETCH_MAX.max(1.0 / crate::dynamics::STRETCH_MIN);
866                    let node_w = lens_w * deformation_headroom + crate::dynamics::BULGE_MAX + 20.0;
867                    let node_h = lens_h * deformation_headroom + crate::dynamics::BULGE_MAX + 16.0;
868                    let lens_center_x = BLOB_MARGIN + lens_px + lens_tab_w * 0.5;
869                    let node_x = lens_center_x - node_w * 0.5;
870                    let node_top = tab_lens_node_top(node_h);
871                    let pill_w = lens_tab_w * count as f32 + 2.0 * BLOB_MARGIN;
872                    let (base_w, base_h) = tab_lens_base_size(lens_tab_w, lens_activity);
873                    let geometry = TabFlightGeometry {
874                        center: (lens_center_x, BAR_HEIGHT * 0.5),
875                        base_size: Size::new(base_w, base_h),
876                        pose,
877                        lens_position: lens_px,
878                        lens_activity,
879                        resting_tint: colors.fill,
880                        accessory_center: has_accessory.then_some((
881                            pill_w + tab_bar_accessory_gap(true) + BAR_HEIGHT * 0.5,
882                            BAR_HEIGHT * 0.5,
883                        )),
884                    };
885                    let lens_node = TabFlightNode {
886                        origin: (node_x, node_top),
887                        size: Size::new(node_w, node_h),
888                    };
889
890                    let lens_geometry = geometry;
891                    let lens = Modifier::empty()
892                        // required_size: the stack is pinned to BAR_HEIGHT so
893                        // the taller lens can never inflate the bar; the node
894                        // still measures (and draws) at its full size and the
895                        // offset centers it on the pill.
896                        .required_size(lens_node.size)
897                        .offset(node_x, node_top)
898                        .glass_effect_with(
899                            tab_flight_lens_material(
900                                colors.label,
901                                tab_selection_content_color(colors),
902                            ),
903                            move || tab_flight_dynamics(lens_geometry, lens_node),
904                        );
905                    Box(lens, BoxSpec::default(), || {});
906                },
907            );
908
909            if has_accessory {
910                Box(
911                    Modifier::empty().width(tab_bar_accessory_gap(true)),
912                    BoxSpec::default(),
913                    || {},
914                );
915                (accessory.borrow_mut())();
916            }
917        },
918    );
919}
920
921/// The standard detached accessory: a circular glass search button.
922#[composable]
923#[allow(non_snake_case)]
924pub fn LiquidTabBarSearchAccessory(on_click: impl Fn() + 'static) {
925    // The reference search circle is nearly flush with the bar height.
926    crate::widgets::GlassIconButton(
927        Modifier::empty(),
928        crate::widgets::GlassButtonSpec::glass(),
929        BAR_HEIGHT * 0.94,
930        on_click,
931        crate::icons::SEARCH,
932    );
933}
934
935#[cfg(test)]
936mod tests {
937    use super::*;
938
939    #[test]
940    fn tab_bar_spec_normalizes_the_maximum_cell_width() {
941        assert_eq!(LiquidTabBarSpec::default().max_tab_width, TAB_WIDTH);
942        assert_eq!(LiquidTabBarSpec::new(85.0).max_tab_width, 85.0);
943        assert_eq!(LiquidTabBarSpec::new(0.0).max_tab_width, 1.0);
944        assert_eq!(LiquidTabBarSpec::new(f32::NAN).max_tab_width, TAB_WIDTH);
945    }
946
947    #[test]
948    fn drag_pointer_centers_the_lens_and_preserves_end_overdrag() {
949        let width = 100.0;
950        assert_eq!(tab_lens_left(50.0, width, 4, true), 0.0);
951        assert_eq!(tab_lens_left(250.0, width, 4, true), 200.0);
952        assert_eq!(tab_lens_left(-100.0, width, 4, true), -20.0);
953        assert_eq!(tab_lens_left(500.0, width, 4, true), 355.0);
954
955        assert_eq!(tab_lens_left(-100.0, width, 4, false), 0.0);
956        assert_eq!(tab_lens_left(500.0, width, 4, false), 300.0);
957    }
958
959    #[test]
960    fn resting_lens_centers_on_its_cell_and_stays_inside_the_pill() {
961        let tab = 78.0;
962        // Every cell settles cell-centered, the reference behavior
963        // (bottom-bar-click f_0000: end bubble center == cell center).
964        assert_eq!(tab_lens_resting_left(0, tab, 5), 0.0);
965        assert_eq!(tab_lens_resting_left(1, tab, 5), tab);
966        assert_eq!(tab_lens_resting_left(3, tab, 5), 3.0 * tab);
967        assert_eq!(tab_lens_resting_left(4, tab, 5), 4.0 * tab);
968        assert_eq!(tab_lens_resting_left(9, tab, 5), 4.0 * tab);
969        // What makes the cell-centered end legal: the rest bubble's
970        // overhang past its cell never exceeds the pill inset, so the
971        // bubble edge lands flush inside the pill's rounded end instead
972        // of crossing it (the reference gap).
973        let overhang = (tab_lens_rest_width(tab) - tab) * 0.5;
974        assert!(overhang <= BLOB_MARGIN + 1.0e-4);
975    }
976
977    #[test]
978    fn flight_lens_node_is_centered_on_the_bar_axis() {
979        for node_height in [48.0, 64.0, 96.0, 128.0] {
980            let center = tab_lens_node_top(node_height) + node_height * 0.5;
981            assert!((center - BAR_HEIGHT * 0.5).abs() < f32::EPSILON);
982        }
983    }
984
985    #[test]
986    fn liquid_tab_builds_reference_content() {
987        assert_eq!(TAB_ICON_SIZE, 32.0);
988        let tab = LiquidTab::new(crate::icons::STAR, "Discover");
989        assert_eq!(tab.icon, crate::icons::STAR);
990        assert_eq!(tab.label, "Discover");
991        assert_eq!(tab.icon_style, LiquidTabIconStyle::Plain);
992
993        let badge = LiquidTab::app_badge(crate::icons::APPLE, "WWDC");
994        assert_eq!(badge.icon_style, LiquidTabIconStyle::AppBadge);
995
996        let compact = LiquidTab::new(crate::icons::ACCOUNT_CIRCLE, "Account").with_icon_scale(0.72);
997        assert!((compact.icon_scale - 0.72).abs() < f32::EPSILON);
998        assert_eq!(tab.clone().with_icon_scale(f32::NAN).icon_scale, 1.0);
999        assert_eq!(tab.with_icon_scale(2.0).icon_scale, 1.5);
1000    }
1001
1002    #[test]
1003    fn app_badge_geometry_honors_the_tab_optical_scale() {
1004        let full = app_badge_geometry(1.0);
1005        let corrected = app_badge_geometry(0.85);
1006        assert_eq!(full.size, Size::new(20.0, 32.0));
1007        assert_eq!(corrected.size, Size::new(17.0, 27.2));
1008        assert!((corrected.glyph.width - 11.9).abs() < 1.0e-5);
1009        assert!((corrected.corner_radius / full.corner_radius - 0.85).abs() < f32::EPSILON);
1010        assert!((corrected.stripe.x / full.stripe.x - 0.85).abs() < f32::EPSILON);
1011        assert!((corrected.glyph.width / full.glyph.width - 0.85).abs() < f32::EPSILON);
1012    }
1013
1014    #[test]
1015    fn base_tab_content_remains_neutral_under_the_moving_selection_layer() {
1016        let colors = crate::theme::LiquidColors::light(cranpose_ui_graphics::Color::from_rgb_u8(
1017            0, 122, 255,
1018        ));
1019        assert_eq!(tab_base_content_color(colors), colors.label);
1020        assert_eq!(tab_selection_content_color(colors), colors.accent);
1021    }
1022
1023    #[test]
1024    fn selection_mask_and_lens_resolve_the_same_global_sdf() {
1025        let geometry = TabFlightGeometry {
1026            center: (212.0, 32.0),
1027            base_size: Size::new(106.0, 64.0),
1028            pose: crate::dynamics::LiquidPose::default(),
1029            lens_position: 160.0,
1030            lens_activity: 1.0,
1031            resting_tint: Color::BLACK.with_alpha(0.10),
1032            accessory_center: None,
1033        };
1034        let mask_node = TabFlightNode {
1035            origin: (0.0, 0.0),
1036            size: Size::new(328.0, 64.0),
1037        };
1038        let lens_node = TabFlightNode {
1039            origin: (132.0, -22.0),
1040            size: Size::new(160.0, 108.0),
1041        };
1042        let mask = tab_flight_dynamics(geometry, mask_node)
1043            .morph
1044            .expect("selection mask morph");
1045        let lens = tab_flight_dynamics(geometry, lens_node)
1046            .morph
1047            .expect("lens morph");
1048        assert_eq!(
1049            (
1050                mask.primary.0 + mask_node.origin.0,
1051                mask.primary.1 + mask_node.origin.1
1052            ),
1053            geometry.center
1054        );
1055        assert_eq!(
1056            (
1057                lens.primary.0 + lens_node.origin.0,
1058                lens.primary.1 + lens_node.origin.1
1059            ),
1060            geometry.center
1061        );
1062        assert_eq!(
1063            (mask.primary.2, mask.primary.3, mask.primary.4),
1064            (lens.primary.2, lens.primary.3, lens.primary.4)
1065        );
1066        assert_eq!(mask.node_size, (328.0, 64.0));
1067        assert_eq!(lens.node_size, (160.0, 108.0));
1068    }
1069
1070    #[test]
1071    fn unified_bar_has_no_detached_accessory_gap() {
1072        assert_eq!(tab_bar_accessory_gap(false), 0.0);
1073        assert_eq!(tab_bar_accessory_gap(true), 10.0);
1074    }
1075
1076    #[test]
1077    fn flight_lens_only_joins_accessory_after_surface_contact() {
1078        assert!(!accessory_surfaces_touch(0.01));
1079        assert!(accessory_surfaces_touch(0.0));
1080        assert!(accessory_surfaces_touch(-4.0));
1081    }
1082
1083    #[test]
1084    fn lens_contact_swell_is_vertical_only() {
1085        let resting = tab_lens_base_size(TAB_WIDTH, 0.0);
1086        let raised = tab_lens_base_size(TAB_WIDTH, 1.0);
1087        assert_eq!(
1088            resting,
1089            (TAB_WIDTH * TAB_LENS_REST_WIDTH_FACTOR, BLOB_HEIGHT)
1090        );
1091        // Measured on the raw hold recording: the held bubble keeps its
1092        // rest width (1.09x pitch held vs 1.10x at rest — no growth) and
1093        // stands ~82dp against the 64dp bar, ~9dp past both edges.
1094        assert_eq!(raised.0, resting.0);
1095        assert!((raised.1 / resting.1 - FLIGHT_LENS_HEIGHT_PROJECTION).abs() < 0.001);
1096        assert!((raised.1 - 68.0).abs() < 0.5);
1097    }
1098
1099    #[test]
1100    fn tab_grid_matches_the_reference_pitch() {
1101        assert_eq!(TAB_WIDTH, 78.0);
1102    }
1103
1104    #[test]
1105    fn tab_grid_matches_the_reference_inner_inset() {
1106        // The bubble fills the bar to ~4dp insets (56/64, re-judged
1107        // against the user's reference frames).
1108        assert_eq!(BLOB_MARGIN, 4.0);
1109        assert_eq!(BLOB_HEIGHT + 2.0 * BLOB_MARGIN, BAR_HEIGHT);
1110    }
1111
1112    #[test]
1113    fn flight_lens_uses_the_clear_wcksrd_contract() {
1114        let glass = tab_flight_lens_material(
1115            cranpose_ui_graphics::Color::BLACK,
1116            cranpose_ui_graphics::Color::from_rgb_u8(0, 122, 255),
1117        );
1118        let generic_lens = Glass::lens();
1119        // A modest raised milk (activity-scaled) — clear enough to keep
1120        // the wcKSRD face readable, lifted enough to match the held rows.
1121        assert!(glass.lift.is_some_and(|lift| (0.0..=0.15).contains(&lift)));
1122        // The etalon's full-face field: interior spans edge to center so a
1123        // gap-centered flight frame pulls both neighbor icons in instead of
1124        // transmitting blank bar white (the two-cell milk blob).
1125        assert_eq!(glass.refraction_depth, 1.0);
1126        assert!(glass.refraction_curve < generic_lens.refraction_curve);
1127        // The ride runs toggle-class dispersion; the REST bubble stays
1128        // subtle because the dynamics floor press_depth 0.3 scales it —
1129        // the effective resting split sits below the generic default.
1130        assert!(glass.dispersion * 0.3 < generic_lens.dispersion);
1131        assert_eq!(glass.blur_radius, Some(0.0));
1132        assert!(glass.highlight < generic_lens.highlight);
1133        assert!(
1134            glass.shadow,
1135            "the moving lens needs its target-visible SDF contact outline"
1136        );
1137        // A clear glass so the covered icon shows through, recolored and
1138        // fringed (light-mode reference recording): a milkier tint washed
1139        // the content to white.
1140        assert!(glass
1141            .tint
1142            .is_some_and(|tint| { tint.r() < 0.05 && (0.055..=0.065).contains(&tint.a()) }));
1143        assert_eq!(glass.adaptive_frost, 0.0);
1144    }
1145
1146    #[test]
1147    fn flight_lens_retains_neutral_tint_through_direct_motion() {
1148        assert_eq!(tab_flight_tint_multiplier(0.0), 1.0);
1149        assert!((tab_flight_tint_multiplier(1.0) - 0.75).abs() < f32::EPSILON);
1150        assert_eq!(tab_flight_tint_multiplier(-1.0), 1.0);
1151        assert!((tab_flight_tint_multiplier(2.0) - 0.75).abs() < f32::EPSILON);
1152    }
1153
1154    #[test]
1155    fn bar_surface_adapts_frost_to_its_foreground() {
1156        let glass = tab_bar_surface_material(cranpose_ui_graphics::Color::BLACK);
1157        // 9dp drowns bold section headers to the reference's faint ghost
1158        // (bar_over_headers) — 4dp let them read as strong dark smears.
1159        assert_eq!(glass.blur_radius, Some(9.0));
1160        assert_eq!(glass.saturation, Some(1.15));
1161        assert_eq!(glass.lift, Some(0.60));
1162        assert_eq!(glass.refraction_depth, 0.34);
1163        assert_eq!(glass.adaptive_frost, 0.28);
1164    }
1165
1166    #[test]
1167    fn bar_surface_lift_tracks_the_local_foreground_polarity() {
1168        let light_surface = tab_bar_surface_material(cranpose_ui_graphics::Color::BLACK);
1169        assert_eq!(light_surface.lift, Some(0.60));
1170
1171        let dark_surface = tab_bar_surface_material(cranpose_ui_graphics::Color::WHITE);
1172        assert_eq!(dark_surface.lift, Some(-0.24));
1173    }
1174
1175    #[test]
1176    fn bar_surface_tint_separates_from_same_polarity_backdrops() {
1177        let light_surface = tab_bar_surface_material(cranpose_ui_graphics::Color::BLACK)
1178            .tint
1179            .expect("bar tint");
1180        assert!(light_surface.r() < 0.05);
1181        assert_eq!(light_surface.a(), 0.0);
1182
1183        let dark_surface = tab_bar_surface_material(cranpose_ui_graphics::Color::WHITE)
1184            .tint
1185            .expect("bar tint");
1186        assert!(dark_surface.r() > 0.95);
1187        assert!((0.03..=0.05).contains(&dark_surface.a()));
1188    }
1189
1190    #[test]
1191    fn contact_rises_continuously_and_returns_on_the_measured_settle() {
1192        let cranpose_animation::AnimationType::Spring(rise) = tab_lens_activity_motion(true) else {
1193            panic!("contact rise must use a spring");
1194        };
1195        assert_eq!(rise.stiffness, 1400.0);
1196        let cranpose_animation::AnimationType::Spring(settle) = tab_lens_activity_motion(false)
1197        else {
1198            panic!("arrival contraction must use a spring");
1199        };
1200        assert_eq!(settle.damping_ratio, 1.0);
1201        assert_eq!(settle.stiffness, 900.0);
1202    }
1203
1204    /// Destinations appear in declaration order, and each shorthand builds the
1205    /// icon treatment it names.
1206    #[test]
1207    fn a_scope_declares_destinations_in_order() {
1208        let tabs = collect_tabs(|scope| {
1209            scope.tab("M0 0", "Discover");
1210            scope.app_badge("M1 1", "WWDC");
1211            scope.push(LiquidTab::new("M2 2", "Account").with_icon_scale(0.95));
1212        });
1213
1214        assert_eq!(tabs.len(), 3);
1215        assert_eq!(tabs[0].label, "Discover");
1216        assert_eq!(tabs[0].icon_style, LiquidTabIconStyle::Plain);
1217        assert_eq!(tabs[0].icon_scale, 1.0);
1218        assert_eq!(tabs[1].icon_style, LiquidTabIconStyle::AppBadge);
1219        assert_eq!(tabs[2].icon_scale, 0.95);
1220    }
1221
1222    #[test]
1223    fn a_bar_with_no_destinations_declares_none() {
1224        assert!(collect_tabs(|_| {}).is_empty());
1225    }
1226
1227    #[test]
1228    fn a_resting_lens_overhangs_its_cell_within_the_legal_margin() {
1229        // The pill is the cell strip plus BLOB_MARGIN a side, so an end
1230        // bubble crosses it the moment its overhang outgrows that margin.
1231        // A caller picks the cell width (`LiquidTabBarSpec::new`), so the
1232        // rule has to hold across the range, not just at TAB_WIDTH.
1233        for tab_width in [24.0_f32, 44.0, 78.0, 80.0, 120.0, 400.0] {
1234            let width = tab_lens_rest_width(tab_width);
1235            assert!(
1236                width > tab_width,
1237                "the bubble reads wider than its cell ({width} vs {tab_width})"
1238            );
1239            let overhang = (width - tab_width) * 0.5;
1240            assert!(
1241                overhang <= BLOB_MARGIN + 1.0e-4,
1242                "an end cell's overhang ({overhang}) at a {tab_width}dp cell \
1243                 has to stay inside the pill inset ({BLOB_MARGIN})"
1244            );
1245        }
1246    }
1247
1248    #[test]
1249    fn a_reference_width_cell_keeps_the_measured_rest_proportion() {
1250        // The cap must not disturb the widths the reference was measured
1251        // at — it only takes over once a cell outgrows the pill inset.
1252        for tab_width in [24.0_f32, 44.0, 78.0, 80.0] {
1253            assert!(
1254                (tab_lens_rest_width(tab_width) - tab_width * TAB_LENS_REST_WIDTH_FACTOR).abs()
1255                    < 1.0e-4,
1256                "a {tab_width}dp cell still rests at the measured 1.10 factor"
1257            );
1258        }
1259        assert!(
1260            tab_lens_rest_width(200.0) < 200.0 * TAB_LENS_REST_WIDTH_FACTOR,
1261            "past the inset the margin governs, not the factor"
1262        );
1263    }
1264
1265    #[test]
1266    fn a_resting_lens_stays_centered_on_the_cell_it_rests_in() {
1267        let tab_width = 72.0;
1268        let width = tab_lens_rest_width(tab_width);
1269        for selected in 0..4 {
1270            let left = tab_lens_resting_left(selected, tab_width, 4);
1271            let cell_center = left + tab_width * 0.5;
1272            let lens_center = left - (width - tab_width) * 0.5 + width * 0.5;
1273            assert!(
1274                (lens_center - cell_center).abs() < 1e-4,
1275                "cell {selected}: lens center {lens_center} vs cell center {cell_center}"
1276            );
1277        }
1278    }
1279}