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