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_PROJECTION_SCALE: f32 = 1.375;
93const TAB_LENS_REST_WIDTH_FACTOR: f32 = 1.10;
99const TAB_WIDTH: f32 = 78.0;
101const TAB_ICON_SIZE: f32 = 32.0;
103const TAB_LABEL_SIZE: f32 = 11.0;
104const TAP_SLOP: f32 = 6.0;
106const ACCESSORY_GAP: f32 = 10.0;
107
108#[derive(Clone, Copy, Debug, PartialEq)]
110pub struct LiquidTabBarSpec {
111 max_tab_width: f32,
112}
113
114impl LiquidTabBarSpec {
115 pub fn new(max_tab_width: f32) -> Self {
116 Self {
117 max_tab_width: if max_tab_width.is_finite() {
118 max_tab_width.max(1.0)
119 } else {
120 TAB_WIDTH
121 },
122 }
123 }
124}
125
126impl Default for LiquidTabBarSpec {
127 fn default() -> Self {
128 Self::new(TAB_WIDTH)
129 }
130}
131
132fn tab_flight_lens_material(foreground: cranpose_ui_graphics::Color, accent: Color) -> Glass {
133 Glass::lens()
134 .no_clip()
135 .tint(neutral_surface_tint(foreground, 0.13, 0.10))
136 .ink_recolor(accent, 0.85)
140 .blur_radius(0.0)
141 .refraction_depth(0.26)
147 .refraction_curve(0.25)
148 .dispersion(0.24)
149 .lift(0.0)
150 .highlight(0.35)
151}
152
153fn tab_bar_surface_material(foreground: cranpose_ui_graphics::Color) -> Glass {
154 Glass::regular()
155 .tint(neutral_surface_tint(foreground, 0.0, 0.04))
156 .blur_radius(4.0)
157 .saturation(1.12)
161 .lift(neutral_surface_lift(foreground, 0.30, -0.24))
162 .highlight(0.20)
163 .fold_depth(8.0)
168 .adaptive_frost(foreground, 0.28)
169}
170
171fn tab_flight_tint_multiplier(activity: f32) -> f32 {
172 1.0 - 0.25 * activity.clamp(0.0, 1.0)
173}
174
175fn tab_lens_activity_motion(raised: bool) -> cranpose_animation::AnimationType {
176 if raised {
177 cranpose_animation::spring(0.9, 1400.0)
180 } else {
181 cranpose_animation::spring(1.0, 900.0)
183 }
184}
185
186fn tab_lens_left(pointer_x: f32, tab_width: f32, count: usize, has_accessory: bool) -> f32 {
187 let last_tab = tab_width * count.saturating_sub(1) as f32;
188 let min = if has_accessory { -tab_width * 0.2 } else { 0.0 };
189 let max = if has_accessory {
190 tab_width * (count as f32 - 0.45)
191 } else {
192 last_tab
193 };
194 (pointer_x - tab_width * 0.5).clamp(min, max)
195}
196
197pub fn tab_lens_resting_left(selected: usize, tab_width: f32, count: usize) -> f32 {
204 tab_width * selected.min(count.saturating_sub(1)) as f32
205}
206
207pub fn tab_lens_rest_width(tab_width: f32) -> f32 {
210 tab_width * TAB_LENS_REST_WIDTH_FACTOR
211}
212
213#[derive(Clone, Copy, Debug, PartialEq)]
214struct AppBadgeGeometry {
215 size: Size,
216 corner_radius: f32,
217 stripe: Rect,
218 glyph: Rect,
219}
220
221fn app_badge_geometry(optical_scale: f32) -> AppBadgeGeometry {
222 let scale = normalize_icon_scale(optical_scale);
223 AppBadgeGeometry {
224 size: Size::new(20.0 * scale, 32.0 * scale),
225 corner_radius: 5.0 * scale,
226 stripe: Rect {
227 x: 7.0 * scale,
228 y: 4.5 * scale,
229 width: 6.0 * scale,
230 height: 1.5 * scale,
231 },
232 glyph: Rect {
233 x: 3.0 * scale,
234 y: 10.0 * scale,
235 width: 14.0 * scale,
236 height: 14.0 * scale,
237 },
238 }
239}
240
241#[composable]
242#[allow(non_snake_case)]
243fn TabIcon(icon: &'static str, style: LiquidTabIconStyle, color: Color, optical_scale: f32) {
244 const FRAME_HEIGHT: f32 = 32.0;
245 Box(
246 Modifier::empty().size(Size::new(TAB_ICON_SIZE, FRAME_HEIGHT)),
247 BoxSpec::default().content_alignment(Alignment::CENTER),
248 move || match style {
249 LiquidTabIconStyle::Plain => {
250 crate::icons::Icon(icon, TAB_ICON_SIZE * optical_scale, color)
251 }
252 LiquidTabIconStyle::AppBadge => {
253 let geometry = app_badge_geometry(optical_scale);
254 Box(
255 Modifier::empty()
256 .size(geometry.size)
257 .draw_behind(move |scope| {
258 scope.draw_round_rect(
259 Brush::solid(color),
260 CornerRadii::uniform(geometry.corner_radius),
261 );
262 scope.draw_rect_at(geometry.stripe, Brush::solid(Color::WHITE));
263 }),
264 BoxSpec::default(),
265 move || {
266 Box(
267 Modifier::empty()
268 .offset(geometry.glyph.x, geometry.glyph.y)
269 .size(Size::new(geometry.glyph.width, geometry.glyph.height)),
270 BoxSpec::default(),
271 move || crate::icons::Icon(icon, geometry.glyph.width, Color::WHITE),
272 );
273 },
274 );
275 }
276 },
277 );
278}
279
280#[derive(Clone, Copy, PartialEq)]
281struct TabCellsSpec {
282 base_color: Color,
283 selected: Option<usize>,
284 selected_color: Color,
285 interactive: bool,
286 selection_only: bool,
287}
288
289fn tab_cell_is_visible(index: usize, selected: Option<usize>, selection_only: bool) -> bool {
290 !selection_only || selected == Some(index)
291}
292
293#[composable]
294#[allow(non_snake_case)]
295fn TabCells(
296 modifier: Modifier,
297 tabs: Rc<Vec<LiquidTab>>,
298 typography: LiquidTypography,
299 tab_width: f32,
300 spec: TabCellsSpec,
301) {
302 Row(modifier, RowSpec::default(), move || {
303 for (index, tab) in tabs.iter().enumerate() {
304 let visible = tab_cell_is_visible(index, spec.selected, spec.selection_only);
305 let color = if spec.selected == Some(index) {
306 spec.selected_color
307 } else {
308 spec.base_color
309 };
310 let label_for_semantics = tab.label;
311 let mut cell = Modifier::empty().size(Size::new(tab_width, BLOB_HEIGHT));
312 if spec.interactive {
313 cell = cell.semantics(move |config| {
314 config.is_button = true;
315 config.is_clickable = true;
316 config.content_description = Some(label_for_semantics.to_string());
317 });
318 }
319 let icon = tab.icon;
320 let icon_style = tab.icon_style;
321 let icon_scale = tab.icon_scale;
322 let label = tab.label;
323 let label_style = TextStyle {
324 span_style: SpanStyle {
325 color: Some(color),
326 font_size: cranpose_ui::text::TextUnit::Sp(TAB_LABEL_SIZE),
327 font_weight: Some(FontWeight::MEDIUM),
328 ..typography.caption1.span_style.clone()
329 },
330 ..typography.caption1.clone()
331 };
332 Box(
333 cell,
334 BoxSpec::default().content_alignment(Alignment::CENTER),
335 move || {
336 if !visible {
337 return;
338 }
339 let label_style = label_style.clone();
340 Column(
341 Modifier::empty(),
342 ColumnSpec::default()
343 .horizontal_alignment(HorizontalAlignment::CenterHorizontally),
344 move || {
345 TabIcon(icon, icon_style, color, icon_scale);
346 Text(label, Modifier::empty(), label_style.clone());
347 },
348 );
349 },
350 );
351 }
352 });
353}
354
355fn tab_lens_node_top(node_height: f32) -> f32 {
356 (BAR_HEIGHT - node_height) * 0.5
357}
358
359fn tab_bar_accessory_gap(has_accessory: bool) -> f32 {
360 if has_accessory {
361 ACCESSORY_GAP
362 } else {
363 0.0
364 }
365}
366
367fn accessory_surfaces_touch(edge_gap: f32) -> bool {
368 edge_gap <= 0.0
369}
370
371fn tab_lens_base_size(tab_width: f32, activity: f32) -> (f32, f32) {
372 let activity = activity.clamp(0.0, 1.0);
373 let ease = activity * activity * (3.0 - 2.0 * activity);
374 let rest_width = tab_width * TAB_LENS_REST_WIDTH_FACTOR;
375 let projection = 1.0 + (FLIGHT_LENS_PROJECTION_SCALE - 1.0) * ease;
376 (rest_width * projection, BLOB_HEIGHT * projection)
377}
378
379#[derive(Clone, Copy, Debug, PartialEq)]
380struct TabFlightGeometry {
381 center: (f32, f32),
382 base_size: Size,
383 pose: crate::dynamics::LiquidPose,
384 lens_position: f32,
385 lens_activity: f32,
386 resting_tint: Color,
387 accessory_center: Option<(f32, f32)>,
388}
389
390#[derive(Clone, Copy, Debug, PartialEq)]
391struct TabFlightNode {
392 origin: (f32, f32),
393 size: Size,
394}
395
396fn tab_flight_dynamics(geometry: TabFlightGeometry, node: TabFlightNode) -> GlassDynamics {
397 let activity = geometry.lens_activity.clamp(0.0, 1.0);
398 let energy = geometry.pose.energy() * activity;
399 let radius = geometry.base_size.height * (0.48 + 0.02 * energy);
400 let glue = 20.0;
401 let shapes = geometry
402 .accessory_center
403 .filter(|(x, _)| {
404 let edge_gap = (*x - geometry.center.0).abs()
405 - geometry.base_size.width * geometry.pose.stretch.max(geometry.pose.ortho) * 0.5
406 - BAR_HEIGHT * 0.5;
407 accessory_surfaces_touch(edge_gap)
408 })
409 .map(|(x, y)| {
410 vec![(
411 x - node.origin.0,
412 y - node.origin.1,
413 BAR_HEIGHT,
414 BAR_HEIGHT,
415 -1.0,
416 )]
417 })
418 .unwrap_or_default();
419 GlassDynamics {
420 morph: Some(GlassMorph {
421 node_size: (node.size.width, node.size.height),
422 primary: (
423 geometry.center.0 - node.origin.0,
424 geometry.center.1 - node.origin.1,
425 geometry.base_size.width,
426 geometry.base_size.height,
427 radius,
428 ),
429 shapes,
430 glue,
431 wobble_amplitude: 1.1 * energy,
432 wobble_phase: geometry.lens_position * 0.045,
433 bulge_amplitude: geometry.pose.bulge_amplitude.min(8.0) * activity,
434 bulge_direction: geometry.pose.bulge_direction,
435 ellipse_blend: 0.0,
436 deformation: Some(crate::material::GlassDeformation::incompressible(
437 geometry.pose.axis,
438 1.0 + (geometry.pose.stretch - 1.0) * activity,
439 )),
440 }),
441 activity: Some(activity),
442 resting_tint: Some(geometry.resting_tint),
443 tint_alpha_multiplier: Some(tab_flight_tint_multiplier(geometry.lens_activity)),
444 ..Default::default()
445 }
446}
447
448#[composable]
450#[allow(non_snake_case)]
451pub fn LiquidTabBar(
452 modifier: Modifier,
453 spec: LiquidTabBarSpec,
454 tabs: Vec<LiquidTab>,
455 selected: usize,
456 on_select: impl Fn(usize) + 'static,
457) {
458 LiquidTabBarLayout(modifier, spec, tabs, selected, on_select, false, || {});
459}
460
461#[composable]
463#[allow(non_snake_case)]
464pub fn LiquidTabBarWithAccessory(
465 modifier: Modifier,
466 spec: LiquidTabBarSpec,
467 tabs: Vec<LiquidTab>,
468 selected: usize,
469 on_select: impl Fn(usize) + 'static,
470 accessory: impl FnMut() + 'static,
471) {
472 LiquidTabBarLayout(modifier, spec, tabs, selected, on_select, true, accessory);
473}
474
475#[composable]
476#[allow(non_snake_case)]
477fn LiquidTabBarLayout(
478 modifier: Modifier,
479 spec: LiquidTabBarSpec,
480 tabs: Vec<LiquidTab>,
481 selected: usize,
482 on_select: impl Fn(usize) + 'static,
483 has_accessory: bool,
484 accessory: impl FnMut() + 'static,
485) {
486 let colors = liquid_colors();
487 let typography = liquid_typography();
488 let count = tabs.len().max(1);
489 let selected = selected.min(count - 1);
490 let on_select: Rc<dyn Fn(usize)> = Rc::new(on_select);
491 let tabs = Rc::new(tabs);
492 let accessory = Rc::new(RefCell::new(accessory));
493
494 Row(
495 modifier,
496 RowSpec::default().vertical_alignment(VerticalAlignment::CenterVertically),
497 move || {
498 let tabs = Rc::clone(&tabs);
499 let typography = typography.clone();
500 let on_select = Rc::clone(&on_select);
501 let accessory = Rc::clone(&accessory);
502
503 let lens_x_outer = cranpose_core::remember(|| {
506 cranpose_core::mutableStateOf((
507 0.0f32,
508 0.0f32,
509 0.0f32,
510 crate::dynamics::LiquidPose::default(),
511 ))
512 })
513 .with(|state| *state);
514 let bar_touch =
520 cranpose_core::remember(|| cranpose_core::mutableStateOf((0.0f32, 0.0f32)))
521 .with(|state| *state);
522 let bar_held = cranpose_core::remember(|| cranpose_core::mutableStateOf(false))
523 .with(|state| *state);
524 let bar_press = cranpose_animation::animateFloatAsState(
525 if bar_held.get() { 1.0 } else { 0.0 },
526 cranpose_animation::spring(1.0, 600.0),
527 "tabbar-hold-press",
528 );
529 let bar_lift = Modifier::empty().graphics_layer(move || {
535 let press = bar_press.get().clamp(0.0, 1.0);
536 let rise = 1.0 + 0.03 * press;
537 cranpose_ui_graphics::GraphicsLayer {
538 scale_x: rise,
539 scale_y: rise,
540 translation_y: -2.5 * press,
541 ..Default::default()
542 }
543 });
544 Box(
548 bar_lift.then(Modifier::empty().height(BAR_HEIGHT)),
549 BoxSpec::default(),
550 move || {
551 let tabs = Rc::clone(&tabs);
552 let typography = typography.clone();
553 let on_select = Rc::clone(&on_select);
554 let pill = Modifier::empty()
557 .glass_effect_with(
558 tab_bar_surface_material(colors.label),
563 move || {
564 let press = bar_press.get().clamp(0.0, 1.0);
565 let (touch_x, touch_y) = bar_touch.get();
566 GlassDynamics {
567 highlight_boost: 0.45 * press,
568 saturation_boost: 0.12 * press,
569 touch: (press > 0.01).then_some((touch_x, touch_y, press)),
570 ..Default::default()
571 }
572 },
573 )
574 .height(BAR_HEIGHT);
575 Box(pill, BoxSpec::default(), move || {
576 let tabs = Rc::clone(&tabs);
577 let typography = typography.clone();
578 let on_select = Rc::clone(&on_select);
579 BoxWithConstraints(Modifier::empty().padding(BLOB_MARGIN), move |scope| {
580 let tabs = Rc::clone(&tabs);
581 let typography = typography.clone();
582 let on_select = Rc::clone(&on_select);
583 let constrained = scope.constraints().max_width;
584 let tab_width = if constrained.is_finite() && constrained > 1.0 {
585 (constrained / count as f32).min(spec.max_tab_width)
586 } else {
587 spec.max_tab_width
588 };
589
590 let lens_pressed =
593 cranpose_core::remember(|| cranpose_core::mutableStateOf(false))
594 .with(|state| *state);
595 let resting_lens_x = tab_lens_resting_left(selected, tab_width, count);
596 let lens_axis =
597 crate::motion::remember_liquid_drag_axis(resting_lens_x);
598 if !lens_pressed.get() {
604 lens_axis.settle_to(resting_lens_x, LiquidMotion::glide());
605 }
606 let lens_x = lens_axis.value();
607 let lens_pose = lens_axis.liquid_pose();
608 let lens_in_flight = !lens_axis.is_dragging()
609 && (lens_x - resting_lens_x).abs() > tab_width * 0.15;
610 let lens_raised = lens_pressed.get() || lens_in_flight;
611 let lens_activity_target = if lens_raised { 1.0 } else { 0.0 };
612 let lens_activity_anim = cranpose_animation::animateFloatAsState(
613 lens_activity_target,
614 tab_lens_activity_motion(lens_raised),
615 "tabbar-lens-activity",
616 );
617 let lens_activity = lens_activity_anim.get();
622 TabCells(
630 Modifier::empty(),
631 Rc::clone(&tabs),
632 typography.clone(),
633 tab_width,
634 TabCellsSpec {
635 base_color: tab_base_content_color(colors),
636 selected: Some(selected),
637 selected_color: tab_selection_content_color(colors),
638 interactive: true,
639 selection_only: false,
640 },
641 );
642
643 let row_width = tab_width * count as f32;
647 let gesture = Modifier::empty()
648 .size(Size::new(row_width, BLOB_HEIGHT))
649 .pointer_input(selected, {
650 let on_select = Rc::clone(&on_select);
651 let lens_axis = Rc::clone(&lens_axis);
652 move |scope: PointerInputScope| {
653 let on_select = Rc::clone(&on_select);
654 let lens_axis = Rc::clone(&lens_axis);
655 crate::motion::liquid_lens_gesture(
656 scope,
657 crate::motion::LiquidLensGesture {
658 axis: lens_axis,
659 cell_width: tab_width,
660 count,
661 tap_slop: TAP_SLOP,
662 drag_left: Rc::new(move |x| {
663 tab_lens_left(
664 x,
665 tab_width,
666 count,
667 has_accessory,
668 )
669 }),
670 rest_left: Rc::new(move |index| {
671 tab_lens_resting_left(index, tab_width, count)
672 }),
673 selected,
674 on_pressed: Rc::new(move |down| {
675 lens_pressed.set(down);
676 bar_held.set(down);
677 }),
678 on_touch: Rc::new(move |x, y| {
679 bar_touch
680 .set((x + BLOB_MARGIN, y + BLOB_MARGIN));
681 }),
682 on_select,
683 },
684 )
685 }
686 });
687 Box(gesture, BoxSpec::default(), || {});
688
689 let published = (lens_x, lens_activity, tab_width, lens_pose);
693 if lens_x_outer.get() != published {
694 lens_x_outer.set(published);
695 }
696 });
697 });
698
699 let (lens_px, lens_activity, lens_tab_w, pose) = lens_x_outer.get();
708 let lens_w =
709 lens_tab_w * TAB_LENS_REST_WIDTH_FACTOR * FLIGHT_LENS_PROJECTION_SCALE;
710 let lens_h = BLOB_HEIGHT * FLIGHT_LENS_PROJECTION_SCALE;
711 let deformation_headroom =
714 crate::dynamics::STRETCH_MAX.max(1.0 / crate::dynamics::STRETCH_MIN);
715 let node_w = lens_w * deformation_headroom + crate::dynamics::BULGE_MAX + 20.0;
716 let node_h = lens_h * deformation_headroom + crate::dynamics::BULGE_MAX + 16.0;
717 let lens_center_x = BLOB_MARGIN + lens_px + lens_tab_w * 0.5;
718 let node_x = lens_center_x - node_w * 0.5;
719 let node_top = tab_lens_node_top(node_h);
720 let pill_w = lens_tab_w * count as f32 + 2.0 * BLOB_MARGIN;
721 let (base_w, base_h) = tab_lens_base_size(lens_tab_w, lens_activity);
722 let geometry = TabFlightGeometry {
723 center: (lens_center_x, BAR_HEIGHT * 0.5),
724 base_size: Size::new(base_w, base_h),
725 pose,
726 lens_position: lens_px,
727 lens_activity,
728 resting_tint: colors.fill,
729 accessory_center: has_accessory.then_some((
730 pill_w + tab_bar_accessory_gap(true) + BAR_HEIGHT * 0.5,
731 BAR_HEIGHT * 0.5,
732 )),
733 };
734 let lens_node = TabFlightNode {
735 origin: (node_x, node_top),
736 size: Size::new(node_w, node_h),
737 };
738
739 let lens_geometry = geometry;
740 let lens = Modifier::empty()
741 .required_size(lens_node.size)
746 .offset(node_x, node_top)
747 .glass_effect_with(
748 tab_flight_lens_material(
749 colors.label,
750 tab_selection_content_color(colors),
751 ),
752 move || tab_flight_dynamics(lens_geometry, lens_node),
753 );
754 Box(lens, BoxSpec::default(), || {});
755 },
756 );
757
758 if has_accessory {
759 Box(
760 Modifier::empty().width(tab_bar_accessory_gap(true)),
761 BoxSpec::default(),
762 || {},
763 );
764 (accessory.borrow_mut())();
765 }
766 },
767 );
768}
769
770#[composable]
772#[allow(non_snake_case)]
773pub fn LiquidTabBarSearchAccessory(on_click: impl Fn() + 'static) {
774 crate::widgets::GlassIconButton(
776 Modifier::empty(),
777 crate::widgets::GlassButtonSpec::glass(),
778 BAR_HEIGHT * 0.94,
779 on_click,
780 crate::icons::SEARCH,
781 );
782}
783
784#[cfg(test)]
785mod tests {
786 use super::*;
787
788 #[test]
789 fn tab_bar_spec_normalizes_the_maximum_cell_width() {
790 assert_eq!(LiquidTabBarSpec::default().max_tab_width, TAB_WIDTH);
791 assert_eq!(LiquidTabBarSpec::new(85.0).max_tab_width, 85.0);
792 assert_eq!(LiquidTabBarSpec::new(0.0).max_tab_width, 1.0);
793 assert_eq!(LiquidTabBarSpec::new(f32::NAN).max_tab_width, TAB_WIDTH);
794 }
795
796 #[test]
797 fn drag_pointer_centers_the_lens_and_preserves_end_overdrag() {
798 let width = 100.0;
799 assert_eq!(tab_lens_left(50.0, width, 4, true), 0.0);
800 assert_eq!(tab_lens_left(250.0, width, 4, true), 200.0);
801 assert_eq!(tab_lens_left(-100.0, width, 4, true), -20.0);
802 assert_eq!(tab_lens_left(500.0, width, 4, true), 355.0);
803
804 assert_eq!(tab_lens_left(-100.0, width, 4, false), 0.0);
805 assert_eq!(tab_lens_left(500.0, width, 4, false), 300.0);
806 }
807
808 #[test]
809 fn resting_lens_centers_on_its_cell_and_stays_inside_the_pill() {
810 let tab = 78.0;
811 assert_eq!(tab_lens_resting_left(0, tab, 5), 0.0);
814 assert_eq!(tab_lens_resting_left(1, tab, 5), tab);
815 assert_eq!(tab_lens_resting_left(3, tab, 5), 3.0 * tab);
816 assert_eq!(tab_lens_resting_left(4, tab, 5), 4.0 * tab);
817 assert_eq!(tab_lens_resting_left(9, tab, 5), 4.0 * tab);
818 let overhang = tab * (TAB_LENS_REST_WIDTH_FACTOR - 1.0) * 0.5;
823 assert!(overhang <= BLOB_MARGIN + 1.0e-4);
824 }
825
826 #[test]
827 fn flight_lens_node_is_centered_on_the_bar_axis() {
828 for node_height in [48.0, 64.0, 96.0, 128.0] {
829 let center = tab_lens_node_top(node_height) + node_height * 0.5;
830 assert!((center - BAR_HEIGHT * 0.5).abs() < f32::EPSILON);
831 }
832 }
833
834 #[test]
835 fn liquid_tab_builds_reference_content() {
836 assert_eq!(TAB_ICON_SIZE, 32.0);
837 let tab = LiquidTab::new(crate::icons::STAR, "Discover");
838 assert_eq!(tab.icon, crate::icons::STAR);
839 assert_eq!(tab.label, "Discover");
840 assert_eq!(tab.icon_style, LiquidTabIconStyle::Plain);
841
842 let badge = LiquidTab::app_badge(crate::icons::APPLE, "WWDC");
843 assert_eq!(badge.icon_style, LiquidTabIconStyle::AppBadge);
844
845 let compact = LiquidTab::new(crate::icons::ACCOUNT_CIRCLE, "Account").with_icon_scale(0.72);
846 assert!((compact.icon_scale - 0.72).abs() < f32::EPSILON);
847 assert_eq!(tab.clone().with_icon_scale(f32::NAN).icon_scale, 1.0);
848 assert_eq!(tab.with_icon_scale(2.0).icon_scale, 1.5);
849 }
850
851 #[test]
852 fn app_badge_geometry_honors_the_tab_optical_scale() {
853 let full = app_badge_geometry(1.0);
854 let corrected = app_badge_geometry(0.85);
855 assert_eq!(full.size, Size::new(20.0, 32.0));
856 assert_eq!(corrected.size, Size::new(17.0, 27.2));
857 assert!((corrected.glyph.width - 11.9).abs() < 1.0e-5);
858 assert!((corrected.corner_radius / full.corner_radius - 0.85).abs() < f32::EPSILON);
859 assert!((corrected.stripe.x / full.stripe.x - 0.85).abs() < f32::EPSILON);
860 assert!((corrected.glyph.width / full.glyph.width - 0.85).abs() < f32::EPSILON);
861 }
862
863 #[test]
864 fn base_tab_content_remains_neutral_under_the_moving_selection_layer() {
865 let colors = crate::theme::LiquidColors::light(cranpose_ui_graphics::Color::from_rgb_u8(
866 0, 122, 255,
867 ));
868 assert_eq!(tab_base_content_color(colors), colors.label);
869 assert_eq!(tab_selection_content_color(colors), colors.accent);
870 }
871
872 #[test]
873 fn selection_mask_and_lens_resolve_the_same_global_sdf() {
874 let geometry = TabFlightGeometry {
875 center: (212.0, 32.0),
876 base_size: Size::new(106.0, 64.0),
877 pose: crate::dynamics::LiquidPose::default(),
878 lens_position: 160.0,
879 lens_activity: 1.0,
880 resting_tint: Color::BLACK.with_alpha(0.10),
881 accessory_center: None,
882 };
883 let mask_node = TabFlightNode {
884 origin: (0.0, 0.0),
885 size: Size::new(328.0, 64.0),
886 };
887 let lens_node = TabFlightNode {
888 origin: (132.0, -22.0),
889 size: Size::new(160.0, 108.0),
890 };
891 let mask = tab_flight_dynamics(geometry, mask_node)
892 .morph
893 .expect("selection mask morph");
894 let lens = tab_flight_dynamics(geometry, lens_node)
895 .morph
896 .expect("lens morph");
897 assert_eq!(
898 (
899 mask.primary.0 + mask_node.origin.0,
900 mask.primary.1 + mask_node.origin.1
901 ),
902 geometry.center
903 );
904 assert_eq!(
905 (
906 lens.primary.0 + lens_node.origin.0,
907 lens.primary.1 + lens_node.origin.1
908 ),
909 geometry.center
910 );
911 assert_eq!(
912 (mask.primary.2, mask.primary.3, mask.primary.4),
913 (lens.primary.2, lens.primary.3, lens.primary.4)
914 );
915 assert_eq!(mask.node_size, (328.0, 64.0));
916 assert_eq!(lens.node_size, (160.0, 108.0));
917 }
918
919 #[test]
920 fn unified_bar_has_no_detached_accessory_gap() {
921 assert_eq!(tab_bar_accessory_gap(false), 0.0);
922 assert_eq!(tab_bar_accessory_gap(true), 10.0);
923 }
924
925 #[test]
926 fn flight_lens_only_joins_accessory_after_surface_contact() {
927 assert!(!accessory_surfaces_touch(0.01));
928 assert!(accessory_surfaces_touch(0.0));
929 assert!(accessory_surfaces_touch(-4.0));
930 }
931
932 #[test]
933 fn lens_depth_is_an_isotropic_projection_separate_from_fluid_strain() {
934 let resting = tab_lens_base_size(TAB_WIDTH, 0.0);
935 let raised = tab_lens_base_size(TAB_WIDTH, 1.0);
936 assert_eq!(
937 resting,
938 (TAB_WIDTH * TAB_LENS_REST_WIDTH_FACTOR, BLOB_HEIGHT)
939 );
940 assert!((raised.0 / resting.0 - FLIGHT_LENS_PROJECTION_SCALE).abs() < 0.001);
941 assert!((raised.1 / resting.1 - FLIGHT_LENS_PROJECTION_SCALE).abs() < 0.001);
942 let area_ratio = raised.0 * raised.1 / (resting.0 * resting.1);
947 let projection_area = FLIGHT_LENS_PROJECTION_SCALE * FLIGHT_LENS_PROJECTION_SCALE;
948 assert!(
949 (area_ratio - projection_area).abs() < 0.01,
950 "raised area must be exactly the isotropic projection, got {area_ratio}"
951 );
952 }
953
954 #[test]
955 fn tab_grid_matches_the_reference_pitch() {
956 assert_eq!(TAB_WIDTH, 78.0);
957 }
958
959 #[test]
960 fn tab_grid_matches_the_reference_inner_inset() {
961 assert_eq!(BLOB_MARGIN, 4.0);
964 assert_eq!(BLOB_HEIGHT + 2.0 * BLOB_MARGIN, BAR_HEIGHT);
965 }
966
967 #[test]
968 fn flight_lens_uses_the_clear_wcksrd_contract() {
969 let glass = tab_flight_lens_material(
970 cranpose_ui_graphics::Color::BLACK,
971 cranpose_ui_graphics::Color::from_rgb_u8(0, 122, 255),
972 );
973 let generic_lens = Glass::lens();
974 assert!(glass
975 .lift
976 .is_some_and(|lift| (-0.02..=0.02).contains(&lift)));
977 assert!(glass.refraction_depth < generic_lens.refraction_depth);
978 assert!(glass.refraction_curve < generic_lens.refraction_curve);
979 assert!(glass.dispersion < generic_lens.dispersion);
980 assert_eq!(glass.blur_radius, Some(0.0));
981 assert!(glass.highlight < generic_lens.highlight);
982 assert!(
983 glass.shadow,
984 "the moving lens needs its target-visible SDF contact outline"
985 );
986 assert!(glass
987 .tint
988 .is_some_and(|tint| { tint.r() < 0.05 && (0.125..=0.135).contains(&tint.a()) }));
989 assert_eq!(glass.adaptive_frost, 0.0);
990 }
991
992 #[test]
993 fn flight_lens_retains_neutral_tint_through_direct_motion() {
994 assert_eq!(tab_flight_tint_multiplier(0.0), 1.0);
995 assert!((tab_flight_tint_multiplier(1.0) - 0.75).abs() < f32::EPSILON);
996 assert_eq!(tab_flight_tint_multiplier(-1.0), 1.0);
997 assert!((tab_flight_tint_multiplier(2.0) - 0.75).abs() < f32::EPSILON);
998 }
999
1000 #[test]
1001 fn bar_surface_adapts_frost_to_its_foreground() {
1002 let glass = tab_bar_surface_material(cranpose_ui_graphics::Color::BLACK);
1003 assert_eq!(glass.blur_radius, Some(4.0));
1004 assert_eq!(glass.saturation, Some(1.12));
1005 assert_eq!(glass.lift, Some(0.30));
1006 assert_eq!(glass.refraction_depth, 0.34);
1007 assert_eq!(glass.adaptive_frost, 0.28);
1008 }
1009
1010 #[test]
1011 fn bar_surface_lift_tracks_the_local_foreground_polarity() {
1012 let light_surface = tab_bar_surface_material(cranpose_ui_graphics::Color::BLACK);
1013 assert_eq!(light_surface.lift, Some(0.30));
1014
1015 let dark_surface = tab_bar_surface_material(cranpose_ui_graphics::Color::WHITE);
1016 assert_eq!(dark_surface.lift, Some(-0.24));
1017 }
1018
1019 #[test]
1020 fn bar_surface_tint_separates_from_same_polarity_backdrops() {
1021 let light_surface = tab_bar_surface_material(cranpose_ui_graphics::Color::BLACK)
1022 .tint
1023 .expect("bar tint");
1024 assert!(light_surface.r() < 0.05);
1025 assert_eq!(light_surface.a(), 0.0);
1026
1027 let dark_surface = tab_bar_surface_material(cranpose_ui_graphics::Color::WHITE)
1028 .tint
1029 .expect("bar tint");
1030 assert!(dark_surface.r() > 0.95);
1031 assert!((0.03..=0.05).contains(&dark_surface.a()));
1032 }
1033
1034 #[test]
1035 fn contact_rises_continuously_and_returns_on_the_measured_settle() {
1036 let cranpose_animation::AnimationType::Spring(rise) = tab_lens_activity_motion(true) else {
1037 panic!("contact rise must use a spring");
1038 };
1039 assert_eq!(rise.stiffness, 1400.0);
1040 let cranpose_animation::AnimationType::Spring(settle) = tab_lens_activity_motion(false)
1041 else {
1042 panic!("arrival contraction must use a spring");
1043 };
1044 assert_eq!(settle.damping_ratio, 1.0);
1045 assert_eq!(settle.stiffness, 900.0);
1046 }
1047}