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