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