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