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 cranpose_macros::composable;
12use cranpose_ui::text::{FontWeight, SpanStyle, TextStyle};
13use cranpose_ui::widgets::{
14 Box, BoxSpec, BoxWithConstraints, BoxWithConstraintsScope, Column, ColumnSpec, Row, RowSpec,
15 Text,
16};
17use cranpose_ui::{
18 Brush, Color, CornerRadii, Modifier, PointerInputScope, Rect, SemanticsWidgetRole, Size,
19};
20use cranpose_ui_layout::{Alignment, HorizontalAlignment, VerticalAlignment};
21use std::cell::RefCell;
22use std::rc::Rc;
23
24#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
26pub enum LiquidTabIconStyle {
27 #[default]
28 Plain,
29 AppBadge,
30}
31
32#[derive(Clone, Debug, PartialEq)]
34pub struct LiquidTab {
35 pub icon: &'static str,
36 pub label: &'static str,
37 pub icon_style: LiquidTabIconStyle,
38 pub icon_scale: f32,
41}
42
43impl LiquidTab {
44 pub fn new(icon: &'static str, label: &'static str) -> Self {
45 Self {
46 icon,
47 label,
48 icon_style: LiquidTabIconStyle::Plain,
49 icon_scale: 1.0,
50 }
51 }
52
53 pub fn app_badge(icon: &'static str, label: &'static str) -> Self {
54 Self {
55 icon,
56 label,
57 icon_style: LiquidTabIconStyle::AppBadge,
58 icon_scale: 1.0,
59 }
60 }
61
62 pub fn with_icon_scale(mut self, scale: f32) -> Self {
63 self.icon_scale = normalize_icon_scale(scale);
64 self
65 }
66}
67
68fn normalize_icon_scale(scale: f32) -> f32 {
69 if scale.is_finite() {
70 scale.clamp(0.5, 1.5)
71 } else {
72 1.0
73 }
74}
75
76fn tab_base_content_color(colors: crate::theme::LiquidColors) -> Color {
77 colors.label
78}
79
80fn tab_selection_content_color(colors: crate::theme::LiquidColors) -> Color {
81 colors.accent
82}
83
84const BAR_HEIGHT: f32 = 64.0;
85const BLOB_HEIGHT: f32 = 56.0;
89const BLOB_MARGIN: f32 = 4.0;
90const FLIGHT_LENS_HEIGHT_PROJECTION: f32 = 68.0 / BLOB_HEIGHT;
99const FLIGHT_ELLIPSE_BLEND: f32 = 0.25;
107const TAB_LENS_REST_WIDTH_FACTOR: f32 = 1.10;
113const TAB_STRAIN_RESPONSE: f32 = 0.30;
117const TAB_WIDTH: f32 = 78.0;
119const TAB_ICON_SIZE: f32 = 32.0;
121const TAB_LABEL_SIZE: f32 = 11.0;
122const TAP_SLOP: f32 = 6.0;
124const ACCESSORY_GAP: f32 = 10.0;
125
126#[derive(Clone, Copy, Debug, PartialEq)]
128pub struct LiquidTabBarSpec {
129 max_tab_width: f32,
130}
131
132impl LiquidTabBarSpec {
133 pub fn new(max_tab_width: f32) -> Self {
134 Self {
135 max_tab_width: if max_tab_width.is_finite() {
136 max_tab_width.max(1.0)
137 } else {
138 TAB_WIDTH
139 },
140 }
141 }
142}
143
144impl Default for LiquidTabBarSpec {
145 fn default() -> Self {
146 Self::new(TAB_WIDTH)
147 }
148}
149
150fn tab_flight_lens_material(foreground: cranpose_ui_graphics::Color, accent: Color) -> Glass {
151 Glass::lens()
152 .no_clip()
153 .tint(neutral_surface_tint(foreground, 0.06, 0.05))
154 .ink_recolor(accent, 0.85)
158 .blur_radius(0.0)
159 .refraction_depth(1.0)
167 .refraction_curve(0.25)
168 .optical_zoom(1.22)
169 .fold_depth(2.5)
177 .dispersion(0.9)
182 .lift(0.05)
188 .highlight(0.18)
189 .shadow_style(GlassShadow::new(
193 cranpose_ui_graphics::Color::BLACK.with_alpha(0.14),
194 12.0,
195 4.0,
196 -2.0,
197 ))
198}
199
200fn tab_bar_surface_material(foreground: cranpose_ui_graphics::Color) -> Glass {
201 Glass::regular()
202 .tint(neutral_surface_tint(foreground, 0.0, 0.04))
203 .blur_radius(9.0)
209 .saturation(1.15)
215 .lift(neutral_surface_lift(foreground, 0.60, -0.24))
216 .highlight(0.20)
217 .fold_depth(8.0)
222 .adaptive_frost(foreground, 0.28)
223}
224
225fn tab_flight_tint_multiplier(activity: f32) -> f32 {
226 1.0 - 0.25 * activity.clamp(0.0, 1.0)
227}
228
229fn tab_lens_activity_motion(raised: bool) -> cranpose_animation::AnimationType {
230 if raised {
231 cranpose_animation::spring(0.9, 1400.0)
234 } else {
235 cranpose_animation::spring(1.0, 900.0)
237 }
238}
239
240fn tab_lens_left(pointer_x: f32, tab_width: f32, count: usize, has_accessory: bool) -> f32 {
241 let last_tab = tab_width * count.saturating_sub(1) as f32;
242 let min = if has_accessory { -tab_width * 0.2 } else { 0.0 };
243 let max = if has_accessory {
244 tab_width * (count as f32 - 0.45)
245 } else {
246 last_tab
247 };
248 (pointer_x - tab_width * 0.5).clamp(min, max)
249}
250
251pub fn tab_lens_resting_left(selected: usize, tab_width: f32, count: usize) -> f32 {
258 tab_width * selected.min(count.saturating_sub(1)) as f32
259}
260
261pub fn tab_lens_rest_width(tab_width: f32) -> f32 {
264 tab_width * TAB_LENS_REST_WIDTH_FACTOR
265}
266
267#[derive(Clone, Copy, Debug, PartialEq)]
268struct AppBadgeGeometry {
269 size: Size,
270 corner_radius: f32,
271 stripe: Rect,
272 glyph: Rect,
273}
274
275fn app_badge_geometry(optical_scale: f32) -> AppBadgeGeometry {
276 let scale = normalize_icon_scale(optical_scale);
277 AppBadgeGeometry {
278 size: Size::new(20.0 * scale, 32.0 * scale),
279 corner_radius: 5.0 * scale,
280 stripe: Rect {
281 x: 7.0 * scale,
282 y: 4.5 * scale,
283 width: 6.0 * scale,
284 height: 1.5 * scale,
285 },
286 glyph: Rect {
287 x: 3.0 * scale,
288 y: 10.0 * scale,
289 width: 14.0 * scale,
290 height: 14.0 * scale,
291 },
292 }
293}
294
295#[composable]
296#[allow(non_snake_case)]
297fn TabIcon(icon: &'static str, style: LiquidTabIconStyle, color: Color, optical_scale: f32) {
298 const FRAME_HEIGHT: f32 = 32.0;
299 Box(
300 Modifier::empty().size(Size::new(TAB_ICON_SIZE, FRAME_HEIGHT)),
301 BoxSpec::default().content_alignment(Alignment::CENTER),
302 move || match style {
303 LiquidTabIconStyle::Plain => {
304 crate::icons::Icon(icon, TAB_ICON_SIZE * optical_scale, color)
305 }
306 LiquidTabIconStyle::AppBadge => {
307 let geometry = app_badge_geometry(optical_scale);
308 Box(
309 Modifier::empty()
310 .size(geometry.size)
311 .draw_behind(move |scope| {
312 scope.draw_round_rect(
313 Brush::solid(color),
314 CornerRadii::uniform(geometry.corner_radius),
315 );
316 scope.draw_rect_at(geometry.stripe, Brush::solid(Color::WHITE));
317 }),
318 BoxSpec::default(),
319 move || {
320 Box(
321 Modifier::empty()
322 .offset(geometry.glyph.x, geometry.glyph.y)
323 .size(Size::new(geometry.glyph.width, geometry.glyph.height)),
324 BoxSpec::default(),
325 move || crate::icons::Icon(icon, geometry.glyph.width, Color::WHITE),
326 );
327 },
328 );
329 }
330 },
331 );
332}
333
334#[derive(Clone, Copy, PartialEq)]
335struct TabCellsSpec {
336 base_color: Color,
337 selected: Option<usize>,
338 selected_color: Color,
339 interactive: bool,
340 selection_only: bool,
341}
342
343fn tab_cell_is_visible(index: usize, selected: Option<usize>, selection_only: bool) -> bool {
344 !selection_only || selected == Some(index)
345}
346
347#[composable]
348#[allow(non_snake_case)]
349fn TabCells(
350 modifier: Modifier,
351 tabs: Rc<Vec<LiquidTab>>,
352 typography: LiquidTypography,
353 tab_width: f32,
354 spec: TabCellsSpec,
355) {
356 Row(modifier, RowSpec::default(), move || {
357 for (index, tab) in tabs.iter().enumerate() {
358 let visible = tab_cell_is_visible(index, spec.selected, spec.selection_only);
359 let color = if spec.selected == Some(index) {
360 spec.selected_color
361 } else {
362 spec.base_color
363 };
364 let label_for_semantics = tab.label;
365 let mut cell = Modifier::empty().size(Size::new(tab_width, BLOB_HEIGHT));
366 if spec.interactive {
367 cell = cell.semantics(move |config| {
368 config.role = Some(SemanticsWidgetRole::Button);
369 config.is_clickable = true;
370 config.content_description = Some(label_for_semantics.to_string());
371 });
372 }
373 let icon = tab.icon;
374 let icon_style = tab.icon_style;
375 let icon_scale = tab.icon_scale;
376 let label = tab.label;
377 let label_style = TextStyle {
378 span_style: SpanStyle {
379 color: Some(color),
380 font_size: cranpose_ui::text::TextUnit::Sp(TAB_LABEL_SIZE),
381 font_weight: Some(FontWeight::MEDIUM),
382 ..typography.caption1.span_style.clone()
383 },
384 ..typography.caption1.clone()
385 };
386 Box(
387 cell,
388 BoxSpec::default().content_alignment(Alignment::CENTER),
389 move || {
390 if !visible {
391 return;
392 }
393 let label_style = label_style.clone();
394 Column(
395 Modifier::empty(),
396 ColumnSpec::default()
397 .horizontal_alignment(HorizontalAlignment::CenterHorizontally),
398 move || {
399 TabIcon(icon, icon_style, color, icon_scale);
400 Text(label, Modifier::empty(), label_style.clone());
401 },
402 );
403 },
404 );
405 }
406 });
407}
408
409fn tab_lens_node_top(node_height: f32) -> f32 {
410 (BAR_HEIGHT - node_height) * 0.5
411}
412
413fn tab_bar_accessory_gap(has_accessory: bool) -> f32 {
414 if has_accessory {
415 ACCESSORY_GAP
416 } else {
417 0.0
418 }
419}
420
421fn accessory_surfaces_touch(edge_gap: f32) -> bool {
422 edge_gap <= 0.0
423}
424
425fn tab_lens_base_size(tab_width: f32, activity: f32) -> (f32, f32) {
426 let activity = activity.clamp(0.0, 1.0);
427 let ease = activity * activity * (3.0 - 2.0 * activity);
428 let rest_width = tab_width * TAB_LENS_REST_WIDTH_FACTOR;
429 let projection = 1.0 + (FLIGHT_LENS_HEIGHT_PROJECTION - 1.0) * ease;
430 (rest_width, BLOB_HEIGHT * projection)
431}
432
433#[derive(Clone, Copy, Debug, PartialEq)]
434struct TabFlightGeometry {
435 center: (f32, f32),
436 base_size: Size,
437 pose: crate::dynamics::LiquidPose,
438 lens_position: f32,
439 lens_activity: f32,
440 resting_tint: Color,
441 accessory_center: Option<(f32, f32)>,
442}
443
444#[derive(Clone, Copy, Debug, PartialEq)]
445struct TabFlightNode {
446 origin: (f32, f32),
447 size: Size,
448}
449
450fn tab_flight_dynamics(geometry: TabFlightGeometry, node: TabFlightNode) -> GlassDynamics {
451 let activity = geometry.lens_activity.clamp(0.0, 1.0);
452 let energy = geometry.pose.energy() * activity;
453 let radius = geometry.base_size.height * (0.48 + 0.02 * energy);
459 let glue = 20.0;
460 let shapes = geometry
461 .accessory_center
462 .filter(|(x, _)| {
463 let effective_stretch =
464 1.0 + (geometry.pose.stretch.max(geometry.pose.ortho) - 1.0) * TAB_STRAIN_RESPONSE;
465 let edge_gap = (*x - geometry.center.0).abs()
466 - geometry.base_size.width * effective_stretch * 0.5
467 - BAR_HEIGHT * 0.5;
468 accessory_surfaces_touch(edge_gap)
469 })
470 .map(|(x, y)| {
471 vec![(
472 x - node.origin.0,
473 y - node.origin.1,
474 BAR_HEIGHT,
475 BAR_HEIGHT,
476 -1.0,
477 )]
478 })
479 .unwrap_or_default();
480 GlassDynamics {
481 morph: Some(GlassMorph {
482 node_size: (node.size.width, node.size.height),
483 primary: (
484 geometry.center.0 - node.origin.0,
485 geometry.center.1 - node.origin.1,
486 geometry.base_size.width,
487 geometry.base_size.height,
488 radius,
489 ),
490 shapes,
491 glue,
492 wobble_amplitude: 0.5 * energy,
496 wobble_phase: geometry.lens_position * 0.045,
497 bulge_amplitude: geometry.pose.bulge_amplitude.min(8.0) * activity,
498 bulge_direction: geometry.pose.bulge_direction,
499 ellipse_blend: FLIGHT_ELLIPSE_BLEND,
500 deformation: Some(crate::material::GlassDeformation::incompressible(
501 geometry.pose.axis,
502 1.0 + (geometry.pose.stretch - 1.0) * activity * TAB_STRAIN_RESPONSE,
506 )),
507 zoom_anchor: (0.0, 0.0),
508 }),
509 activity: Some(activity),
510 press_depth: Some(0.3 + 0.7 * activity),
514 resting_tint: Some(geometry.resting_tint),
515 tint_alpha_multiplier: Some(tab_flight_tint_multiplier(geometry.lens_activity)),
516 ..Default::default()
517 }
518}
519
520#[composable]
522#[allow(non_snake_case)]
523pub fn LiquidTabBar(
524 modifier: Modifier,
525 spec: LiquidTabBarSpec,
526 tabs: Vec<LiquidTab>,
527 selected: usize,
528 on_select: impl Fn(usize) + 'static,
529) {
530 LiquidTabBarLayout(modifier, spec, tabs, selected, on_select, false, || {});
531}
532
533#[composable]
535#[allow(non_snake_case)]
536pub fn LiquidTabBarWithAccessory(
537 modifier: Modifier,
538 spec: LiquidTabBarSpec,
539 tabs: Vec<LiquidTab>,
540 selected: usize,
541 on_select: impl Fn(usize) + 'static,
542 accessory: impl FnMut() + 'static,
543) {
544 LiquidTabBarLayout(modifier, spec, tabs, selected, on_select, true, accessory);
545}
546
547#[composable]
548#[allow(non_snake_case)]
549fn LiquidTabBarLayout(
550 modifier: Modifier,
551 spec: LiquidTabBarSpec,
552 tabs: Vec<LiquidTab>,
553 selected: usize,
554 on_select: impl Fn(usize) + 'static,
555 has_accessory: bool,
556 accessory: impl FnMut() + 'static,
557) {
558 let colors = liquid_colors();
559 let typography = liquid_typography();
560 let count = tabs.len().max(1);
561 let selected = selected.min(count - 1);
562 let on_select: Rc<dyn Fn(usize)> = Rc::new(on_select);
563 let tabs = Rc::new(tabs);
564 let accessory = Rc::new(RefCell::new(accessory));
565
566 Row(
567 modifier,
568 RowSpec::default().vertical_alignment(VerticalAlignment::CenterVertically),
569 move || {
570 let tabs = Rc::clone(&tabs);
571 let typography = typography.clone();
572 let on_select = Rc::clone(&on_select);
573 let accessory = Rc::clone(&accessory);
574
575 let lens_x_outer = cranpose_core::remember(|| {
578 cranpose_core::mutableStateOf((
579 0.0f32,
580 0.0f32,
581 0.0f32,
582 crate::dynamics::LiquidPose::default(),
583 ))
584 })
585 .with(|state| *state);
586 let bar_touch =
592 cranpose_core::remember(|| cranpose_core::mutableStateOf((0.0f32, 0.0f32)))
593 .with(|state| *state);
594 let bar_held = cranpose_core::remember(|| cranpose_core::mutableStateOf(false))
595 .with(|state| *state);
596 let bar_press = cranpose_animation::animateFloatAsState(
597 if bar_held.get() { 1.0 } else { 0.0 },
598 cranpose_animation::spring(1.0, 600.0),
599 "tabbar-hold-press",
600 );
601 let bar_lift = Modifier::empty().graphics_layer(move || {
607 let press = bar_press.get().clamp(0.0, 1.0);
608 let rise = 1.0 + 0.03 * press;
609 cranpose_ui_graphics::GraphicsLayer {
610 scale_x: rise,
611 scale_y: rise,
612 translation_y: -2.5 * press,
613 ..Default::default()
614 }
615 });
616 Box(
620 bar_lift.then(Modifier::empty().height(BAR_HEIGHT)),
621 BoxSpec::default(),
622 move || {
623 let tabs = Rc::clone(&tabs);
624 let typography = typography.clone();
625 let on_select = Rc::clone(&on_select);
626 let pill = Modifier::empty()
629 .glass_effect_with(
630 tab_bar_surface_material(colors.label),
635 move || {
636 let press = bar_press.get().clamp(0.0, 1.0);
637 let (touch_x, touch_y) = bar_touch.get();
638 GlassDynamics {
639 highlight_boost: 0.45 * press,
640 saturation_boost: 0.12 * press,
641 touch: (press > 0.01).then_some((touch_x, touch_y, press)),
642 ..Default::default()
643 }
644 },
645 )
646 .height(BAR_HEIGHT);
647 Box(pill, BoxSpec::default(), move || {
648 let tabs = Rc::clone(&tabs);
649 let typography = typography.clone();
650 let on_select = Rc::clone(&on_select);
651 BoxWithConstraints(Modifier::empty().padding(BLOB_MARGIN), move |scope| {
652 let tabs = Rc::clone(&tabs);
653 let typography = typography.clone();
654 let on_select = Rc::clone(&on_select);
655 let constrained = scope.constraints().max_width;
656 let tab_width = if constrained.is_finite() && constrained > 1.0 {
657 (constrained / count as f32).min(spec.max_tab_width)
658 } else {
659 spec.max_tab_width
660 };
661
662 let lens_pressed =
665 cranpose_core::remember(|| cranpose_core::mutableStateOf(false))
666 .with(|state| *state);
667 let resting_lens_x = tab_lens_resting_left(selected, tab_width, count);
668 let lens_axis =
669 crate::motion::remember_liquid_drag_axis(resting_lens_x);
670 if !lens_pressed.get() {
676 lens_axis.settle_to(resting_lens_x, LiquidMotion::glide());
677 }
678 let lens_x = lens_axis.value();
679 let lens_pose = lens_axis.liquid_pose();
680 let lens_in_flight = !lens_axis.is_dragging()
681 && (lens_x - resting_lens_x).abs() > tab_width * 0.15;
682 let lens_raised = lens_pressed.get() || lens_in_flight;
683 let lens_activity_target = if lens_raised { 1.0 } else { 0.0 };
684 let lens_activity_anim = cranpose_animation::animateFloatAsState(
685 lens_activity_target,
686 tab_lens_activity_motion(lens_raised),
687 "tabbar-lens-activity",
688 );
689 let lens_activity = lens_activity_anim.get();
694 let visual_index = crate::motion::liquid_visual_index(
707 selected,
708 lens_x,
709 tab_width,
710 count,
711 crate::motion::liquid_axis_owns_visual_selection(
712 lens_pressed.get(),
713 lens_x,
714 resting_lens_x,
715 tab_width,
716 ),
717 );
718 TabCells(
719 Modifier::empty(),
720 Rc::clone(&tabs),
721 typography.clone(),
722 tab_width,
723 TabCellsSpec {
724 base_color: tab_base_content_color(colors),
725 selected: Some(visual_index),
726 selected_color: tab_selection_content_color(colors),
727 interactive: true,
728 selection_only: false,
729 },
730 );
731
732 let row_width = tab_width * count as f32;
736 let gesture = Modifier::empty()
737 .size(Size::new(row_width, BLOB_HEIGHT))
738 .pointer_input(selected, {
739 let on_select = Rc::clone(&on_select);
740 let lens_axis = Rc::clone(&lens_axis);
741 move |scope: PointerInputScope| {
742 let on_select = Rc::clone(&on_select);
743 let lens_axis = Rc::clone(&lens_axis);
744 crate::motion::liquid_lens_gesture(
745 scope,
746 crate::motion::LiquidLensGesture {
747 axis: lens_axis,
748 cell_width: tab_width,
749 count,
750 tap_slop: TAP_SLOP,
751 drag_left: Rc::new(move |x| {
752 tab_lens_left(
753 x,
754 tab_width,
755 count,
756 has_accessory,
757 )
758 }),
759 rest_left: Rc::new(move |index| {
760 tab_lens_resting_left(index, tab_width, count)
761 }),
762 selected,
763 on_pressed: Rc::new(move |down| {
764 lens_pressed.set(down);
765 bar_held.set(down);
766 }),
767 on_touch: Rc::new(move |x, y| {
768 bar_touch
769 .set((x + BLOB_MARGIN, y + BLOB_MARGIN));
770 }),
771 on_select,
772 },
773 )
774 }
775 });
776 Box(gesture, BoxSpec::default(), || {});
777
778 let published = (lens_x, lens_activity, tab_width, lens_pose);
782 if lens_x_outer.get() != published {
783 lens_x_outer.set(published);
784 }
785 });
786 });
787
788 let (lens_px, lens_activity, lens_tab_w, pose) = lens_x_outer.get();
797 let lens_w = lens_tab_w * TAB_LENS_REST_WIDTH_FACTOR;
798 let lens_h = BLOB_HEIGHT * FLIGHT_LENS_HEIGHT_PROJECTION;
799 let deformation_headroom =
802 crate::dynamics::STRETCH_MAX.max(1.0 / crate::dynamics::STRETCH_MIN);
803 let node_w = lens_w * deformation_headroom + crate::dynamics::BULGE_MAX + 20.0;
804 let node_h = lens_h * deformation_headroom + crate::dynamics::BULGE_MAX + 16.0;
805 let lens_center_x = BLOB_MARGIN + lens_px + lens_tab_w * 0.5;
806 let node_x = lens_center_x - node_w * 0.5;
807 let node_top = tab_lens_node_top(node_h);
808 let pill_w = lens_tab_w * count as f32 + 2.0 * BLOB_MARGIN;
809 let (base_w, base_h) = tab_lens_base_size(lens_tab_w, lens_activity);
810 let geometry = TabFlightGeometry {
811 center: (lens_center_x, BAR_HEIGHT * 0.5),
812 base_size: Size::new(base_w, base_h),
813 pose,
814 lens_position: lens_px,
815 lens_activity,
816 resting_tint: colors.fill,
817 accessory_center: has_accessory.then_some((
818 pill_w + tab_bar_accessory_gap(true) + BAR_HEIGHT * 0.5,
819 BAR_HEIGHT * 0.5,
820 )),
821 };
822 let lens_node = TabFlightNode {
823 origin: (node_x, node_top),
824 size: Size::new(node_w, node_h),
825 };
826
827 let lens_geometry = geometry;
828 let lens = Modifier::empty()
829 .required_size(lens_node.size)
834 .offset(node_x, node_top)
835 .glass_effect_with(
836 tab_flight_lens_material(
837 colors.label,
838 tab_selection_content_color(colors),
839 ),
840 move || tab_flight_dynamics(lens_geometry, lens_node),
841 );
842 Box(lens, BoxSpec::default(), || {});
843 },
844 );
845
846 if has_accessory {
847 Box(
848 Modifier::empty().width(tab_bar_accessory_gap(true)),
849 BoxSpec::default(),
850 || {},
851 );
852 (accessory.borrow_mut())();
853 }
854 },
855 );
856}
857
858#[composable]
860#[allow(non_snake_case)]
861pub fn LiquidTabBarSearchAccessory(on_click: impl Fn() + 'static) {
862 crate::widgets::GlassIconButton(
864 Modifier::empty(),
865 crate::widgets::GlassButtonSpec::glass(),
866 BAR_HEIGHT * 0.94,
867 on_click,
868 crate::icons::SEARCH,
869 );
870}
871
872#[cfg(test)]
873mod tests {
874 use super::*;
875
876 #[test]
877 fn tab_bar_spec_normalizes_the_maximum_cell_width() {
878 assert_eq!(LiquidTabBarSpec::default().max_tab_width, TAB_WIDTH);
879 assert_eq!(LiquidTabBarSpec::new(85.0).max_tab_width, 85.0);
880 assert_eq!(LiquidTabBarSpec::new(0.0).max_tab_width, 1.0);
881 assert_eq!(LiquidTabBarSpec::new(f32::NAN).max_tab_width, TAB_WIDTH);
882 }
883
884 #[test]
885 fn drag_pointer_centers_the_lens_and_preserves_end_overdrag() {
886 let width = 100.0;
887 assert_eq!(tab_lens_left(50.0, width, 4, true), 0.0);
888 assert_eq!(tab_lens_left(250.0, width, 4, true), 200.0);
889 assert_eq!(tab_lens_left(-100.0, width, 4, true), -20.0);
890 assert_eq!(tab_lens_left(500.0, width, 4, true), 355.0);
891
892 assert_eq!(tab_lens_left(-100.0, width, 4, false), 0.0);
893 assert_eq!(tab_lens_left(500.0, width, 4, false), 300.0);
894 }
895
896 #[test]
897 fn resting_lens_centers_on_its_cell_and_stays_inside_the_pill() {
898 let tab = 78.0;
899 assert_eq!(tab_lens_resting_left(0, tab, 5), 0.0);
902 assert_eq!(tab_lens_resting_left(1, tab, 5), tab);
903 assert_eq!(tab_lens_resting_left(3, tab, 5), 3.0 * tab);
904 assert_eq!(tab_lens_resting_left(4, tab, 5), 4.0 * tab);
905 assert_eq!(tab_lens_resting_left(9, tab, 5), 4.0 * tab);
906 let overhang = tab * (TAB_LENS_REST_WIDTH_FACTOR - 1.0) * 0.5;
911 assert!(overhang <= BLOB_MARGIN + 1.0e-4);
912 }
913
914 #[test]
915 fn flight_lens_node_is_centered_on_the_bar_axis() {
916 for node_height in [48.0, 64.0, 96.0, 128.0] {
917 let center = tab_lens_node_top(node_height) + node_height * 0.5;
918 assert!((center - BAR_HEIGHT * 0.5).abs() < f32::EPSILON);
919 }
920 }
921
922 #[test]
923 fn liquid_tab_builds_reference_content() {
924 assert_eq!(TAB_ICON_SIZE, 32.0);
925 let tab = LiquidTab::new(crate::icons::STAR, "Discover");
926 assert_eq!(tab.icon, crate::icons::STAR);
927 assert_eq!(tab.label, "Discover");
928 assert_eq!(tab.icon_style, LiquidTabIconStyle::Plain);
929
930 let badge = LiquidTab::app_badge(crate::icons::APPLE, "WWDC");
931 assert_eq!(badge.icon_style, LiquidTabIconStyle::AppBadge);
932
933 let compact = LiquidTab::new(crate::icons::ACCOUNT_CIRCLE, "Account").with_icon_scale(0.72);
934 assert!((compact.icon_scale - 0.72).abs() < f32::EPSILON);
935 assert_eq!(tab.clone().with_icon_scale(f32::NAN).icon_scale, 1.0);
936 assert_eq!(tab.with_icon_scale(2.0).icon_scale, 1.5);
937 }
938
939 #[test]
940 fn app_badge_geometry_honors_the_tab_optical_scale() {
941 let full = app_badge_geometry(1.0);
942 let corrected = app_badge_geometry(0.85);
943 assert_eq!(full.size, Size::new(20.0, 32.0));
944 assert_eq!(corrected.size, Size::new(17.0, 27.2));
945 assert!((corrected.glyph.width - 11.9).abs() < 1.0e-5);
946 assert!((corrected.corner_radius / full.corner_radius - 0.85).abs() < f32::EPSILON);
947 assert!((corrected.stripe.x / full.stripe.x - 0.85).abs() < f32::EPSILON);
948 assert!((corrected.glyph.width / full.glyph.width - 0.85).abs() < f32::EPSILON);
949 }
950
951 #[test]
952 fn base_tab_content_remains_neutral_under_the_moving_selection_layer() {
953 let colors = crate::theme::LiquidColors::light(cranpose_ui_graphics::Color::from_rgb_u8(
954 0, 122, 255,
955 ));
956 assert_eq!(tab_base_content_color(colors), colors.label);
957 assert_eq!(tab_selection_content_color(colors), colors.accent);
958 }
959
960 #[test]
961 fn selection_mask_and_lens_resolve_the_same_global_sdf() {
962 let geometry = TabFlightGeometry {
963 center: (212.0, 32.0),
964 base_size: Size::new(106.0, 64.0),
965 pose: crate::dynamics::LiquidPose::default(),
966 lens_position: 160.0,
967 lens_activity: 1.0,
968 resting_tint: Color::BLACK.with_alpha(0.10),
969 accessory_center: None,
970 };
971 let mask_node = TabFlightNode {
972 origin: (0.0, 0.0),
973 size: Size::new(328.0, 64.0),
974 };
975 let lens_node = TabFlightNode {
976 origin: (132.0, -22.0),
977 size: Size::new(160.0, 108.0),
978 };
979 let mask = tab_flight_dynamics(geometry, mask_node)
980 .morph
981 .expect("selection mask morph");
982 let lens = tab_flight_dynamics(geometry, lens_node)
983 .morph
984 .expect("lens morph");
985 assert_eq!(
986 (
987 mask.primary.0 + mask_node.origin.0,
988 mask.primary.1 + mask_node.origin.1
989 ),
990 geometry.center
991 );
992 assert_eq!(
993 (
994 lens.primary.0 + lens_node.origin.0,
995 lens.primary.1 + lens_node.origin.1
996 ),
997 geometry.center
998 );
999 assert_eq!(
1000 (mask.primary.2, mask.primary.3, mask.primary.4),
1001 (lens.primary.2, lens.primary.3, lens.primary.4)
1002 );
1003 assert_eq!(mask.node_size, (328.0, 64.0));
1004 assert_eq!(lens.node_size, (160.0, 108.0));
1005 }
1006
1007 #[test]
1008 fn unified_bar_has_no_detached_accessory_gap() {
1009 assert_eq!(tab_bar_accessory_gap(false), 0.0);
1010 assert_eq!(tab_bar_accessory_gap(true), 10.0);
1011 }
1012
1013 #[test]
1014 fn flight_lens_only_joins_accessory_after_surface_contact() {
1015 assert!(!accessory_surfaces_touch(0.01));
1016 assert!(accessory_surfaces_touch(0.0));
1017 assert!(accessory_surfaces_touch(-4.0));
1018 }
1019
1020 #[test]
1021 fn lens_contact_swell_is_vertical_only() {
1022 let resting = tab_lens_base_size(TAB_WIDTH, 0.0);
1023 let raised = tab_lens_base_size(TAB_WIDTH, 1.0);
1024 assert_eq!(
1025 resting,
1026 (TAB_WIDTH * TAB_LENS_REST_WIDTH_FACTOR, BLOB_HEIGHT)
1027 );
1028 assert_eq!(raised.0, resting.0);
1032 assert!((raised.1 / resting.1 - FLIGHT_LENS_HEIGHT_PROJECTION).abs() < 0.001);
1033 assert!((raised.1 - 68.0).abs() < 0.5);
1034 }
1035
1036 #[test]
1037 fn tab_grid_matches_the_reference_pitch() {
1038 assert_eq!(TAB_WIDTH, 78.0);
1039 }
1040
1041 #[test]
1042 fn tab_grid_matches_the_reference_inner_inset() {
1043 assert_eq!(BLOB_MARGIN, 4.0);
1046 assert_eq!(BLOB_HEIGHT + 2.0 * BLOB_MARGIN, BAR_HEIGHT);
1047 }
1048
1049 #[test]
1050 fn flight_lens_uses_the_clear_wcksrd_contract() {
1051 let glass = tab_flight_lens_material(
1052 cranpose_ui_graphics::Color::BLACK,
1053 cranpose_ui_graphics::Color::from_rgb_u8(0, 122, 255),
1054 );
1055 let generic_lens = Glass::lens();
1056 assert!(glass.lift.is_some_and(|lift| (0.0..=0.15).contains(&lift)));
1059 assert_eq!(glass.refraction_depth, 1.0);
1063 assert!(glass.refraction_curve < generic_lens.refraction_curve);
1064 assert!(glass.dispersion * 0.3 < generic_lens.dispersion);
1068 assert_eq!(glass.blur_radius, Some(0.0));
1069 assert!(glass.highlight < generic_lens.highlight);
1070 assert!(
1071 glass.shadow,
1072 "the moving lens needs its target-visible SDF contact outline"
1073 );
1074 assert!(glass
1078 .tint
1079 .is_some_and(|tint| { tint.r() < 0.05 && (0.055..=0.065).contains(&tint.a()) }));
1080 assert_eq!(glass.adaptive_frost, 0.0);
1081 }
1082
1083 #[test]
1084 fn flight_lens_retains_neutral_tint_through_direct_motion() {
1085 assert_eq!(tab_flight_tint_multiplier(0.0), 1.0);
1086 assert!((tab_flight_tint_multiplier(1.0) - 0.75).abs() < f32::EPSILON);
1087 assert_eq!(tab_flight_tint_multiplier(-1.0), 1.0);
1088 assert!((tab_flight_tint_multiplier(2.0) - 0.75).abs() < f32::EPSILON);
1089 }
1090
1091 #[test]
1092 fn bar_surface_adapts_frost_to_its_foreground() {
1093 let glass = tab_bar_surface_material(cranpose_ui_graphics::Color::BLACK);
1094 assert_eq!(glass.blur_radius, Some(9.0));
1097 assert_eq!(glass.saturation, Some(1.15));
1098 assert_eq!(glass.lift, Some(0.60));
1099 assert_eq!(glass.refraction_depth, 0.34);
1100 assert_eq!(glass.adaptive_frost, 0.28);
1101 }
1102
1103 #[test]
1104 fn bar_surface_lift_tracks_the_local_foreground_polarity() {
1105 let light_surface = tab_bar_surface_material(cranpose_ui_graphics::Color::BLACK);
1106 assert_eq!(light_surface.lift, Some(0.60));
1107
1108 let dark_surface = tab_bar_surface_material(cranpose_ui_graphics::Color::WHITE);
1109 assert_eq!(dark_surface.lift, Some(-0.24));
1110 }
1111
1112 #[test]
1113 fn bar_surface_tint_separates_from_same_polarity_backdrops() {
1114 let light_surface = tab_bar_surface_material(cranpose_ui_graphics::Color::BLACK)
1115 .tint
1116 .expect("bar tint");
1117 assert!(light_surface.r() < 0.05);
1118 assert_eq!(light_surface.a(), 0.0);
1119
1120 let dark_surface = tab_bar_surface_material(cranpose_ui_graphics::Color::WHITE)
1121 .tint
1122 .expect("bar tint");
1123 assert!(dark_surface.r() > 0.95);
1124 assert!((0.03..=0.05).contains(&dark_surface.a()));
1125 }
1126
1127 #[test]
1128 fn contact_rises_continuously_and_returns_on_the_measured_settle() {
1129 let cranpose_animation::AnimationType::Spring(rise) = tab_lens_activity_motion(true) else {
1130 panic!("contact rise must use a spring");
1131 };
1132 assert_eq!(rise.stiffness, 1400.0);
1133 let cranpose_animation::AnimationType::Spring(settle) = tab_lens_activity_motion(false)
1134 else {
1135 panic!("arrival contraction must use a spring");
1136 };
1137 assert_eq!(settle.damping_ratio, 1.0);
1138 assert_eq!(settle.stiffness, 900.0);
1139 }
1140}