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