Skip to main content

cranpose_liquid/widgets/
tab_bar.rs

1use std::{cell::RefCell, rc::Rc};
2
3use cranpose_macros::composable;
4use cranpose_ui::{
5    Brush, Color, CornerRadii, Modifier, PointerInputScope, Rect, SemanticsWidgetRole, Size,
6    text::{FontWeight, SpanStyle, TextStyle},
7    widgets::{
8        Box, BoxSpec, BoxWithConstraints, BoxWithConstraintsScope, ContentScale, Image, Painter,
9        Row, RowSpec, Text,
10    },
11};
12use cranpose_ui_layout::{Alignment, VerticalAlignment};
13
14use crate::{
15    material::{
16        Glass, GlassDynamics, GlassFaceResponse, GlassKeyFill, GlassMorph, GlassShadow,
17        LiquidModifierExt, neutral_surface_tint,
18    },
19    motion::LiquidMotion,
20    theme::{LiquidTypography, liquid_colors, liquid_typography},
21    widgets::content_scope::ScopeContent,
22};
23
24/// Visual treatment for a tab icon.
25#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
26pub enum LiquidTabIconStyle {
27    #[default]
28    Plain,
29    AppBadge,
30}
31
32/// Artwork displayed in a tab's shared icon frame.
33#[derive(Clone, Debug, PartialEq)]
34pub enum LiquidTabIcon {
35    /// Vector path data in a 24×24 view box.
36    Vector(&'static str),
37    /// Template artwork drawn at its supplied logical size and tinted with the tab's color.
38    Painter {
39        /// Image or custom painter supplying the template's alpha mask.
40        painter: Painter,
41        /// Artwork dimensions in logical points before the tab's optical correction.
42        size: Size,
43    },
44}
45
46/// One tab's artwork, label, and icon treatment.
47#[derive(Clone, Debug, PartialEq)]
48pub struct LiquidTab {
49    /// Vector or template artwork shown above the label.
50    pub icon: LiquidTabIcon,
51    pub label: &'static str,
52    pub icon_style: LiquidTabIconStyle,
53    /// Optical correction for symbols whose path bounds do not fill the
54    /// shared icon frame uniformly.
55    pub icon_scale: f32,
56    /// Optical displacement of the artwork inside the shared icon frame, in points.
57    pub icon_offset: (f32, f32),
58}
59
60impl LiquidTab {
61    pub fn new(icon: &'static str, label: &'static str) -> Self {
62        Self {
63            icon: LiquidTabIcon::Vector(icon),
64            label,
65            icon_style: LiquidTabIconStyle::Plain,
66            icon_scale: 1.0,
67            icon_offset: (0.0, 0.0),
68        }
69    }
70
71    /// A tab using template artwork and its logical size.
72    pub fn from_painter(painter: Painter, size: Size, label: &'static str) -> Self {
73        Self {
74            icon: LiquidTabIcon::Painter { painter, size },
75            label,
76            icon_style: LiquidTabIconStyle::Plain,
77            icon_scale: 1.0,
78            icon_offset: (0.0, 0.0),
79        }
80    }
81
82    pub fn app_badge(icon: &'static str, label: &'static str) -> Self {
83        Self {
84            icon: LiquidTabIcon::Vector(icon),
85            label,
86            icon_style: LiquidTabIconStyle::AppBadge,
87            icon_scale: 1.0,
88            icon_offset: (0.0, 0.0),
89        }
90    }
91
92    pub fn with_icon_scale(mut self, scale: f32) -> Self {
93        self.icon_scale = normalize_icon_scale(scale);
94        self
95    }
96
97    /// Moves the artwork without changing its cell or hit bounds; nonfinite offsets become zero.
98    pub fn with_icon_offset(mut self, x: f32, y: f32) -> Self {
99        self.icon_offset = (
100            if x.is_finite() { x } else { 0.0 },
101            if y.is_finite() { y } else { 0.0 },
102        );
103        self
104    }
105}
106
107fn normalize_icon_scale(scale: f32) -> f32 {
108    if scale.is_finite() {
109        scale.clamp(0.5, 1.5)
110    } else {
111        1.0
112    }
113}
114
115fn tab_base_content_color(colors: crate::theme::LiquidColors) -> Color {
116    colors.label
117}
118
119fn tab_selection_content_color(colors: crate::theme::LiquidColors) -> Color {
120    colors.accent
121}
122
123const BAR_HEIGHT: f32 = 62.0;
124const BLOB_HEIGHT: f32 = 54.0;
125const BLOB_MARGIN: f32 = 4.0;
126const FLIGHT_LENS_INFLATION: f32 = 16.0;
127const TAB_LENS_OVERLAP: f32 = 7.0;
128const TAB_WIDTH: f32 = 78.0;
129const TAB_ICON_SIZE: f32 = 32.0;
130const TAB_LABEL_SIZE: f32 = 10.0;
131const TAP_SLOP: f32 = 6.0;
132const ACCESSORY_GAP: f32 = 10.0;
133
134/// Layout parameters for a [`LiquidTabBar`].
135#[derive(Clone, Copy, Debug, PartialEq)]
136pub struct LiquidTabBarSpec {
137    max_tab_width: f32,
138}
139
140impl LiquidTabBarSpec {
141    /// Sets the maximum cell allocation; nonfinite values use the default and finite values are at least one point.
142    pub fn new(max_tab_width: f32) -> Self {
143        Self {
144            max_tab_width: if max_tab_width.is_finite() {
145                max_tab_width.max(1.0)
146            } else {
147                TAB_WIDTH
148            },
149        }
150    }
151
152    fn cell_width(self, available_width: f32, count: usize) -> f32 {
153        let constrained = available_width - BLOB_MARGIN * 2.0;
154        if constrained.is_finite() && constrained > 1.0 {
155            (constrained / count as f32).min(self.max_tab_width)
156        } else {
157            self.max_tab_width
158        }
159    }
160}
161
162impl Default for LiquidTabBarSpec {
163    fn default() -> Self {
164        Self::new(TAB_WIDTH)
165    }
166}
167
168fn tab_flight_lens_material(foreground: cranpose_ui_graphics::Color, activity: f32) -> Glass {
169    let activity = activity.clamp(0.0, 1.0);
170    Glass::lens()
171        .face_lighting(false)
172        .key_fill(GlassKeyFill {
173            height_dp: 1.0,
174            curvature: 0.7 + 0.1 * activity,
175            angle_radians: std::f32::consts::FRAC_PI_4,
176            saturation: 1.5,
177            luma_gain: 0.4367,
178            offset: 1.125,
179            scale_with_surface: true,
180        })
181        .no_clip()
182        .face_response(GlassFaceResponse {
183            gain: 1.0 - 0.03 * activity,
184            start_dp: 1.0 / 3.0,
185            end_dp: 2.0 / 3.0,
186            illumination: 0.0,
187        })
188        .tint(neutral_surface_tint(
189            foreground,
190            0.07333333 * (1.0 - activity),
191            0.1440678 * (1.0 - activity),
192        ))
193        .blur_radius(0.0)
194        .backdrop_blur(4.0, 1.0 - activity)
195        .edge_refraction(9.0 * activity)
196        .refraction_depth_dp(36.0)
197        .refraction_curve(0.25)
198        .optical_zoom(1.0)
199        .meniscus_absorption(0.0)
200        .inner_shadow(GlassShadow::new(
201            Color::BLACK.with_alpha(0.12 * activity),
202            3.0,
203            7.0,
204            0.0,
205        ))
206        .fold_depth(0.0)
207        .edge_spectrum(crate::material::GlassSpectrum {
208            angle_radians: std::f32::consts::FRAC_PI_2
209                - std::f32::consts::PI * 7.0 / 12.0 * activity,
210            step_dp: (4.068 / (112.24138 / 36.0)) / (std::f32::consts::PI / 12.0).cos()
211                * (-5.0 + 8.684211 * activity)
212                / 3.6842105,
213            vertical_scale: 0.8265,
214            opacity_near: activity,
215            opacity_far: 1.0 - activity,
216            fade_depth_dp: 14.0 * activity,
217            extent_dp: 38.88889 * activity,
218        })
219        .dispersion(0.0)
220        .lift(0.0)
221        .highlight(activity)
222        .rim_reflection(activity)
223        .shadow_style(GlassShadow::new(
224            cranpose_ui_graphics::Color::BLACK.with_alpha(0.1 * activity),
225            8.0,
226            7.0,
227            0.0,
228        ))
229}
230
231fn tab_bar_surface_material(foreground: cranpose_ui_graphics::Color) -> Glass {
232    Glass::regular()
233        .face_lighting(false)
234        .key_fill(GlassKeyFill {
235            height_dp: 1.0,
236            curvature: 0.7,
237            angle_radians: std::f32::consts::FRAC_PI_4,
238            saturation: 1.5,
239            luma_gain: 0.1,
240            offset: 0.9,
241            scale_with_surface: false,
242        })
243        .face_response(GlassFaceResponse {
244            gain: 0.97,
245            start_dp: 1.0 / 3.0,
246            end_dp: 2.0 / 3.0,
247            illumination: 0.0,
248        })
249        .tint(Color::TRANSPARENT)
250        .blur_radius(6.0)
251        .surface_refraction(31.0)
252        .refraction_depth_dp(15.5)
253        .transmission_refraction(1.0)
254        .saturation(1.0)
255        .lift(0.0)
256        .contrast(1.0)
257        .highlight(1.0)
258        .adaptive_frost(foreground, 0.0)
259        .adaptive_tone(foreground)
260        .shadow_style(GlassShadow::new(
261            Color::BLACK.with_alpha(0.162),
262            32.0,
263            8.0,
264            1.5,
265        ))
266}
267
268fn tab_flight_tint_multiplier(activity: f32) -> f32 {
269    1.0 - 0.25 * activity.clamp(0.0, 1.0)
270}
271
272#[derive(Clone, Copy, Debug, Default, PartialEq)]
273struct TabGeometry {
274    width: f32,
275    cell_width: f32,
276    pitch: f32,
277}
278
279impl TabGeometry {
280    fn new(allocation: f32, count: usize) -> Self {
281        let width = allocation * count.max(1) as f32;
282        let cell_width = tab_lens_rest_width(allocation).min(width);
283        let pitch = if count > 1 {
284            (width - cell_width) / (count - 1) as f32
285        } else {
286            cell_width
287        };
288        Self {
289            width,
290            cell_width,
291            pitch,
292        }
293    }
294
295    fn drag_left(self, pointer_x: f32, count: usize, has_accessory: bool) -> f32 {
296        let last_tab = self.pitch * count.saturating_sub(1) as f32;
297        let min = if has_accessory {
298            -self.pitch * 0.2
299        } else {
300            0.0
301        };
302        let max = if has_accessory {
303            last_tab + self.pitch * 0.55
304        } else {
305            last_tab
306        };
307        (pointer_x - self.cell_width * 0.5).clamp(min, max)
308    }
309
310    fn optical_pointer_x(self, pointer_x: f32, press: f32, travel: f32) -> f32 {
311        let transform = tab_bar_transform(self.width + 2.0 * BLOB_MARGIN, press, travel);
312        let center = self.width * 0.5;
313        pointer_x + (pointer_x - center) * (transform.scale_x - 1.0) + transform.translation_x
314    }
315
316    fn lens_surface_position(self, position: f32, press: f32, travel: f32) -> f32 {
317        let transform = tab_bar_transform(self.width + 2.0 * BLOB_MARGIN, press, travel);
318        (position + self.cell_width * 0.5 - self.width * 0.5) * transform.scale_x
319    }
320}
321
322/// The settled cell origin for a selected index and the distance between cell centers.
323pub fn tab_lens_resting_left(selected: usize, tab_pitch: f32, count: usize) -> f32 {
324    tab_pitch * selected.min(count.saturating_sub(1)) as f32
325}
326
327/// The resting selection width for one destination's allocation in the content strip.
328/// Cells overlap; their pitch is the remaining strip width divided by the number of gaps.
329pub fn tab_lens_rest_width(tab_allocation: f32) -> f32 {
330    tab_allocation + TAB_LENS_OVERLAP
331}
332
333#[derive(Clone, Copy, Debug, PartialEq)]
334struct AppBadgeGeometry {
335    size: Size,
336    corner_radius: f32,
337    stripe: Rect,
338    glyph: Rect,
339}
340
341fn app_badge_geometry(optical_scale: f32) -> AppBadgeGeometry {
342    let scale = normalize_icon_scale(optical_scale);
343    AppBadgeGeometry {
344        size: Size::new(20.0 * scale, 32.0 * scale),
345        corner_radius: 5.0 * scale,
346        stripe: Rect {
347            x: 7.0 * scale,
348            y: 4.5 * scale,
349            width: 6.0 * scale,
350            height: 1.5 * scale,
351        },
352        glyph: Rect {
353            x: 3.0 * scale,
354            y: 10.0 * scale,
355            width: 14.0 * scale,
356            height: 14.0 * scale,
357        },
358    }
359}
360
361#[composable]
362#[allow(non_snake_case)]
363fn TabIcon(icon: LiquidTabIcon, style: LiquidTabIconStyle, color: Color, optical_scale: f32) {
364    const FRAME_HEIGHT: f32 = 32.0;
365    Box(
366        Modifier::empty().size(Size::new(TAB_ICON_SIZE, FRAME_HEIGHT)),
367        BoxSpec::default().content_alignment(Alignment::CENTER),
368        move || match style {
369            LiquidTabIconStyle::Plain => {
370                TabGlyph(icon.clone(), TAB_ICON_SIZE * optical_scale, color)
371            }
372            LiquidTabIconStyle::AppBadge => {
373                let icon = icon.clone();
374                let geometry = app_badge_geometry(optical_scale);
375                Box(
376                    Modifier::empty()
377                        .size(geometry.size)
378                        .draw_behind(move |scope| {
379                            scope.draw_round_rect(
380                                Brush::solid(color),
381                                CornerRadii::uniform(geometry.corner_radius),
382                            );
383                            scope.draw_rect_at(geometry.stripe, Brush::solid(Color::WHITE));
384                        }),
385                    BoxSpec::default(),
386                    move || {
387                        let icon = icon.clone();
388                        Box(
389                            Modifier::empty()
390                                .offset(geometry.glyph.x, geometry.glyph.y)
391                                .size(Size::new(geometry.glyph.width, geometry.glyph.height)),
392                            BoxSpec::default(),
393                            move || TabGlyph(icon.clone(), geometry.glyph.width, Color::WHITE),
394                        );
395                    },
396                );
397            }
398        },
399    );
400}
401
402#[composable]
403fn TabGlyph(icon: LiquidTabIcon, size: f32, color: Color) {
404    match icon {
405        LiquidTabIcon::Vector(path) => crate::icons::Icon(path, None, size, color),
406        LiquidTabIcon::Painter {
407            painter,
408            size: intrinsic,
409        } => {
410            let scale = size / TAB_ICON_SIZE;
411            Image(
412                painter,
413                None,
414                Modifier::empty()
415                    .size(Size::new(intrinsic.width * scale, intrinsic.height * scale)),
416                Alignment::CENTER,
417                ContentScale::FillBounds,
418                1.0,
419                Some(cranpose_ui_graphics::ColorFilter::tint(color)),
420            );
421        }
422    }
423}
424
425fn tab_ink_selection(
426    geometry: TabGeometry,
427    position: f32,
428    activity: f32,
429    strain: Size,
430    color: Color,
431) -> super::vibrancy::InkSelection {
432    let (width, height) = tab_lens_base_size(geometry.cell_width, activity);
433    let base = Size::new(width, height);
434    let deformed = tab_lens_deformed_size(base, strain);
435    let width = deformed.width;
436    let height = deformed.height;
437    super::vibrancy::InkSelection {
438        bounds: Rect {
439            x: position + geometry.cell_width * 0.5 - width * 0.5,
440            y: BLOB_HEIGHT * 0.5 - height * 0.5,
441            width,
442            height,
443        },
444        color,
445        content_zoom: 1.0 + 0.16 * activity.clamp(0.0, 1.0),
446        activity: activity.clamp(0.0, 1.0),
447        optical_scale: 1.0,
448        projection: Size::new(deformed.width / base.width, deformed.height / base.height),
449    }
450}
451
452#[derive(Clone, Copy, PartialEq)]
453struct TabCellsSpec {
454    base_color: Color,
455    selection: super::vibrancy::InkSelection,
456    selected: usize,
457    committed_selection: usize,
458}
459
460#[composable]
461#[allow(non_snake_case)]
462fn TabCells(
463    modifier: Modifier,
464    tabs: Rc<Vec<LiquidTab>>,
465    typography: LiquidTypography,
466    geometry: TabGeometry,
467    spec: TabCellsSpec,
468    transform: cranpose_ui_graphics::GraphicsLayer,
469    on_select: impl Fn(usize) + 'static,
470) {
471    let on_select: Rc<dyn Fn(usize)> = Rc::new(on_select);
472    let size = Size::new(geometry.width + BLOB_MARGIN * 2.0, BAR_HEIGHT);
473    let mut selection = spec.selection;
474    let center = selection.bounds.x + selection.bounds.width * 0.5 + BLOB_MARGIN;
475    selection.bounds.width *= transform.scale_x;
476    selection.bounds.height *= transform.scale_y;
477    selection.optical_scale = transform.scale_x.min(transform.scale_y);
478    selection.bounds.x = (center - size.width * 0.5) * transform.scale_x
479        + size.width * 0.5
480        + transform.translation_x
481        - selection.bounds.width * 0.5;
482    selection.bounds.y = (BAR_HEIGHT - selection.bounds.height) * 0.5;
483    let grid = super::vibrancy::InkGrid {
484        first_center: (
485            (BLOB_MARGIN + geometry.cell_width * 0.5 - size.width * 0.5) * transform.scale_x
486                + size.width * 0.5
487                + transform.translation_x,
488            BAR_HEIGHT * 0.5,
489        ),
490        pitch: geometry.pitch * transform.scale_x,
491        count: tabs.len(),
492    };
493    super::vibrancy::VibrantContent(
494        modifier,
495        size,
496        spec.base_color,
497        selection,
498        grid,
499        move || {
500            let tabs = Rc::clone(&tabs);
501            let typography = typography.clone();
502            let on_select = Rc::clone(&on_select);
503            Box(
504                Modifier::empty()
505                    .size(size)
506                    .selectable_group()
507                    .role(SemanticsWidgetRole::TabBar)
508                    .graphics_layer_value(transform.clone()),
509                BoxSpec::default(),
510                move || {
511                    for (index, tab) in tabs.iter().enumerate() {
512                        let color = spec.base_color;
513                        let label_for_semantics = tab.label;
514                        let icon_offset = tab.icon_offset;
515                        let on_select = Rc::clone(&on_select);
516                        let cell = Modifier::empty()
517                            .offset(BLOB_MARGIN + index as f32 * geometry.pitch, BLOB_MARGIN)
518                            .size(Size::new(geometry.cell_width, BLOB_HEIGHT))
519                            .semantics(super::selection::selection_semantics(
520                                label_for_semantics.to_string(),
521                                SemanticsWidgetRole::Tab,
522                                index,
523                                spec.committed_selection,
524                                on_select,
525                            ))
526                            .focusable();
527                        let icon = tab.icon.clone();
528                        let icon_style = tab.icon_style;
529                        let icon_scale = tab.icon_scale;
530                        let label = tab.label;
531                        let label_style = TextStyle {
532                            span_style: SpanStyle {
533                                color: Some(color),
534                                font_size: cranpose_ui::text::TextUnit::Sp(TAB_LABEL_SIZE),
535                                font_weight: Some(if spec.selected == index {
536                                    FontWeight::SEMI_BOLD
537                                } else {
538                                    FontWeight::MEDIUM
539                                }),
540                                ..typography.caption1.span_style.clone()
541                            },
542                            paragraph_style: cranpose_ui::text::ParagraphStyle {
543                                line_height: cranpose_ui::text::TextUnit::Sp(12.0),
544                                ..typography.caption1.paragraph_style.clone()
545                            },
546                        };
547                        Box(cell, BoxSpec::default(), move || {
548                            let label_style = label_style.clone();
549                            let icon = icon.clone();
550                            Box(
551                                Modifier::empty()
552                                    .offset(
553                                        (geometry.cell_width - TAB_ICON_SIZE) * 0.5 + icon_offset.0,
554                                        3.0 + icon_offset.1,
555                                    )
556                                    .size(Size::new(TAB_ICON_SIZE, TAB_ICON_SIZE)),
557                                BoxSpec::default(),
558                                move || TabIcon(icon.clone(), icon_style, color, icon_scale),
559                            );
560                            Box(
561                                Modifier::empty()
562                                    .offset(0.0, 35.0)
563                                    .size(Size::new(geometry.cell_width, 12.0)),
564                                BoxSpec::default().content_alignment(Alignment::CENTER),
565                                move || {
566                                    Text(label, Modifier::empty(), label_style.clone());
567                                },
568                            );
569                        });
570                    }
571                },
572            );
573        },
574    );
575}
576
577fn tab_lens_node_top(node_height: f32) -> f32 {
578    (BAR_HEIGHT - node_height) * 0.5
579}
580
581#[derive(Clone, Copy)]
582struct TabBarTouch {
583    position: cranpose_core::MutableState<(f32, f32)>,
584    origin: cranpose_core::MutableState<Option<f32>>,
585    travel: cranpose_core::MutableState<f32>,
586}
587
588impl TabBarTouch {
589    fn pressed(self, down: bool) {
590        if down {
591            self.origin.set(None);
592            self.travel.set(0.0);
593        }
594    }
595
596    fn update(self, x: f32, y: f32, span: f32) {
597        self.position.set((x + BLOB_MARGIN, y + BLOB_MARGIN));
598        if let Some(origin) = self.origin.get() {
599            self.travel
600                .set(((x - origin) / span.max(1.0)).clamp(-1.0, 1.0));
601        } else {
602            self.origin.set(Some(x));
603        }
604    }
605}
606
607#[composable]
608fn remember_tab_bar_touch() -> TabBarTouch {
609    TabBarTouch {
610        position: cranpose_core::remember(|| cranpose_core::mutableStateOf((0.0f32, 0.0f32)))
611            .with(|state| *state),
612        origin: cranpose_core::remember(|| cranpose_core::mutableStateOf(None::<f32>))
613            .with(|state| *state),
614        travel: cranpose_core::remember(|| cranpose_core::mutableStateOf(0.0f32))
615            .with(|state| *state),
616    }
617}
618
619fn tab_bar_transform(width: f32, press: f32, travel: f32) -> cranpose_ui_graphics::GraphicsLayer {
620    let press = press.max(0.0);
621    let travel = travel.clamp(-1.0, 1.0);
622    let rise = 14.14 / width.max(1.0) * press;
623    let strain = 2.63 / width.max(1.0) * travel.abs() * press;
624    cranpose_ui_graphics::GraphicsLayer {
625        scale_x: 1.0 + rise + strain,
626        scale_y: 1.0 + rise - strain,
627        translation_x: 2.756 * travel * press,
628        ..Default::default()
629    }
630}
631
632fn tab_bar_accessory_gap(has_accessory: bool) -> f32 {
633    if has_accessory { ACCESSORY_GAP } else { 0.0 }
634}
635
636fn accessory_surfaces_touch(edge_gap: f32) -> bool {
637    edge_gap <= 0.0
638}
639
640fn tab_lens_base_size(cell_width: f32, activity: f32) -> (f32, f32) {
641    let inflation = FLIGHT_LENS_INFLATION * activity.clamp(0.0, 1.0);
642    (cell_width + inflation, BLOB_HEIGHT + inflation)
643}
644
645#[derive(Clone, Copy, Debug, PartialEq)]
646struct TabFlightGeometry {
647    center: (f32, f32),
648    base_size: Size,
649    strain: Size,
650    lens_position: f32,
651    lens_activity: f32,
652    resting_tint: Color,
653    accessory_center: Option<(f32, f32)>,
654}
655
656#[derive(Clone, Copy, Debug, PartialEq)]
657struct TabFlightNode {
658    origin: (f32, f32),
659    size: Size,
660}
661
662fn tab_lens_deformed_size(base: Size, strain: Size) -> Size {
663    Size::new(
664        base.width * (1.0 + strain.width.clamp(-0.35, 0.35)),
665        base.height * (1.0 + strain.height.clamp(-0.35, 0.35)),
666    )
667}
668
669fn tab_flight_dynamics(geometry: TabFlightGeometry, node: TabFlightNode) -> GlassDynamics {
670    let activity = geometry.lens_activity.clamp(0.0, 1.0);
671    let size = geometry.base_size;
672    let deformed = tab_lens_deformed_size(size, geometry.strain);
673    let radius = size.width.min(size.height) * 0.5;
674    let glue = 20.0;
675    let shapes = geometry
676        .accessory_center
677        .filter(|(x, _)| {
678            let edge_gap = (*x - geometry.center.0).abs() - size.width * 0.5 - BAR_HEIGHT * 0.5;
679            accessory_surfaces_touch(edge_gap)
680        })
681        .map(|(x, y)| {
682            vec![(
683                x - node.origin.0,
684                y - node.origin.1,
685                BAR_HEIGHT,
686                BAR_HEIGHT,
687                -1.0,
688            )]
689        })
690        .unwrap_or_default();
691    GlassDynamics {
692        edge_return_depth_dp: Some(7.0 * activity),
693        optical_projection: Some((deformed.width / size.width, deformed.height / size.height)),
694        morph: Some(GlassMorph {
695            node_size: (node.size.width, node.size.height),
696            primary: (
697                geometry.center.0 - node.origin.0,
698                geometry.center.1 - node.origin.1,
699                size.width,
700                size.height,
701                radius,
702            ),
703            shapes,
704            glue,
705            wobble_amplitude: 0.0,
706            wobble_phase: geometry.lens_position * 0.045,
707            bulge_amplitude: 0.0,
708            bulge_direction: 0.0,
709            ellipse_blend: 0.0,
710            capsule_smoothing_dp: ((size.width - size.height).abs() * 0.5).min(radius * 0.6)
711                * activity,
712            deformation: None,
713            zoom_anchor: (0.0, 0.0),
714        }),
715        activity: Some(1.0),
716        resting_tint: Some(geometry.resting_tint),
717        tint_alpha_multiplier: Some(tab_flight_tint_multiplier(geometry.lens_activity)),
718        ..Default::default()
719    }
720}
721
722/// The scope a tab bar's destinations are declared in.
723///
724/// Each call adds one destination, in the order it is made, which is also the
725/// order the indices passed to `on_select` count in.
726pub struct LiquidTabBarScope {
727    tabs: ScopeContent<LiquidTab>,
728}
729
730impl LiquidTabBarScope {
731    /// A destination showing `icon` above `label`.
732    pub fn tab(&self, icon: &'static str, label: &'static str) {
733        self.push(LiquidTab::new(icon, label));
734    }
735
736    /// A destination whose icon is drawn as an application badge.
737    pub fn app_badge(&self, icon: &'static str, label: &'static str) {
738        self.push(LiquidTab::app_badge(icon, label));
739    }
740
741    /// A destination built out, for the optical corrections a particular
742    /// symbol needs — see [`LiquidTab`].
743    pub fn push(&self, tab: LiquidTab) {
744        self.tabs.push(tab);
745    }
746}
747
748fn collect_tabs(content: impl FnOnce(&LiquidTabBarScope)) -> Vec<LiquidTab> {
749    ScopeContent::collect(|tabs| LiquidTabBarScope { tabs }, content)
750}
751
752/// A unified floating glass tab bar with every destination inside one pill.
753///
754/// ```rust,ignore
755/// LiquidTabBar(Modifier::empty(), LiquidTabBarSpec::default(), selected, on_select, |scope| {
756///     scope.tab(icons::DOCUMENT, "Today");
757///     scope.tab(icons::SEARCH, "Search");
758/// });
759/// ```
760#[composable]
761#[allow(non_snake_case)]
762pub fn LiquidTabBar(
763    modifier: Modifier,
764    spec: LiquidTabBarSpec,
765    selected: usize,
766    on_select: impl Fn(usize) + 'static,
767    content: impl FnOnce(&LiquidTabBarScope),
768) {
769    LiquidTabBarLayout(
770        modifier,
771        spec,
772        collect_tabs(content),
773        selected,
774        on_select,
775        false,
776        || {},
777    );
778}
779
780/// A floating glass tab bar with a detached accessory to its right.
781#[composable]
782#[allow(non_snake_case)]
783pub fn LiquidTabBarWithAccessory(
784    modifier: Modifier,
785    spec: LiquidTabBarSpec,
786    selected: usize,
787    on_select: impl Fn(usize) + 'static,
788    content: impl FnOnce(&LiquidTabBarScope),
789    accessory: impl FnMut() + 'static,
790) {
791    LiquidTabBarLayout(
792        modifier,
793        spec,
794        collect_tabs(content),
795        selected,
796        on_select,
797        true,
798        accessory,
799    );
800}
801
802#[composable]
803#[allow(non_snake_case)]
804fn LiquidTabBarLayout(
805    modifier: Modifier,
806    spec: LiquidTabBarSpec,
807    tabs: Vec<LiquidTab>,
808    selected: usize,
809    on_select: impl Fn(usize) + 'static,
810    has_accessory: bool,
811    accessory: impl FnMut() + 'static,
812) {
813    let colors = liquid_colors();
814    let typography = liquid_typography();
815    let tint_amount = crate::theme::liquid_glass_tint_amount();
816    let count = tabs.len().max(1);
817    let selected = selected.min(count - 1);
818    let on_select: Rc<dyn Fn(usize)> = Rc::new(on_select);
819    let tabs = Rc::new(tabs);
820    let accessory = Rc::new(RefCell::new(accessory));
821
822    Row(
823        modifier,
824        RowSpec::default().vertical_alignment(VerticalAlignment::CenterVertically),
825        move || {
826            let tabs = Rc::clone(&tabs);
827            let typography = typography.clone();
828            let on_select = Rc::clone(&on_select);
829            let accessory = Rc::clone(&accessory);
830
831            let lens_x_outer = cranpose_core::remember(move || {
832                cranpose_core::mutableStateOf((
833                    0.0f32,
834                    0.0f32,
835                    0.0f32,
836                    Size::new(0.0, 0.0),
837                    selected,
838                ))
839            })
840            .with(|state| *state);
841            let bar_touch = remember_tab_bar_touch();
842            let contact_motion = super::tab_motion::remember_tab_contact_motion();
843            let bar_press = contact_motion.bar_state();
844            let glow = contact_motion.glow_state();
845            let local_glow_factor = contact_motion.local_glow_factor_state();
846            let travel = cranpose_animation::animateFloatAsState(
847                bar_touch.travel.get(),
848                cranpose_animation::spring(0.85, 650.0),
849                "tabbar-travel",
850            );
851            let bar_lift = Modifier::empty().graphics_layer(move || {
852                let width = lens_x_outer.get().2 * count as f32 + 2.0 * BLOB_MARGIN;
853                tab_bar_transform(width, bar_press.get(), travel.get())
854            });
855            Box(
856                Modifier::empty().height(BAR_HEIGHT),
857                BoxSpec::default(),
858                move || {
859                    let tabs = Rc::clone(&tabs);
860                    let typography = typography.clone();
861                    let on_select = Rc::clone(&on_select);
862                    let semantic_selection = Rc::clone(&on_select);
863                    let contact_motion = Rc::clone(&contact_motion);
864                    let pill = bar_lift.clone().height(BAR_HEIGHT);
865                    Box(pill, BoxSpec::default(), move || {
866                        let on_select = Rc::clone(&on_select);
867                        let contact_motion = Rc::clone(&contact_motion);
868                        BoxWithConstraints(Modifier::empty(), move |scope| {
869                            let on_select = Rc::clone(&on_select);
870                            let tab_width = spec.cell_width(scope.constraints().max_width, count);
871
872                            let geometry = TabGeometry::new(tab_width, count);
873                            Box(
874                                Modifier::empty()
875                                    .glass_effect(
876                                        tab_bar_surface_material(colors.label)
877                                            .tint_amount(tint_amount),
878                                    )
879                                    .size(Size::new(
880                                        geometry.width + BLOB_MARGIN * 2.0,
881                                        BAR_HEIGHT,
882                                    )),
883                                BoxSpec::default(),
884                                || {},
885                            );
886                            let lens_pressed =
887                                cranpose_core::remember(|| cranpose_core::mutableStateOf(false))
888                                    .with(|state| *state);
889                            let resting_lens_x =
890                                tab_lens_resting_left(selected, geometry.pitch, count);
891                            let lens_axis = crate::motion::remember_liquid_follow_axis(
892                                resting_lens_x,
893                                cranpose_animation::spring(0.85, 650.0),
894                            );
895                            if !lens_pressed.get() {
896                                lens_axis.settle_to(resting_lens_x, LiquidMotion::glide());
897                            }
898                            let lens_x = lens_axis.value();
899                            let lens_shape = super::tab_motion::remember_tab_lens_shape();
900                            let strain = lens_shape.sample(geometry.lens_surface_position(
901                                lens_x,
902                                bar_press.get(),
903                                travel.get(),
904                            ));
905                            let lens_activity_anim = contact_motion.lens_state();
906                            let lens_activity = lens_activity_anim.get().clamp(0.0, 1.0);
907                            let visual_index = crate::motion::liquid_visual_index(
908                                selected,
909                                lens_x,
910                                geometry.pitch,
911                                count,
912                                crate::motion::liquid_axis_owns_visual_selection(
913                                    lens_pressed.get(),
914                                    lens_x,
915                                    resting_lens_x,
916                                    geometry.pitch,
917                                ),
918                            );
919                            if bar_touch.travel.get().abs() > 0.001 && visual_index != selected {
920                                contact_motion.crossed_tab();
921                            }
922                            let row_width = geometry.width;
923                            let gesture = Modifier::empty()
924                                .offset(BLOB_MARGIN, BLOB_MARGIN)
925                                .size(Size::new(row_width, BLOB_HEIGHT))
926                                .pointer_input(selected, {
927                                    let on_select = Rc::clone(&on_select);
928                                    let lens_axis = Rc::clone(&lens_axis);
929                                    let contact_motion = Rc::clone(&contact_motion);
930                                    move |scope: PointerInputScope| {
931                                        let on_select = Rc::clone(&on_select);
932                                        let lens_axis = Rc::clone(&lens_axis);
933                                        let contact_motion = Rc::clone(&contact_motion);
934                                        crate::motion::liquid_lens_gesture(
935                                            scope,
936                                            crate::motion::LiquidLensGesture {
937                                                axis: lens_axis,
938                                                cell_width: geometry.pitch,
939                                                cell_offset: (geometry.cell_width - geometry.pitch)
940                                                    * 0.5,
941                                                count,
942                                                tap_slop: TAP_SLOP,
943                                                drag_left: Rc::new(move |x| {
944                                                    geometry.drag_left(
945                                                        geometry.optical_pointer_x(
946                                                            x,
947                                                            bar_press.get(),
948                                                            travel.get(),
949                                                        ),
950                                                        count,
951                                                        has_accessory,
952                                                    )
953                                                }),
954                                                rest_left: Rc::new(move |index| {
955                                                    tab_lens_resting_left(
956                                                        index,
957                                                        geometry.pitch,
958                                                        count,
959                                                    )
960                                                }),
961                                                selected,
962                                                on_pressed: Rc::new(move |down, time| {
963                                                    lens_pressed.set(down);
964                                                    contact_motion.pressed(down, time);
965                                                    bar_touch.pressed(down);
966                                                }),
967                                                on_touch: Rc::new(move |x, y| {
968                                                    bar_touch.update(
969                                                        geometry.optical_pointer_x(
970                                                            x,
971                                                            bar_press.get(),
972                                                            travel.get(),
973                                                        ),
974                                                        y,
975                                                        geometry.pitch
976                                                            * count.saturating_sub(1) as f32,
977                                                    );
978                                                }),
979                                                on_select,
980                                            },
981                                        )
982                                    }
983                                });
984                            Box(gesture, BoxSpec::default(), || {});
985
986                            let published =
987                                (lens_x, lens_activity, tab_width, strain, visual_index);
988                            if lens_x_outer.get() != published {
989                                lens_x_outer.set(published);
990                            }
991                        });
992                    });
993
994                    let (lens_px, lens_activity, lens_tab_w, strain, visual_index) =
995                        lens_x_outer.get();
996                    let cells = TabGeometry::new(lens_tab_w, count);
997                    let (lens_w, lens_h) = tab_lens_base_size(cells.cell_width, 1.0);
998                    let deformation_headroom =
999                        crate::dynamics::STRETCH_MAX.max(1.0 / crate::dynamics::STRETCH_MIN);
1000                    let node_w = lens_w * deformation_headroom + crate::dynamics::BULGE_MAX + 20.0;
1001                    let node_h = lens_h * deformation_headroom + crate::dynamics::BULGE_MAX + 16.0;
1002                    let pill_w = cells.width + 2.0 * BLOB_MARGIN;
1003                    let transform = tab_bar_transform(pill_w, bar_press.get(), travel.get());
1004                    let local_center_x = BLOB_MARGIN + lens_px + cells.cell_width * 0.5;
1005                    let lens_center_x = local_center_x;
1006                    let node_x = lens_center_x - node_w * 0.5;
1007                    let node_top = tab_lens_node_top(node_h);
1008                    let (base_w, base_h) = tab_lens_base_size(cells.cell_width, lens_activity);
1009                    let geometry = TabFlightGeometry {
1010                        center: (lens_center_x, BAR_HEIGHT * 0.5),
1011                        base_size: Size::new(base_w, base_h),
1012                        strain,
1013                        lens_position: lens_px,
1014                        lens_activity,
1015                        resting_tint: colors.fill,
1016                        accessory_center: has_accessory.then_some((
1017                            pill_w + tab_bar_accessory_gap(true) + BAR_HEIGHT * 0.5,
1018                            BAR_HEIGHT * 0.5,
1019                        )),
1020                    };
1021                    let lens_node = TabFlightNode {
1022                        origin: (node_x, node_top),
1023                        size: Size::new(node_w, node_h),
1024                    };
1025
1026                    let lens_geometry = geometry;
1027                    let mut lens_transform = transform.clone();
1028                    lens_transform.translation_x +=
1029                        (local_center_x - pill_w * 0.5) * (transform.scale_x - 1.0);
1030                    let layers = Rc::new(crate::material::cached_glass_content_layers(
1031                        tab_flight_lens_material(colors.label, lens_activity).resolve(&colors),
1032                    ));
1033                    let lens_node_modifier = Modifier::empty()
1034                        .required_size(lens_node.size)
1035                        .offset(node_x, node_top)
1036                        .graphics_layer_value(lens_transform);
1037                    let background_layers = Rc::clone(&layers);
1038                    let lens = lens_node_modifier.clone().graphics_layer(move || {
1039                        background_layers(
1040                            cranpose_ui::current_density(),
1041                            tab_flight_dynamics(lens_geometry, lens_node),
1042                        )[0]
1043                        .clone()
1044                    });
1045                    super::tab_lighting::TabLighting(
1046                        Size::new(pill_w, BAR_HEIGHT),
1047                        transform.clone(),
1048                        bar_touch.position,
1049                        glow,
1050                        local_glow_factor,
1051                    );
1052                    Box(lens, BoxSpec::default(), || {});
1053                    TabCells(
1054                        Modifier::empty(),
1055                        Rc::clone(&tabs),
1056                        typography.clone(),
1057                        cells,
1058                        TabCellsSpec {
1059                            base_color: tab_base_content_color(colors),
1060                            selection: tab_ink_selection(
1061                                cells,
1062                                lens_px,
1063                                lens_activity,
1064                                strain,
1065                                tab_selection_content_color(colors),
1066                            ),
1067                            selected: visual_index,
1068                            committed_selection: selected,
1069                        },
1070                        transform,
1071                        move |index| semantic_selection(index),
1072                    );
1073                    Box(
1074                        lens_node_modifier.graphics_layer(move || {
1075                            layers(
1076                                cranpose_ui::current_density(),
1077                                tab_flight_dynamics(lens_geometry, lens_node),
1078                            )[1]
1079                            .clone()
1080                        }),
1081                        BoxSpec::default(),
1082                        || {},
1083                    );
1084                },
1085            );
1086
1087            if has_accessory {
1088                Box(
1089                    Modifier::empty().width(tab_bar_accessory_gap(true)),
1090                    BoxSpec::default(),
1091                    || {},
1092                );
1093                (accessory.borrow_mut())();
1094            }
1095        },
1096    );
1097}
1098
1099/// The standard detached accessory: a circular glass search button.
1100#[composable]
1101#[allow(non_snake_case)]
1102pub fn LiquidTabBarSearchAccessory(on_click: impl Fn() + 'static) {
1103    crate::widgets::GlassIconButton(
1104        Modifier::empty().content_description("Search"),
1105        crate::widgets::GlassButtonSpec::glass(),
1106        BAR_HEIGHT * 0.94,
1107        on_click,
1108        crate::icons::SEARCH,
1109    );
1110}
1111
1112#[cfg(test)]
1113mod tests {
1114    use super::*;
1115    use crate::widgets::tab_motion::tab_lens_activity_motion;
1116
1117    #[test]
1118    fn tab_bar_spec_normalizes_the_maximum_cell_width() {
1119        assert_eq!(LiquidTabBarSpec::default().max_tab_width, TAB_WIDTH);
1120        assert_eq!(LiquidTabBarSpec::new(85.0).max_tab_width, 85.0);
1121        assert_eq!(LiquidTabBarSpec::new(0.0).max_tab_width, 1.0);
1122        assert_eq!(LiquidTabBarSpec::new(f32::NAN).max_tab_width, TAB_WIDTH);
1123    }
1124
1125    #[test]
1126    fn drag_pointer_centers_the_lens_and_preserves_end_overdrag() {
1127        let geometry = TabGeometry {
1128            width: 400.0,
1129            cell_width: 100.0,
1130            pitch: 100.0,
1131        };
1132        assert_eq!(geometry.drag_left(50.0, 4, true), 0.0);
1133        assert_eq!(geometry.drag_left(250.0, 4, true), 200.0);
1134        assert_eq!(geometry.drag_left(-100.0, 4, true), -20.0);
1135        assert_eq!(geometry.drag_left(500.0, 4, true), 355.0);
1136
1137        assert_eq!(geometry.drag_left(-100.0, 4, false), 0.0);
1138        assert_eq!(geometry.drag_left(500.0, 4, false), 300.0);
1139    }
1140
1141    #[test]
1142    fn resting_lens_centers_on_its_cell_and_stays_inside_the_pill() {
1143        let tab = 78.0;
1144        assert_eq!(tab_lens_resting_left(0, tab, 5), 0.0);
1145        assert_eq!(tab_lens_resting_left(1, tab, 5), tab);
1146        assert_eq!(tab_lens_resting_left(3, tab, 5), 3.0 * tab);
1147        assert_eq!(tab_lens_resting_left(4, tab, 5), 4.0 * tab);
1148        assert_eq!(tab_lens_resting_left(9, tab, 5), 4.0 * tab);
1149    }
1150
1151    #[test]
1152    fn flight_deformation_keeps_native_optics_in_the_unscaled_capsule() {
1153        let geometry = TabFlightGeometry {
1154            center: (80.0, 54.0),
1155            base_size: Size::new(111.0, 70.0),
1156            strain: Size::new(14.0 / 111.0, -14.0 / 70.0),
1157            lens_position: 0.0,
1158            lens_activity: 1.0,
1159            resting_tint: Color::TRANSPARENT,
1160            accessory_center: None,
1161        };
1162        let dynamics = tab_flight_dynamics(
1163            geometry,
1164            TabFlightNode {
1165                origin: (0.0, 0.0),
1166                size: Size::new(160.0, 108.0),
1167            },
1168        );
1169        let shape = dynamics.morph.unwrap().primary;
1170        assert_eq!((shape.2, shape.3, shape.4), (111.0, 70.0, 35.0));
1171    }
1172
1173    #[test]
1174    fn released_bubble_keeps_its_elastic_rebound() {
1175        let base = Size::new(104.5, 54.0);
1176        let strain = Size::new(0.017416525, -0.024551005);
1177        let actual = tab_lens_deformed_size(base, strain);
1178        assert!((actual.width - 106.32003).abs() < 0.0001);
1179        assert!((actual.height - 52.67425).abs() < 0.0001);
1180    }
1181
1182    #[test]
1183    fn flight_lens_node_is_centered_on_the_bar_axis() {
1184        for node_height in [48.0, 64.0, 96.0, 128.0] {
1185            let center = tab_lens_node_top(node_height) + node_height * 0.5;
1186            assert!((center - BAR_HEIGHT * 0.5).abs() < f32::EPSILON);
1187        }
1188    }
1189
1190    #[test]
1191    fn liquid_tab_builds_reference_content() {
1192        assert_eq!(TAB_ICON_SIZE, 32.0);
1193        let tab = LiquidTab::new(crate::icons::STAR, "Discover");
1194        assert_eq!(tab.icon, LiquidTabIcon::Vector(crate::icons::STAR));
1195        assert_eq!(tab.label, "Discover");
1196        assert_eq!(tab.icon_style, LiquidTabIconStyle::Plain);
1197
1198        let badge = LiquidTab::app_badge(crate::icons::APPLE, "WWDC");
1199        assert_eq!(badge.icon_style, LiquidTabIconStyle::AppBadge);
1200
1201        let compact = LiquidTab::new(crate::icons::ACCOUNT_CIRCLE, "Account").with_icon_scale(0.72);
1202        assert!((compact.icon_scale - 0.72).abs() < f32::EPSILON);
1203        assert_eq!(tab.clone().with_icon_scale(f32::NAN).icon_scale, 1.0);
1204        assert_eq!(tab.with_icon_scale(2.0).icon_scale, 1.5);
1205    }
1206
1207    #[test]
1208    fn artwork_offsets_preserve_the_tab_and_reject_nonfinite_coordinates() {
1209        let tab = LiquidTab::new(crate::icons::STAR, "Discover");
1210        let shifted = tab.clone().with_icon_offset(-1.0 / 3.0, 1.0);
1211        assert_eq!(shifted.icon_offset, (-1.0 / 3.0, 1.0));
1212        assert_eq!(shifted.icon, tab.icon);
1213        assert_eq!(shifted.label, tab.label);
1214        assert_eq!(
1215            tab.clone().with_icon_offset(f32::NAN, 1.0).icon_offset,
1216            (0.0, 1.0)
1217        );
1218        assert_eq!(
1219            tab.with_icon_offset(2.0, f32::INFINITY).icon_offset,
1220            (2.0, 0.0)
1221        );
1222    }
1223
1224    #[test]
1225    fn template_tab_keeps_artwork_size_and_label() {
1226        let bitmap = cranpose_ui_graphics::ImageBitmap::from_rgba8(1, 1, vec![0, 0, 0, 255])
1227            .expect("template pixel");
1228        let painter = Painter::from_bitmap(bitmap);
1229        let size = Size::new(24.0, 28.0);
1230        let tab = LiquidTab::from_painter(painter.clone(), size, "Saved");
1231        assert_eq!(tab.icon, LiquidTabIcon::Painter { painter, size });
1232        assert_eq!(tab.label, "Saved");
1233        assert_eq!(tab.icon_style, LiquidTabIconStyle::Plain);
1234    }
1235
1236    #[test]
1237    fn app_badge_geometry_honors_the_tab_optical_scale() {
1238        let full = app_badge_geometry(1.0);
1239        let corrected = app_badge_geometry(0.85);
1240        assert_eq!(full.size, Size::new(20.0, 32.0));
1241        assert_eq!(corrected.size, Size::new(17.0, 27.2));
1242        assert!((corrected.glyph.width - 11.9).abs() < 1.0e-5);
1243        assert!((corrected.corner_radius / full.corner_radius - 0.85).abs() < f32::EPSILON);
1244        assert!((corrected.stripe.x / full.stripe.x - 0.85).abs() < f32::EPSILON);
1245        assert!((corrected.glyph.width / full.glyph.width - 0.85).abs() < f32::EPSILON);
1246    }
1247
1248    #[test]
1249    fn base_tab_content_remains_neutral_under_the_moving_selection_layer() {
1250        let colors = crate::theme::LiquidColors::light(cranpose_ui_graphics::Color::from_rgb_u8(
1251            0, 122, 255,
1252        ));
1253        assert_eq!(tab_base_content_color(colors), colors.label);
1254        assert_eq!(tab_selection_content_color(colors), colors.accent);
1255    }
1256
1257    #[test]
1258    fn tab_deformation_preserves_independent_native_axis_motion() {
1259        let base = Size::new(120.5, 70.0);
1260        let strain = Size::new(0.1820888, -0.2760726);
1261        let deformed = tab_lens_deformed_size(base, strain);
1262        assert!((deformed.width - 142.4417).abs() < 0.001);
1263        assert!((deformed.height - 50.67492).abs() < 0.001);
1264        assert_eq!(tab_lens_deformed_size(base, Size::new(0.0, 0.0)), base);
1265    }
1266
1267    #[test]
1268    fn selection_mask_and_lens_resolve_the_same_global_sdf() {
1269        let geometry = TabFlightGeometry {
1270            center: (212.0, 32.0),
1271            base_size: Size::new(106.0, 64.0),
1272            strain: Size::new(0.0, 0.0),
1273            lens_position: 160.0,
1274            lens_activity: 1.0,
1275            resting_tint: Color::BLACK.with_alpha(0.10),
1276            accessory_center: None,
1277        };
1278        let mask_node = TabFlightNode {
1279            origin: (0.0, 0.0),
1280            size: Size::new(328.0, 64.0),
1281        };
1282        let lens_node = TabFlightNode {
1283            origin: (132.0, -22.0),
1284            size: Size::new(160.0, 108.0),
1285        };
1286        let mask = tab_flight_dynamics(geometry, mask_node)
1287            .morph
1288            .expect("selection mask morph");
1289        let lens = tab_flight_dynamics(geometry, lens_node)
1290            .morph
1291            .expect("lens morph");
1292        assert_eq!(
1293            (
1294                mask.primary.0 + mask_node.origin.0,
1295                mask.primary.1 + mask_node.origin.1
1296            ),
1297            geometry.center
1298        );
1299        assert_eq!(
1300            (
1301                lens.primary.0 + lens_node.origin.0,
1302                lens.primary.1 + lens_node.origin.1
1303            ),
1304            geometry.center
1305        );
1306        assert_eq!(
1307            (mask.primary.2, mask.primary.3, mask.primary.4),
1308            (lens.primary.2, lens.primary.3, lens.primary.4)
1309        );
1310        assert_eq!(mask.node_size, (328.0, 64.0));
1311        assert_eq!(lens.node_size, (160.0, 108.0));
1312    }
1313
1314    #[test]
1315    fn unified_bar_has_no_detached_accessory_gap() {
1316        assert_eq!(tab_bar_accessory_gap(false), 0.0);
1317        assert_eq!(tab_bar_accessory_gap(true), 10.0);
1318    }
1319
1320    #[test]
1321    fn flight_lens_only_joins_accessory_after_surface_contact() {
1322        assert!(!accessory_surfaces_touch(0.01));
1323        assert!(accessory_surfaces_touch(0.0));
1324        assert!(accessory_surfaces_touch(-4.0));
1325    }
1326
1327    #[test]
1328    fn lens_contact_swell_matches_the_raised_reference() {
1329        for (cell, raised_width) in [(95.0, 111.0), (104.5, 120.5)] {
1330            assert_eq!(tab_lens_base_size(cell, 0.0), (cell, 54.0));
1331            let raised = tab_lens_base_size(cell, 1.0);
1332            assert!((raised.0 - raised_width).abs() < 0.001);
1333            assert!((raised.1 - 70.0).abs() < 0.001);
1334        }
1335    }
1336
1337    #[test]
1338    fn held_bar_grows_about_its_center_and_strains_with_travel() {
1339        let rest = tab_bar_transform(360.0, 0.0, 1.0);
1340        assert_eq!(rest.scale_x, 1.0);
1341        assert_eq!(rest.scale_y, 1.0);
1342        assert_eq!(rest.translation_x, 0.0);
1343        for travel in [-1.0, 0.0, 1.0] {
1344            let layer = tab_bar_transform(360.0, 1.0, travel);
1345            assert_eq!(layer.translation_y, 0.0);
1346            assert!((360.0 * layer.scale_x - (374.14 + 2.63 * travel.abs())).abs() < 1e-3);
1347            assert!((62.0 * layer.scale_y - (64.4352 - 0.45294 * travel.abs())).abs() < 1e-3);
1348            assert!((layer.translation_x - travel * 2.756).abs() < 1e-3);
1349        }
1350        for width in [360.0, 398.0] {
1351            let layer = tab_bar_transform(width, 1.0, 0.0);
1352            assert!((width * (layer.scale_x - 1.0) - 14.14).abs() < 0.001);
1353        }
1354    }
1355
1356    #[test]
1357    fn tab_grid_matches_the_reference_pitch() {
1358        assert_eq!(TAB_WIDTH, 78.0);
1359    }
1360
1361    #[test]
1362    fn tab_grid_matches_the_reference_inner_inset() {
1363        assert_eq!(BLOB_MARGIN, 4.0);
1364        assert_eq!(BLOB_HEIGHT + 2.0 * BLOB_MARGIN, BAR_HEIGHT);
1365    }
1366
1367    #[test]
1368    fn flight_lens_uses_the_measured_sequential_warps() {
1369        let glass = tab_flight_lens_material(cranpose_ui_graphics::Color::BLACK, 1.0);
1370        let generic_lens = Glass::lens();
1371        assert!(glass.lift.is_some_and(|lift| (0.0..=0.15).contains(&lift)));
1372        assert_eq!(glass.refraction_depth_dp, Some(36.0));
1373        assert_eq!(
1374            glass.refraction,
1375            crate::material::GlassRefraction::EdgeLens { reach_dp: 9.0 }
1376        );
1377        assert!(glass.refraction_curve < generic_lens.refraction_curve);
1378        assert!(glass.dispersion * 0.3 < generic_lens.dispersion);
1379        assert_eq!(glass.blur_radius, Some(0.0));
1380        assert_eq!(glass.backdrop_blur, Some((4.0, 0.0)));
1381        assert_eq!(glass.highlight, 1.0);
1382        assert_eq!(glass.key_fill.map(|light| light.curvature), Some(0.8));
1383        assert!(
1384            glass.shadow,
1385            "the moving lens needs its target-visible SDF contact outline"
1386        );
1387        assert!(glass.tint.is_some_and(|tint| tint.a() == 0.0));
1388        assert_eq!(glass.face_response.unwrap().gain, 0.97);
1389        assert_eq!(glass.meniscus_absorption, 0.0);
1390        assert_eq!(glass.adaptive_frost, 0.0);
1391    }
1392
1393    #[test]
1394    fn resting_lens_does_not_blend_two_different_text_projections() {
1395        let rest = tab_flight_lens_material(Color::BLACK, 0.0);
1396        let held = tab_flight_lens_material(Color::BLACK, 1.0);
1397        assert_eq!(rest.optical_zoom, 1.0);
1398        assert_eq!(rest.backdrop_blur, Some((4.0, 1.0)));
1399        assert_eq!(rest.refraction_depth_dp, Some(36.0));
1400        assert_eq!(rest.fold_depth, 0.0);
1401        assert_eq!(rest.highlight, 0.0);
1402        assert_eq!(rest.meniscus_absorption, 0.0);
1403        assert_eq!(rest.shadow_style.unwrap().color.a(), 0.0);
1404        assert_eq!(held.optical_zoom, 1.0);
1405        assert_eq!(rest.refraction_depth_dp, held.refraction_depth_dp);
1406        let dynamics = tab_flight_dynamics(
1407            TabFlightGeometry {
1408                center: (50.0, 31.0),
1409                base_size: Size::new(95.0, 54.0),
1410                strain: Size::new(0.0, 0.0),
1411                lens_position: 0.0,
1412                lens_activity: 0.0,
1413                resting_tint: Color::TRANSPARENT,
1414                accessory_center: None,
1415            },
1416            TabFlightNode {
1417                origin: (0.0, 0.0),
1418                size: Size::new(140.0, 100.0),
1419            },
1420        );
1421        assert_eq!(dynamics.activity, Some(1.0));
1422    }
1423
1424    #[test]
1425    fn flight_lens_retains_neutral_tint_through_direct_motion() {
1426        assert_eq!(tab_flight_tint_multiplier(0.0), 1.0);
1427        assert!((tab_flight_tint_multiplier(1.0) - 0.75).abs() < f32::EPSILON);
1428        assert_eq!(tab_flight_tint_multiplier(-1.0), 1.0);
1429        assert!((tab_flight_tint_multiplier(2.0) - 0.75).abs() < f32::EPSILON);
1430    }
1431
1432    #[test]
1433    fn bar_surface_adapts_tone_to_its_foreground() {
1434        let glass = tab_bar_surface_material(cranpose_ui_graphics::Color::BLACK);
1435        assert_eq!(glass.blur_radius, Some(6.0));
1436        assert_eq!(glass.saturation, Some(1.0));
1437        assert_eq!(glass.lift, Some(0.0));
1438        assert_eq!(glass.refraction_depth, 0.0);
1439        assert_eq!(glass.refraction_depth_dp, Some(15.5));
1440        assert_eq!(
1441            glass.refraction,
1442            crate::material::GlassRefraction::Surface { reach_dp: 31.0 }
1443        );
1444        assert_eq!(glass.transmission_refraction, 1.0);
1445        assert_eq!(glass.adaptive_frost, 0.0);
1446        assert!(glass.adaptive_tone);
1447    }
1448
1449    #[test]
1450    fn bar_surface_keeps_its_refraction_after_release() {
1451        let glass = tab_bar_surface_material(Color::BLACK);
1452        assert_eq!(glass.refraction_depth_dp, Some(15.5));
1453        assert_eq!(glass.face_response.unwrap().illumination, 0.0);
1454    }
1455
1456    #[test]
1457    fn bar_surface_tone_tracks_the_local_foreground_polarity() {
1458        for foreground in [Color::BLACK, Color::WHITE] {
1459            let surface = tab_bar_surface_material(foreground);
1460            assert!(surface.adaptive_tone);
1461            assert_eq!(surface.foreground, Some(foreground));
1462            assert_eq!(surface.lift, Some(0.0));
1463        }
1464    }
1465
1466    #[test]
1467    fn bar_surface_tone_keeps_the_face_tint_neutral() {
1468        for foreground in [Color::BLACK, Color::WHITE] {
1469            assert_eq!(
1470                tab_bar_surface_material(foreground).tint,
1471                Some(Color::TRANSPARENT)
1472            );
1473        }
1474    }
1475
1476    #[test]
1477    fn contact_growth_follows_the_native_presentation_clock() {
1478        let mut samples = 0;
1479        for pressed in [false, true] {
1480            for route in 0..4 {
1481                let runtime = cranpose_core::Runtime::new(std::sync::Arc::new(
1482                    cranpose_core::DefaultScheduler,
1483                ));
1484                let mut activity = cranpose_animation::Animatable::new(
1485                    if pressed { 0.0 } else { 1.0 },
1486                    runtime.handle(),
1487                );
1488                activity.animate_to_at(
1489                    f32::from(pressed),
1490                    tab_lens_activity_motion(pressed),
1491                    1_000_000_000,
1492                );
1493                let mut error = 0.0;
1494                let mut count = 0;
1495                for line in include_str!("../../tests/fixtures/native_tab_contact.csv")
1496                    .lines()
1497                    .skip(1)
1498                {
1499                    let sample = line
1500                        .split(',')
1501                        .map(|v| v.parse::<f64>().expect("contact sample"))
1502                        .collect::<Vec<_>>();
1503                    if sample[0] != f64::from(pressed) || sample[1] != f64::from(route) {
1504                        continue;
1505                    }
1506                    runtime
1507                        .handle()
1508                        .drain_frame_callbacks(1_000_000_000 + (sample[2] * 1e9) as u64);
1509                    error += (activity.state().value().clamp(0.0, 1.0) - sample[3] as f32).powi(2);
1510                    count += 1;
1511                }
1512                assert!(count >= 30, "every native route must execute");
1513                let rms = (error / count as f32).sqrt();
1514                assert!(
1515                    rms < 0.025,
1516                    "native contact progress pressed={pressed} route={route} RMS: {rms}"
1517                );
1518                samples += count;
1519            }
1520        }
1521        assert!(samples >= 300);
1522    }
1523
1524    #[test]
1525    fn a_scope_declares_destinations_in_order() {
1526        let tabs = collect_tabs(|scope| {
1527            scope.tab("M0 0", "Discover");
1528            scope.app_badge("M1 1", "WWDC");
1529            scope.push(LiquidTab::new("M2 2", "Account").with_icon_scale(0.95));
1530        });
1531
1532        assert_eq!(tabs.len(), 3);
1533        assert_eq!(tabs[0].label, "Discover");
1534        assert_eq!(tabs[0].icon_style, LiquidTabIconStyle::Plain);
1535        assert_eq!(tabs[0].icon_scale, 1.0);
1536        assert_eq!(tabs[1].icon_style, LiquidTabIconStyle::AppBadge);
1537        assert_eq!(tabs[2].icon_scale, 0.95);
1538    }
1539
1540    #[test]
1541    fn a_bar_with_no_destinations_declares_none() {
1542        assert!(collect_tabs(|_| {}).is_empty());
1543    }
1544
1545    #[test]
1546    fn native_cells_overlap_without_crossing_the_strip_ends() {
1547        let geometry = TabGeometry::new(88.0, 4);
1548        assert_eq!(geometry.width, 352.0);
1549        assert!((geometry.cell_width - 95.0).abs() < 1e-4);
1550        for (index, center) in [72.5, 158.16667, 243.83333, 329.5].into_iter().enumerate() {
1551            let actual =
1552                25.0 + tab_lens_resting_left(index, geometry.pitch, 4) + geometry.cell_width * 0.5;
1553            assert!((actual - center).abs() < 1e-4);
1554        }
1555        for count in [0, 1, 2, 4, 8] {
1556            for allocation in [1.0, 24.0, 78.0, 120.0, 400.0] {
1557                let geometry = TabGeometry::new(allocation, count);
1558                let last = tab_lens_resting_left(count, geometry.pitch, count);
1559                assert!((last + geometry.cell_width - geometry.width).abs() < 1e-3);
1560            }
1561        }
1562    }
1563
1564    #[test]
1565    fn selection_width_uses_the_destination_allocation() {
1566        for (allocation, native_width) in [(88.0, 95.0), (97.5, 104.5)] {
1567            assert!((tab_lens_rest_width(allocation) - native_width).abs() < 1e-4);
1568        }
1569    }
1570    #[test]
1571    fn held_drag_coordinates_follow_the_lifted_cell_in_both_directions() {
1572        let geometry = TabGeometry::new(88.0, 4);
1573        for (travel, physical_x) in [(1.0, 304.66666), (-1.0, 47.333332)] {
1574            let transform = tab_bar_transform(360.0, 1.0, travel);
1575            let center = geometry.width * 0.5;
1576            let local_x =
1577                (physical_x - center - transform.translation_x) / transform.scale_x + center;
1578            let optical_x = geometry.optical_pointer_x(local_x, 1.0, travel);
1579            assert!(
1580                (optical_x - physical_x).abs() < 0.001,
1581                "pointer drift: {}",
1582                optical_x - physical_x
1583            );
1584            assert_eq!(
1585                geometry.optical_pointer_x(physical_x, 0.0, travel),
1586                physical_x
1587            );
1588        }
1589    }
1590    #[test]
1591    fn ink_selection_uses_the_same_bounded_shape_before_face_magnification() {
1592        let geometry = TabGeometry::new(88.0, 4);
1593        for activity in [0.0, 0.5, 1.0] {
1594            for value in [-1000.0, -0.1, 0.0, 0.1, 1000.0] {
1595                let strain = Size::new(value, -value * 1.5);
1596                let selected = tab_ink_selection(geometry, 120.0, activity, strain, Color::BLUE);
1597                let (width, height) = tab_lens_base_size(geometry.cell_width, activity);
1598                let size = tab_lens_deformed_size(Size::new(width, height), strain);
1599                assert_eq!(selected.content_zoom, 1.0 + 0.16 * activity);
1600                assert!((selected.bounds.width - size.width).abs() < 0.0001);
1601                assert!((selected.bounds.height - size.height).abs() < 0.0001);
1602                assert!((selected.bounds.x + selected.bounds.width * 0.5 - 167.5).abs() < 0.0001);
1603                assert!(selected.bounds.width > 0.0 && selected.bounds.height > 0.0);
1604            }
1605        }
1606    }
1607}