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