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::{neutral_surface_tint, Glass, GlassDynamics, GlassMorph, LiquidModifierExt};
6use crate::motion::LiquidMotion;
7use crate::theme::{liquid_colors, liquid_typography};
8use cranpose_foundation::PointerId;
9use cranpose_macros::composable;
10use cranpose_services::{default_haptics, HapticFeedback};
11use cranpose_ui::text::{FontWeight, SpanStyle, TextStyle};
12use cranpose_ui::widgets::{
13    Box, BoxSpec, BoxWithConstraints, BoxWithConstraintsScope, Column, ColumnSpec, Row, RowSpec,
14    Text,
15};
16use cranpose_ui::{
17    Brush, Color, CornerRadii, Modifier, PointerEventKind, PointerInputScope, Rect, Size,
18};
19use cranpose_ui_graphics::{GlassProfileCurve, GlassSurfaceProfile};
20use cranpose_ui_layout::{Alignment, HorizontalAlignment, VerticalAlignment};
21use std::cell::RefCell;
22use std::rc::Rc;
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 = if scale.is_finite() {
64            scale.clamp(0.5, 1.5)
65        } else {
66            1.0
67        };
68        self
69    }
70}
71
72const BAR_HEIGHT: f32 = 64.0;
73const BLOB_HEIGHT: f32 = 52.0;
74const BLOB_MARGIN: f32 = 8.0;
75const FLIGHT_LENS_WIDTH_FACTOR: f32 = 1.17;
76const FLIGHT_LENS_HEIGHT_FACTOR: f32 = 1.0;
77const FLIGHT_LENS_DEPTH_ACTIVE: f32 = 0.0;
78const FLIGHT_LENS_DEPTH_MOTION: f32 = 0.12;
79const FLIGHT_SURFACE_RADIAL_POWER: f32 = 1.5;
80const FLIGHT_SURFACE_AXIS_COUPLING: f32 = 0.78;
81/// Width allotted to each tab inside the pill.
82const TAB_WIDTH: f32 = 78.0;
83/// Plain icon frame size (its path occupies about 25dp over 11dp labels).
84const TAB_ICON_SIZE: f32 = 32.0;
85const TAB_LABEL_SIZE: f32 = 11.0;
86/// The drag lens overflows the pill vertically like the reference bubble.
87const TAP_SLOP: f32 = 6.0;
88const ACCESSORY_GAP: f32 = 10.0;
89const VISUAL_HANDOFF_TOLERANCE_IN_TABS: f32 = 0.12;
90
91fn flight_surface_profile() -> GlassSurfaceProfile {
92    let x_profile = GlassProfileCurve::from_points(&[
93        (0.0, 0.50),
94        (0.16, 0.508),
95        (0.34, 0.54),
96        (0.56, 0.44),
97        (0.78, 0.34),
98        (1.0, 0.24),
99    ])
100    .expect("tab flight X-Z profile is valid");
101    let y_profile = GlassProfileCurve::from_points(&[
102        (0.0, 0.50),
103        (0.16, 0.50),
104        (0.30, 0.50),
105        (0.52, 0.515),
106        (0.76, 0.558),
107        (1.0, 0.51),
108    ])
109    .expect("tab flight Y-Z profile is valid");
110    GlassSurfaceProfile::new(x_profile, y_profile, 30.0, FLIGHT_SURFACE_RADIAL_POWER)
111        .and_then(|profile| profile.with_axis_coupling(FLIGHT_SURFACE_AXIS_COUPLING))
112        .expect("tab flight surface profile is coherent")
113}
114
115fn tab_flight_lens_material(
116    foreground: cranpose_ui_graphics::Color,
117    accent: cranpose_ui_graphics::Color,
118) -> Glass {
119    Glass::lens()
120        .no_clip()
121        .tint(neutral_surface_tint(foreground, 0.08, 0.10))
122        .content_recolor(accent, 1.0)
123        .blur_radius(2.0)
124        .lift(0.0)
125        .highlight(0.32)
126        .surface_profile(flight_surface_profile())
127        .sheen(0.12)
128        .chromatic_aberration(1.2)
129        .displacement(55.0)
130}
131
132fn tab_bar_surface_material(foreground: cranpose_ui_graphics::Color) -> Glass {
133    Glass::regular()
134        .tint(neutral_surface_tint(foreground, 0.028, 0.04))
135        .blur_radius(16.0)
136        .saturation(0.95)
137        .lift(0.48)
138        .highlight(0.20)
139        .surface_profile(
140            GlassSurfaceProfile::regular()
141                .with_depth(3.0)
142                .expect("tab bar surface depth is valid"),
143        )
144        .adaptive_frost(foreground, 0.42)
145        .edge_fold(1.0)
146}
147
148fn tab_flight_depth_boost(activity: f32, energy: f32) -> f32 {
149    activity.clamp(0.0, 1.0)
150        * (FLIGHT_LENS_DEPTH_ACTIVE + FLIGHT_LENS_DEPTH_MOTION * energy.clamp(0.0, 1.0))
151}
152
153fn tab_flight_tint_multiplier(activity: f32) -> f32 {
154    1.0 - 0.60 * activity.clamp(0.0, 1.0)
155}
156
157fn tab_lens_activity_motion(active: bool) -> cranpose_animation::AnimationType {
158    if active {
159        LiquidMotion::smooth()
160    } else {
161        cranpose_animation::spring(1.0, 1400.0)
162    }
163}
164
165fn tab_lens_left(pointer_x: f32, tab_width: f32, count: usize) -> f32 {
166    (pointer_x - tab_width * 0.5).clamp(-tab_width * 0.2, tab_width * (count as f32 - 0.45))
167}
168
169fn tab_visual_index(
170    selected: usize,
171    lens_x: f32,
172    tab_width: f32,
173    count: usize,
174    direct: bool,
175) -> usize {
176    if count == 0 {
177        return 0;
178    }
179    let selected = selected.min(count - 1);
180    if !direct || !lens_x.is_finite() || tab_width <= f32::EPSILON {
181        return selected;
182    }
183    (lens_x / tab_width)
184        .round()
185        .clamp(0.0, count.saturating_sub(1) as f32) as usize
186}
187
188fn tab_lens_owns_visual_selection(
189    direct: bool,
190    lens_x: f32,
191    target_x: f32,
192    tab_width: f32,
193) -> bool {
194    direct
195        || (lens_x - target_x).abs()
196            > tab_width.max(f32::EPSILON) * VISUAL_HANDOFF_TOLERANCE_IN_TABS
197}
198
199#[composable]
200#[allow(non_snake_case)]
201fn TabIcon(icon: &'static str, style: LiquidTabIconStyle, color: Color, optical_scale: f32) {
202    const FRAME_HEIGHT: f32 = 32.0;
203    Box(
204        Modifier::empty().size(Size::new(TAB_ICON_SIZE, FRAME_HEIGHT)),
205        BoxSpec::default().content_alignment(Alignment::CENTER),
206        move || match style {
207            LiquidTabIconStyle::Plain => {
208                crate::icons::Icon(icon, TAB_ICON_SIZE * optical_scale, color)
209            }
210            LiquidTabIconStyle::AppBadge => {
211                Box(
212                    Modifier::empty()
213                        .size(Size::new(20.0, FRAME_HEIGHT))
214                        .draw_behind(move |scope| {
215                            scope.draw_round_rect(Brush::solid(color), CornerRadii::uniform(5.0));
216                            scope.draw_rect_at(
217                                Rect {
218                                    x: 7.0,
219                                    y: 4.5,
220                                    width: 6.0,
221                                    height: 1.5,
222                                },
223                                Brush::solid(Color::WHITE),
224                            );
225                        }),
226                    BoxSpec::default(),
227                    move || {
228                        Box(
229                            Modifier::empty()
230                                .offset(4.0, 11.0)
231                                .size(Size::new(12.0, 12.0)),
232                            BoxSpec::default(),
233                            move || crate::icons::Icon(icon, 12.0, Color::WHITE),
234                        );
235                    },
236                );
237            }
238        },
239    );
240}
241
242fn tab_lens_node_top(node_height: f32) -> f32 {
243    (BAR_HEIGHT - node_height) * 0.5
244}
245
246fn tab_bar_accessory_gap(has_accessory: bool) -> f32 {
247    if has_accessory {
248        ACCESSORY_GAP
249    } else {
250        0.0
251    }
252}
253
254fn tab_lens_base_size(tab_width: f32, activity: f32) -> (f32, f32) {
255    let activity = activity.clamp(0.0, 1.0);
256    let ease = activity * activity * (3.0 - 2.0 * activity);
257    let rest_width = tab_width * 1.08;
258    let active_width = tab_width * FLIGHT_LENS_WIDTH_FACTOR;
259    (
260        rest_width + (active_width - rest_width) * ease,
261        BLOB_HEIGHT + (BAR_HEIGHT * FLIGHT_LENS_HEIGHT_FACTOR - BLOB_HEIGHT) * ease,
262    )
263}
264
265fn tab_flight_launch_wake(
266    pose: crate::dynamics::LiquidPose,
267    base_height: f32,
268) -> Option<(f32, f32)> {
269    let compression = ((1.0 - pose.stretch) / (1.0 - crate::dynamics::STRETCH_MIN)).clamp(0.0, 1.0);
270    if compression <= 0.03 {
271        return None;
272    }
273    let amplitude = base_height * (0.04 + 0.04 * compression);
274    let trailing = (-pose.axis.0, -pose.axis.1);
275    Some((amplitude, trailing.1.atan2(trailing.0)))
276}
277
278/// A unified floating glass tab bar with every destination inside one pill.
279#[composable]
280#[allow(non_snake_case)]
281pub fn LiquidTabBar(
282    modifier: Modifier,
283    tabs: Vec<LiquidTab>,
284    selected: usize,
285    on_select: impl Fn(usize) + 'static,
286) {
287    LiquidTabBarLayout(modifier, tabs, selected, on_select, false, || {});
288}
289
290/// A floating glass tab bar with a detached accessory to its right.
291#[composable]
292#[allow(non_snake_case)]
293pub fn LiquidTabBarWithAccessory(
294    modifier: Modifier,
295    tabs: Vec<LiquidTab>,
296    selected: usize,
297    on_select: impl Fn(usize) + 'static,
298    accessory: impl FnMut() + 'static,
299) {
300    LiquidTabBarLayout(modifier, tabs, selected, on_select, true, accessory);
301}
302
303#[composable]
304#[allow(non_snake_case)]
305fn LiquidTabBarLayout(
306    modifier: Modifier,
307    tabs: Vec<LiquidTab>,
308    selected: usize,
309    on_select: impl Fn(usize) + 'static,
310    has_accessory: bool,
311    accessory: impl FnMut() + 'static,
312) {
313    let colors = liquid_colors();
314    let typography = liquid_typography();
315    let count = tabs.len().max(1);
316    let selected = selected.min(count - 1);
317    let on_select: Rc<dyn Fn(usize)> = Rc::new(on_select);
318    let tabs = Rc::new(tabs);
319    let accessory = Rc::new(RefCell::new(accessory));
320
321    Row(
322        modifier,
323        RowSpec::default().vertical_alignment(VerticalAlignment::CenterVertically),
324        move || {
325            let tabs = Rc::clone(&tabs);
326            let typography = typography.clone();
327            let on_select = Rc::clone(&on_select);
328            let accessory = Rc::clone(&accessory);
329
330            // The main pill (wrapped in a stack so the drag lens can float
331            // ABOVE the finished bar and magnify icons + glass together).
332            let lens_x_outer = cranpose_core::remember(|| {
333                cranpose_core::mutableStateOf((
334                    0.0f32,
335                    0.0f32,
336                    0.0f32,
337                    crate::dynamics::LiquidPose::default(),
338                ))
339            })
340            .with(|state| *state);
341            // The stack is pinned to the pill height so the mounting lens
342            // (taller than the bar) can never inflate it — an unpinned stack
343            // grew on press and the centering Row shifted the whole bar down.
344            Box(
345                Modifier::empty().height(BAR_HEIGHT),
346                BoxSpec::default(),
347                move || {
348                    let tabs = Rc::clone(&tabs);
349                    let typography = typography.clone();
350                    let on_select = Rc::clone(&on_select);
351                    // The bar's edge is defined by shadow and contrast, not a
352                    // bright rim stroke.
353                    let pill = Modifier::empty()
354                        .glass_effect(
355                            // Dark labels must never sink into dark content
356                            // scrolling beneath: the glass lifts adaptively over
357                            // dark backdrops (inert over the light ones the
358                            // pinned captures use).
359                            tab_bar_surface_material(colors.label),
360                        )
361                        .height(BAR_HEIGHT);
362                    Box(pill, BoxSpec::default(), move || {
363                        let tabs = Rc::clone(&tabs);
364                        let typography = typography.clone();
365                        let on_select = Rc::clone(&on_select);
366                        BoxWithConstraints(Modifier::empty().padding(BLOB_MARGIN), move |scope| {
367                            let tabs = Rc::clone(&tabs);
368                            let typography = typography.clone();
369                            let on_select = Rc::clone(&on_select);
370                            let constrained = scope.constraints().max_width;
371                            let tab_width = if constrained.is_finite() && constrained > 1.0 {
372                                (constrained / count as f32).min(TAB_WIDTH)
373                            } else {
374                                TAB_WIDTH
375                            };
376
377                            // One optical body owns selection at rest, under direct
378                            // manipulation, and throughout release settle.
379                            let lens_pressed =
380                                cranpose_core::remember(|| cranpose_core::mutableStateOf(false))
381                                    .with(|state| *state);
382                            let resting_lens_x = tab_width * selected as f32;
383                            let lens_axis =
384                                crate::motion::remember_liquid_drag_axis(resting_lens_x);
385                            lens_axis.settle_to(resting_lens_x, LiquidMotion::glide());
386                            let lens_x = lens_axis.value();
387                            let lens_pose = lens_axis.liquid_pose();
388                            let lens_settling = !lens_axis.is_dragging()
389                                && (lens_x - resting_lens_x).abs() > tab_width * 0.75;
390                            let lens_activity_target = if lens_pressed.get() || lens_settling {
391                                1.0
392                            } else {
393                                0.0
394                            };
395                            let lens_activity_anim = cranpose_animation::animateFloatAsState(
396                                lens_activity_target,
397                                tab_lens_activity_motion(lens_activity_target > 0.5),
398                                "tabbar-lens-activity",
399                            );
400                            let lens_activity = move || {
401                                if lens_activity_target > 0.5 {
402                                    1.0
403                                } else {
404                                    lens_activity_anim.get()
405                                }
406                            };
407
408                            let cells_tabs = Rc::clone(&tabs);
409                            let visual_index = tab_visual_index(
410                                selected,
411                                lens_x,
412                                tab_width,
413                                count,
414                                tab_lens_owns_visual_selection(
415                                    lens_pressed.get(),
416                                    lens_x,
417                                    resting_lens_x,
418                                    tab_width,
419                                ),
420                            );
421                            Row(Modifier::empty(), RowSpec::default(), move || {
422                                for (index, tab) in cells_tabs.iter().enumerate() {
423                                    let color = if index == visual_index {
424                                        colors.accent
425                                    } else {
426                                        colors.label
427                                    };
428                                    let label_for_semantics = tab.label;
429                                    let cell = Modifier::empty()
430                                        .size(Size::new(tab_width, BLOB_HEIGHT))
431                                        .semantics(move |config| {
432                                            config.is_button = true;
433                                            config.is_clickable = true;
434                                            config.content_description =
435                                                Some(label_for_semantics.to_string());
436                                        });
437                                    let icon = tab.icon;
438                                    let icon_style = tab.icon_style;
439                                    let icon_scale = tab.icon_scale;
440                                    let label = tab.label;
441                                    let label_style = TextStyle {
442                                        span_style: SpanStyle {
443                                            color: Some(color),
444                                            font_size: cranpose_ui::text::TextUnit::Sp(
445                                                TAB_LABEL_SIZE,
446                                            ),
447                                            font_weight: Some(FontWeight::MEDIUM),
448                                            ..typography.caption1.span_style.clone()
449                                        },
450                                        ..typography.caption1.clone()
451                                    };
452                                    Box(
453                                        cell,
454                                        BoxSpec::default().content_alignment(Alignment::CENTER),
455                                        move || {
456                                            let label_style = label_style.clone();
457                                            Column(
458                                                Modifier::empty(),
459                                                ColumnSpec::default().horizontal_alignment(
460                                                    HorizontalAlignment::CenterHorizontally,
461                                                ),
462                                                move || {
463                                                    TabIcon(icon, icon_style, color, icon_scale);
464                                                    Text(
465                                                        label,
466                                                        Modifier::empty(),
467                                                        label_style.clone(),
468                                                    );
469                                                },
470                                            );
471                                        },
472                                    );
473                                }
474                            });
475
476                            // Swipe/tap surface across the whole pill interior.
477                            let row_width = tab_width * count as f32;
478                            let gesture = Modifier::empty()
479                                .size(Size::new(row_width, BLOB_HEIGHT))
480                                .pointer_input(selected, {
481                                    let on_select = Rc::clone(&on_select);
482                                    let lens_axis = Rc::clone(&lens_axis);
483                                    move |scope: PointerInputScope| {
484                                        let on_select = Rc::clone(&on_select);
485                                        let lens_axis = Rc::clone(&lens_axis);
486                                        async move {
487                                            scope
488                                                .await_pointer_event_scope(
489                                                    |await_scope| async move {
490                                                        let mut down_x = 0.0f32;
491                                                        let mut active_pointer =
492                                                            Option::<PointerId>::None;
493                                                        let mut moved = false;
494                                                        loop {
495                                                            let event = await_scope
496                                                                .await_pointer_event()
497                                                                .await;
498                                                            match event.kind {
499                                                                PointerEventKind::Down
500                                                                    if active_pointer.is_none() =>
501                                                                {
502                                                                    active_pointer = Some(event.id);
503                                                                    moved = false;
504                                                                    down_x = event.position.x;
505                                                                    lens_axis.begin(
506                                                                        tab_lens_left(
507                                                                            event.position.x,
508                                                                            tab_width,
509                                                                            count,
510                                                                        ),
511                                                                        event.time_ms,
512                                                                    );
513                                                                    lens_pressed.set(true);
514                                                                    default_haptics().perform(
515                                                                        HapticFeedback::Selection,
516                                                                    );
517                                                                    event.consume();
518                                                                }
519                                                                PointerEventKind::Move
520                                                                    if active_pointer
521                                                                        == Some(event.id) =>
522                                                                {
523                                                                    moved |= (event.position.x
524                                                                        - down_x)
525                                                                        .abs()
526                                                                        > TAP_SLOP;
527                                                                    lens_axis.move_to(
528                                                                        tab_lens_left(
529                                                                            event.position.x,
530                                                                            tab_width,
531                                                                            count,
532                                                                        ),
533                                                                        event.time_ms,
534                                                                    );
535                                                                    event.consume();
536                                                                }
537                                                                PointerEventKind::Up
538                                                                    if active_pointer
539                                                                        == Some(event.id) =>
540                                                                {
541                                                                    active_pointer = None;
542                                                                    lens_pressed.set(false);
543                                                                    let commit_x = if moved {
544                                                                        event.position.x
545                                                                    } else {
546                                                                        down_x
547                                                                    };
548                                                                    let index = ((commit_x
549                                                                        / tab_width)
550                                                                        .floor()
551                                                                        as isize)
552                                                                        .clamp(
553                                                                            0,
554                                                                            count as isize - 1,
555                                                                        )
556                                                                        as usize;
557                                                                    lens_axis.release_to(
558                                                                        tab_width * index as f32,
559                                                                        event.time_ms,
560                                                                        LiquidMotion::glide(),
561                                                                    );
562                                                                    default_haptics().perform(
563                                                                        HapticFeedback::ImpactLight,
564                                                                    );
565                                                                    on_select(index);
566                                                                    event.consume();
567                                                                }
568                                                                PointerEventKind::Cancel
569                                                                    if active_pointer
570                                                                        == Some(event.id) =>
571                                                                {
572                                                                    active_pointer = None;
573                                                                    lens_pressed.set(false);
574                                                                    lens_axis.release_to(
575                                                                        resting_lens_x,
576                                                                        event.time_ms,
577                                                                        LiquidMotion::glide(),
578                                                                    );
579                                                                    event.consume();
580                                                                }
581                                                                _ => {}
582                                                            }
583                                                        }
584                                                    },
585                                                )
586                                                .await;
587                                        }
588                                    }
589                                });
590                            Box(gesture, BoxSpec::default(), || {});
591
592                            // Publish the lens springs for the overlay rendered
593                            // ABOVE the finished bar (outside this glass layer, so
594                            // the lens magnifies icons and glass together).
595                            let published = (lens_x, lens_activity(), tab_width, lens_pose);
596                            if lens_x_outer.get() != published {
597                                lens_x_outer.set(published);
598                            }
599                        });
600                    });
601
602                    // The lens bubble, floating above the whole pill. Its shape
603                    // follows the droplet law (crate::dynamics): cruising speed
604                    // stretches it along the travel axis, launch compresses it,
605                    // braking swells the leading edge — orthogonal axis inverse,
606                    // area conserved — and it magnifies harder in motion. The
607                    // search accessory's circle joins its liquid field: drag the
608                    // lens to the bar's end and the two glue through a
609                    // smooth-union neck.
610                    let (lens_px, lens_activity, lens_tab_w, pose) = lens_x_outer.get();
611                    {
612                        let lens_w = lens_tab_w * FLIGHT_LENS_WIDTH_FACTOR;
613                        let lens_h = BAR_HEIGHT * FLIGHT_LENS_HEIGHT_FACTOR;
614                        // Node headroom for the deformation extremes (max axis
615                        // stretch + leading bulge, max ortho swell) and rim glow.
616                        let deformation_headroom =
617                            crate::dynamics::STRETCH_MAX.max(1.0 / crate::dynamics::STRETCH_MIN);
618                        let node_w =
619                            lens_w * deformation_headroom + crate::dynamics::BULGE_MAX + 20.0;
620                        let node_h =
621                            lens_h * deformation_headroom + crate::dynamics::BULGE_MAX + 16.0;
622                        let lens_center_x = BLOB_MARGIN + lens_px + lens_tab_w * 0.5;
623                        let node_x = lens_center_x - node_w * 0.5;
624                        let pill_w = lens_tab_w * count as f32 + 2.0 * BLOB_MARGIN;
625                        // Accessory circle center in lens-node-local coords.
626                        let accessory_cx =
627                            pill_w + tab_bar_accessory_gap(has_accessory) + BAR_HEIGHT * 0.5
628                                - node_x;
629                        let accessory_cy = node_h * 0.5;
630                        let lens = Modifier::empty()
631                            // required_size: the stack is pinned to BAR_HEIGHT so
632                            // the taller lens can never inflate the bar; the node
633                            // still measures (and draws) at its full size and the
634                            // offset centers it on the pill.
635                            .required_size(Size::new(node_w, node_h))
636                            .offset(node_x, tab_lens_node_top(node_h))
637                            .glass_effect_with(
638                                tab_flight_lens_material(colors.label, colors.accent),
639                                move || {
640                                    let (base_w, base_h) =
641                                        tab_lens_base_size(lens_tab_w, lens_activity);
642                                    let energy = pose.energy();
643                                    // Continuous-curvature read: the resting lens
644                                    // is a flattened squircle, not a stadium; it
645                                    // rounds toward a capsule only at speed.
646                                    let radius = base_h * (0.48 + 0.02 * energy);
647                                    // The search circle joins the liquid field only
648                                    // when the lens edge actually gets within glue
649                                    // reach — passing glue, not a permanent overdraw
650                                    // (a far shape re-rendered by this node would
651                                    // paint a spurious rim over the real button).
652                                    let glue = 20.0;
653                                    let edge_gap = (accessory_cx - node_w * 0.5).abs()
654                                        - base_w * pose.stretch.max(pose.ortho) * 0.5
655                                        - BAR_HEIGHT * 0.5;
656                                    // Join only when nearly touching: a parked lens
657                                    // one cell away must not repaint the circle.
658                                    let shapes = if edge_gap < 10.0 {
659                                        vec![(
660                                            accessory_cx,
661                                            accessory_cy,
662                                            BAR_HEIGHT,
663                                            BAR_HEIGHT,
664                                            -1.0,
665                                        )]
666                                    } else {
667                                        Vec::new()
668                                    };
669                                    let (bulge_amplitude, bulge_direction) =
670                                        tab_flight_launch_wake(pose, base_h).unwrap_or((
671                                            pose.bulge_amplitude.min(8.0),
672                                            pose.bulge_direction,
673                                        ));
674                                    GlassDynamics {
675                                        morph: Some(GlassMorph {
676                                            node_size: (node_w, node_h),
677                                            primary: (
678                                                node_w * 0.5,
679                                                node_h * 0.5,
680                                                base_w,
681                                                base_h,
682                                                radius,
683                                            ),
684                                            shapes,
685                                            glue,
686                                            // A whisper: the reference outline
687                                            // stays one smooth curve in every
688                                            // frame — strong lobes read as a
689                                            // lumpy peanut.
690                                            wobble_amplitude: 1.1 * energy,
691                                            wobble_phase: lens_px * 0.045,
692                                            bulge_amplitude,
693                                            bulge_direction,
694                                            ellipse_blend: 0.0,
695                                            deformation: Some(pose.deformation()),
696                                        }),
697                                        surface_depth_boost: tab_flight_depth_boost(
698                                            lens_activity,
699                                            energy,
700                                        ),
701                                        tint_alpha_multiplier: Some(tab_flight_tint_multiplier(
702                                            lens_activity,
703                                        )),
704                                        optical_strength: 0.18 + 0.67 * lens_activity,
705                                        ..Default::default()
706                                    }
707                                },
708                            );
709                        Box(lens, BoxSpec::default(), || {});
710                    }
711                },
712            );
713
714            if has_accessory {
715                Box(
716                    Modifier::empty().width(tab_bar_accessory_gap(true)),
717                    BoxSpec::default(),
718                    || {},
719                );
720                (accessory.borrow_mut())();
721            }
722        },
723    );
724}
725
726/// The standard detached accessory: a circular glass search button.
727#[composable]
728#[allow(non_snake_case)]
729pub fn LiquidTabBarSearchAccessory(on_click: impl Fn() + 'static) {
730    // The reference search circle is nearly flush with the bar height.
731    crate::widgets::GlassIconButton(
732        Modifier::empty(),
733        crate::widgets::GlassButtonSpec::glass(),
734        BAR_HEIGHT * 0.94,
735        on_click,
736        crate::icons::SEARCH,
737    );
738}
739
740#[cfg(test)]
741mod tests {
742    use super::*;
743
744    #[test]
745    fn drag_pointer_centers_the_lens_and_preserves_end_overdrag() {
746        let width = 100.0;
747        assert_eq!(tab_lens_left(50.0, width, 4), 0.0);
748        assert_eq!(tab_lens_left(250.0, width, 4), 200.0);
749        assert_eq!(tab_lens_left(-100.0, width, 4), -20.0);
750        assert_eq!(tab_lens_left(500.0, width, 4), 355.0);
751    }
752
753    #[test]
754    fn flight_lens_node_is_centered_on_the_bar_axis() {
755        for node_height in [48.0, 64.0, 96.0, 128.0] {
756            let center = tab_lens_node_top(node_height) + node_height * 0.5;
757            assert!((center - BAR_HEIGHT * 0.5).abs() < f32::EPSILON);
758        }
759    }
760
761    #[test]
762    fn liquid_tab_builds_reference_content() {
763        assert_eq!(TAB_ICON_SIZE, 32.0);
764        let tab = LiquidTab::new(crate::icons::STAR, "Discover");
765        assert_eq!(tab.icon, crate::icons::STAR);
766        assert_eq!(tab.label, "Discover");
767        assert_eq!(tab.icon_style, LiquidTabIconStyle::Plain);
768
769        let badge = LiquidTab::app_badge(crate::icons::APPLE, "WWDC");
770        assert_eq!(badge.icon_style, LiquidTabIconStyle::AppBadge);
771
772        let compact = LiquidTab::new(crate::icons::ACCOUNT_CIRCLE, "Account").with_icon_scale(0.72);
773        assert!((compact.icon_scale - 0.72).abs() < f32::EPSILON);
774        assert_eq!(tab.clone().with_icon_scale(f32::NAN).icon_scale, 1.0);
775        assert_eq!(tab.with_icon_scale(2.0).icon_scale, 1.5);
776    }
777
778    #[test]
779    fn direct_drag_selects_the_visual_tab_under_the_lens() {
780        assert_eq!(tab_visual_index(2, 0.0, TAB_WIDTH, 4, false), 2);
781        assert_eq!(tab_visual_index(0, 2.0 * TAB_WIDTH, TAB_WIDTH, 4, true), 2);
782        assert_eq!(tab_visual_index(0, 99.0 * TAB_WIDTH, TAB_WIDTH, 4, true), 3);
783        assert!(tab_lens_owns_visual_selection(
784            false, TAB_WIDTH, 0.0, TAB_WIDTH
785        ));
786        assert!(!tab_lens_owns_visual_selection(
787            false,
788            TAB_WIDTH * 0.05,
789            0.0,
790            TAB_WIDTH
791        ));
792    }
793
794    #[test]
795    fn unified_bar_has_no_detached_accessory_gap() {
796        assert_eq!(tab_bar_accessory_gap(false), 0.0);
797        assert_eq!(tab_bar_accessory_gap(true), 10.0);
798    }
799
800    #[test]
801    fn one_lens_morphs_between_rest_and_full_flight_footprints() {
802        let width = TAB_WIDTH * FLIGHT_LENS_WIDTH_FACTOR;
803        let height = BAR_HEIGHT * FLIGHT_LENS_HEIGHT_FACTOR;
804        assert!(
805            (1.165..=1.175).contains(&(width / TAB_WIDTH)),
806            "the undeformed optic must reach the target width after physical flight stretch: {width}"
807        );
808        assert!(
809            (0.98..=1.02).contains(&(height / BAR_HEIGHT)),
810            "the moving optic shares the target bar's 64dp vertical footprint: {height}"
811        );
812        assert_eq!(
813            tab_lens_base_size(TAB_WIDTH, 0.0),
814            (TAB_WIDTH * 1.08, BLOB_HEIGHT)
815        );
816        assert_eq!(tab_lens_base_size(TAB_WIDTH, 1.0), (width, height));
817    }
818
819    #[test]
820    fn tab_grid_matches_the_reference_pitch() {
821        assert_eq!(TAB_WIDTH, 78.0);
822    }
823
824    #[test]
825    fn tab_grid_matches_the_reference_inner_inset() {
826        assert_eq!(BLOB_MARGIN, 8.0);
827    }
828
829    #[test]
830    fn flight_lens_couples_principal_profiles_into_rounded_side_lobes() {
831        let coupling = flight_surface_profile().axis_coupling();
832        assert!(
833            (0.70..=0.85).contains(&coupling),
834            "the target's rounded side lobes require both principal profiles off-axis; full toric isolation leaves upright icon columns, got {coupling}"
835        );
836    }
837
838    #[test]
839    fn flight_lens_uses_clear_target_range_optics() {
840        let accent = cranpose_ui_graphics::Color::rgb(0.0, 0.48, 1.0);
841        let glass = tab_flight_lens_material(cranpose_ui_graphics::Color::BLACK, accent);
842        assert!(glass
843            .lift
844            .is_some_and(|lift| (-0.02..=0.02).contains(&lift)));
845        assert!((1.1..=1.3).contains(&glass.chromatic_aberration));
846        assert_eq!(glass.blur_radius, Some(2.0));
847        assert!((0.28..=0.36).contains(&glass.highlight));
848        assert!((52.0..=58.0).contains(&glass.displacement));
849        assert!((29.0..=31.0).contains(&glass.surface_profile.depth()));
850        assert_eq!(glass.surface_profile.radial_power(), 1.5);
851        assert_eq!(glass.surface_profile.axis_coupling(), 0.78);
852        assert!(
853            glass.shadow,
854            "the moving lens needs its target-visible SDF contact outline"
855        );
856        assert!(glass
857            .tint
858            .is_some_and(|tint| { tint.r() < 0.05 && (0.075..=0.085).contains(&tint.a()) }));
859        assert!(glass
860            .sheen
861            .is_some_and(|sheen| (0.08..=0.16).contains(&sheen)));
862        assert!(glass.content_recolor.is_some_and(|(color, strength)| {
863            color == accent && (0.95..=1.0).contains(&strength)
864        }));
865        assert!(
866            (-9.0..=-5.0).contains(
867                &(glass.surface_profile.y_profile().evaluate(0.94).1
868                    * glass.surface_profile.depth())
869            ),
870            "the short-axis outer return must form the target's deep crown ridge"
871        );
872        let depth = glass.surface_profile.depth();
873        let x_return_inner = glass.surface_profile.x_profile().evaluate(0.453).1 * depth;
874        let x_return_mid = glass.surface_profile.x_profile().evaluate(0.705).1 * depth;
875        let y_inner_shoulder = glass.surface_profile.y_profile().evaluate(0.22).1 * depth;
876        let y_shoulder = glass.surface_profile.y_profile().evaluate(0.65).1 * depth;
877        let y_outer = glass.surface_profile.y_profile().evaluate(0.90).1 * depth;
878        assert!(
879            (-19.0..=-15.0).contains(&x_return_inner)
880                && (-15.0..=-11.0).contains(&x_return_mid)
881                && y_inner_shoulder.abs() <= 0.5
882                && y_shoulder > 0.0
883                && (-9.0..=-5.0).contains(&y_outer),
884            "the long-axis return must allocate broad side lobes while the short axis magnifies through its inner shoulder and keeps its crown return: x=({x_return_inner}, {x_return_mid}), y=({y_inner_shoulder}, {y_shoulder}, {y_outer})"
885        );
886        assert_eq!(
887            glass
888                .surface_profile
889                .y_profile()
890                .knots()
891                .last()
892                .map(|knot| knot.height()),
893            Some(0.51)
894        );
895        assert_eq!(glass.surface_profile, flight_surface_profile());
896        let curve = glass.surface_profile.x_profile();
897        let epsilon = 0.001;
898        let center_curvature = (curve.evaluate(epsilon).1 - curve.evaluate(0.0).1) / epsilon;
899        let half_width = TAB_WIDTH * FLIGHT_LENS_WIDTH_FACTOR * 0.5;
900        let bend = 1.0 - 1.0 / 1.5;
901        let active_optical_strength = 0.18 + 0.67;
902        let source_scale = 1.0
903            - center_curvature
904                * glass.surface_profile.depth()
905                * bend
906                * glass.displacement
907                * active_optical_strength
908                / (half_width * half_width);
909        let magnification = 1.0 / source_scale;
910        assert!(
911            (1.20..=1.35).contains(&magnification),
912            "the authored surface must physically magnify the tab content, got {magnification}x"
913        );
914        let moving_depth = glass.surface_profile.depth() * (1.0 + tab_flight_depth_boost(1.0, 1.0));
915        let flight_strain = 1.07;
916        let moving_half_width = half_width * flight_strain;
917        let moving_source_scale = 1.0
918            - center_curvature * moving_depth * bend * glass.displacement * active_optical_strength
919                / (moving_half_width * moving_half_width);
920        let moving_magnification = 1.0 / moving_source_scale;
921        assert!(
922            (1.15..=1.35).contains(&moving_magnification),
923            "the fully resolved moving optic must stay in the measured target range, got {moving_magnification}x"
924        );
925        let horizontal_mapping = |normalized_x: f32| {
926            normalized_x
927                - curve.evaluate(normalized_x).1
928                    * moving_depth
929                    * bend
930                    * glass.displacement
931                    * active_optical_strength
932                    / (moving_half_width * moving_half_width)
933        };
934        assert!(
935            (0.58..=0.61).contains(&horizontal_mapping(0.466))
936                && (0.79..=0.82).contains(&horizontal_mapping(0.705)),
937            "the long-axis return must map the source badge into the target lobe bounds: inner={}, mid={}",
938            horizontal_mapping(0.466),
939            horizontal_mapping(0.705)
940        );
941        for step in 1..=49 {
942            let normalized_x = step as f32 * 0.02;
943            let source_scale = (horizontal_mapping(normalized_x + epsilon)
944                - horizontal_mapping(normalized_x - epsilon))
945                / (2.0 * epsilon);
946            assert!(
947                source_scale > 0.05,
948                "the full target lens must remain a single monotonic image at x={normalized_x}, got {source_scale}"
949            );
950        }
951        let vertical_mapping = |normalized_y: f32| {
952            let gradient = glass
953                .surface_profile
954                .sample_normalized((0.68, normalized_y))
955                .gradient
956                .1;
957            let half_height = BAR_HEIGHT * FLIGHT_LENS_HEIGHT_FACTOR * 0.5;
958            let normalized_gain =
959                moving_depth * glass.displacement * bend * active_optical_strength
960                    / (half_height * half_height);
961            normalized_y - gradient * normalized_gain
962        };
963        let vertical_source_scale =
964            (vertical_mapping(0.22 + epsilon) - vertical_mapping(0.22 - epsilon)) / (2.0 * epsilon);
965        assert!(
966            (0.95..=1.05).contains(&vertical_source_scale),
967            "the side-band vertical Jacobian must preserve the target glyph's native proportions, got {vertical_source_scale}"
968        );
969        assert!(
970            (0.49..=0.51).contains(&vertical_mapping(0.58)),
971            "the outer Y-Z zone must expand the source badge into the target lobe height, got {}",
972            vertical_mapping(0.58)
973        );
974        for step in 1..=35 {
975            let normalized_y = step as f32 * 0.02;
976            let source_scale = (vertical_mapping(normalized_y + epsilon)
977                - vertical_mapping(normalized_y - epsilon))
978                / (2.0 * epsilon);
979            assert!(
980                source_scale > 0.05,
981                "the visible biconic interior must stay fold-free at y={normalized_y}, got {source_scale}"
982            );
983        }
984        assert_eq!(tab_flight_depth_boost(0.0, 1.0), 0.0);
985        let still = tab_flight_depth_boost(1.0, 0.0);
986        let cruise = tab_flight_depth_boost(1.0, 1.0);
987        assert_eq!(still, 0.0);
988        assert!((0.11..=0.13).contains(&cruise));
989    }
990
991    #[test]
992    fn flight_lens_retains_neutral_tint_through_direct_motion() {
993        assert_eq!(tab_flight_tint_multiplier(0.0), 1.0);
994        assert!((tab_flight_tint_multiplier(1.0) - 0.4).abs() < f32::EPSILON);
995        assert_eq!(tab_flight_tint_multiplier(-1.0), 1.0);
996        assert!((tab_flight_tint_multiplier(2.0) - 0.4).abs() < f32::EPSILON);
997    }
998
999    #[test]
1000    fn bar_surface_frosts_color_inside_a_bright_folded_body() {
1001        let glass = tab_bar_surface_material(cranpose_ui_graphics::Color::BLACK);
1002        assert_eq!(glass.blur_radius, Some(16.0));
1003        assert_eq!(glass.saturation, Some(0.95));
1004        assert_eq!(glass.lift, Some(0.48));
1005        assert_eq!(glass.edge_fold, 1.0);
1006        assert!((2.8..=3.2).contains(&glass.surface_profile.depth()));
1007    }
1008
1009    #[test]
1010    fn bar_surface_tint_separates_from_same_polarity_backdrops() {
1011        let light_surface = tab_bar_surface_material(cranpose_ui_graphics::Color::BLACK)
1012            .tint
1013            .expect("bar tint");
1014        assert!(light_surface.r() < 0.05);
1015        assert!((0.02..=0.04).contains(&light_surface.a()));
1016
1017        let dark_surface = tab_bar_surface_material(cranpose_ui_graphics::Color::WHITE)
1018            .tint
1019            .expect("bar tint");
1020        assert!(dark_surface.r() > 0.95);
1021        assert!((0.03..=0.05).contains(&dark_surface.a()));
1022    }
1023
1024    #[test]
1025    fn arrival_contraction_uses_the_measured_fast_settle() {
1026        let cranpose_animation::AnimationType::Spring(settle) = tab_lens_activity_motion(false)
1027        else {
1028            panic!("arrival contraction must use a spring");
1029        };
1030        assert_eq!(settle.damping_ratio, 1.0);
1031        assert!((1300.0..=1500.0).contains(&settle.stiffness));
1032    }
1033
1034    #[test]
1035    fn launch_inertia_drives_one_trailing_perimeter_lobe() {
1036        let launch = crate::dynamics::LiquidPose {
1037            stretch: 0.84,
1038            ortho: 1.0 / 0.84,
1039            axis: (-1.0, 0.0),
1040            ..Default::default()
1041        };
1042        let (amplitude, direction) = tab_flight_launch_wake(launch, 72.0)
1043            .expect("accelerating fluid needs a trailing inertia lobe");
1044        assert!((3.0..=7.0).contains(&amplitude));
1045        assert!(
1046            direction.abs() < 1e-4,
1047            "leftward flight must trail to the right"
1048        );
1049
1050        let cruise = crate::dynamics::LiquidPose {
1051            stretch: 1.02,
1052            ortho: 1.0 / 1.02,
1053            axis: (-1.0, 0.0),
1054            ..Default::default()
1055        };
1056        assert!(tab_flight_launch_wake(cruise, 72.0).is_none());
1057    }
1058}