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