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