1use crate::material::{
6 neutral_surface_lift, neutral_surface_tint, Glass, GlassDynamics, GlassMorph, GlassShadow,
7 LiquidModifierExt,
8};
9use crate::motion::LiquidMotion;
10use crate::theme::{liquid_colors, liquid_typography, LiquidTypography};
11use crate::widgets::content_scope::ScopeContent;
12use cranpose_macros::composable;
13use cranpose_ui::text::{FontWeight, SpanStyle, TextStyle};
14use cranpose_ui::widgets::{
15 Box, BoxSpec, BoxWithConstraints, BoxWithConstraintsScope, Column, ColumnSpec, Row, RowSpec,
16 Text,
17};
18use cranpose_ui::{
19 Brush, Color, CornerRadii, Modifier, PointerInputScope, Rect, SemanticsWidgetRole, Size,
20};
21use cranpose_ui_layout::{Alignment, HorizontalAlignment, VerticalAlignment};
22use std::cell::RefCell;
23use std::rc::Rc;
24
25#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
27pub enum LiquidTabIconStyle {
28 #[default]
29 Plain,
30 AppBadge,
31}
32
33#[derive(Clone, Debug, PartialEq)]
35pub struct LiquidTab {
36 pub icon: &'static str,
37 pub label: &'static str,
38 pub icon_style: LiquidTabIconStyle,
39 pub icon_scale: f32,
42}
43
44impl LiquidTab {
45 pub fn new(icon: &'static str, label: &'static str) -> Self {
46 Self {
47 icon,
48 label,
49 icon_style: LiquidTabIconStyle::Plain,
50 icon_scale: 1.0,
51 }
52 }
53
54 pub fn app_badge(icon: &'static str, label: &'static str) -> Self {
55 Self {
56 icon,
57 label,
58 icon_style: LiquidTabIconStyle::AppBadge,
59 icon_scale: 1.0,
60 }
61 }
62
63 pub fn with_icon_scale(mut self, scale: f32) -> Self {
64 self.icon_scale = normalize_icon_scale(scale);
65 self
66 }
67}
68
69fn normalize_icon_scale(scale: f32) -> f32 {
70 if scale.is_finite() {
71 scale.clamp(0.5, 1.5)
72 } else {
73 1.0
74 }
75}
76
77fn tab_base_content_color(colors: crate::theme::LiquidColors) -> Color {
78 colors.label
79}
80
81fn tab_selection_content_color(colors: crate::theme::LiquidColors) -> Color {
82 colors.accent
83}
84
85const BAR_HEIGHT: f32 = 64.0;
86const BLOB_HEIGHT: f32 = 56.0;
90const BLOB_MARGIN: f32 = 4.0;
91const FLIGHT_LENS_HEIGHT_PROJECTION: f32 = 68.0 / BLOB_HEIGHT;
100const FLIGHT_ELLIPSE_BLEND: f32 = 0.25;
108const TAB_LENS_REST_WIDTH_FACTOR: f32 = 1.10;
115const TAB_STRAIN_RESPONSE: f32 = 0.30;
119const TAB_WIDTH: f32 = 78.0;
121const TAB_ICON_SIZE: f32 = 32.0;
123const TAB_LABEL_SIZE: f32 = 11.0;
124const TAP_SLOP: f32 = 6.0;
126const ACCESSORY_GAP: f32 = 10.0;
127
128#[derive(Clone, Copy, Debug, PartialEq)]
130pub struct LiquidTabBarSpec {
131 max_tab_width: f32,
132}
133
134impl LiquidTabBarSpec {
135 pub fn new(max_tab_width: f32) -> Self {
136 Self {
137 max_tab_width: if max_tab_width.is_finite() {
138 max_tab_width.max(1.0)
139 } else {
140 TAB_WIDTH
141 },
142 }
143 }
144}
145
146impl Default for LiquidTabBarSpec {
147 fn default() -> Self {
148 Self::new(TAB_WIDTH)
149 }
150}
151
152fn tab_flight_lens_material(foreground: cranpose_ui_graphics::Color, accent: Color) -> Glass {
153 Glass::lens()
154 .no_clip()
155 .tint(neutral_surface_tint(foreground, 0.06, 0.05))
156 .ink_recolor(accent, 0.85)
160 .blur_radius(0.0)
161 .refraction_depth(1.0)
169 .refraction_curve(0.25)
170 .optical_zoom(1.22)
171 .fold_depth(2.5)
179 .dispersion(0.9)
184 .lift(0.05)
190 .highlight(0.18)
191 .shadow_style(GlassShadow::new(
195 cranpose_ui_graphics::Color::BLACK.with_alpha(0.14),
196 12.0,
197 4.0,
198 -2.0,
199 ))
200}
201
202fn tab_bar_surface_material(foreground: cranpose_ui_graphics::Color) -> Glass {
203 Glass::regular()
204 .tint(neutral_surface_tint(foreground, 0.0, 0.04))
205 .blur_radius(9.0)
211 .saturation(1.15)
217 .lift(neutral_surface_lift(foreground, 0.60, -0.24))
218 .highlight(0.20)
219 .fold_depth(8.0)
224 .adaptive_frost(foreground, 0.28)
225}
226
227fn tab_flight_tint_multiplier(activity: f32) -> f32 {
228 1.0 - 0.25 * activity.clamp(0.0, 1.0)
229}
230
231fn tab_lens_activity_motion(raised: bool) -> cranpose_animation::AnimationType {
232 if raised {
233 cranpose_animation::spring(0.9, 1400.0)
236 } else {
237 cranpose_animation::spring(1.0, 900.0)
239 }
240}
241
242fn tab_lens_left(pointer_x: f32, tab_width: f32, count: usize, has_accessory: bool) -> f32 {
243 let last_tab = tab_width * count.saturating_sub(1) as f32;
244 let min = if has_accessory { -tab_width * 0.2 } else { 0.0 };
245 let max = if has_accessory {
246 tab_width * (count as f32 - 0.45)
247 } else {
248 last_tab
249 };
250 (pointer_x - tab_width * 0.5).clamp(min, max)
251}
252
253pub fn tab_lens_resting_left(selected: usize, tab_width: f32, count: usize) -> f32 {
260 tab_width * selected.min(count.saturating_sub(1)) as f32
261}
262
263pub fn tab_lens_rest_width(tab_width: f32) -> f32 {
273 let overhang = (tab_width * (TAB_LENS_REST_WIDTH_FACTOR - 1.0) * 0.5).min(BLOB_MARGIN);
274 tab_width + 2.0 * overhang
275}
276
277#[derive(Clone, Copy, Debug, PartialEq)]
278struct AppBadgeGeometry {
279 size: Size,
280 corner_radius: f32,
281 stripe: Rect,
282 glyph: Rect,
283}
284
285fn app_badge_geometry(optical_scale: f32) -> AppBadgeGeometry {
286 let scale = normalize_icon_scale(optical_scale);
287 AppBadgeGeometry {
288 size: Size::new(20.0 * scale, 32.0 * scale),
289 corner_radius: 5.0 * scale,
290 stripe: Rect {
291 x: 7.0 * scale,
292 y: 4.5 * scale,
293 width: 6.0 * scale,
294 height: 1.5 * scale,
295 },
296 glyph: Rect {
297 x: 3.0 * scale,
298 y: 10.0 * scale,
299 width: 14.0 * scale,
300 height: 14.0 * scale,
301 },
302 }
303}
304
305#[composable]
306#[allow(non_snake_case)]
307fn TabIcon(icon: &'static str, style: LiquidTabIconStyle, color: Color, optical_scale: f32) {
308 const FRAME_HEIGHT: f32 = 32.0;
309 Box(
310 Modifier::empty().size(Size::new(TAB_ICON_SIZE, FRAME_HEIGHT)),
311 BoxSpec::default().content_alignment(Alignment::CENTER),
312 move || match style {
313 LiquidTabIconStyle::Plain => {
314 crate::icons::Icon(icon, TAB_ICON_SIZE * optical_scale, color)
315 }
316 LiquidTabIconStyle::AppBadge => {
317 let geometry = app_badge_geometry(optical_scale);
318 Box(
319 Modifier::empty()
320 .size(geometry.size)
321 .draw_behind(move |scope| {
322 scope.draw_round_rect(
323 Brush::solid(color),
324 CornerRadii::uniform(geometry.corner_radius),
325 );
326 scope.draw_rect_at(geometry.stripe, Brush::solid(Color::WHITE));
327 }),
328 BoxSpec::default(),
329 move || {
330 Box(
331 Modifier::empty()
332 .offset(geometry.glyph.x, geometry.glyph.y)
333 .size(Size::new(geometry.glyph.width, geometry.glyph.height)),
334 BoxSpec::default(),
335 move || crate::icons::Icon(icon, geometry.glyph.width, Color::WHITE),
336 );
337 },
338 );
339 }
340 },
341 );
342}
343
344#[derive(Clone, Copy, PartialEq)]
345struct TabCellsSpec {
346 base_color: Color,
347 selected: Option<usize>,
348 selected_color: Color,
349 interactive: bool,
350 selection_only: bool,
351}
352
353fn tab_cell_is_visible(index: usize, selected: Option<usize>, selection_only: bool) -> bool {
354 !selection_only || selected == Some(index)
355}
356
357#[composable]
358#[allow(non_snake_case)]
359fn TabCells(
360 modifier: Modifier,
361 tabs: Rc<Vec<LiquidTab>>,
362 typography: LiquidTypography,
363 tab_width: f32,
364 spec: TabCellsSpec,
365) {
366 Row(modifier, RowSpec::default(), move || {
367 for (index, tab) in tabs.iter().enumerate() {
368 let visible = tab_cell_is_visible(index, spec.selected, spec.selection_only);
369 let color = if spec.selected == Some(index) {
370 spec.selected_color
371 } else {
372 spec.base_color
373 };
374 let label_for_semantics = tab.label;
375 let mut cell = Modifier::empty().size(Size::new(tab_width, BLOB_HEIGHT));
376 if spec.interactive {
377 cell = cell.semantics(move |config| {
378 config.role = Some(SemanticsWidgetRole::Button);
379 config.is_clickable = true;
380 config.content_description = Some(label_for_semantics.to_string());
381 });
382 }
383 let icon = tab.icon;
384 let icon_style = tab.icon_style;
385 let icon_scale = tab.icon_scale;
386 let label = tab.label;
387 let label_style = TextStyle {
388 span_style: SpanStyle {
389 color: Some(color),
390 font_size: cranpose_ui::text::TextUnit::Sp(TAB_LABEL_SIZE),
391 font_weight: Some(FontWeight::MEDIUM),
392 ..typography.caption1.span_style.clone()
393 },
394 ..typography.caption1.clone()
395 };
396 Box(
397 cell,
398 BoxSpec::default().content_alignment(Alignment::CENTER),
399 move || {
400 if !visible {
401 return;
402 }
403 let label_style = label_style.clone();
404 Column(
405 Modifier::empty(),
406 ColumnSpec::default()
407 .horizontal_alignment(HorizontalAlignment::CenterHorizontally),
408 move || {
409 TabIcon(icon, icon_style, color, icon_scale);
410 Text(label, Modifier::empty(), label_style.clone());
411 },
412 );
413 },
414 );
415 }
416 });
417}
418
419fn tab_lens_node_top(node_height: f32) -> f32 {
420 (BAR_HEIGHT - node_height) * 0.5
421}
422
423fn tab_bar_accessory_gap(has_accessory: bool) -> f32 {
424 if has_accessory {
425 ACCESSORY_GAP
426 } else {
427 0.0
428 }
429}
430
431fn accessory_surfaces_touch(edge_gap: f32) -> bool {
432 edge_gap <= 0.0
433}
434
435fn tab_lens_base_size(tab_width: f32, activity: f32) -> (f32, f32) {
436 let activity = activity.clamp(0.0, 1.0);
437 let ease = activity * activity * (3.0 - 2.0 * activity);
438 let rest_width = tab_lens_rest_width(tab_width);
439 let projection = 1.0 + (FLIGHT_LENS_HEIGHT_PROJECTION - 1.0) * ease;
440 (rest_width, BLOB_HEIGHT * projection)
441}
442
443#[derive(Clone, Copy, Debug, PartialEq)]
444struct TabFlightGeometry {
445 center: (f32, f32),
446 base_size: Size,
447 pose: crate::dynamics::LiquidPose,
448 lens_position: f32,
449 lens_activity: f32,
450 resting_tint: Color,
451 accessory_center: Option<(f32, f32)>,
452}
453
454#[derive(Clone, Copy, Debug, PartialEq)]
455struct TabFlightNode {
456 origin: (f32, f32),
457 size: Size,
458}
459
460fn tab_flight_dynamics(geometry: TabFlightGeometry, node: TabFlightNode) -> GlassDynamics {
461 let activity = geometry.lens_activity.clamp(0.0, 1.0);
462 let energy = geometry.pose.energy() * activity;
463 let radius = geometry.base_size.height * (0.48 + 0.02 * energy);
469 let glue = 20.0;
470 let shapes = geometry
471 .accessory_center
472 .filter(|(x, _)| {
473 let effective_stretch =
474 1.0 + (geometry.pose.stretch.max(geometry.pose.ortho) - 1.0) * TAB_STRAIN_RESPONSE;
475 let edge_gap = (*x - geometry.center.0).abs()
476 - geometry.base_size.width * effective_stretch * 0.5
477 - BAR_HEIGHT * 0.5;
478 accessory_surfaces_touch(edge_gap)
479 })
480 .map(|(x, y)| {
481 vec![(
482 x - node.origin.0,
483 y - node.origin.1,
484 BAR_HEIGHT,
485 BAR_HEIGHT,
486 -1.0,
487 )]
488 })
489 .unwrap_or_default();
490 GlassDynamics {
491 morph: Some(GlassMorph {
492 node_size: (node.size.width, node.size.height),
493 primary: (
494 geometry.center.0 - node.origin.0,
495 geometry.center.1 - node.origin.1,
496 geometry.base_size.width,
497 geometry.base_size.height,
498 radius,
499 ),
500 shapes,
501 glue,
502 wobble_amplitude: 0.5 * energy,
506 wobble_phase: geometry.lens_position * 0.045,
507 bulge_amplitude: geometry.pose.bulge_amplitude.min(8.0) * activity,
508 bulge_direction: geometry.pose.bulge_direction,
509 ellipse_blend: FLIGHT_ELLIPSE_BLEND,
510 deformation: Some(crate::material::GlassDeformation::incompressible(
511 geometry.pose.axis,
512 1.0 + (geometry.pose.stretch - 1.0) * activity * TAB_STRAIN_RESPONSE,
516 )),
517 zoom_anchor: (0.0, 0.0),
518 }),
519 activity: Some(activity),
520 press_depth: Some(0.3 + 0.7 * activity),
524 resting_tint: Some(geometry.resting_tint),
525 tint_alpha_multiplier: Some(tab_flight_tint_multiplier(geometry.lens_activity)),
526 ..Default::default()
527 }
528}
529
530pub struct LiquidTabBarScope {
535 tabs: ScopeContent<LiquidTab>,
536}
537
538impl LiquidTabBarScope {
539 pub fn tab(&self, icon: &'static str, label: &'static str) {
541 self.push(LiquidTab::new(icon, label));
542 }
543
544 pub fn app_badge(&self, icon: &'static str, label: &'static str) {
546 self.push(LiquidTab::app_badge(icon, label));
547 }
548
549 pub fn push(&self, tab: LiquidTab) {
552 self.tabs.push(tab);
553 }
554}
555
556fn collect_tabs(content: impl FnOnce(&LiquidTabBarScope)) -> Vec<LiquidTab> {
557 ScopeContent::collect(|tabs| LiquidTabBarScope { tabs }, content)
558}
559
560#[composable]
569#[allow(non_snake_case)]
570pub fn LiquidTabBar(
571 modifier: Modifier,
572 spec: LiquidTabBarSpec,
573 selected: usize,
574 on_select: impl Fn(usize) + 'static,
575 content: impl FnOnce(&LiquidTabBarScope),
576) {
577 LiquidTabBarLayout(
578 modifier,
579 spec,
580 collect_tabs(content),
581 selected,
582 on_select,
583 false,
584 || {},
585 );
586}
587
588#[composable]
590#[allow(non_snake_case)]
591pub fn LiquidTabBarWithAccessory(
592 modifier: Modifier,
593 spec: LiquidTabBarSpec,
594 selected: usize,
595 on_select: impl Fn(usize) + 'static,
596 content: impl FnOnce(&LiquidTabBarScope),
597 accessory: impl FnMut() + 'static,
598) {
599 LiquidTabBarLayout(
600 modifier,
601 spec,
602 collect_tabs(content),
603 selected,
604 on_select,
605 true,
606 accessory,
607 );
608}
609
610#[composable]
611#[allow(non_snake_case)]
612fn LiquidTabBarLayout(
613 modifier: Modifier,
614 spec: LiquidTabBarSpec,
615 tabs: Vec<LiquidTab>,
616 selected: usize,
617 on_select: impl Fn(usize) + 'static,
618 has_accessory: bool,
619 accessory: impl FnMut() + 'static,
620) {
621 let colors = liquid_colors();
622 let typography = liquid_typography();
623 let count = tabs.len().max(1);
624 let selected = selected.min(count - 1);
625 let on_select: Rc<dyn Fn(usize)> = Rc::new(on_select);
626 let tabs = Rc::new(tabs);
627 let accessory = Rc::new(RefCell::new(accessory));
628
629 Row(
630 modifier,
631 RowSpec::default().vertical_alignment(VerticalAlignment::CenterVertically),
632 move || {
633 let tabs = Rc::clone(&tabs);
634 let typography = typography.clone();
635 let on_select = Rc::clone(&on_select);
636 let accessory = Rc::clone(&accessory);
637
638 let lens_x_outer = cranpose_core::remember(|| {
641 cranpose_core::mutableStateOf((
642 0.0f32,
643 0.0f32,
644 0.0f32,
645 crate::dynamics::LiquidPose::default(),
646 ))
647 })
648 .with(|state| *state);
649 let bar_touch =
655 cranpose_core::remember(|| cranpose_core::mutableStateOf((0.0f32, 0.0f32)))
656 .with(|state| *state);
657 let bar_held = cranpose_core::remember(|| cranpose_core::mutableStateOf(false))
658 .with(|state| *state);
659 let bar_press = cranpose_animation::animateFloatAsState(
660 if bar_held.get() { 1.0 } else { 0.0 },
661 cranpose_animation::spring(1.0, 600.0),
662 "tabbar-hold-press",
663 );
664 let bar_lift = Modifier::empty().graphics_layer(move || {
670 let press = bar_press.get().clamp(0.0, 1.0);
671 let rise = 1.0 + 0.03 * press;
672 cranpose_ui_graphics::GraphicsLayer {
673 scale_x: rise,
674 scale_y: rise,
675 translation_y: -2.5 * press,
676 ..Default::default()
677 }
678 });
679 Box(
683 bar_lift.then(Modifier::empty().height(BAR_HEIGHT)),
684 BoxSpec::default(),
685 move || {
686 let tabs = Rc::clone(&tabs);
687 let typography = typography.clone();
688 let on_select = Rc::clone(&on_select);
689 let pill = Modifier::empty()
692 .glass_effect_with(
693 tab_bar_surface_material(colors.label),
698 move || {
699 let press = bar_press.get().clamp(0.0, 1.0);
700 let (touch_x, touch_y) = bar_touch.get();
701 GlassDynamics {
702 highlight_boost: 0.45 * press,
703 saturation_boost: 0.12 * press,
704 touch: (press > 0.01).then_some((touch_x, touch_y, press)),
705 ..Default::default()
706 }
707 },
708 )
709 .height(BAR_HEIGHT);
710 Box(pill, BoxSpec::default(), move || {
711 let tabs = Rc::clone(&tabs);
712 let typography = typography.clone();
713 let on_select = Rc::clone(&on_select);
714 BoxWithConstraints(Modifier::empty().padding(BLOB_MARGIN), move |scope| {
715 let tabs = Rc::clone(&tabs);
716 let typography = typography.clone();
717 let on_select = Rc::clone(&on_select);
718 let constrained = scope.constraints().max_width;
719 let tab_width = if constrained.is_finite() && constrained > 1.0 {
720 (constrained / count as f32).min(spec.max_tab_width)
721 } else {
722 spec.max_tab_width
723 };
724
725 let lens_pressed =
728 cranpose_core::remember(|| cranpose_core::mutableStateOf(false))
729 .with(|state| *state);
730 let resting_lens_x = tab_lens_resting_left(selected, tab_width, count);
731 let lens_axis =
732 crate::motion::remember_liquid_drag_axis(resting_lens_x);
733 if !lens_pressed.get() {
739 lens_axis.settle_to(resting_lens_x, LiquidMotion::glide());
740 }
741 let lens_x = lens_axis.value();
742 let lens_pose = lens_axis.liquid_pose();
743 let lens_in_flight = !lens_axis.is_dragging()
744 && (lens_x - resting_lens_x).abs() > tab_width * 0.15;
745 let lens_raised = lens_pressed.get() || lens_in_flight;
746 let lens_activity_target = if lens_raised { 1.0 } else { 0.0 };
747 let lens_activity_anim = cranpose_animation::animateFloatAsState(
748 lens_activity_target,
749 tab_lens_activity_motion(lens_raised),
750 "tabbar-lens-activity",
751 );
752 let lens_activity = lens_activity_anim.get();
757 let visual_index = crate::motion::liquid_visual_index(
770 selected,
771 lens_x,
772 tab_width,
773 count,
774 crate::motion::liquid_axis_owns_visual_selection(
775 lens_pressed.get(),
776 lens_x,
777 resting_lens_x,
778 tab_width,
779 ),
780 );
781 TabCells(
782 Modifier::empty(),
783 Rc::clone(&tabs),
784 typography.clone(),
785 tab_width,
786 TabCellsSpec {
787 base_color: tab_base_content_color(colors),
788 selected: Some(visual_index),
789 selected_color: tab_selection_content_color(colors),
790 interactive: true,
791 selection_only: false,
792 },
793 );
794
795 let row_width = tab_width * count as f32;
799 let gesture = Modifier::empty()
800 .size(Size::new(row_width, BLOB_HEIGHT))
801 .pointer_input(selected, {
802 let on_select = Rc::clone(&on_select);
803 let lens_axis = Rc::clone(&lens_axis);
804 move |scope: PointerInputScope| {
805 let on_select = Rc::clone(&on_select);
806 let lens_axis = Rc::clone(&lens_axis);
807 crate::motion::liquid_lens_gesture(
808 scope,
809 crate::motion::LiquidLensGesture {
810 axis: lens_axis,
811 cell_width: tab_width,
812 count,
813 tap_slop: TAP_SLOP,
814 drag_left: Rc::new(move |x| {
815 tab_lens_left(
816 x,
817 tab_width,
818 count,
819 has_accessory,
820 )
821 }),
822 rest_left: Rc::new(move |index| {
823 tab_lens_resting_left(index, tab_width, count)
824 }),
825 selected,
826 on_pressed: Rc::new(move |down| {
827 lens_pressed.set(down);
828 bar_held.set(down);
829 }),
830 on_touch: Rc::new(move |x, y| {
831 bar_touch
832 .set((x + BLOB_MARGIN, y + BLOB_MARGIN));
833 }),
834 on_select,
835 },
836 )
837 }
838 });
839 Box(gesture, BoxSpec::default(), || {});
840
841 let published = (lens_x, lens_activity, tab_width, lens_pose);
845 if lens_x_outer.get() != published {
846 lens_x_outer.set(published);
847 }
848 });
849 });
850
851 let (lens_px, lens_activity, lens_tab_w, pose) = lens_x_outer.get();
860 let lens_w = tab_lens_rest_width(lens_tab_w);
861 let lens_h = BLOB_HEIGHT * FLIGHT_LENS_HEIGHT_PROJECTION;
862 let deformation_headroom =
865 crate::dynamics::STRETCH_MAX.max(1.0 / crate::dynamics::STRETCH_MIN);
866 let node_w = lens_w * deformation_headroom + crate::dynamics::BULGE_MAX + 20.0;
867 let node_h = lens_h * deformation_headroom + crate::dynamics::BULGE_MAX + 16.0;
868 let lens_center_x = BLOB_MARGIN + lens_px + lens_tab_w * 0.5;
869 let node_x = lens_center_x - node_w * 0.5;
870 let node_top = tab_lens_node_top(node_h);
871 let pill_w = lens_tab_w * count as f32 + 2.0 * BLOB_MARGIN;
872 let (base_w, base_h) = tab_lens_base_size(lens_tab_w, lens_activity);
873 let geometry = TabFlightGeometry {
874 center: (lens_center_x, BAR_HEIGHT * 0.5),
875 base_size: Size::new(base_w, base_h),
876 pose,
877 lens_position: lens_px,
878 lens_activity,
879 resting_tint: colors.fill,
880 accessory_center: has_accessory.then_some((
881 pill_w + tab_bar_accessory_gap(true) + BAR_HEIGHT * 0.5,
882 BAR_HEIGHT * 0.5,
883 )),
884 };
885 let lens_node = TabFlightNode {
886 origin: (node_x, node_top),
887 size: Size::new(node_w, node_h),
888 };
889
890 let lens_geometry = geometry;
891 let lens = Modifier::empty()
892 .required_size(lens_node.size)
897 .offset(node_x, node_top)
898 .glass_effect_with(
899 tab_flight_lens_material(
900 colors.label,
901 tab_selection_content_color(colors),
902 ),
903 move || tab_flight_dynamics(lens_geometry, lens_node),
904 );
905 Box(lens, BoxSpec::default(), || {});
906 },
907 );
908
909 if has_accessory {
910 Box(
911 Modifier::empty().width(tab_bar_accessory_gap(true)),
912 BoxSpec::default(),
913 || {},
914 );
915 (accessory.borrow_mut())();
916 }
917 },
918 );
919}
920
921#[composable]
923#[allow(non_snake_case)]
924pub fn LiquidTabBarSearchAccessory(on_click: impl Fn() + 'static) {
925 crate::widgets::GlassIconButton(
927 Modifier::empty(),
928 crate::widgets::GlassButtonSpec::glass(),
929 BAR_HEIGHT * 0.94,
930 on_click,
931 crate::icons::SEARCH,
932 );
933}
934
935#[cfg(test)]
936mod tests {
937 use super::*;
938
939 #[test]
940 fn tab_bar_spec_normalizes_the_maximum_cell_width() {
941 assert_eq!(LiquidTabBarSpec::default().max_tab_width, TAB_WIDTH);
942 assert_eq!(LiquidTabBarSpec::new(85.0).max_tab_width, 85.0);
943 assert_eq!(LiquidTabBarSpec::new(0.0).max_tab_width, 1.0);
944 assert_eq!(LiquidTabBarSpec::new(f32::NAN).max_tab_width, TAB_WIDTH);
945 }
946
947 #[test]
948 fn drag_pointer_centers_the_lens_and_preserves_end_overdrag() {
949 let width = 100.0;
950 assert_eq!(tab_lens_left(50.0, width, 4, true), 0.0);
951 assert_eq!(tab_lens_left(250.0, width, 4, true), 200.0);
952 assert_eq!(tab_lens_left(-100.0, width, 4, true), -20.0);
953 assert_eq!(tab_lens_left(500.0, width, 4, true), 355.0);
954
955 assert_eq!(tab_lens_left(-100.0, width, 4, false), 0.0);
956 assert_eq!(tab_lens_left(500.0, width, 4, false), 300.0);
957 }
958
959 #[test]
960 fn resting_lens_centers_on_its_cell_and_stays_inside_the_pill() {
961 let tab = 78.0;
962 assert_eq!(tab_lens_resting_left(0, tab, 5), 0.0);
965 assert_eq!(tab_lens_resting_left(1, tab, 5), tab);
966 assert_eq!(tab_lens_resting_left(3, tab, 5), 3.0 * tab);
967 assert_eq!(tab_lens_resting_left(4, tab, 5), 4.0 * tab);
968 assert_eq!(tab_lens_resting_left(9, tab, 5), 4.0 * tab);
969 let overhang = (tab_lens_rest_width(tab) - tab) * 0.5;
974 assert!(overhang <= BLOB_MARGIN + 1.0e-4);
975 }
976
977 #[test]
978 fn flight_lens_node_is_centered_on_the_bar_axis() {
979 for node_height in [48.0, 64.0, 96.0, 128.0] {
980 let center = tab_lens_node_top(node_height) + node_height * 0.5;
981 assert!((center - BAR_HEIGHT * 0.5).abs() < f32::EPSILON);
982 }
983 }
984
985 #[test]
986 fn liquid_tab_builds_reference_content() {
987 assert_eq!(TAB_ICON_SIZE, 32.0);
988 let tab = LiquidTab::new(crate::icons::STAR, "Discover");
989 assert_eq!(tab.icon, crate::icons::STAR);
990 assert_eq!(tab.label, "Discover");
991 assert_eq!(tab.icon_style, LiquidTabIconStyle::Plain);
992
993 let badge = LiquidTab::app_badge(crate::icons::APPLE, "WWDC");
994 assert_eq!(badge.icon_style, LiquidTabIconStyle::AppBadge);
995
996 let compact = LiquidTab::new(crate::icons::ACCOUNT_CIRCLE, "Account").with_icon_scale(0.72);
997 assert!((compact.icon_scale - 0.72).abs() < f32::EPSILON);
998 assert_eq!(tab.clone().with_icon_scale(f32::NAN).icon_scale, 1.0);
999 assert_eq!(tab.with_icon_scale(2.0).icon_scale, 1.5);
1000 }
1001
1002 #[test]
1003 fn app_badge_geometry_honors_the_tab_optical_scale() {
1004 let full = app_badge_geometry(1.0);
1005 let corrected = app_badge_geometry(0.85);
1006 assert_eq!(full.size, Size::new(20.0, 32.0));
1007 assert_eq!(corrected.size, Size::new(17.0, 27.2));
1008 assert!((corrected.glyph.width - 11.9).abs() < 1.0e-5);
1009 assert!((corrected.corner_radius / full.corner_radius - 0.85).abs() < f32::EPSILON);
1010 assert!((corrected.stripe.x / full.stripe.x - 0.85).abs() < f32::EPSILON);
1011 assert!((corrected.glyph.width / full.glyph.width - 0.85).abs() < f32::EPSILON);
1012 }
1013
1014 #[test]
1015 fn base_tab_content_remains_neutral_under_the_moving_selection_layer() {
1016 let colors = crate::theme::LiquidColors::light(cranpose_ui_graphics::Color::from_rgb_u8(
1017 0, 122, 255,
1018 ));
1019 assert_eq!(tab_base_content_color(colors), colors.label);
1020 assert_eq!(tab_selection_content_color(colors), colors.accent);
1021 }
1022
1023 #[test]
1024 fn selection_mask_and_lens_resolve_the_same_global_sdf() {
1025 let geometry = TabFlightGeometry {
1026 center: (212.0, 32.0),
1027 base_size: Size::new(106.0, 64.0),
1028 pose: crate::dynamics::LiquidPose::default(),
1029 lens_position: 160.0,
1030 lens_activity: 1.0,
1031 resting_tint: Color::BLACK.with_alpha(0.10),
1032 accessory_center: None,
1033 };
1034 let mask_node = TabFlightNode {
1035 origin: (0.0, 0.0),
1036 size: Size::new(328.0, 64.0),
1037 };
1038 let lens_node = TabFlightNode {
1039 origin: (132.0, -22.0),
1040 size: Size::new(160.0, 108.0),
1041 };
1042 let mask = tab_flight_dynamics(geometry, mask_node)
1043 .morph
1044 .expect("selection mask morph");
1045 let lens = tab_flight_dynamics(geometry, lens_node)
1046 .morph
1047 .expect("lens morph");
1048 assert_eq!(
1049 (
1050 mask.primary.0 + mask_node.origin.0,
1051 mask.primary.1 + mask_node.origin.1
1052 ),
1053 geometry.center
1054 );
1055 assert_eq!(
1056 (
1057 lens.primary.0 + lens_node.origin.0,
1058 lens.primary.1 + lens_node.origin.1
1059 ),
1060 geometry.center
1061 );
1062 assert_eq!(
1063 (mask.primary.2, mask.primary.3, mask.primary.4),
1064 (lens.primary.2, lens.primary.3, lens.primary.4)
1065 );
1066 assert_eq!(mask.node_size, (328.0, 64.0));
1067 assert_eq!(lens.node_size, (160.0, 108.0));
1068 }
1069
1070 #[test]
1071 fn unified_bar_has_no_detached_accessory_gap() {
1072 assert_eq!(tab_bar_accessory_gap(false), 0.0);
1073 assert_eq!(tab_bar_accessory_gap(true), 10.0);
1074 }
1075
1076 #[test]
1077 fn flight_lens_only_joins_accessory_after_surface_contact() {
1078 assert!(!accessory_surfaces_touch(0.01));
1079 assert!(accessory_surfaces_touch(0.0));
1080 assert!(accessory_surfaces_touch(-4.0));
1081 }
1082
1083 #[test]
1084 fn lens_contact_swell_is_vertical_only() {
1085 let resting = tab_lens_base_size(TAB_WIDTH, 0.0);
1086 let raised = tab_lens_base_size(TAB_WIDTH, 1.0);
1087 assert_eq!(
1088 resting,
1089 (TAB_WIDTH * TAB_LENS_REST_WIDTH_FACTOR, BLOB_HEIGHT)
1090 );
1091 assert_eq!(raised.0, resting.0);
1095 assert!((raised.1 / resting.1 - FLIGHT_LENS_HEIGHT_PROJECTION).abs() < 0.001);
1096 assert!((raised.1 - 68.0).abs() < 0.5);
1097 }
1098
1099 #[test]
1100 fn tab_grid_matches_the_reference_pitch() {
1101 assert_eq!(TAB_WIDTH, 78.0);
1102 }
1103
1104 #[test]
1105 fn tab_grid_matches_the_reference_inner_inset() {
1106 assert_eq!(BLOB_MARGIN, 4.0);
1109 assert_eq!(BLOB_HEIGHT + 2.0 * BLOB_MARGIN, BAR_HEIGHT);
1110 }
1111
1112 #[test]
1113 fn flight_lens_uses_the_clear_wcksrd_contract() {
1114 let glass = tab_flight_lens_material(
1115 cranpose_ui_graphics::Color::BLACK,
1116 cranpose_ui_graphics::Color::from_rgb_u8(0, 122, 255),
1117 );
1118 let generic_lens = Glass::lens();
1119 assert!(glass.lift.is_some_and(|lift| (0.0..=0.15).contains(&lift)));
1122 assert_eq!(glass.refraction_depth, 1.0);
1126 assert!(glass.refraction_curve < generic_lens.refraction_curve);
1127 assert!(glass.dispersion * 0.3 < generic_lens.dispersion);
1131 assert_eq!(glass.blur_radius, Some(0.0));
1132 assert!(glass.highlight < generic_lens.highlight);
1133 assert!(
1134 glass.shadow,
1135 "the moving lens needs its target-visible SDF contact outline"
1136 );
1137 assert!(glass
1141 .tint
1142 .is_some_and(|tint| { tint.r() < 0.05 && (0.055..=0.065).contains(&tint.a()) }));
1143 assert_eq!(glass.adaptive_frost, 0.0);
1144 }
1145
1146 #[test]
1147 fn flight_lens_retains_neutral_tint_through_direct_motion() {
1148 assert_eq!(tab_flight_tint_multiplier(0.0), 1.0);
1149 assert!((tab_flight_tint_multiplier(1.0) - 0.75).abs() < f32::EPSILON);
1150 assert_eq!(tab_flight_tint_multiplier(-1.0), 1.0);
1151 assert!((tab_flight_tint_multiplier(2.0) - 0.75).abs() < f32::EPSILON);
1152 }
1153
1154 #[test]
1155 fn bar_surface_adapts_frost_to_its_foreground() {
1156 let glass = tab_bar_surface_material(cranpose_ui_graphics::Color::BLACK);
1157 assert_eq!(glass.blur_radius, Some(9.0));
1160 assert_eq!(glass.saturation, Some(1.15));
1161 assert_eq!(glass.lift, Some(0.60));
1162 assert_eq!(glass.refraction_depth, 0.34);
1163 assert_eq!(glass.adaptive_frost, 0.28);
1164 }
1165
1166 #[test]
1167 fn bar_surface_lift_tracks_the_local_foreground_polarity() {
1168 let light_surface = tab_bar_surface_material(cranpose_ui_graphics::Color::BLACK);
1169 assert_eq!(light_surface.lift, Some(0.60));
1170
1171 let dark_surface = tab_bar_surface_material(cranpose_ui_graphics::Color::WHITE);
1172 assert_eq!(dark_surface.lift, Some(-0.24));
1173 }
1174
1175 #[test]
1176 fn bar_surface_tint_separates_from_same_polarity_backdrops() {
1177 let light_surface = tab_bar_surface_material(cranpose_ui_graphics::Color::BLACK)
1178 .tint
1179 .expect("bar tint");
1180 assert!(light_surface.r() < 0.05);
1181 assert_eq!(light_surface.a(), 0.0);
1182
1183 let dark_surface = tab_bar_surface_material(cranpose_ui_graphics::Color::WHITE)
1184 .tint
1185 .expect("bar tint");
1186 assert!(dark_surface.r() > 0.95);
1187 assert!((0.03..=0.05).contains(&dark_surface.a()));
1188 }
1189
1190 #[test]
1191 fn contact_rises_continuously_and_returns_on_the_measured_settle() {
1192 let cranpose_animation::AnimationType::Spring(rise) = tab_lens_activity_motion(true) else {
1193 panic!("contact rise must use a spring");
1194 };
1195 assert_eq!(rise.stiffness, 1400.0);
1196 let cranpose_animation::AnimationType::Spring(settle) = tab_lens_activity_motion(false)
1197 else {
1198 panic!("arrival contraction must use a spring");
1199 };
1200 assert_eq!(settle.damping_ratio, 1.0);
1201 assert_eq!(settle.stiffness, 900.0);
1202 }
1203
1204 #[test]
1207 fn a_scope_declares_destinations_in_order() {
1208 let tabs = collect_tabs(|scope| {
1209 scope.tab("M0 0", "Discover");
1210 scope.app_badge("M1 1", "WWDC");
1211 scope.push(LiquidTab::new("M2 2", "Account").with_icon_scale(0.95));
1212 });
1213
1214 assert_eq!(tabs.len(), 3);
1215 assert_eq!(tabs[0].label, "Discover");
1216 assert_eq!(tabs[0].icon_style, LiquidTabIconStyle::Plain);
1217 assert_eq!(tabs[0].icon_scale, 1.0);
1218 assert_eq!(tabs[1].icon_style, LiquidTabIconStyle::AppBadge);
1219 assert_eq!(tabs[2].icon_scale, 0.95);
1220 }
1221
1222 #[test]
1223 fn a_bar_with_no_destinations_declares_none() {
1224 assert!(collect_tabs(|_| {}).is_empty());
1225 }
1226
1227 #[test]
1228 fn a_resting_lens_overhangs_its_cell_within_the_legal_margin() {
1229 for tab_width in [24.0_f32, 44.0, 78.0, 80.0, 120.0, 400.0] {
1234 let width = tab_lens_rest_width(tab_width);
1235 assert!(
1236 width > tab_width,
1237 "the bubble reads wider than its cell ({width} vs {tab_width})"
1238 );
1239 let overhang = (width - tab_width) * 0.5;
1240 assert!(
1241 overhang <= BLOB_MARGIN + 1.0e-4,
1242 "an end cell's overhang ({overhang}) at a {tab_width}dp cell \
1243 has to stay inside the pill inset ({BLOB_MARGIN})"
1244 );
1245 }
1246 }
1247
1248 #[test]
1249 fn a_reference_width_cell_keeps_the_measured_rest_proportion() {
1250 for tab_width in [24.0_f32, 44.0, 78.0, 80.0] {
1253 assert!(
1254 (tab_lens_rest_width(tab_width) - tab_width * TAB_LENS_REST_WIDTH_FACTOR).abs()
1255 < 1.0e-4,
1256 "a {tab_width}dp cell still rests at the measured 1.10 factor"
1257 );
1258 }
1259 assert!(
1260 tab_lens_rest_width(200.0) < 200.0 * TAB_LENS_REST_WIDTH_FACTOR,
1261 "past the inset the margin governs, not the factor"
1262 );
1263 }
1264
1265 #[test]
1266 fn a_resting_lens_stays_centered_on_the_cell_it_rests_in() {
1267 let tab_width = 72.0;
1268 let width = tab_lens_rest_width(tab_width);
1269 for selected in 0..4 {
1270 let left = tab_lens_resting_left(selected, tab_width, 4);
1271 let cell_center = left + tab_width * 0.5;
1272 let lens_center = left - (width - tab_width) * 0.5 + width * 0.5;
1273 assert!(
1274 (lens_center - cell_center).abs() < 1e-4,
1275 "cell {selected}: lens center {lens_center} vs cell center {cell_center}"
1276 );
1277 }
1278 }
1279}