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