Skip to main content

cranpose_liquid/widgets/
tab_bar.rs

1use std::{cell::RefCell, rc::Rc};
2
3use cranpose_macros::composable;
4use cranpose_ui::{
5    Brush, Color, CornerRadii, Modifier, PointerInputScope, Rect, SemanticsWidgetRole, Size,
6    text::{FontWeight, SpanStyle, TextStyle},
7    widgets::{
8        Box, BoxSpec, BoxWithConstraints, BoxWithConstraintsScope, Column, ColumnSpec, Row,
9        RowSpec, Text,
10    },
11};
12use cranpose_ui_layout::{Alignment, HorizontalAlignment, VerticalAlignment};
13
14use crate::{
15    material::{
16        Glass, GlassDynamics, GlassMorph, GlassShadow, LiquidModifierExt, neutral_surface_lift,
17        neutral_surface_tint,
18    },
19    motion::LiquidMotion,
20    theme::{LiquidTypography, liquid_colors, liquid_typography},
21    widgets::content_scope::ScopeContent,
22};
23
24/// Visual treatment for a tab icon.
25#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
26pub enum LiquidTabIconStyle {
27    #[default]
28    Plain,
29    AppBadge,
30}
31
32/// One tab: icon path data (24×24 viewBox), label, and icon treatment.
33#[derive(Clone, Debug, PartialEq)]
34pub struct LiquidTab {
35    pub icon: &'static str,
36    pub label: &'static str,
37    pub icon_style: LiquidTabIconStyle,
38    /// Optical correction for symbols whose path bounds do not fill the
39    /// shared icon frame uniformly.
40    pub icon_scale: f32,
41}
42
43impl LiquidTab {
44    pub fn new(icon: &'static str, label: &'static str) -> Self {
45        Self {
46            icon,
47            label,
48            icon_style: LiquidTabIconStyle::Plain,
49            icon_scale: 1.0,
50        }
51    }
52
53    pub fn app_badge(icon: &'static str, label: &'static str) -> Self {
54        Self {
55            icon,
56            label,
57            icon_style: LiquidTabIconStyle::AppBadge,
58            icon_scale: 1.0,
59        }
60    }
61
62    pub fn with_icon_scale(mut self, scale: f32) -> Self {
63        self.icon_scale = normalize_icon_scale(scale);
64        self
65    }
66}
67
68fn normalize_icon_scale(scale: f32) -> f32 {
69    if scale.is_finite() {
70        scale.clamp(0.5, 1.5)
71    } else {
72        1.0
73    }
74}
75
76fn tab_base_content_color(colors: crate::theme::LiquidColors) -> Color {
77    colors.label
78}
79
80fn tab_selection_content_color(colors: crate::theme::LiquidColors) -> Color {
81    colors.accent
82}
83
84const BAR_HEIGHT: f32 = 64.0;
85const BLOB_HEIGHT: f32 = 56.0;
86const BLOB_MARGIN: f32 = 4.0;
87const FLIGHT_LENS_HEIGHT_PROJECTION: f32 = 68.0 / BLOB_HEIGHT;
88const FLIGHT_ELLIPSE_BLEND: f32 = 0.25;
89const TAB_LENS_REST_WIDTH_FACTOR: f32 = 1.10;
90const TAB_STRAIN_RESPONSE: f32 = 0.30;
91const TAB_WIDTH: f32 = 78.0;
92const TAB_ICON_SIZE: f32 = 32.0;
93const TAB_LABEL_SIZE: f32 = 11.0;
94const TAP_SLOP: f32 = 6.0;
95const ACCESSORY_GAP: f32 = 10.0;
96
97/// Layout parameters for a [`LiquidTabBar`].
98#[derive(Clone, Copy, Debug, PartialEq)]
99pub struct LiquidTabBarSpec {
100    max_tab_width: f32,
101}
102
103impl LiquidTabBarSpec {
104    pub fn new(max_tab_width: f32) -> Self {
105        Self {
106            max_tab_width: if max_tab_width.is_finite() {
107                max_tab_width.max(1.0)
108            } else {
109                TAB_WIDTH
110            },
111        }
112    }
113}
114
115impl Default for LiquidTabBarSpec {
116    fn default() -> Self {
117        Self::new(TAB_WIDTH)
118    }
119}
120
121fn tab_flight_lens_material(foreground: cranpose_ui_graphics::Color, accent: Color) -> Glass {
122    Glass::lens()
123        .no_clip()
124        .tint(neutral_surface_tint(foreground, 0.06, 0.05))
125        .ink_recolor(accent, 0.85)
126        .blur_radius(0.0)
127        .refraction_depth(1.0)
128        .refraction_curve(0.25)
129        .optical_zoom(1.22)
130        .fold_depth(2.5)
131        .dispersion(0.9)
132        .lift(0.05)
133        .highlight(0.18)
134        .shadow_style(GlassShadow::new(
135            cranpose_ui_graphics::Color::BLACK.with_alpha(0.14),
136            12.0,
137            4.0,
138            -2.0,
139        ))
140}
141
142fn tab_bar_surface_material(foreground: cranpose_ui_graphics::Color) -> Glass {
143    Glass::regular()
144        .tint(neutral_surface_tint(foreground, 0.0, 0.04))
145        .blur_radius(9.0)
146        .saturation(1.15)
147        .lift(neutral_surface_lift(foreground, 0.60, -0.24))
148        .highlight(0.20)
149        .fold_depth(8.0)
150        .adaptive_frost(foreground, 0.28)
151}
152
153fn tab_flight_tint_multiplier(activity: f32) -> f32 {
154    1.0 - 0.25 * activity.clamp(0.0, 1.0)
155}
156
157fn tab_lens_activity_motion(raised: bool) -> cranpose_animation::AnimationType {
158    if raised {
159        cranpose_animation::spring(0.9, 1400.0)
160    } else {
161        cranpose_animation::spring(1.0, 900.0)
162    }
163}
164
165fn tab_lens_left(pointer_x: f32, tab_width: f32, count: usize, has_accessory: bool) -> f32 {
166    let last_tab = tab_width * count.saturating_sub(1) as f32;
167    let min = if has_accessory { -tab_width * 0.2 } else { 0.0 };
168    let max = if has_accessory {
169        tab_width * (count as f32 - 0.45)
170    } else {
171        last_tab
172    };
173    (pointer_x - tab_width * 0.5).clamp(min, max)
174}
175
176/// The settled lens position for a selected cell: CELL-CENTERED at every
177/// index, exactly like the reference (bottom-bar-click f_0000 measures the
178/// end bubble's center on its cell center, its edge flush with the pill's
179/// rounded end). The rest width (`TAB_LENS_REST_WIDTH_FACTOR`) is what
180/// keeps the end cells legal — its overhang stays within `BLOB_MARGIN`.
181/// Public so alignment tests assert the same rule the widget settles to.
182pub fn tab_lens_resting_left(selected: usize, tab_width: f32, count: usize) -> f32 {
183    tab_width * selected.min(count.saturating_sub(1)) as f32
184}
185
186/// The resting bubble's width for a cell pitch — the second half of the
187/// public resting contract ([`tab_lens_resting_left`] gives the position).
188///
189/// A cell-centered bubble on an end cell overhangs the cell strip by half its
190/// excess, and the pill only extends `BLOB_MARGIN` past that strip, so the
191/// overhang is what decides whether the end bubble lands flush inside the
192/// pill's rounded end or crosses it. `TAB_LENS_REST_WIDTH_FACTOR` sets the
193/// reference proportion; the margin caps it, so a bar built with wide cells
194/// keeps its ends legal instead of poking the bubble outside the pill.
195pub fn tab_lens_rest_width(tab_width: f32) -> f32 {
196    let overhang = (tab_width * (TAB_LENS_REST_WIDTH_FACTOR - 1.0) * 0.5).min(BLOB_MARGIN);
197    tab_width + 2.0 * overhang
198}
199
200#[derive(Clone, Copy, Debug, PartialEq)]
201struct AppBadgeGeometry {
202    size: Size,
203    corner_radius: f32,
204    stripe: Rect,
205    glyph: Rect,
206}
207
208fn app_badge_geometry(optical_scale: f32) -> AppBadgeGeometry {
209    let scale = normalize_icon_scale(optical_scale);
210    AppBadgeGeometry {
211        size: Size::new(20.0 * scale, 32.0 * scale),
212        corner_radius: 5.0 * scale,
213        stripe: Rect {
214            x: 7.0 * scale,
215            y: 4.5 * scale,
216            width: 6.0 * scale,
217            height: 1.5 * scale,
218        },
219        glyph: Rect {
220            x: 3.0 * scale,
221            y: 10.0 * scale,
222            width: 14.0 * scale,
223            height: 14.0 * scale,
224        },
225    }
226}
227
228#[composable]
229#[allow(non_snake_case)]
230fn TabIcon(icon: &'static str, style: LiquidTabIconStyle, color: Color, optical_scale: f32) {
231    const FRAME_HEIGHT: f32 = 32.0;
232    Box(
233        Modifier::empty().size(Size::new(TAB_ICON_SIZE, FRAME_HEIGHT)),
234        BoxSpec::default().content_alignment(Alignment::CENTER),
235        move || match style {
236            LiquidTabIconStyle::Plain => {
237                crate::icons::Icon(icon, TAB_ICON_SIZE * optical_scale, color)
238            }
239            LiquidTabIconStyle::AppBadge => {
240                let geometry = app_badge_geometry(optical_scale);
241                Box(
242                    Modifier::empty()
243                        .size(geometry.size)
244                        .draw_behind(move |scope| {
245                            scope.draw_round_rect(
246                                Brush::solid(color),
247                                CornerRadii::uniform(geometry.corner_radius),
248                            );
249                            scope.draw_rect_at(geometry.stripe, Brush::solid(Color::WHITE));
250                        }),
251                    BoxSpec::default(),
252                    move || {
253                        Box(
254                            Modifier::empty()
255                                .offset(geometry.glyph.x, geometry.glyph.y)
256                                .size(Size::new(geometry.glyph.width, geometry.glyph.height)),
257                            BoxSpec::default(),
258                            move || crate::icons::Icon(icon, geometry.glyph.width, Color::WHITE),
259                        );
260                    },
261                );
262            }
263        },
264    );
265}
266
267#[derive(Clone, Copy, PartialEq)]
268struct TabCellsSpec {
269    base_color: Color,
270    selected: Option<usize>,
271    selected_color: Color,
272    interactive: bool,
273    selection_only: bool,
274}
275
276fn tab_cell_is_visible(index: usize, selected: Option<usize>, selection_only: bool) -> bool {
277    !selection_only || selected == Some(index)
278}
279
280#[composable]
281#[allow(non_snake_case)]
282fn TabCells(
283    modifier: Modifier,
284    tabs: Rc<Vec<LiquidTab>>,
285    typography: LiquidTypography,
286    tab_width: f32,
287    spec: TabCellsSpec,
288) {
289    Row(modifier, RowSpec::default(), move || {
290        for (index, tab) in tabs.iter().enumerate() {
291            let visible = tab_cell_is_visible(index, spec.selected, spec.selection_only);
292            let color = if spec.selected == Some(index) {
293                spec.selected_color
294            } else {
295                spec.base_color
296            };
297            let label_for_semantics = tab.label;
298            let mut cell = Modifier::empty().size(Size::new(tab_width, BLOB_HEIGHT));
299            if spec.interactive {
300                cell = cell.semantics(move |config| {
301                    config.role = Some(SemanticsWidgetRole::Button);
302                    config.is_clickable = true;
303                    config.content_description = Some(label_for_semantics.to_string());
304                });
305            }
306            let icon = tab.icon;
307            let icon_style = tab.icon_style;
308            let icon_scale = tab.icon_scale;
309            let label = tab.label;
310            let label_style = TextStyle {
311                span_style: SpanStyle {
312                    color: Some(color),
313                    font_size: cranpose_ui::text::TextUnit::Sp(TAB_LABEL_SIZE),
314                    font_weight: Some(FontWeight::MEDIUM),
315                    ..typography.caption1.span_style.clone()
316                },
317                ..typography.caption1.clone()
318            };
319            Box(
320                cell,
321                BoxSpec::default().content_alignment(Alignment::CENTER),
322                move || {
323                    if !visible {
324                        return;
325                    }
326                    let label_style = label_style.clone();
327                    Column(
328                        Modifier::empty(),
329                        ColumnSpec::default()
330                            .horizontal_alignment(HorizontalAlignment::CenterHorizontally),
331                        move || {
332                            TabIcon(icon, icon_style, color, icon_scale);
333                            Text(label, Modifier::empty(), label_style.clone());
334                        },
335                    );
336                },
337            );
338        }
339    });
340}
341
342fn tab_lens_node_top(node_height: f32) -> f32 {
343    (BAR_HEIGHT - node_height) * 0.5
344}
345
346fn tab_bar_accessory_gap(has_accessory: bool) -> f32 {
347    if has_accessory { ACCESSORY_GAP } else { 0.0 }
348}
349
350fn accessory_surfaces_touch(edge_gap: f32) -> bool {
351    edge_gap <= 0.0
352}
353
354fn tab_lens_base_size(tab_width: f32, activity: f32) -> (f32, f32) {
355    let activity = activity.clamp(0.0, 1.0);
356    let ease = activity * activity * (3.0 - 2.0 * activity);
357    let rest_width = tab_lens_rest_width(tab_width);
358    let projection = 1.0 + (FLIGHT_LENS_HEIGHT_PROJECTION - 1.0) * ease;
359    (rest_width, BLOB_HEIGHT * projection)
360}
361
362#[derive(Clone, Copy, Debug, PartialEq)]
363struct TabFlightGeometry {
364    center: (f32, f32),
365    base_size: Size,
366    pose: crate::dynamics::LiquidPose,
367    lens_position: f32,
368    lens_activity: f32,
369    resting_tint: Color,
370    accessory_center: Option<(f32, f32)>,
371}
372
373#[derive(Clone, Copy, Debug, PartialEq)]
374struct TabFlightNode {
375    origin: (f32, f32),
376    size: Size,
377}
378
379fn tab_flight_dynamics(geometry: TabFlightGeometry, node: TabFlightNode) -> GlassDynamics {
380    let activity = geometry.lens_activity.clamp(0.0, 1.0);
381    let energy = geometry.pose.energy() * activity;
382    let radius = geometry.base_size.height * (0.48 + 0.02 * energy);
383    let glue = 20.0;
384    let shapes = geometry
385        .accessory_center
386        .filter(|(x, _)| {
387            let effective_stretch =
388                1.0 + (geometry.pose.stretch.max(geometry.pose.ortho) - 1.0) * TAB_STRAIN_RESPONSE;
389            let edge_gap = (*x - geometry.center.0).abs()
390                - geometry.base_size.width * effective_stretch * 0.5
391                - BAR_HEIGHT * 0.5;
392            accessory_surfaces_touch(edge_gap)
393        })
394        .map(|(x, y)| {
395            vec![(
396                x - node.origin.0,
397                y - node.origin.1,
398                BAR_HEIGHT,
399                BAR_HEIGHT,
400                -1.0,
401            )]
402        })
403        .unwrap_or_default();
404    GlassDynamics {
405        morph: Some(GlassMorph {
406            node_size: (node.size.width, node.size.height),
407            primary: (
408                geometry.center.0 - node.origin.0,
409                geometry.center.1 - node.origin.1,
410                geometry.base_size.width,
411                geometry.base_size.height,
412                radius,
413            ),
414            shapes,
415            glue,
416            wobble_amplitude: 0.5 * energy,
417            wobble_phase: geometry.lens_position * 0.045,
418            bulge_amplitude: geometry.pose.bulge_amplitude.min(8.0) * activity,
419            bulge_direction: geometry.pose.bulge_direction,
420            ellipse_blend: FLIGHT_ELLIPSE_BLEND,
421            deformation: Some(crate::material::GlassDeformation::incompressible(
422                geometry.pose.axis,
423                1.0 + (geometry.pose.stretch - 1.0) * activity * TAB_STRAIN_RESPONSE,
424            )),
425            zoom_anchor: (0.0, 0.0),
426        }),
427        activity: Some(activity),
428        press_depth: Some(0.3 + 0.7 * activity),
429        resting_tint: Some(geometry.resting_tint),
430        tint_alpha_multiplier: Some(tab_flight_tint_multiplier(geometry.lens_activity)),
431        ..Default::default()
432    }
433}
434
435/// The scope a tab bar's destinations are declared in.
436///
437/// Each call adds one destination, in the order it is made, which is also the
438/// order the indices passed to `on_select` count in.
439pub struct LiquidTabBarScope {
440    tabs: ScopeContent<LiquidTab>,
441}
442
443impl LiquidTabBarScope {
444    /// A destination showing `icon` above `label`.
445    pub fn tab(&self, icon: &'static str, label: &'static str) {
446        self.push(LiquidTab::new(icon, label));
447    }
448
449    /// A destination whose icon is drawn as an application badge.
450    pub fn app_badge(&self, icon: &'static str, label: &'static str) {
451        self.push(LiquidTab::app_badge(icon, label));
452    }
453
454    /// A destination built out, for the optical corrections a particular
455    /// symbol needs — see [`LiquidTab`].
456    pub fn push(&self, tab: LiquidTab) {
457        self.tabs.push(tab);
458    }
459}
460
461fn collect_tabs(content: impl FnOnce(&LiquidTabBarScope)) -> Vec<LiquidTab> {
462    ScopeContent::collect(|tabs| LiquidTabBarScope { tabs }, content)
463}
464
465/// A unified floating glass tab bar with every destination inside one pill.
466///
467/// ```rust,ignore
468/// LiquidTabBar(Modifier::empty(), LiquidTabBarSpec::default(), selected, on_select, |scope| {
469///     scope.tab(icons::DOCUMENT, "Today");
470///     scope.tab(icons::SEARCH, "Search");
471/// });
472/// ```
473#[composable]
474#[allow(non_snake_case)]
475pub fn LiquidTabBar(
476    modifier: Modifier,
477    spec: LiquidTabBarSpec,
478    selected: usize,
479    on_select: impl Fn(usize) + 'static,
480    content: impl FnOnce(&LiquidTabBarScope),
481) {
482    LiquidTabBarLayout(
483        modifier,
484        spec,
485        collect_tabs(content),
486        selected,
487        on_select,
488        false,
489        || {},
490    );
491}
492
493/// A floating glass tab bar with a detached accessory to its right.
494#[composable]
495#[allow(non_snake_case)]
496pub fn LiquidTabBarWithAccessory(
497    modifier: Modifier,
498    spec: LiquidTabBarSpec,
499    selected: usize,
500    on_select: impl Fn(usize) + 'static,
501    content: impl FnOnce(&LiquidTabBarScope),
502    accessory: impl FnMut() + 'static,
503) {
504    LiquidTabBarLayout(
505        modifier,
506        spec,
507        collect_tabs(content),
508        selected,
509        on_select,
510        true,
511        accessory,
512    );
513}
514
515#[composable]
516#[allow(non_snake_case)]
517fn LiquidTabBarLayout(
518    modifier: Modifier,
519    spec: LiquidTabBarSpec,
520    tabs: Vec<LiquidTab>,
521    selected: usize,
522    on_select: impl Fn(usize) + 'static,
523    has_accessory: bool,
524    accessory: impl FnMut() + 'static,
525) {
526    let colors = liquid_colors();
527    let typography = liquid_typography();
528    let count = tabs.len().max(1);
529    let selected = selected.min(count - 1);
530    let on_select: Rc<dyn Fn(usize)> = Rc::new(on_select);
531    let tabs = Rc::new(tabs);
532    let accessory = Rc::new(RefCell::new(accessory));
533
534    Row(
535        modifier,
536        RowSpec::default().vertical_alignment(VerticalAlignment::CenterVertically),
537        move || {
538            let tabs = Rc::clone(&tabs);
539            let typography = typography.clone();
540            let on_select = Rc::clone(&on_select);
541            let accessory = Rc::clone(&accessory);
542
543            let lens_x_outer = cranpose_core::remember(|| {
544                cranpose_core::mutableStateOf((
545                    0.0f32,
546                    0.0f32,
547                    0.0f32,
548                    crate::dynamics::LiquidPose::default(),
549                ))
550            })
551            .with(|state| *state);
552            let bar_touch =
553                cranpose_core::remember(|| cranpose_core::mutableStateOf((0.0f32, 0.0f32)))
554                    .with(|state| *state);
555            let bar_held = cranpose_core::remember(|| cranpose_core::mutableStateOf(false))
556                .with(|state| *state);
557            let bar_press = cranpose_animation::animateFloatAsState(
558                if bar_held.get() { 1.0 } else { 0.0 },
559                cranpose_animation::spring(1.0, 600.0),
560                "tabbar-hold-press",
561            );
562            let bar_lift = Modifier::empty().graphics_layer(move || {
563                let press = bar_press.get().clamp(0.0, 1.0);
564                let rise = 1.0 + 0.03 * press;
565                cranpose_ui_graphics::GraphicsLayer {
566                    scale_x: rise,
567                    scale_y: rise,
568                    translation_y: -2.5 * press,
569                    ..Default::default()
570                }
571            });
572            Box(
573                bar_lift.then(Modifier::empty().height(BAR_HEIGHT)),
574                BoxSpec::default(),
575                move || {
576                    let tabs = Rc::clone(&tabs);
577                    let typography = typography.clone();
578                    let on_select = Rc::clone(&on_select);
579                    let pill = Modifier::empty()
580                        .glass_effect_with(tab_bar_surface_material(colors.label), move || {
581                            let press = bar_press.get().clamp(0.0, 1.0);
582                            let (touch_x, touch_y) = bar_touch.get();
583                            GlassDynamics {
584                                highlight_boost: 0.45 * press,
585                                saturation_boost: 0.12 * press,
586                                touch: (press > 0.01).then_some((touch_x, touch_y, press)),
587                                press_depth: Some(press),
588                                ..Default::default()
589                            }
590                        })
591                        .height(BAR_HEIGHT);
592                    Box(pill, BoxSpec::default(), move || {
593                        let tabs = Rc::clone(&tabs);
594                        let typography = typography.clone();
595                        let on_select = Rc::clone(&on_select);
596                        BoxWithConstraints(Modifier::empty().padding(BLOB_MARGIN), move |scope| {
597                            let tabs = Rc::clone(&tabs);
598                            let typography = typography.clone();
599                            let on_select = Rc::clone(&on_select);
600                            let constrained = scope.constraints().max_width;
601                            let tab_width = if constrained.is_finite() && constrained > 1.0 {
602                                (constrained / count as f32).min(spec.max_tab_width)
603                            } else {
604                                spec.max_tab_width
605                            };
606
607                            let lens_pressed =
608                                cranpose_core::remember(|| cranpose_core::mutableStateOf(false))
609                                    .with(|state| *state);
610                            let resting_lens_x = tab_lens_resting_left(selected, tab_width, count);
611                            let lens_axis =
612                                crate::motion::remember_liquid_drag_axis(resting_lens_x);
613                            if !lens_pressed.get() {
614                                lens_axis.settle_to(resting_lens_x, LiquidMotion::glide());
615                            }
616                            let lens_x = lens_axis.value();
617                            let lens_pose = lens_axis.liquid_pose();
618                            let lens_in_flight = !lens_axis.is_dragging()
619                                && (lens_x - resting_lens_x).abs() > tab_width * 0.15;
620                            let lens_raised = lens_pressed.get() || lens_in_flight;
621                            let lens_activity_target = if lens_raised { 1.0 } else { 0.0 };
622                            let lens_activity_anim = cranpose_animation::animateFloatAsState(
623                                lens_activity_target,
624                                tab_lens_activity_motion(lens_raised),
625                                "tabbar-lens-activity",
626                            );
627                            let lens_activity = lens_activity_anim.get();
628                            let visual_index = crate::motion::liquid_visual_index(
629                                selected,
630                                lens_x,
631                                tab_width,
632                                count,
633                                crate::motion::liquid_axis_owns_visual_selection(
634                                    lens_pressed.get(),
635                                    lens_x,
636                                    resting_lens_x,
637                                    tab_width,
638                                ),
639                            );
640                            TabCells(
641                                Modifier::empty(),
642                                Rc::clone(&tabs),
643                                typography.clone(),
644                                tab_width,
645                                TabCellsSpec {
646                                    base_color: tab_base_content_color(colors),
647                                    selected: Some(visual_index),
648                                    selected_color: tab_selection_content_color(colors),
649                                    interactive: true,
650                                    selection_only: false,
651                                },
652                            );
653
654                            let row_width = tab_width * count as f32;
655                            let gesture = Modifier::empty()
656                                .size(Size::new(row_width, BLOB_HEIGHT))
657                                .pointer_input(selected, {
658                                    let on_select = Rc::clone(&on_select);
659                                    let lens_axis = Rc::clone(&lens_axis);
660                                    move |scope: PointerInputScope| {
661                                        let on_select = Rc::clone(&on_select);
662                                        let lens_axis = Rc::clone(&lens_axis);
663                                        crate::motion::liquid_lens_gesture(
664                                            scope,
665                                            crate::motion::LiquidLensGesture {
666                                                axis: lens_axis,
667                                                cell_width: tab_width,
668                                                count,
669                                                tap_slop: TAP_SLOP,
670                                                drag_left: Rc::new(move |x| {
671                                                    tab_lens_left(
672                                                        x,
673                                                        tab_width,
674                                                        count,
675                                                        has_accessory,
676                                                    )
677                                                }),
678                                                rest_left: Rc::new(move |index| {
679                                                    tab_lens_resting_left(index, tab_width, count)
680                                                }),
681                                                selected,
682                                                on_pressed: Rc::new(move |down| {
683                                                    lens_pressed.set(down);
684                                                    bar_held.set(down);
685                                                }),
686                                                on_touch: Rc::new(move |x, y| {
687                                                    bar_touch
688                                                        .set((x + BLOB_MARGIN, y + BLOB_MARGIN));
689                                                }),
690                                                on_select,
691                                            },
692                                        )
693                                    }
694                                });
695                            Box(gesture, BoxSpec::default(), || {});
696
697                            let published = (lens_x, lens_activity, tab_width, lens_pose);
698                            if lens_x_outer.get() != published {
699                                lens_x_outer.set(published);
700                            }
701                        });
702                    });
703
704                    let (lens_px, lens_activity, lens_tab_w, pose) = lens_x_outer.get();
705                    let lens_w = tab_lens_rest_width(lens_tab_w);
706                    let lens_h = BLOB_HEIGHT * FLIGHT_LENS_HEIGHT_PROJECTION;
707                    let deformation_headroom =
708                        crate::dynamics::STRETCH_MAX.max(1.0 / crate::dynamics::STRETCH_MIN);
709                    let node_w = lens_w * deformation_headroom + crate::dynamics::BULGE_MAX + 20.0;
710                    let node_h = lens_h * deformation_headroom + crate::dynamics::BULGE_MAX + 16.0;
711                    let lens_center_x = BLOB_MARGIN + lens_px + lens_tab_w * 0.5;
712                    let node_x = lens_center_x - node_w * 0.5;
713                    let node_top = tab_lens_node_top(node_h);
714                    let pill_w = lens_tab_w * count as f32 + 2.0 * BLOB_MARGIN;
715                    let (base_w, base_h) = tab_lens_base_size(lens_tab_w, lens_activity);
716                    let geometry = TabFlightGeometry {
717                        center: (lens_center_x, BAR_HEIGHT * 0.5),
718                        base_size: Size::new(base_w, base_h),
719                        pose,
720                        lens_position: lens_px,
721                        lens_activity,
722                        resting_tint: colors.fill,
723                        accessory_center: has_accessory.then_some((
724                            pill_w + tab_bar_accessory_gap(true) + BAR_HEIGHT * 0.5,
725                            BAR_HEIGHT * 0.5,
726                        )),
727                    };
728                    let lens_node = TabFlightNode {
729                        origin: (node_x, node_top),
730                        size: Size::new(node_w, node_h),
731                    };
732
733                    let lens_geometry = geometry;
734                    let lens = Modifier::empty()
735                        .required_size(lens_node.size)
736                        .offset(node_x, node_top)
737                        .glass_effect_with(
738                            tab_flight_lens_material(
739                                colors.label,
740                                tab_selection_content_color(colors),
741                            ),
742                            move || tab_flight_dynamics(lens_geometry, lens_node),
743                        );
744                    Box(lens, BoxSpec::default(), || {});
745                },
746            );
747
748            if has_accessory {
749                Box(
750                    Modifier::empty().width(tab_bar_accessory_gap(true)),
751                    BoxSpec::default(),
752                    || {},
753                );
754                (accessory.borrow_mut())();
755            }
756        },
757    );
758}
759
760/// The standard detached accessory: a circular glass search button.
761#[composable]
762#[allow(non_snake_case)]
763pub fn LiquidTabBarSearchAccessory(on_click: impl Fn() + 'static) {
764    crate::widgets::GlassIconButton(
765        Modifier::empty(),
766        crate::widgets::GlassButtonSpec::glass(),
767        BAR_HEIGHT * 0.94,
768        on_click,
769        crate::icons::SEARCH,
770    );
771}
772
773#[cfg(test)]
774mod tests {
775    use super::*;
776
777    #[test]
778    fn tab_bar_spec_normalizes_the_maximum_cell_width() {
779        assert_eq!(LiquidTabBarSpec::default().max_tab_width, TAB_WIDTH);
780        assert_eq!(LiquidTabBarSpec::new(85.0).max_tab_width, 85.0);
781        assert_eq!(LiquidTabBarSpec::new(0.0).max_tab_width, 1.0);
782        assert_eq!(LiquidTabBarSpec::new(f32::NAN).max_tab_width, TAB_WIDTH);
783    }
784
785    #[test]
786    fn drag_pointer_centers_the_lens_and_preserves_end_overdrag() {
787        let width = 100.0;
788        assert_eq!(tab_lens_left(50.0, width, 4, true), 0.0);
789        assert_eq!(tab_lens_left(250.0, width, 4, true), 200.0);
790        assert_eq!(tab_lens_left(-100.0, width, 4, true), -20.0);
791        assert_eq!(tab_lens_left(500.0, width, 4, true), 355.0);
792
793        assert_eq!(tab_lens_left(-100.0, width, 4, false), 0.0);
794        assert_eq!(tab_lens_left(500.0, width, 4, false), 300.0);
795    }
796
797    #[test]
798    fn resting_lens_centers_on_its_cell_and_stays_inside_the_pill() {
799        let tab = 78.0;
800        assert_eq!(tab_lens_resting_left(0, tab, 5), 0.0);
801        assert_eq!(tab_lens_resting_left(1, tab, 5), tab);
802        assert_eq!(tab_lens_resting_left(3, tab, 5), 3.0 * tab);
803        assert_eq!(tab_lens_resting_left(4, tab, 5), 4.0 * tab);
804        assert_eq!(tab_lens_resting_left(9, tab, 5), 4.0 * tab);
805        let overhang = (tab_lens_rest_width(tab) - tab) * 0.5;
806        assert!(overhang <= BLOB_MARGIN + 1.0e-4);
807    }
808
809    #[test]
810    fn flight_lens_node_is_centered_on_the_bar_axis() {
811        for node_height in [48.0, 64.0, 96.0, 128.0] {
812            let center = tab_lens_node_top(node_height) + node_height * 0.5;
813            assert!((center - BAR_HEIGHT * 0.5).abs() < f32::EPSILON);
814        }
815    }
816
817    #[test]
818    fn liquid_tab_builds_reference_content() {
819        assert_eq!(TAB_ICON_SIZE, 32.0);
820        let tab = LiquidTab::new(crate::icons::STAR, "Discover");
821        assert_eq!(tab.icon, crate::icons::STAR);
822        assert_eq!(tab.label, "Discover");
823        assert_eq!(tab.icon_style, LiquidTabIconStyle::Plain);
824
825        let badge = LiquidTab::app_badge(crate::icons::APPLE, "WWDC");
826        assert_eq!(badge.icon_style, LiquidTabIconStyle::AppBadge);
827
828        let compact = LiquidTab::new(crate::icons::ACCOUNT_CIRCLE, "Account").with_icon_scale(0.72);
829        assert!((compact.icon_scale - 0.72).abs() < f32::EPSILON);
830        assert_eq!(tab.clone().with_icon_scale(f32::NAN).icon_scale, 1.0);
831        assert_eq!(tab.with_icon_scale(2.0).icon_scale, 1.5);
832    }
833
834    #[test]
835    fn app_badge_geometry_honors_the_tab_optical_scale() {
836        let full = app_badge_geometry(1.0);
837        let corrected = app_badge_geometry(0.85);
838        assert_eq!(full.size, Size::new(20.0, 32.0));
839        assert_eq!(corrected.size, Size::new(17.0, 27.2));
840        assert!((corrected.glyph.width - 11.9).abs() < 1.0e-5);
841        assert!((corrected.corner_radius / full.corner_radius - 0.85).abs() < f32::EPSILON);
842        assert!((corrected.stripe.x / full.stripe.x - 0.85).abs() < f32::EPSILON);
843        assert!((corrected.glyph.width / full.glyph.width - 0.85).abs() < f32::EPSILON);
844    }
845
846    #[test]
847    fn base_tab_content_remains_neutral_under_the_moving_selection_layer() {
848        let colors = crate::theme::LiquidColors::light(cranpose_ui_graphics::Color::from_rgb_u8(
849            0, 122, 255,
850        ));
851        assert_eq!(tab_base_content_color(colors), colors.label);
852        assert_eq!(tab_selection_content_color(colors), colors.accent);
853    }
854
855    #[test]
856    fn selection_mask_and_lens_resolve_the_same_global_sdf() {
857        let geometry = TabFlightGeometry {
858            center: (212.0, 32.0),
859            base_size: Size::new(106.0, 64.0),
860            pose: crate::dynamics::LiquidPose::default(),
861            lens_position: 160.0,
862            lens_activity: 1.0,
863            resting_tint: Color::BLACK.with_alpha(0.10),
864            accessory_center: None,
865        };
866        let mask_node = TabFlightNode {
867            origin: (0.0, 0.0),
868            size: Size::new(328.0, 64.0),
869        };
870        let lens_node = TabFlightNode {
871            origin: (132.0, -22.0),
872            size: Size::new(160.0, 108.0),
873        };
874        let mask = tab_flight_dynamics(geometry, mask_node)
875            .morph
876            .expect("selection mask morph");
877        let lens = tab_flight_dynamics(geometry, lens_node)
878            .morph
879            .expect("lens morph");
880        assert_eq!(
881            (
882                mask.primary.0 + mask_node.origin.0,
883                mask.primary.1 + mask_node.origin.1
884            ),
885            geometry.center
886        );
887        assert_eq!(
888            (
889                lens.primary.0 + lens_node.origin.0,
890                lens.primary.1 + lens_node.origin.1
891            ),
892            geometry.center
893        );
894        assert_eq!(
895            (mask.primary.2, mask.primary.3, mask.primary.4),
896            (lens.primary.2, lens.primary.3, lens.primary.4)
897        );
898        assert_eq!(mask.node_size, (328.0, 64.0));
899        assert_eq!(lens.node_size, (160.0, 108.0));
900    }
901
902    #[test]
903    fn unified_bar_has_no_detached_accessory_gap() {
904        assert_eq!(tab_bar_accessory_gap(false), 0.0);
905        assert_eq!(tab_bar_accessory_gap(true), 10.0);
906    }
907
908    #[test]
909    fn flight_lens_only_joins_accessory_after_surface_contact() {
910        assert!(!accessory_surfaces_touch(0.01));
911        assert!(accessory_surfaces_touch(0.0));
912        assert!(accessory_surfaces_touch(-4.0));
913    }
914
915    #[test]
916    fn lens_contact_swell_is_vertical_only() {
917        let resting = tab_lens_base_size(TAB_WIDTH, 0.0);
918        let raised = tab_lens_base_size(TAB_WIDTH, 1.0);
919        assert_eq!(
920            resting,
921            (TAB_WIDTH * TAB_LENS_REST_WIDTH_FACTOR, BLOB_HEIGHT)
922        );
923        assert_eq!(raised.0, resting.0);
924        assert!((raised.1 / resting.1 - FLIGHT_LENS_HEIGHT_PROJECTION).abs() < 0.001);
925        assert!((raised.1 - 68.0).abs() < 0.5);
926    }
927
928    #[test]
929    fn tab_grid_matches_the_reference_pitch() {
930        assert_eq!(TAB_WIDTH, 78.0);
931    }
932
933    #[test]
934    fn tab_grid_matches_the_reference_inner_inset() {
935        assert_eq!(BLOB_MARGIN, 4.0);
936        assert_eq!(BLOB_HEIGHT + 2.0 * BLOB_MARGIN, BAR_HEIGHT);
937    }
938
939    #[test]
940    fn flight_lens_uses_the_clear_wcksrd_contract() {
941        let glass = tab_flight_lens_material(
942            cranpose_ui_graphics::Color::BLACK,
943            cranpose_ui_graphics::Color::from_rgb_u8(0, 122, 255),
944        );
945        let generic_lens = Glass::lens();
946        assert!(glass.lift.is_some_and(|lift| (0.0..=0.15).contains(&lift)));
947        assert_eq!(glass.refraction_depth, 1.0);
948        assert!(glass.refraction_curve < generic_lens.refraction_curve);
949        assert!(glass.dispersion * 0.3 < generic_lens.dispersion);
950        assert_eq!(glass.blur_radius, Some(0.0));
951        assert!(glass.highlight < generic_lens.highlight);
952        assert!(
953            glass.shadow,
954            "the moving lens needs its target-visible SDF contact outline"
955        );
956        assert!(
957            glass
958                .tint
959                .is_some_and(|tint| { tint.r() < 0.05 && (0.055..=0.065).contains(&tint.a()) })
960        );
961        assert_eq!(glass.adaptive_frost, 0.0);
962    }
963
964    #[test]
965    fn flight_lens_retains_neutral_tint_through_direct_motion() {
966        assert_eq!(tab_flight_tint_multiplier(0.0), 1.0);
967        assert!((tab_flight_tint_multiplier(1.0) - 0.75).abs() < f32::EPSILON);
968        assert_eq!(tab_flight_tint_multiplier(-1.0), 1.0);
969        assert!((tab_flight_tint_multiplier(2.0) - 0.75).abs() < f32::EPSILON);
970    }
971
972    #[test]
973    fn bar_surface_adapts_frost_to_its_foreground() {
974        let glass = tab_bar_surface_material(cranpose_ui_graphics::Color::BLACK);
975        assert_eq!(glass.blur_radius, Some(9.0));
976        assert_eq!(glass.saturation, Some(1.15));
977        assert_eq!(glass.lift, Some(0.60));
978        assert_eq!(glass.refraction_depth, 0.0);
979        assert_eq!(glass.transmission_refraction, 0.0);
980        assert_eq!(glass.adaptive_frost, 0.28);
981    }
982
983    #[test]
984    fn bar_surface_lift_tracks_the_local_foreground_polarity() {
985        let light_surface = tab_bar_surface_material(cranpose_ui_graphics::Color::BLACK);
986        assert_eq!(light_surface.lift, Some(0.60));
987
988        let dark_surface = tab_bar_surface_material(cranpose_ui_graphics::Color::WHITE);
989        assert_eq!(dark_surface.lift, Some(-0.24));
990    }
991
992    #[test]
993    fn bar_surface_tint_separates_from_same_polarity_backdrops() {
994        let light_surface = tab_bar_surface_material(cranpose_ui_graphics::Color::BLACK)
995            .tint
996            .expect("bar tint");
997        assert!(light_surface.r() < 0.05);
998        assert_eq!(light_surface.a(), 0.0);
999
1000        let dark_surface = tab_bar_surface_material(cranpose_ui_graphics::Color::WHITE)
1001            .tint
1002            .expect("bar tint");
1003        assert!(dark_surface.r() > 0.95);
1004        assert!((0.03..=0.05).contains(&dark_surface.a()));
1005    }
1006
1007    #[test]
1008    fn contact_rises_continuously_and_returns_on_the_measured_settle() {
1009        let cranpose_animation::AnimationType::Spring(rise) = tab_lens_activity_motion(true) else {
1010            panic!("contact rise must use a spring");
1011        };
1012        assert_eq!(rise.stiffness, 1400.0);
1013        let cranpose_animation::AnimationType::Spring(settle) = tab_lens_activity_motion(false)
1014        else {
1015            panic!("arrival contraction must use a spring");
1016        };
1017        assert_eq!(settle.damping_ratio, 1.0);
1018        assert_eq!(settle.stiffness, 900.0);
1019    }
1020
1021    #[test]
1022    fn a_scope_declares_destinations_in_order() {
1023        let tabs = collect_tabs(|scope| {
1024            scope.tab("M0 0", "Discover");
1025            scope.app_badge("M1 1", "WWDC");
1026            scope.push(LiquidTab::new("M2 2", "Account").with_icon_scale(0.95));
1027        });
1028
1029        assert_eq!(tabs.len(), 3);
1030        assert_eq!(tabs[0].label, "Discover");
1031        assert_eq!(tabs[0].icon_style, LiquidTabIconStyle::Plain);
1032        assert_eq!(tabs[0].icon_scale, 1.0);
1033        assert_eq!(tabs[1].icon_style, LiquidTabIconStyle::AppBadge);
1034        assert_eq!(tabs[2].icon_scale, 0.95);
1035    }
1036
1037    #[test]
1038    fn a_bar_with_no_destinations_declares_none() {
1039        assert!(collect_tabs(|_| {}).is_empty());
1040    }
1041
1042    #[test]
1043    fn a_resting_lens_overhangs_its_cell_within_the_legal_margin() {
1044        for tab_width in [24.0_f32, 44.0, 78.0, 80.0, 120.0, 400.0] {
1045            let width = tab_lens_rest_width(tab_width);
1046            assert!(
1047                width > tab_width,
1048                "the bubble reads wider than its cell ({width} vs {tab_width})"
1049            );
1050            let overhang = (width - tab_width) * 0.5;
1051            assert!(
1052                overhang <= BLOB_MARGIN + 1.0e-4,
1053                "an end cell's overhang ({overhang}) at a {tab_width}dp cell \
1054                 has to stay inside the pill inset ({BLOB_MARGIN})"
1055            );
1056        }
1057    }
1058
1059    #[test]
1060    fn a_reference_width_cell_keeps_the_measured_rest_proportion() {
1061        for tab_width in [24.0_f32, 44.0, 78.0, 80.0] {
1062            assert!(
1063                (tab_lens_rest_width(tab_width) - tab_width * TAB_LENS_REST_WIDTH_FACTOR).abs()
1064                    < 1.0e-4,
1065                "a {tab_width}dp cell still rests at the measured 1.10 factor"
1066            );
1067        }
1068        assert!(
1069            tab_lens_rest_width(200.0) < 200.0 * TAB_LENS_REST_WIDTH_FACTOR,
1070            "past the inset the margin governs, not the factor"
1071        );
1072    }
1073
1074    #[test]
1075    fn a_resting_lens_stays_centered_on_the_cell_it_rests_in() {
1076        let tab_width = 72.0;
1077        let width = tab_lens_rest_width(tab_width);
1078        for selected in 0..4 {
1079            let left = tab_lens_resting_left(selected, tab_width, 4);
1080            let cell_center = left + tab_width * 0.5;
1081            let lens_center = left - (width - tab_width) * 0.5 + width * 0.5;
1082            assert!(
1083                (lens_center - cell_center).abs() < 1e-4,
1084                "cell {selected}: lens center {lens_center} vs cell center {cell_center}"
1085            );
1086        }
1087    }
1088}