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::{liquid_axis_owns_visual_selection, liquid_visual_index, 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) -> Glass {
124    Glass::lens()
125        .no_clip()
126        .tint(neutral_surface_tint(foreground, 0.13, 0.10))
127        .blur_radius(0.0)
128        .refraction_depth(0.26)
129        .refraction_curve(0.25)
130        .fold_depth(6.0)
131        // Mid-ride the reference lens shows the icon under it clearly
132        // enlarged (~1.6x, tab-swipe sheet); rim mapping stays wcKSRD.
133        .optical_zoom(1.5)
134        .dispersion(0.24)
135        .lift(0.0)
136        .highlight(0.24)
137}
138
139fn tab_bar_surface_material(foreground: cranpose_ui_graphics::Color) -> Glass {
140    Glass::regular()
141        .tint(neutral_surface_tint(foreground, 0.0, 0.04))
142        .blur_radius(4.0)
143        // The reference bar body lifts toward white yet keeps the tiles'
144        // orange/purple vivid through the glass (bottom-bar-form sheet);
145        // the stronger wash read pale.
146        .saturation(1.12)
147        .lift(neutral_surface_lift(foreground, 0.30, -0.24))
148        .highlight(0.20)
149        // The reference bar folds nearby content inside its long edges:
150        // section headers under the top edge render mirrored upside-down
151        // (bar_headers_folded) — the same pure-displacement fold as the
152        // toggle, shallower.
153        .fold_depth(8.0)
154        .adaptive_frost(foreground, 0.28)
155}
156
157fn tab_flight_tint_multiplier(activity: f32) -> f32 {
158    1.0 - 0.25 * activity.clamp(0.0, 1.0)
159}
160
161fn tab_lens_activity_motion() -> cranpose_animation::AnimationType {
162    cranpose_animation::spring(1.0, 900.0)
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
176fn tab_visual_selection(
177    selected: usize,
178    lens_position: f32,
179    tab_width: f32,
180    count: usize,
181    direct: bool,
182) -> usize {
183    let selected = selected.min(count.saturating_sub(1));
184    let state_position = tab_width * selected as f32;
185    let lens_owns_selection =
186        liquid_axis_owns_visual_selection(direct, lens_position, state_position, tab_width);
187    liquid_visual_index(
188        selected,
189        lens_position,
190        tab_width,
191        count,
192        lens_owns_selection,
193    )
194}
195
196#[derive(Clone, Copy, Debug, PartialEq)]
197struct AppBadgeGeometry {
198    size: Size,
199    corner_radius: f32,
200    stripe: Rect,
201    glyph: Rect,
202}
203
204fn app_badge_geometry(optical_scale: f32) -> AppBadgeGeometry {
205    let scale = normalize_icon_scale(optical_scale);
206    AppBadgeGeometry {
207        size: Size::new(20.0 * scale, 32.0 * scale),
208        corner_radius: 5.0 * scale,
209        stripe: Rect {
210            x: 7.0 * scale,
211            y: 4.5 * scale,
212            width: 6.0 * scale,
213            height: 1.5 * scale,
214        },
215        glyph: Rect {
216            x: 3.0 * scale,
217            y: 10.0 * scale,
218            width: 14.0 * scale,
219            height: 14.0 * scale,
220        },
221    }
222}
223
224#[composable]
225#[allow(non_snake_case)]
226fn TabIcon(icon: &'static str, style: LiquidTabIconStyle, color: Color, optical_scale: f32) {
227    const FRAME_HEIGHT: f32 = 32.0;
228    Box(
229        Modifier::empty().size(Size::new(TAB_ICON_SIZE, FRAME_HEIGHT)),
230        BoxSpec::default().content_alignment(Alignment::CENTER),
231        move || match style {
232            LiquidTabIconStyle::Plain => {
233                crate::icons::Icon(icon, TAB_ICON_SIZE * optical_scale, color)
234            }
235            LiquidTabIconStyle::AppBadge => {
236                let geometry = app_badge_geometry(optical_scale);
237                Box(
238                    Modifier::empty()
239                        .size(geometry.size)
240                        .draw_behind(move |scope| {
241                            scope.draw_round_rect(
242                                Brush::solid(color),
243                                CornerRadii::uniform(geometry.corner_radius),
244                            );
245                            scope.draw_rect_at(geometry.stripe, Brush::solid(Color::WHITE));
246                        }),
247                    BoxSpec::default(),
248                    move || {
249                        Box(
250                            Modifier::empty()
251                                .offset(geometry.glyph.x, geometry.glyph.y)
252                                .size(Size::new(geometry.glyph.width, geometry.glyph.height)),
253                            BoxSpec::default(),
254                            move || crate::icons::Icon(icon, geometry.glyph.width, Color::WHITE),
255                        );
256                    },
257                );
258            }
259        },
260    );
261}
262
263#[derive(Clone, Copy, PartialEq)]
264struct TabCellsSpec {
265    base_color: Color,
266    selected: Option<usize>,
267    selected_color: Color,
268    interactive: bool,
269    selection_only: bool,
270}
271
272fn tab_cell_is_visible(index: usize, selected: Option<usize>, selection_only: bool) -> bool {
273    !selection_only || selected == Some(index)
274}
275
276#[composable]
277#[allow(non_snake_case)]
278fn TabCells(
279    modifier: Modifier,
280    tabs: Rc<Vec<LiquidTab>>,
281    typography: LiquidTypography,
282    tab_width: f32,
283    spec: TabCellsSpec,
284) {
285    Row(modifier, RowSpec::default(), move || {
286        for (index, tab) in tabs.iter().enumerate() {
287            let visible = tab_cell_is_visible(index, spec.selected, spec.selection_only);
288            let color = if spec.selected == Some(index) {
289                spec.selected_color
290            } else {
291                spec.base_color
292            };
293            let label_for_semantics = tab.label;
294            let mut cell = Modifier::empty().size(Size::new(tab_width, BLOB_HEIGHT));
295            if spec.interactive {
296                cell = cell.semantics(move |config| {
297                    config.is_button = true;
298                    config.is_clickable = true;
299                    config.content_description = Some(label_for_semantics.to_string());
300                });
301            }
302            let icon = tab.icon;
303            let icon_style = tab.icon_style;
304            let icon_scale = tab.icon_scale;
305            let label = tab.label;
306            let label_style = TextStyle {
307                span_style: SpanStyle {
308                    color: Some(color),
309                    font_size: cranpose_ui::text::TextUnit::Sp(TAB_LABEL_SIZE),
310                    font_weight: Some(FontWeight::MEDIUM),
311                    ..typography.caption1.span_style.clone()
312                },
313                ..typography.caption1.clone()
314            };
315            Box(
316                cell,
317                BoxSpec::default().content_alignment(Alignment::CENTER),
318                move || {
319                    if !visible {
320                        return;
321                    }
322                    let label_style = label_style.clone();
323                    Column(
324                        Modifier::empty(),
325                        ColumnSpec::default()
326                            .horizontal_alignment(HorizontalAlignment::CenterHorizontally),
327                        move || {
328                            TabIcon(icon, icon_style, color, icon_scale);
329                            Text(label, Modifier::empty(), label_style.clone());
330                        },
331                    );
332                },
333            );
334        }
335    });
336}
337
338fn tab_lens_node_top(node_height: f32) -> f32 {
339    (BAR_HEIGHT - node_height) * 0.5
340}
341
342fn tab_bar_accessory_gap(has_accessory: bool) -> f32 {
343    if has_accessory {
344        ACCESSORY_GAP
345    } else {
346        0.0
347    }
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_width * TAB_LENS_REST_WIDTH_FACTOR;
358    let projection = 1.0 + (FLIGHT_LENS_PROJECTION_SCALE - 1.0) * ease;
359    (rest_width * projection, 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 edge_gap = (*x - geometry.center.0).abs()
388                - geometry.base_size.width * geometry.pose.stretch.max(geometry.pose.ortho) * 0.5
389                - BAR_HEIGHT * 0.5;
390            accessory_surfaces_touch(edge_gap)
391        })
392        .map(|(x, y)| {
393            vec![(
394                x - node.origin.0,
395                y - node.origin.1,
396                BAR_HEIGHT,
397                BAR_HEIGHT,
398                -1.0,
399            )]
400        })
401        .unwrap_or_default();
402    GlassDynamics {
403        morph: Some(GlassMorph {
404            node_size: (node.size.width, node.size.height),
405            primary: (
406                geometry.center.0 - node.origin.0,
407                geometry.center.1 - node.origin.1,
408                geometry.base_size.width,
409                geometry.base_size.height,
410                radius,
411            ),
412            shapes,
413            glue,
414            wobble_amplitude: 1.1 * energy,
415            wobble_phase: geometry.lens_position * 0.045,
416            bulge_amplitude: geometry.pose.bulge_amplitude.min(8.0) * activity,
417            bulge_direction: geometry.pose.bulge_direction,
418            ellipse_blend: 0.0,
419            deformation: Some(crate::material::GlassDeformation::incompressible(
420                geometry.pose.axis,
421                1.0 + (geometry.pose.stretch - 1.0) * activity,
422            )),
423        }),
424        activity: Some(activity),
425        resting_tint: Some(geometry.resting_tint),
426        tint_alpha_multiplier: Some(tab_flight_tint_multiplier(geometry.lens_activity)),
427        ..Default::default()
428    }
429}
430
431/// A unified floating glass tab bar with every destination inside one pill.
432#[composable]
433#[allow(non_snake_case)]
434pub fn LiquidTabBar(
435    modifier: Modifier,
436    spec: LiquidTabBarSpec,
437    tabs: Vec<LiquidTab>,
438    selected: usize,
439    on_select: impl Fn(usize) + 'static,
440) {
441    LiquidTabBarLayout(modifier, spec, tabs, selected, on_select, false, || {});
442}
443
444/// A floating glass tab bar with a detached accessory to its right.
445#[composable]
446#[allow(non_snake_case)]
447pub fn LiquidTabBarWithAccessory(
448    modifier: Modifier,
449    spec: LiquidTabBarSpec,
450    tabs: Vec<LiquidTab>,
451    selected: usize,
452    on_select: impl Fn(usize) + 'static,
453    accessory: impl FnMut() + 'static,
454) {
455    LiquidTabBarLayout(modifier, spec, tabs, selected, on_select, true, accessory);
456}
457
458#[composable]
459#[allow(non_snake_case)]
460fn LiquidTabBarLayout(
461    modifier: Modifier,
462    spec: LiquidTabBarSpec,
463    tabs: Vec<LiquidTab>,
464    selected: usize,
465    on_select: impl Fn(usize) + 'static,
466    has_accessory: bool,
467    accessory: impl FnMut() + 'static,
468) {
469    let colors = liquid_colors();
470    let typography = liquid_typography();
471    let count = tabs.len().max(1);
472    let selected = selected.min(count - 1);
473    let on_select: Rc<dyn Fn(usize)> = Rc::new(on_select);
474    let tabs = Rc::new(tabs);
475    let accessory = Rc::new(RefCell::new(accessory));
476
477    Row(
478        modifier,
479        RowSpec::default().vertical_alignment(VerticalAlignment::CenterVertically),
480        move || {
481            let tabs = Rc::clone(&tabs);
482            let typography = typography.clone();
483            let on_select = Rc::clone(&on_select);
484            let accessory = Rc::clone(&accessory);
485
486            // The main pill (wrapped in a stack so the drag lens can float
487            // ABOVE the finished bar and magnify icons + glass together).
488            let lens_x_outer = cranpose_core::remember(|| {
489                cranpose_core::mutableStateOf((
490                    0.0f32,
491                    0.0f32,
492                    0.0f32,
493                    crate::dynamics::LiquidPose::default(),
494                ))
495            })
496            .with(|state| *state);
497            // The stack is pinned to the pill height so the mounting lens
498            // (taller than the bar) can never inflate it — an unpinned stack
499            // grew on press and the centering Row shifted the whole bar down.
500            Box(
501                Modifier::empty().height(BAR_HEIGHT),
502                BoxSpec::default(),
503                move || {
504                    let tabs = Rc::clone(&tabs);
505                    let typography = typography.clone();
506                    let on_select = Rc::clone(&on_select);
507                    // The bar's edge is defined by shadow and contrast, not a
508                    // bright rim stroke.
509                    let pill = Modifier::empty()
510                        .glass_effect(
511                            // Dark labels must never sink into dark content
512                            // scrolling beneath: the glass lifts adaptively over
513                            // dark backdrops (inert over the light ones the
514                            // pinned captures use).
515                            tab_bar_surface_material(colors.label),
516                        )
517                        .height(BAR_HEIGHT);
518                    Box(pill, BoxSpec::default(), move || {
519                        let tabs = Rc::clone(&tabs);
520                        let typography = typography.clone();
521                        let on_select = Rc::clone(&on_select);
522                        BoxWithConstraints(Modifier::empty().padding(BLOB_MARGIN), move |scope| {
523                            let tabs = Rc::clone(&tabs);
524                            let typography = typography.clone();
525                            let on_select = Rc::clone(&on_select);
526                            let constrained = scope.constraints().max_width;
527                            let tab_width = if constrained.is_finite() && constrained > 1.0 {
528                                (constrained / count as f32).min(spec.max_tab_width)
529                            } else {
530                                spec.max_tab_width
531                            };
532
533                            // One optical body owns selection at rest, under direct
534                            // manipulation, and throughout release settle.
535                            let lens_pressed =
536                                cranpose_core::remember(|| cranpose_core::mutableStateOf(false))
537                                    .with(|state| *state);
538                            let resting_lens_x = tab_width * selected as f32;
539                            let lens_axis =
540                                crate::motion::remember_liquid_drag_axis(resting_lens_x);
541                            lens_axis.settle_to(resting_lens_x, LiquidMotion::glide());
542                            let lens_x = lens_axis.value();
543                            let lens_pose = lens_axis.liquid_pose();
544                            let lens_in_flight = !lens_axis.is_dragging()
545                                && (lens_x - resting_lens_x).abs() > tab_width * 0.15;
546                            let lens_raised = lens_pressed.get() || lens_in_flight;
547                            let lens_activity_target = if lens_raised { 1.0 } else { 0.0 };
548                            let lens_activity_anim = cranpose_animation::animateFloatAsState(
549                                lens_activity_target,
550                                tab_lens_activity_motion(),
551                                "tabbar-lens-activity",
552                            );
553                            // Contact and controlled flight raise the optic in
554                            // the delivered frame. Only the return into the bar
555                            // is animated, by continuously reducing the same
556                            // material channels after motion has settled.
557                            let lens_activity = if lens_raised {
558                                1.0
559                            } else {
560                                lens_activity_anim.get()
561                            };
562                            let visual_selection = tab_visual_selection(
563                                selected,
564                                lens_x,
565                                tab_width,
566                                count,
567                                lens_axis.is_dragging(),
568                            );
569                            // The lens-owned cell carries the accent in the
570                            // BASE bar: the flight lens samples this backdrop,
571                            // so the icon it magnifies mid-ride is already
572                            // blue (tab-swipe reference flips the whole cell
573                            // to accent on ownership). An overlay copy can't
574                            // do this — the lens's backdrop snapshot never
575                            // contains layers of its own composite unit.
576                            TabCells(
577                                Modifier::empty(),
578                                Rc::clone(&tabs),
579                                typography.clone(),
580                                tab_width,
581                                TabCellsSpec {
582                                    base_color: tab_base_content_color(colors),
583                                    selected: Some(visual_selection),
584                                    selected_color: tab_selection_content_color(colors),
585                                    interactive: true,
586                                    selection_only: false,
587                                },
588                            );
589
590                            // Swipe/tap surface across the whole pill interior.
591                            let row_width = tab_width * count as f32;
592                            let gesture = Modifier::empty()
593                                .size(Size::new(row_width, BLOB_HEIGHT))
594                                .pointer_input(selected, {
595                                    let on_select = Rc::clone(&on_select);
596                                    let lens_axis = Rc::clone(&lens_axis);
597                                    move |scope: PointerInputScope| {
598                                        let on_select = Rc::clone(&on_select);
599                                        let lens_axis = Rc::clone(&lens_axis);
600                                        async move {
601                                            scope
602                                                .await_pointer_event_scope(
603                                                    |await_scope| async move {
604                                                        let mut down_x = 0.0f32;
605                                                        let mut active_pointer =
606                                                            Option::<PointerId>::None;
607                                                        let mut moved = false;
608                                                        loop {
609                                                            let event = await_scope
610                                                                .await_pointer_event()
611                                                                .await;
612                                                            match event.kind {
613                                                                PointerEventKind::Down
614                                                                    if active_pointer.is_none() =>
615                                                                {
616                                                                    active_pointer = Some(event.id);
617                                                                    moved = false;
618                                                                    down_x = event.position.x;
619                                                                    // Raise IN PLACE: a tap on
620                                                                    // another cell must FLY the
621                                                                    // lens there on release, not
622                                                                    // teleport it to the finger.
623                                                                    lens_axis.begin(
624                                                                        lens_axis.value(),
625                                                                        event.time_ms,
626                                                                    );
627                                                                    lens_pressed.set(true);
628                                                                    default_haptics().perform(
629                                                                        HapticFeedback::Selection,
630                                                                    );
631                                                                    event.consume();
632                                                                }
633                                                                PointerEventKind::Move
634                                                                    if active_pointer
635                                                                        == Some(event.id) =>
636                                                                {
637                                                                    moved |= (event.position.x
638                                                                        - down_x)
639                                                                        .abs()
640                                                                        > TAP_SLOP;
641                                                                    // Below the slop this is
642                                                                    // still a tap: keep the lens
643                                                                    // anchored so release FLIES
644                                                                    // it. Feeding micro-jitter
645                                                                    // into the direct axis
646                                                                    // teleported the lens to the
647                                                                    // finger — the intermittent
648                                                                    // "snap instead of flight".
649                                                                    if moved {
650                                                                        lens_axis.move_to(
651                                                                            tab_lens_left(
652                                                                                event.position.x,
653                                                                                tab_width,
654                                                                                count,
655                                                                                has_accessory,
656                                                                            ),
657                                                                            event.time_ms,
658                                                                        );
659                                                                    }
660                                                                    event.consume();
661                                                                }
662                                                                PointerEventKind::Up
663                                                                    if active_pointer
664                                                                        == Some(event.id) =>
665                                                                {
666                                                                    active_pointer = None;
667                                                                    lens_pressed.set(false);
668                                                                    let commit_x = if moved {
669                                                                        event.position.x
670                                                                    } else {
671                                                                        down_x
672                                                                    };
673                                                                    let index = ((commit_x
674                                                                        / tab_width)
675                                                                        .floor()
676                                                                        as isize)
677                                                                        .clamp(
678                                                                            0,
679                                                                            count as isize - 1,
680                                                                        )
681                                                                        as usize;
682                                                                    lens_axis.release_to(
683                                                                        tab_width * index as f32,
684                                                                        event.time_ms,
685                                                                        LiquidMotion::glide(),
686                                                                    );
687                                                                    default_haptics().perform(
688                                                                        HapticFeedback::ImpactLight,
689                                                                    );
690                                                                    on_select(index);
691                                                                    event.consume();
692                                                                }
693                                                                PointerEventKind::Cancel
694                                                                    if active_pointer
695                                                                        == Some(event.id) =>
696                                                                {
697                                                                    active_pointer = None;
698                                                                    lens_pressed.set(false);
699                                                                    lens_axis.release_to(
700                                                                        resting_lens_x,
701                                                                        event.time_ms,
702                                                                        LiquidMotion::glide(),
703                                                                    );
704                                                                    event.consume();
705                                                                }
706                                                                _ => {}
707                                                            }
708                                                        }
709                                                    },
710                                                )
711                                                .await;
712                                        }
713                                    }
714                                });
715                            Box(gesture, BoxSpec::default(), || {});
716
717                            // Publish the lens springs for the overlay rendered
718                            // ABOVE the finished bar (outside this glass layer, so
719                            // the lens magnifies icons and glass together).
720                            let published = (lens_x, lens_activity, tab_width, lens_pose);
721                            if lens_x_outer.get() != published {
722                                lens_x_outer.set(published);
723                            }
724                        });
725                    });
726
727                    // The lens bubble, floating above the whole pill. Its shape
728                    // follows the droplet law (crate::dynamics): cruising speed
729                    // stretches it along the travel axis, launch compresses it,
730                    // braking swells the leading edge — orthogonal axis inverse,
731                    // area conserved — and it magnifies harder in motion. The
732                    // search accessory's circle joins its liquid field: drag the
733                    // lens to the bar's end and the two glue through a
734                    // smooth-union neck.
735                    let (lens_px, lens_activity, lens_tab_w, pose) = lens_x_outer.get();
736                    let lens_w =
737                        lens_tab_w * TAB_LENS_REST_WIDTH_FACTOR * FLIGHT_LENS_PROJECTION_SCALE;
738                    let lens_h = BLOB_HEIGHT * FLIGHT_LENS_PROJECTION_SCALE;
739                    // Node headroom for the deformation extremes (max axis
740                    // stretch + leading bulge, max ortho swell) and rim glow.
741                    let deformation_headroom =
742                        crate::dynamics::STRETCH_MAX.max(1.0 / crate::dynamics::STRETCH_MIN);
743                    let node_w = lens_w * deformation_headroom + crate::dynamics::BULGE_MAX + 20.0;
744                    let node_h = lens_h * deformation_headroom + crate::dynamics::BULGE_MAX + 16.0;
745                    let lens_center_x = BLOB_MARGIN + lens_px + lens_tab_w * 0.5;
746                    let node_x = lens_center_x - node_w * 0.5;
747                    let node_top = tab_lens_node_top(node_h);
748                    let pill_w = lens_tab_w * count as f32 + 2.0 * BLOB_MARGIN;
749                    let (base_w, base_h) = tab_lens_base_size(lens_tab_w, lens_activity);
750                    let geometry = TabFlightGeometry {
751                        center: (lens_center_x, BAR_HEIGHT * 0.5),
752                        base_size: Size::new(base_w, base_h),
753                        pose,
754                        lens_position: lens_px,
755                        lens_activity,
756                        resting_tint: colors.fill,
757                        accessory_center: has_accessory.then_some((
758                            pill_w + tab_bar_accessory_gap(true) + BAR_HEIGHT * 0.5,
759                            BAR_HEIGHT * 0.5,
760                        )),
761                    };
762                    let lens_node = TabFlightNode {
763                        origin: (node_x, node_top),
764                        size: Size::new(node_w, node_h),
765                    };
766
767                    let lens_geometry = geometry;
768                    let lens = Modifier::empty()
769                        // required_size: the stack is pinned to BAR_HEIGHT so
770                        // the taller lens can never inflate the bar; the node
771                        // still measures (and draws) at its full size and the
772                        // offset centers it on the pill.
773                        .required_size(lens_node.size)
774                        .offset(node_x, node_top)
775                        .glass_effect_with(tab_flight_lens_material(colors.label), move || {
776                            tab_flight_dynamics(lens_geometry, lens_node)
777                        });
778                    Box(lens, BoxSpec::default(), || {});
779                },
780            );
781
782            if has_accessory {
783                Box(
784                    Modifier::empty().width(tab_bar_accessory_gap(true)),
785                    BoxSpec::default(),
786                    || {},
787                );
788                (accessory.borrow_mut())();
789            }
790        },
791    );
792}
793
794/// The standard detached accessory: a circular glass search button.
795#[composable]
796#[allow(non_snake_case)]
797pub fn LiquidTabBarSearchAccessory(on_click: impl Fn() + 'static) {
798    // The reference search circle is nearly flush with the bar height.
799    crate::widgets::GlassIconButton(
800        Modifier::empty(),
801        crate::widgets::GlassButtonSpec::glass(),
802        BAR_HEIGHT * 0.94,
803        on_click,
804        crate::icons::SEARCH,
805    );
806}
807
808#[cfg(test)]
809mod tests {
810    use super::*;
811
812    #[test]
813    fn tab_bar_spec_normalizes_the_maximum_cell_width() {
814        assert_eq!(LiquidTabBarSpec::default().max_tab_width, TAB_WIDTH);
815        assert_eq!(LiquidTabBarSpec::new(85.0).max_tab_width, 85.0);
816        assert_eq!(LiquidTabBarSpec::new(0.0).max_tab_width, 1.0);
817        assert_eq!(LiquidTabBarSpec::new(f32::NAN).max_tab_width, TAB_WIDTH);
818    }
819
820    #[test]
821    fn drag_pointer_centers_the_lens_and_preserves_end_overdrag() {
822        let width = 100.0;
823        assert_eq!(tab_lens_left(50.0, width, 4, true), 0.0);
824        assert_eq!(tab_lens_left(250.0, width, 4, true), 200.0);
825        assert_eq!(tab_lens_left(-100.0, width, 4, true), -20.0);
826        assert_eq!(tab_lens_left(500.0, width, 4, true), 355.0);
827
828        assert_eq!(tab_lens_left(-100.0, width, 4, false), 0.0);
829        assert_eq!(tab_lens_left(500.0, width, 4, false), 300.0);
830    }
831
832    #[test]
833    fn flight_lens_node_is_centered_on_the_bar_axis() {
834        for node_height in [48.0, 64.0, 96.0, 128.0] {
835            let center = tab_lens_node_top(node_height) + node_height * 0.5;
836            assert!((center - BAR_HEIGHT * 0.5).abs() < f32::EPSILON);
837        }
838    }
839
840    #[test]
841    fn liquid_tab_builds_reference_content() {
842        assert_eq!(TAB_ICON_SIZE, 32.0);
843        let tab = LiquidTab::new(crate::icons::STAR, "Discover");
844        assert_eq!(tab.icon, crate::icons::STAR);
845        assert_eq!(tab.label, "Discover");
846        assert_eq!(tab.icon_style, LiquidTabIconStyle::Plain);
847
848        let badge = LiquidTab::app_badge(crate::icons::APPLE, "WWDC");
849        assert_eq!(badge.icon_style, LiquidTabIconStyle::AppBadge);
850
851        let compact = LiquidTab::new(crate::icons::ACCOUNT_CIRCLE, "Account").with_icon_scale(0.72);
852        assert!((compact.icon_scale - 0.72).abs() < f32::EPSILON);
853        assert_eq!(tab.clone().with_icon_scale(f32::NAN).icon_scale, 1.0);
854        assert_eq!(tab.with_icon_scale(2.0).icon_scale, 1.5);
855    }
856
857    #[test]
858    fn app_badge_geometry_honors_the_tab_optical_scale() {
859        let full = app_badge_geometry(1.0);
860        let corrected = app_badge_geometry(0.85);
861        assert_eq!(full.size, Size::new(20.0, 32.0));
862        assert_eq!(corrected.size, Size::new(17.0, 27.2));
863        assert!((corrected.glyph.width - 11.9).abs() < 1.0e-5);
864        assert!((corrected.corner_radius / full.corner_radius - 0.85).abs() < f32::EPSILON);
865        assert!((corrected.stripe.x / full.stripe.x - 0.85).abs() < f32::EPSILON);
866        assert!((corrected.glyph.width / full.glyph.width - 0.85).abs() < f32::EPSILON);
867    }
868
869    #[test]
870    fn base_tab_content_remains_neutral_under_the_moving_selection_layer() {
871        let colors = crate::theme::LiquidColors::light(cranpose_ui_graphics::Color::from_rgb_u8(
872            0, 122, 255,
873        ));
874        assert_eq!(tab_base_content_color(colors), colors.label);
875        assert_eq!(tab_selection_content_color(colors), colors.accent);
876    }
877
878    #[test]
879    fn flight_overlay_accents_only_the_lens_owned_cell() {
880        assert_eq!(
881            tab_visual_selection(3, TAB_WIDTH * 2.5, TAB_WIDTH, 4, true),
882            2
883        );
884        assert!(tab_cell_is_visible(2, Some(2), true));
885        assert!(!tab_cell_is_visible(3, Some(2), true));
886        assert!(tab_cell_is_visible(3, Some(2), false));
887    }
888
889    #[test]
890    fn selection_mask_and_lens_resolve_the_same_global_sdf() {
891        let geometry = TabFlightGeometry {
892            center: (212.0, 32.0),
893            base_size: Size::new(106.0, 64.0),
894            pose: crate::dynamics::LiquidPose::default(),
895            lens_position: 160.0,
896            lens_activity: 1.0,
897            resting_tint: Color::BLACK.with_alpha(0.10),
898            accessory_center: None,
899        };
900        let mask_node = TabFlightNode {
901            origin: (0.0, 0.0),
902            size: Size::new(328.0, 64.0),
903        };
904        let lens_node = TabFlightNode {
905            origin: (132.0, -22.0),
906            size: Size::new(160.0, 108.0),
907        };
908        let mask = tab_flight_dynamics(geometry, mask_node)
909            .morph
910            .expect("selection mask morph");
911        let lens = tab_flight_dynamics(geometry, lens_node)
912            .morph
913            .expect("lens morph");
914        assert_eq!(
915            (
916                mask.primary.0 + mask_node.origin.0,
917                mask.primary.1 + mask_node.origin.1
918            ),
919            geometry.center
920        );
921        assert_eq!(
922            (
923                lens.primary.0 + lens_node.origin.0,
924                lens.primary.1 + lens_node.origin.1
925            ),
926            geometry.center
927        );
928        assert_eq!(
929            (mask.primary.2, mask.primary.3, mask.primary.4),
930            (lens.primary.2, lens.primary.3, lens.primary.4)
931        );
932        assert_eq!(mask.node_size, (328.0, 64.0));
933        assert_eq!(lens.node_size, (160.0, 108.0));
934    }
935
936    #[test]
937    fn unified_bar_has_no_detached_accessory_gap() {
938        assert_eq!(tab_bar_accessory_gap(false), 0.0);
939        assert_eq!(tab_bar_accessory_gap(true), 10.0);
940    }
941
942    #[test]
943    fn flight_lens_only_joins_accessory_after_surface_contact() {
944        assert!(!accessory_surfaces_touch(0.01));
945        assert!(accessory_surfaces_touch(0.0));
946        assert!(accessory_surfaces_touch(-4.0));
947    }
948
949    #[test]
950    fn lens_depth_is_an_isotropic_projection_separate_from_fluid_strain() {
951        let resting = tab_lens_base_size(TAB_WIDTH, 0.0);
952        let raised = tab_lens_base_size(TAB_WIDTH, 1.0);
953        assert_eq!(
954            resting,
955            (TAB_WIDTH * TAB_LENS_REST_WIDTH_FACTOR, BLOB_HEIGHT)
956        );
957        assert!((raised.0 / resting.0 - FLIGHT_LENS_PROJECTION_SCALE).abs() < 0.001);
958        assert!((raised.1 / resting.1 - FLIGHT_LENS_PROJECTION_SCALE).abs() < 0.001);
959        assert!(
960            raised.0 * raised.1 / (resting.0 * resting.1) < 1.38,
961            "depth projection must not masquerade as fluid volume inflation"
962        );
963    }
964
965    #[test]
966    fn tab_grid_matches_the_reference_pitch() {
967        assert_eq!(TAB_WIDTH, 78.0);
968    }
969
970    #[test]
971    fn tab_grid_matches_the_reference_inner_inset() {
972        assert_eq!(BLOB_MARGIN, 8.0);
973    }
974
975    #[test]
976    fn flight_lens_uses_the_clear_wcksrd_contract() {
977        let glass = tab_flight_lens_material(cranpose_ui_graphics::Color::BLACK);
978        let generic_lens = Glass::lens();
979        assert!(glass
980            .lift
981            .is_some_and(|lift| (-0.02..=0.02).contains(&lift)));
982        assert!(glass.refraction_depth < generic_lens.refraction_depth);
983        assert!(glass.refraction_curve < generic_lens.refraction_curve);
984        assert!(glass.dispersion < generic_lens.dispersion);
985        assert_eq!(glass.blur_radius, Some(0.0));
986        assert!(glass.highlight < generic_lens.highlight);
987        assert!(
988            glass.shadow,
989            "the moving lens needs its target-visible SDF contact outline"
990        );
991        assert!(glass
992            .tint
993            .is_some_and(|tint| { tint.r() < 0.05 && (0.125..=0.135).contains(&tint.a()) }));
994        assert_eq!(glass.adaptive_frost, 0.0);
995    }
996
997    #[test]
998    fn flight_lens_retains_neutral_tint_through_direct_motion() {
999        assert_eq!(tab_flight_tint_multiplier(0.0), 1.0);
1000        assert!((tab_flight_tint_multiplier(1.0) - 0.75).abs() < f32::EPSILON);
1001        assert_eq!(tab_flight_tint_multiplier(-1.0), 1.0);
1002        assert!((tab_flight_tint_multiplier(2.0) - 0.75).abs() < f32::EPSILON);
1003    }
1004
1005    #[test]
1006    fn bar_surface_adapts_frost_to_its_foreground() {
1007        let glass = tab_bar_surface_material(cranpose_ui_graphics::Color::BLACK);
1008        assert_eq!(glass.blur_radius, Some(4.0));
1009        assert_eq!(glass.saturation, Some(1.12));
1010        assert_eq!(glass.lift, Some(0.30));
1011        assert_eq!(glass.refraction_depth, 0.34);
1012        assert_eq!(glass.adaptive_frost, 0.28);
1013    }
1014
1015    #[test]
1016    fn bar_surface_lift_tracks_the_local_foreground_polarity() {
1017        let light_surface = tab_bar_surface_material(cranpose_ui_graphics::Color::BLACK);
1018        assert_eq!(light_surface.lift, Some(0.30));
1019
1020        let dark_surface = tab_bar_surface_material(cranpose_ui_graphics::Color::WHITE);
1021        assert_eq!(dark_surface.lift, Some(-0.24));
1022    }
1023
1024    #[test]
1025    fn bar_surface_tint_separates_from_same_polarity_backdrops() {
1026        let light_surface = tab_bar_surface_material(cranpose_ui_graphics::Color::BLACK)
1027            .tint
1028            .expect("bar tint");
1029        assert!(light_surface.r() < 0.05);
1030        assert_eq!(light_surface.a(), 0.0);
1031
1032        let dark_surface = tab_bar_surface_material(cranpose_ui_graphics::Color::WHITE)
1033            .tint
1034            .expect("bar tint");
1035        assert!(dark_surface.r() > 0.95);
1036        assert!((0.03..=0.05).contains(&dark_surface.a()));
1037    }
1038
1039    #[test]
1040    fn arrival_contraction_uses_the_measured_fast_settle() {
1041        let cranpose_animation::AnimationType::Spring(settle) = tab_lens_activity_motion() else {
1042            panic!("arrival contraction must use a spring");
1043        };
1044        assert_eq!(settle.damping_ratio, 1.0);
1045        assert_eq!(settle.stiffness, 900.0);
1046    }
1047}