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