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::{LiquidColors, LiquidTypography, liquid_colors, liquid_typography},
21    widgets::content_scope::ScopeContent,
22};
23
24/// Visual treatment for a tab icon.
25#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
26pub enum LiquidTabIconStyle {
27    #[default]
28    Plain,
29    AppBadge,
30}
31
32/// Artwork displayed in a tab's shared icon frame.
33#[derive(Clone, Debug, PartialEq)]
34pub enum LiquidTabIcon {
35    /// Vector path data in a 24×24 view box.
36    Vector(&'static str),
37    /// Template artwork drawn at its supplied logical size and tinted with the tab's color.
38    Painter {
39        /// Image or custom painter supplying the template's alpha mask.
40        painter: Painter,
41        /// Artwork dimensions in logical points before the tab's optical correction.
42        size: Size,
43    },
44}
45
46/// One tab's artwork, label, and icon treatment.
47#[derive(Clone, Debug, PartialEq)]
48pub struct LiquidTab {
49    /// Vector or template artwork shown above the label.
50    pub icon: LiquidTabIcon,
51    pub label: &'static str,
52    pub icon_style: LiquidTabIconStyle,
53    /// Optical correction for symbols whose path bounds do not fill the
54    /// shared icon frame uniformly.
55    pub icon_scale: f32,
56    /// Optical displacement of the artwork inside the shared icon frame, in points.
57    pub icon_offset: (f32, f32),
58}
59
60impl LiquidTab {
61    pub fn new(icon: &'static str, label: &'static str) -> Self {
62        Self {
63            icon: LiquidTabIcon::Vector(icon),
64            label,
65            icon_style: LiquidTabIconStyle::Plain,
66            icon_scale: 1.0,
67            icon_offset: (0.0, 0.0),
68        }
69    }
70
71    /// A tab using template artwork and its logical size.
72    pub fn from_painter(painter: Painter, size: Size, label: &'static str) -> Self {
73        Self {
74            icon: LiquidTabIcon::Painter { painter, size },
75            label,
76            icon_style: LiquidTabIconStyle::Plain,
77            icon_scale: 1.0,
78            icon_offset: (0.0, 0.0),
79        }
80    }
81
82    pub fn app_badge(icon: &'static str, label: &'static str) -> Self {
83        Self {
84            icon: LiquidTabIcon::Vector(icon),
85            label,
86            icon_style: LiquidTabIconStyle::AppBadge,
87            icon_scale: 1.0,
88            icon_offset: (0.0, 0.0),
89        }
90    }
91
92    pub fn with_icon_scale(mut self, scale: f32) -> Self {
93        self.icon_scale = normalize_icon_scale(scale);
94        self
95    }
96
97    /// Moves the artwork without changing its cell or hit bounds; nonfinite offsets become zero.
98    pub fn with_icon_offset(mut self, x: f32, y: f32) -> Self {
99        self.icon_offset = (
100            if x.is_finite() { x } else { 0.0 },
101            if y.is_finite() { y } else { 0.0 },
102        );
103        self
104    }
105}
106
107fn normalize_icon_scale(scale: f32) -> f32 {
108    if scale.is_finite() {
109        scale.clamp(0.5, 1.5)
110    } else {
111        1.0
112    }
113}
114
115fn tab_base_content_color(colors: crate::theme::LiquidColors) -> Color {
116    colors.label
117}
118
119fn tab_selection_content_color(colors: crate::theme::LiquidColors) -> Color {
120    colors.accent
121}
122
123const BAR_HEIGHT: f32 = 62.0;
124const BLOB_HEIGHT: f32 = 54.0;
125const BLOB_MARGIN: f32 = 4.0;
126const FLIGHT_LENS_INFLATION: f32 = 16.0;
127const TAB_LENS_OVERLAP: f32 = 7.0;
128const TAB_WIDTH: f32 = 78.0;
129const TAB_ICON_SIZE: f32 = 32.0;
130const TAB_LABEL_SIZE: f32 = 10.0;
131const TAP_SLOP: f32 = 6.0;
132const ACCESSORY_GAP: f32 = 10.0;
133
134/// Layout parameters for a [`LiquidTabBar`].
135#[derive(Clone, Copy, Debug, PartialEq)]
136pub struct LiquidTabBarSpec {
137    max_tab_width: f32,
138}
139
140impl LiquidTabBarSpec {
141    /// Sets the maximum cell allocation; nonfinite values use the default and finite values are at least one point.
142    pub fn new(max_tab_width: f32) -> Self {
143        Self {
144            max_tab_width: if max_tab_width.is_finite() {
145                max_tab_width.max(1.0)
146            } else {
147                TAB_WIDTH
148            },
149        }
150    }
151
152    fn cell_width(self, available_width: f32, count: usize) -> f32 {
153        let constrained = available_width - BLOB_MARGIN * 2.0;
154        if constrained.is_finite() && constrained > 1.0 {
155            (constrained / count as f32).min(self.max_tab_width)
156        } else {
157            self.max_tab_width
158        }
159    }
160}
161
162impl Default for LiquidTabBarSpec {
163    fn default() -> Self {
164        Self::new(TAB_WIDTH)
165    }
166}
167
168fn tab_flight_lens_material(foreground: cranpose_ui_graphics::Color, activity: f32) -> Glass {
169    let activity = activity.clamp(0.0, 1.0);
170    Glass::lens()
171        .face_lighting(false)
172        .key_fill(GlassKeyFill {
173            height_dp: 1.0,
174            curvature: 0.7 + 0.1 * activity,
175            angle_radians: std::f32::consts::FRAC_PI_4,
176            saturation: 1.5,
177            luma_gain: 0.4367,
178            offset: 1.125,
179            scale_with_surface: true,
180        })
181        .no_clip()
182        .face_response(GlassFaceResponse {
183            gain: 1.0 - 0.03 * activity,
184            start_dp: 1.0 / 3.0,
185            end_dp: 2.0 / 3.0,
186            illumination: 0.0,
187        })
188        .tint(neutral_surface_tint(
189            foreground,
190            0.07333333 * (1.0 - activity),
191            0.1440678 * (1.0 - activity),
192        ))
193        .blur_radius(0.0)
194        .backdrop_blur(4.0, 1.0 - activity)
195        .edge_refraction(9.0 * activity)
196        .refraction_depth_dp(36.0)
197        .refraction_curve(0.25)
198        .optical_zoom(1.0)
199        .meniscus_absorption(0.0)
200        .inner_shadow(GlassShadow::new(
201            Color::BLACK.with_alpha(0.12 * activity),
202            3.0,
203            7.0,
204            0.0,
205        ))
206        .fold_depth(0.0)
207        .edge_spectrum(crate::material::GlassSpectrum {
208            angle_radians: std::f32::consts::FRAC_PI_2
209                - std::f32::consts::PI * 7.0 / 12.0 * activity,
210            step_dp: (4.068 / (112.24138 / 36.0)) / (std::f32::consts::PI / 12.0).cos()
211                * (-5.0 + 8.684211 * activity)
212                / 3.6842105,
213            vertical_scale: 0.8265,
214            opacity_near: activity,
215            opacity_far: 1.0 - activity,
216            fade_depth_dp: 14.0 * activity,
217            extent_dp: 38.88889 * activity,
218        })
219        .dispersion(0.0)
220        .lift(0.0)
221        .highlight(activity)
222        .rim_reflection(activity)
223        .shadow_style(GlassShadow::new(
224            cranpose_ui_graphics::Color::BLACK.with_alpha(0.1 * activity),
225            8.0,
226            7.0,
227            0.0,
228        ))
229}
230
231fn tab_bar_surface_material(foreground: cranpose_ui_graphics::Color) -> Glass {
232    Glass::regular()
233        .face_lighting(false)
234        .key_fill(GlassKeyFill {
235            height_dp: 1.0,
236            curvature: 0.7,
237            angle_radians: std::f32::consts::FRAC_PI_4,
238            saturation: 1.5,
239            luma_gain: 0.1,
240            offset: 0.9,
241            scale_with_surface: false,
242        })
243        .face_response(GlassFaceResponse {
244            gain: 0.97,
245            start_dp: 1.0 / 3.0,
246            end_dp: 2.0 / 3.0,
247            illumination: 0.0,
248        })
249        .tint(Color::TRANSPARENT)
250        .blur_radius(6.0)
251        .surface_refraction(31.0)
252        .refraction_depth_dp(15.5)
253        .transmission_refraction(1.0)
254        .saturation(1.0)
255        .lift(0.0)
256        .contrast(1.0)
257        .highlight(1.0)
258        .adaptive_frost(foreground, 0.0)
259        .adaptive_tone(foreground)
260        .shadow_style(GlassShadow::new(
261            Color::BLACK.with_alpha(0.162),
262            32.0,
263            8.0,
264            1.5,
265        ))
266}
267
268fn tab_flight_tint_multiplier(activity: f32) -> f32 {
269    1.0 - 0.25 * activity.clamp(0.0, 1.0)
270}
271
272#[derive(Clone, Copy, Debug, Default, PartialEq)]
273struct TabGeometry {
274    width: f32,
275    cell_width: f32,
276    pitch: f32,
277}
278
279impl TabGeometry {
280    fn new(allocation: f32, count: usize) -> Self {
281        let width = allocation * count.max(1) as f32;
282        let cell_width = tab_lens_rest_width(allocation).min(width);
283        let pitch = if count > 1 {
284            (width - cell_width) / (count - 1) as f32
285        } else {
286            cell_width
287        };
288        Self {
289            width,
290            cell_width,
291            pitch,
292        }
293    }
294
295    fn drag_left(self, pointer_x: f32, count: usize, has_accessory: bool) -> f32 {
296        let last_tab = self.pitch * count.saturating_sub(1) as f32;
297        let min = if has_accessory {
298            -self.pitch * 0.2
299        } else {
300            0.0
301        };
302        let max = if has_accessory {
303            last_tab + self.pitch * 0.55
304        } else {
305            last_tab
306        };
307        (pointer_x - self.cell_width * 0.5).clamp(min, max)
308    }
309
310    fn optical_pointer_x(self, pointer_x: f32, press: f32, travel: f32) -> f32 {
311        let transform = tab_bar_transform(self.width + 2.0 * BLOB_MARGIN, press, travel);
312        let center = self.width * 0.5;
313        pointer_x + (pointer_x - center) * (transform.scale_x - 1.0) + transform.translation_x
314    }
315
316    fn lens_surface_position(self, position: f32, press: f32, travel: f32) -> f32 {
317        let transform = tab_bar_transform(self.width + 2.0 * BLOB_MARGIN, press, travel);
318        (position + self.cell_width * 0.5 - self.width * 0.5) * transform.scale_x
319    }
320}
321
322/// The settled cell origin for a selected index and the distance between cell centers.
323pub fn tab_lens_resting_left(selected: usize, tab_pitch: f32, count: usize) -> f32 {
324    tab_pitch * selected.min(count.saturating_sub(1)) as f32
325}
326
327/// The resting selection width for one destination's allocation in the content strip.
328/// Cells overlap; their pitch is the remaining strip width divided by the number of gaps.
329pub fn tab_lens_rest_width(tab_allocation: f32) -> f32 {
330    tab_allocation + TAB_LENS_OVERLAP
331}
332
333#[derive(Clone, Copy, Debug, PartialEq)]
334struct AppBadgeGeometry {
335    size: Size,
336    corner_radius: f32,
337    stripe: Rect,
338    glyph: Rect,
339}
340
341fn app_badge_geometry(optical_scale: f32) -> AppBadgeGeometry {
342    let scale = normalize_icon_scale(optical_scale);
343    AppBadgeGeometry {
344        size: Size::new(20.0 * scale, 32.0 * scale),
345        corner_radius: 5.0 * scale,
346        stripe: Rect {
347            x: 7.0 * scale,
348            y: 4.5 * scale,
349            width: 6.0 * scale,
350            height: 1.5 * scale,
351        },
352        glyph: Rect {
353            x: 3.0 * scale,
354            y: 10.0 * scale,
355            width: 14.0 * scale,
356            height: 14.0 * scale,
357        },
358    }
359}
360
361#[composable]
362fn TabIcon(icon: LiquidTabIcon, style: LiquidTabIconStyle, color: Color, optical_scale: f32) {
363    const FRAME_HEIGHT: f32 = 32.0;
364    Box(
365        Modifier::empty().size(Size::new(TAB_ICON_SIZE, FRAME_HEIGHT)),
366        BoxSpec::default().content_alignment(Alignment::CENTER),
367        move || match style {
368            LiquidTabIconStyle::Plain => {
369                TabGlyph(icon.clone(), TAB_ICON_SIZE * optical_scale, color);
370            }
371            LiquidTabIconStyle::AppBadge => {
372                let icon = icon.clone();
373                let geometry = app_badge_geometry(optical_scale);
374                Box(
375                    Modifier::empty()
376                        .size(geometry.size)
377                        .draw_behind(move |scope| {
378                            scope.draw_round_rect(
379                                Brush::solid(color),
380                                CornerRadii::uniform(geometry.corner_radius),
381                            );
382                            scope.draw_rect_at(geometry.stripe, Brush::solid(Color::WHITE));
383                        }),
384                    BoxSpec::default(),
385                    move || {
386                        let icon = icon.clone();
387                        Box(
388                            Modifier::empty()
389                                .offset(geometry.glyph.x, geometry.glyph.y)
390                                .size(Size::new(geometry.glyph.width, geometry.glyph.height)),
391                            BoxSpec::default(),
392                            move || TabGlyph(icon.clone(), geometry.glyph.width, Color::WHITE),
393                        );
394                    },
395                );
396            }
397        },
398    );
399}
400
401#[composable]
402fn TabGlyph(icon: LiquidTabIcon, size: f32, color: Color) {
403    match icon {
404        LiquidTabIcon::Vector(path) => crate::icons::Icon(path, None, size, color),
405        LiquidTabIcon::Painter {
406            painter,
407            size: intrinsic,
408        } => {
409            let scale = size / TAB_ICON_SIZE;
410            Image(
411                painter,
412                None,
413                Modifier::empty()
414                    .size(Size::new(intrinsic.width * scale, intrinsic.height * scale)),
415                Alignment::CENTER,
416                ContentScale::FillBounds,
417                1.0,
418                Some(cranpose_ui_graphics::ColorFilter::tint(color)),
419            );
420        }
421    }
422}
423
424fn tab_ink_selection(
425    geometry: TabGeometry,
426    position: f32,
427    activity: f32,
428    strain: Size,
429    color: Color,
430) -> super::vibrancy::InkSelection {
431    let (width, height) = tab_lens_base_size(geometry.cell_width, activity);
432    let base = Size::new(width, height);
433    let deformed = tab_lens_deformed_size(base, strain);
434    let width = deformed.width;
435    let height = deformed.height;
436    super::vibrancy::InkSelection {
437        bounds: Rect {
438            x: position + geometry.cell_width * 0.5 - width * 0.5,
439            y: BLOB_HEIGHT * 0.5 - height * 0.5,
440            width,
441            height,
442        },
443        color,
444        content_zoom: 1.0 + 0.16 * activity.clamp(0.0, 1.0),
445        activity: activity.clamp(0.0, 1.0),
446        optical_scale: 1.0,
447        projection: Size::new(deformed.width / base.width, deformed.height / base.height),
448    }
449}
450
451#[derive(Clone, Copy, PartialEq)]
452struct TabCellsSpec {
453    geometry: TabGeometry,
454    base_color: Color,
455    selected: usize,
456    committed_selection: usize,
457}
458
459/// Where the lens's ink falls on the cells this frame, under the bar's lift.
460fn lifted_ink(
461    geometry: TabGeometry,
462    count: usize,
463    selection: super::vibrancy::InkSelection,
464    transform: &cranpose_ui_graphics::GraphicsLayer,
465) -> (super::vibrancy::InkSelection, super::vibrancy::InkGrid) {
466    let size = Size::new(geometry.width + BLOB_MARGIN * 2.0, BAR_HEIGHT);
467    let mut selection = selection;
468    let center = selection.bounds.x + selection.bounds.width * 0.5 + BLOB_MARGIN;
469    selection.bounds.width *= transform.scale_x;
470    selection.bounds.height *= transform.scale_y;
471    selection.optical_scale = transform.scale_x.min(transform.scale_y);
472    selection.bounds.x = (center - size.width * 0.5) * transform.scale_x
473        + size.width * 0.5
474        + transform.translation_x
475        - selection.bounds.width * 0.5;
476    selection.bounds.y = (BAR_HEIGHT - selection.bounds.height) * 0.5;
477    let grid = super::vibrancy::InkGrid {
478        first_center: (
479            (BLOB_MARGIN + geometry.cell_width * 0.5 - size.width * 0.5) * transform.scale_x
480                + size.width * 0.5
481                + transform.translation_x,
482            BAR_HEIGHT * 0.5,
483        ),
484        pitch: geometry.pitch * transform.scale_x,
485        count,
486    };
487    (selection, grid)
488}
489
490#[composable]
491fn TabCells(
492    modifier: Modifier,
493    tabs: Rc<Vec<LiquidTab>>,
494    typography: LiquidTypography,
495    spec: TabCellsSpec,
496    selection: impl Fn() -> super::vibrancy::InkSelection + 'static,
497    transform: impl Fn() -> cranpose_ui_graphics::GraphicsLayer + 'static,
498    on_select: impl Fn(usize) + 'static,
499) {
500    let on_select: Rc<dyn Fn(usize)> = Rc::new(on_select);
501    let transform: Rc<dyn Fn() -> cranpose_ui_graphics::GraphicsLayer> = Rc::new(transform);
502    let geometry = spec.geometry;
503    let size = Size::new(geometry.width + BLOB_MARGIN * 2.0, BAR_HEIGHT);
504    let count = tabs.len();
505    let ink = {
506        let transform = Rc::clone(&transform);
507        move || lifted_ink(geometry, count, selection(), &transform())
508    };
509    super::vibrancy::VibrantContent(modifier, size, spec.base_color, ink, move || {
510        let tabs = Rc::clone(&tabs);
511        let typography = typography.clone();
512        let on_select = Rc::clone(&on_select);
513        let transform = Rc::clone(&transform);
514        Box(
515            Modifier::empty()
516                .size(size)
517                .selectable_group()
518                .role(SemanticsWidgetRole::TabBar)
519                .graphics_layer(move || transform()),
520            BoxSpec::default(),
521            move || {
522                for (index, tab) in tabs.iter().enumerate() {
523                    let color = spec.base_color;
524                    let label_for_semantics = tab.label;
525                    let icon_offset = tab.icon_offset;
526                    let on_select = Rc::clone(&on_select);
527                    let cell = Modifier::empty()
528                        .offset(BLOB_MARGIN + index as f32 * geometry.pitch, BLOB_MARGIN)
529                        .size(Size::new(geometry.cell_width, BLOB_HEIGHT))
530                        .semantics(super::selection::selection_semantics(
531                            label_for_semantics.to_string(),
532                            SemanticsWidgetRole::Tab,
533                            index,
534                            spec.committed_selection,
535                            on_select,
536                        ))
537                        .focusable();
538                    let icon = tab.icon.clone();
539                    let icon_style = tab.icon_style;
540                    let icon_scale = tab.icon_scale;
541                    let label = tab.label;
542                    let label_style = TextStyle {
543                        span_style: SpanStyle {
544                            color: Some(color),
545                            font_size: cranpose_ui::text::TextUnit::Sp(TAB_LABEL_SIZE),
546                            font_weight: Some(if spec.selected == index {
547                                FontWeight::SEMI_BOLD
548                            } else {
549                                FontWeight::MEDIUM
550                            }),
551                            ..typography.caption1.span_style.clone()
552                        },
553                        paragraph_style: cranpose_ui::text::ParagraphStyle {
554                            line_height: cranpose_ui::text::TextUnit::Sp(12.0),
555                            ..typography.caption1.paragraph_style.clone()
556                        },
557                    };
558                    Box(cell, BoxSpec::default(), move || {
559                        let label_style = label_style.clone();
560                        let icon = icon.clone();
561                        Box(
562                            Modifier::empty()
563                                .offset(
564                                    (geometry.cell_width - TAB_ICON_SIZE) * 0.5 + icon_offset.0,
565                                    3.0 + icon_offset.1,
566                                )
567                                .size(Size::new(TAB_ICON_SIZE, TAB_ICON_SIZE)),
568                            BoxSpec::default(),
569                            move || TabIcon(icon.clone(), icon_style, color, icon_scale),
570                        );
571                        Box(
572                            Modifier::empty()
573                                .offset(0.0, 35.0)
574                                .size(Size::new(geometry.cell_width, 12.0)),
575                            BoxSpec::default().content_alignment(Alignment::CENTER),
576                            move || {
577                                Text(label, Modifier::empty(), label_style.clone());
578                            },
579                        );
580                    });
581                }
582            },
583        );
584    });
585}
586
587fn tab_lens_node_top(node_height: f32) -> f32 {
588    (BAR_HEIGHT - node_height) * 0.5
589}
590
591#[derive(Clone, Copy)]
592struct TabBarTouch {
593    position: cranpose_core::MutableState<(f32, f32)>,
594    origin: cranpose_core::MutableState<Option<f32>>,
595    travel: cranpose_core::MutableState<f32>,
596}
597
598impl TabBarTouch {
599    fn pressed(self, down: bool) {
600        if down {
601            self.origin.set(None);
602            self.travel.set(0.0);
603        }
604    }
605
606    fn update(self, x: f32, y: f32, span: f32) {
607        self.position.set((x + BLOB_MARGIN, y + BLOB_MARGIN));
608        if let Some(origin) = self.origin.get() {
609            self.travel
610                .set(((x - origin) / span.max(1.0)).clamp(-1.0, 1.0));
611        } else {
612            self.origin.set(Some(x));
613        }
614    }
615}
616
617#[composable]
618fn remember_tab_bar_touch() -> TabBarTouch {
619    TabBarTouch {
620        position: cranpose_core::remember(|| cranpose_core::mutableStateOf((0.0f32, 0.0f32)))
621            .with(|state| *state),
622        origin: cranpose_core::remember(|| cranpose_core::mutableStateOf(None::<f32>))
623            .with(|state| *state),
624        travel: cranpose_core::remember(|| cranpose_core::mutableStateOf(0.0f32))
625            .with(|state| *state),
626    }
627}
628
629fn tab_bar_transform(width: f32, press: f32, travel: f32) -> cranpose_ui_graphics::GraphicsLayer {
630    let press = press.max(0.0);
631    let travel = travel.clamp(-1.0, 1.0);
632    let rise = 14.14 / width.max(1.0) * press;
633    let strain = 2.63 / width.max(1.0) * travel.abs() * press;
634    cranpose_ui_graphics::GraphicsLayer {
635        scale_x: 1.0 + rise + strain,
636        scale_y: 1.0 + rise - strain,
637        translation_x: 2.756 * travel * press,
638        ..Default::default()
639    }
640}
641
642fn tab_bar_accessory_gap(has_accessory: bool) -> f32 {
643    if has_accessory { ACCESSORY_GAP } else { 0.0 }
644}
645
646fn accessory_surfaces_touch(edge_gap: f32) -> bool {
647    edge_gap <= 0.0
648}
649
650fn tab_lens_base_size(cell_width: f32, activity: f32) -> (f32, f32) {
651    let inflation = FLIGHT_LENS_INFLATION * activity.clamp(0.0, 1.0);
652    (cell_width + inflation, BLOB_HEIGHT + inflation)
653}
654
655#[derive(Clone, Copy, Debug, PartialEq)]
656struct TabFlightGeometry {
657    center: (f32, f32),
658    base_size: Size,
659    strain: Size,
660    lens_position: f32,
661    lens_activity: f32,
662    resting_tint: Color,
663    accessory_center: Option<(f32, f32)>,
664}
665
666#[derive(Clone, Copy, Debug, PartialEq)]
667struct TabFlightNode {
668    origin: (f32, f32),
669    size: Size,
670}
671
672fn tab_lens_deformed_size(base: Size, strain: Size) -> Size {
673    Size::new(
674        base.width * (1.0 + strain.width.clamp(-0.35, 0.35)),
675        base.height * (1.0 + strain.height.clamp(-0.35, 0.35)),
676    )
677}
678
679fn tab_flight_dynamics(geometry: TabFlightGeometry, node: TabFlightNode) -> GlassDynamics {
680    let activity = geometry.lens_activity.clamp(0.0, 1.0);
681    let size = geometry.base_size;
682    let deformed = tab_lens_deformed_size(size, geometry.strain);
683    let radius = size.width.min(size.height) * 0.5;
684    let glue = 20.0;
685    let shapes = geometry
686        .accessory_center
687        .filter(|(x, _)| {
688            let edge_gap = (*x - geometry.center.0).abs() - size.width * 0.5 - BAR_HEIGHT * 0.5;
689            accessory_surfaces_touch(edge_gap)
690        })
691        .map(|(x, y)| {
692            vec![(
693                x - node.origin.0,
694                y - node.origin.1,
695                BAR_HEIGHT,
696                BAR_HEIGHT,
697                -1.0,
698            )]
699        })
700        .unwrap_or_default();
701    GlassDynamics {
702        edge_return_depth_dp: Some(7.0 * activity),
703        optical_projection: Some((deformed.width / size.width, deformed.height / size.height)),
704        morph: Some(GlassMorph {
705            node_size: (node.size.width, node.size.height),
706            primary: (
707                geometry.center.0 - node.origin.0,
708                geometry.center.1 - node.origin.1,
709                size.width,
710                size.height,
711                radius,
712            ),
713            shapes,
714            glue,
715            wobble_amplitude: 0.0,
716            wobble_phase: geometry.lens_position * 0.045,
717            bulge_amplitude: 0.0,
718            bulge_direction: 0.0,
719            ellipse_blend: 0.0,
720            capsule_smoothing_dp: ((size.width - size.height).abs() * 0.5).min(radius * 0.6)
721                * activity,
722            deformation: None,
723            zoom_anchor: (0.0, 0.0),
724        }),
725        activity: Some(1.0),
726        resting_tint: Some(geometry.resting_tint),
727        tint_alpha_multiplier: Some(tab_flight_tint_multiplier(geometry.lens_activity)),
728        ..Default::default()
729    }
730}
731
732/// The scope a tab bar's destinations are declared in.
733///
734/// Each call adds one destination, in the order it is made, which is also the
735/// order the indices passed to `on_select` count in.
736pub struct LiquidTabBarScope {
737    tabs: ScopeContent<LiquidTab>,
738}
739
740impl LiquidTabBarScope {
741    /// A destination showing `icon` above `label`.
742    pub fn tab(&self, icon: &'static str, label: &'static str) {
743        self.push(LiquidTab::new(icon, label));
744    }
745
746    /// A destination whose icon is drawn as an application badge.
747    pub fn app_badge(&self, icon: &'static str, label: &'static str) {
748        self.push(LiquidTab::app_badge(icon, label));
749    }
750
751    /// A destination built out, for the optical corrections a particular
752    /// symbol needs — see [`LiquidTab`].
753    pub fn push(&self, tab: LiquidTab) {
754        self.tabs.push(tab);
755    }
756}
757
758fn collect_tabs(content: impl FnOnce(&LiquidTabBarScope)) -> Vec<LiquidTab> {
759    ScopeContent::collect(|tabs| LiquidTabBarScope { tabs }, content)
760}
761
762/// A unified floating glass tab bar with every destination inside one pill.
763///
764/// ```rust,ignore
765/// LiquidTabBar(Modifier::empty(), LiquidTabBarSpec::default(), selected, on_select, |scope| {
766///     scope.tab(icons::DOCUMENT, "Today");
767///     scope.tab(icons::SEARCH, "Search");
768/// });
769/// ```
770#[composable]
771pub fn LiquidTabBar(
772    modifier: Modifier,
773    spec: LiquidTabBarSpec,
774    selected: usize,
775    on_select: impl Fn(usize) + 'static,
776    content: impl FnOnce(&LiquidTabBarScope),
777) {
778    LiquidTabBarLayout(
779        modifier,
780        spec,
781        collect_tabs(content),
782        selected,
783        on_select,
784        false,
785        || {},
786    );
787}
788
789/// A floating glass tab bar with a detached accessory to its right.
790#[composable]
791pub fn LiquidTabBarWithAccessory(
792    modifier: Modifier,
793    spec: LiquidTabBarSpec,
794    selected: usize,
795    on_select: impl Fn(usize) + 'static,
796    content: impl FnOnce(&LiquidTabBarScope),
797    accessory: impl FnMut() + 'static,
798) {
799    LiquidTabBarLayout(
800        modifier,
801        spec,
802        collect_tabs(content),
803        selected,
804        on_select,
805        true,
806        accessory,
807    );
808}
809
810#[composable]
811fn LiquidTabBarLayout(
812    modifier: Modifier,
813    spec: LiquidTabBarSpec,
814    tabs: Vec<LiquidTab>,
815    selected: usize,
816    on_select: impl Fn(usize) + 'static,
817    has_accessory: bool,
818    accessory: impl FnMut() + 'static,
819) {
820    let colors = liquid_colors();
821    let typography = liquid_typography();
822    let tint_amount = crate::theme::liquid_glass_tint_amount();
823    let count = tabs.len().max(1);
824    let selected = selected.min(count - 1);
825    let on_select: Rc<dyn Fn(usize)> = Rc::new(on_select);
826    let tabs = Rc::new(tabs);
827    let accessory = Rc::new(RefCell::new(accessory));
828
829    Row(
830        modifier,
831        RowSpec::default().vertical_alignment(VerticalAlignment::CenterVertically),
832        move || {
833            let tabs = Rc::clone(&tabs);
834            let typography = typography.clone();
835            let on_select = Rc::clone(&on_select);
836            let accessory = Rc::clone(&accessory);
837
838            let tab_width = cranpose_core::rememberMutableStateOf(|| 0.0f32);
839            let lens_pressed = cranpose_core::rememberMutableStateOf(|| false);
840            let bar_touch = remember_tab_bar_touch();
841            let contact_motion = super::tab_motion::remember_tab_contact_motion();
842            let bar_press = contact_motion.bar_state();
843            let glow = contact_motion.glow_state();
844            let local_glow_factor = contact_motion.local_glow_factor_state();
845            let travel = cranpose_animation::animateFloatAsState(
846                bar_touch.travel.get(),
847                cranpose_animation::spring(0.85, 650.0),
848                "tabbar-travel",
849            );
850            let motion = remember_tab_bar_motion(bar_press, travel, contact_motion.lens_state());
851            let cells = TabGeometry::new(tab_width.get(), count);
852            let resting_lens_x = tab_lens_resting_left(selected, cells.pitch, count);
853            let visual_index = {
854                let motion = motion.clone();
855                cranpose_core::derivedStateOf(move || {
856                    motion.visual_index(selected, cells, count, lens_pressed.get())
857                })
858                .get()
859            };
860            if bar_touch.travel.get_non_reactive().abs() > 0.001 && visual_index != selected {
861                contact_motion.crossed_tab();
862            }
863            let pill_w = cells.width + 2.0 * BLOB_MARGIN;
864            let bar_lift = Modifier::empty()
865                .graphics_layer(move || tab_bar_transform(pill_w, bar_press.get(), travel.get()));
866            Box(
867                Modifier::empty().height(BAR_HEIGHT),
868                BoxSpec::default(),
869                move || {
870                    let tabs = Rc::clone(&tabs);
871                    let typography = typography.clone();
872                    let on_select = Rc::clone(&on_select);
873                    let semantic_selection = Rc::clone(&on_select);
874                    let contact_motion = Rc::clone(&contact_motion);
875                    let published_axis = Rc::clone(&motion.axis);
876                    let pill = bar_lift.clone().height(BAR_HEIGHT);
877                    Box(pill, BoxSpec::default(), move || {
878                        let on_select = Rc::clone(&on_select);
879                        let contact_motion = Rc::clone(&contact_motion);
880                        let published_axis = Rc::clone(&published_axis);
881                        BoxWithConstraints(Modifier::empty(), move |scope| {
882                            let on_select = Rc::clone(&on_select);
883                            let measured_width =
884                                spec.cell_width(scope.constraints().max_width, count);
885                            tab_width.set(measured_width);
886
887                            let geometry = TabGeometry::new(measured_width, count);
888                            Box(
889                                Modifier::empty()
890                                    .glass_effect(
891                                        tab_bar_surface_material(colors.label)
892                                            .tint_amount(tint_amount),
893                                    )
894                                    .size(Size::new(
895                                        geometry.width + BLOB_MARGIN * 2.0,
896                                        BAR_HEIGHT,
897                                    )),
898                                BoxSpec::default(),
899                                || {},
900                            );
901                            let resting_lens_x =
902                                tab_lens_resting_left(selected, geometry.pitch, count);
903                            let lens_axis = crate::motion::remember_liquid_follow_axis(
904                                resting_lens_x,
905                                cranpose_animation::spring(0.85, 650.0),
906                            );
907                            if !lens_pressed.get() {
908                                lens_axis.settle_to(resting_lens_x, LiquidMotion::glide());
909                            }
910                            *published_axis.borrow_mut() = Some(Rc::clone(&lens_axis));
911                            let row_width = geometry.width;
912                            let gesture = Modifier::empty()
913                                .offset(BLOB_MARGIN, BLOB_MARGIN)
914                                .size(Size::new(row_width, BLOB_HEIGHT))
915                                .pointer_input(selected, {
916                                    let on_select = Rc::clone(&on_select);
917                                    let lens_axis = Rc::clone(&lens_axis);
918                                    let contact_motion = Rc::clone(&contact_motion);
919                                    move |scope: PointerInputScope| {
920                                        let on_select = Rc::clone(&on_select);
921                                        let lens_axis = Rc::clone(&lens_axis);
922                                        let contact_motion = Rc::clone(&contact_motion);
923                                        crate::motion::liquid_lens_gesture(
924                                            scope,
925                                            crate::motion::LiquidLensGesture {
926                                                axis: lens_axis,
927                                                cell_width: geometry.pitch,
928                                                cell_offset: (geometry.cell_width - geometry.pitch)
929                                                    * 0.5,
930                                                count,
931                                                tap_slop: TAP_SLOP,
932                                                drag_left: Rc::new(move |x| {
933                                                    geometry.drag_left(
934                                                        geometry.optical_pointer_x(
935                                                            x,
936                                                            bar_press.get(),
937                                                            travel.get(),
938                                                        ),
939                                                        count,
940                                                        has_accessory,
941                                                    )
942                                                }),
943                                                rest_left: Rc::new(move |index| {
944                                                    tab_lens_resting_left(
945                                                        index,
946                                                        geometry.pitch,
947                                                        count,
948                                                    )
949                                                }),
950                                                selected,
951                                                on_pressed: Rc::new(move |down, time| {
952                                                    lens_pressed.set(down);
953                                                    contact_motion.pressed(down, time);
954                                                    bar_touch.pressed(down);
955                                                }),
956                                                on_touch: Rc::new(move |x, y| {
957                                                    bar_touch.update(
958                                                        geometry.optical_pointer_x(
959                                                            x,
960                                                            bar_press.get(),
961                                                            travel.get(),
962                                                        ),
963                                                        y,
964                                                        geometry.pitch
965                                                            * count.saturating_sub(1) as f32,
966                                                    );
967                                                }),
968                                                on_select,
969                                            },
970                                        )
971                                    }
972                                });
973                            Box(gesture, BoxSpec::default(), || {});
974                        });
975                    });
976
977                    let drawing = Rc::new(TabLensDrawing {
978                        lens: TabLensPlacement::new(cells, resting_lens_x, colors, has_accessory),
979                        motion: motion.clone(),
980                        colors,
981                        held_layers: cranpose_core::remember(|| Rc::new(RefCell::new(None)))
982                            .with(Rc::clone),
983                    });
984                    let lighting = Rc::clone(&drawing);
985                    super::tab_lighting::TabLighting(
986                        Size::new(pill_w, BAR_HEIGHT),
987                        move || lighting.transform(),
988                        bar_touch.position,
989                        glow,
990                        local_glow_factor,
991                    );
992                    Box(drawing.glass_modifier(0), BoxSpec::default(), || {});
993                    let (ink, lift) = (Rc::clone(&drawing), Rc::clone(&drawing));
994                    TabCells(
995                        Modifier::empty(),
996                        Rc::clone(&tabs),
997                        typography,
998                        TabCellsSpec {
999                            geometry: cells,
1000                            base_color: tab_base_content_color(colors),
1001                            selected: visual_index,
1002                            committed_selection: selected,
1003                        },
1004                        move || ink.selection(),
1005                        move || lift.transform(),
1006                        move |index| semantic_selection(index),
1007                    );
1008                    Box(drawing.glass_modifier(1), BoxSpec::default(), || {});
1009                },
1010            );
1011
1012            if has_accessory {
1013                Box(
1014                    Modifier::empty().width(tab_bar_accessory_gap(true)),
1015                    BoxSpec::default(),
1016                    || {},
1017                );
1018                (accessory.borrow_mut())();
1019            }
1020        },
1021    );
1022}
1023
1024/// The flying lens's two glass layers for one material, kept while the lens's
1025/// activity, which picks the material, holds.
1026type TabLensLayers = dyn Fn(f32, GlassDynamics) -> [cranpose_ui_graphics::GraphicsLayer; 2];
1027
1028/// The lens layers held for the activity, by its bits, that picked them.
1029type HeldTabLensLayers = (u32, Rc<TabLensLayers>);
1030
1031/// The tab bar's moving parts, read where they are drawn: a glide or a press
1032/// then redraws the bar each frame and recomposes it only when the tab under
1033/// the lens changes.
1034#[derive(Clone)]
1035struct TabBarMotion {
1036    axis: Rc<RefCell<Option<Rc<crate::motion::LiquidDragAxis>>>>,
1037    lens_shape: Rc<super::tab_motion::TabLensShape>,
1038    bar_press: cranpose_core::State<f32>,
1039    travel: cranpose_core::State<f32>,
1040    lens_activity: cranpose_core::State<f32>,
1041}
1042
1043/// Gathers the states the bar moves with, and remembers the lens's shape and
1044/// the slot its axis is published to.
1045#[composable]
1046fn remember_tab_bar_motion(
1047    bar_press: cranpose_core::State<f32>,
1048    travel: cranpose_core::State<f32>,
1049    lens_activity: cranpose_core::State<f32>,
1050) -> TabBarMotion {
1051    TabBarMotion {
1052        axis: cranpose_core::remember(|| Rc::new(RefCell::new(None))).with(Rc::clone),
1053        lens_shape: super::tab_motion::remember_tab_lens_shape(),
1054        bar_press,
1055        travel,
1056        lens_activity,
1057    }
1058}
1059
1060/// What the lens looks like this frame.
1061#[derive(Clone, Copy)]
1062struct TabLensFrame {
1063    lens_x: f32,
1064    activity: f32,
1065    strain: Size,
1066    press: f32,
1067    travel: f32,
1068}
1069
1070impl TabBarMotion {
1071    /// The lens's left edge, or where it rests before the bar has laid out.
1072    fn lens_x(&self, resting_lens_x: f32) -> f32 {
1073        self.axis
1074            .borrow()
1075            .as_ref()
1076            .map_or(resting_lens_x, |axis| axis.value())
1077    }
1078
1079    /// The tab the lens is over, which the bar shows as selected while the lens
1080    /// owns the selection.
1081    fn visual_index(
1082        &self,
1083        selected: usize,
1084        cells: TabGeometry,
1085        count: usize,
1086        pressed: bool,
1087    ) -> usize {
1088        let resting_lens_x = tab_lens_resting_left(selected, cells.pitch, count);
1089        let lens_x = self.lens_x(resting_lens_x);
1090        crate::motion::liquid_visual_index(
1091            selected,
1092            lens_x,
1093            cells.pitch,
1094            count,
1095            crate::motion::liquid_axis_owns_visual_selection(
1096                pressed,
1097                lens_x,
1098                resting_lens_x,
1099                cells.pitch,
1100            ),
1101        )
1102    }
1103
1104    /// Reads every moving part, so the caller redraws when any of them moves.
1105    /// The lens shape takes the lens's position once a frame, whichever
1106    /// reader draws first, and every reader sees the strain it holds.
1107    fn frame(&self, cells: TabGeometry, resting_lens_x: f32) -> TabLensFrame {
1108        let press = self.bar_press.get();
1109        let travel = self.travel.get();
1110        let activity = self.lens_activity.get().clamp(0.0, 1.0);
1111        let lens_x = self.lens_x(resting_lens_x);
1112        let strain = self
1113            .lens_shape
1114            .sample(cells.lens_surface_position(lens_x, press, travel));
1115        TabLensFrame {
1116            lens_x,
1117            activity,
1118            strain,
1119            press,
1120            travel,
1121        }
1122    }
1123}
1124
1125/// Where the flying lens sits in the bar: everything about it that does not
1126/// move with the lens.
1127#[derive(Clone, Copy)]
1128struct TabLensPlacement {
1129    cells: TabGeometry,
1130    resting_lens_x: f32,
1131    pill_w: f32,
1132    node_size: Size,
1133    node_top: f32,
1134    resting_tint: Color,
1135    accessory_center: Option<(f32, f32)>,
1136}
1137
1138impl TabLensPlacement {
1139    fn new(
1140        cells: TabGeometry,
1141        resting_lens_x: f32,
1142        colors: LiquidColors,
1143        has_accessory: bool,
1144    ) -> Self {
1145        let (lens_w, lens_h) = tab_lens_base_size(cells.cell_width, 1.0);
1146        let deformation_headroom =
1147            crate::dynamics::STRETCH_MAX.max(1.0 / crate::dynamics::STRETCH_MIN);
1148        let node_w = lens_w * deformation_headroom + crate::dynamics::BULGE_MAX + 20.0;
1149        let node_h = lens_h * deformation_headroom + crate::dynamics::BULGE_MAX + 16.0;
1150        let pill_w = cells.width + 2.0 * BLOB_MARGIN;
1151        Self {
1152            cells,
1153            resting_lens_x,
1154            pill_w,
1155            node_size: Size::new(node_w, node_h),
1156            node_top: tab_lens_node_top(node_h),
1157            resting_tint: colors.fill,
1158            accessory_center: has_accessory.then_some((
1159                pill_w + tab_bar_accessory_gap(true) + BAR_HEIGHT * 0.5,
1160                BAR_HEIGHT * 0.5,
1161            )),
1162        }
1163    }
1164
1165    fn center_x(self, frame: TabLensFrame) -> f32 {
1166        BLOB_MARGIN + frame.lens_x + self.cells.cell_width * 0.5
1167    }
1168
1169    fn node(self, frame: TabLensFrame) -> TabFlightNode {
1170        TabFlightNode {
1171            origin: (
1172                self.center_x(frame) - self.node_size.width * 0.5,
1173                self.node_top,
1174            ),
1175            size: self.node_size,
1176        }
1177    }
1178
1179    fn geometry(self, frame: TabLensFrame) -> TabFlightGeometry {
1180        let (base_w, base_h) = tab_lens_base_size(self.cells.cell_width, frame.activity);
1181        TabFlightGeometry {
1182            center: (self.center_x(frame), BAR_HEIGHT * 0.5),
1183            base_size: Size::new(base_w, base_h),
1184            strain: frame.strain,
1185            lens_position: frame.lens_x,
1186            lens_activity: frame.activity,
1187            resting_tint: self.resting_tint,
1188            accessory_center: self.accessory_center,
1189        }
1190    }
1191
1192    /// The lens node's layer: the bar's lift about the pill's center, and the
1193    /// node carried to the lens in the same translation.
1194    fn layer(self, motion: &TabBarMotion) -> cranpose_ui_graphics::GraphicsLayer {
1195        let frame = motion.frame(self.cells, self.resting_lens_x);
1196        let mut layer = tab_bar_transform(self.pill_w, frame.press, frame.travel);
1197        let center_x = self.center_x(frame);
1198        layer.translation_x +=
1199            (center_x - self.pill_w * 0.5) * (layer.scale_x - 1.0) + self.node(frame).origin.0;
1200        layer
1201    }
1202}
1203
1204/// Draws the flying lens and the ink under it from the bar's moving parts,
1205/// each frame, without recomposing the bar.
1206struct TabLensDrawing {
1207    lens: TabLensPlacement,
1208    motion: TabBarMotion,
1209    colors: LiquidColors,
1210    held_layers: Rc<RefCell<Option<HeldTabLensLayers>>>,
1211}
1212
1213impl TabLensDrawing {
1214    /// The bar's lift this frame.
1215    fn transform(&self) -> cranpose_ui_graphics::GraphicsLayer {
1216        tab_bar_transform(
1217            self.lens.pill_w,
1218            self.motion.bar_press.get(),
1219            self.motion.travel.get(),
1220        )
1221    }
1222
1223    /// Where the lens's ink falls on the cells this frame.
1224    fn selection(&self) -> super::vibrancy::InkSelection {
1225        let frame = self.motion.frame(self.lens.cells, self.lens.resting_lens_x);
1226        tab_ink_selection(
1227            self.lens.cells,
1228            frame.lens_x,
1229            frame.activity,
1230            frame.strain,
1231            tab_selection_content_color(self.colors),
1232        )
1233    }
1234
1235    /// The glass layers for the lens's activity, built again only when the
1236    /// activity picks another material.
1237    fn layers(&self, activity: f32) -> Rc<TabLensLayers> {
1238        let key = activity.to_bits();
1239        let mut held = self.held_layers.borrow_mut();
1240        if let Some((held_key, layers)) = &*held
1241            && *held_key == key
1242        {
1243            return Rc::clone(layers);
1244        }
1245        let layers: Rc<TabLensLayers> = Rc::new(crate::material::cached_glass_content_layers(
1246            tab_flight_lens_material(self.colors.label, activity).resolve(&self.colors),
1247        ));
1248        *held = Some((key, Rc::clone(&layers)));
1249        layers
1250    }
1251
1252    /// One of the lens's two glass layers this frame: 0 under the cells, 1 over them.
1253    fn glass_layer(&self, layer: usize) -> cranpose_ui_graphics::GraphicsLayer {
1254        let frame = self.motion.frame(self.lens.cells, self.lens.resting_lens_x);
1255        self.layers(frame.activity)(
1256            cranpose_ui::current_density(),
1257            tab_flight_dynamics(self.lens.geometry(frame), self.lens.node(frame)),
1258        )[layer]
1259            .clone()
1260    }
1261
1262    /// The lens node carried across the bar, drawing one of its glass layers.
1263    fn glass_modifier(self: &Rc<Self>, layer: usize) -> Modifier {
1264        let (node, glass) = (Rc::clone(self), Rc::clone(self));
1265        Modifier::empty()
1266            .required_size(self.lens.node_size)
1267            .offset(0.0, self.lens.node_top)
1268            .graphics_layer(move || node.lens.layer(&node.motion))
1269            .graphics_layer(move || glass.glass_layer(layer))
1270    }
1271}
1272
1273/// The standard detached accessory: a circular glass search button.
1274#[composable]
1275pub fn LiquidTabBarSearchAccessory(on_click: impl Fn() + 'static) {
1276    crate::widgets::GlassIconButton(
1277        Modifier::empty().content_description("Search"),
1278        crate::widgets::GlassButtonSpec::glass(),
1279        BAR_HEIGHT * 0.94,
1280        on_click,
1281        crate::icons::SEARCH,
1282    );
1283}
1284
1285#[cfg(test)]
1286#[path = "tests/tab_bar_tests.rs"]
1287mod tests;