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