1use crate::material::{
6 neutral_surface_lift, neutral_surface_tint, Glass, GlassDynamics, GlassMorph, LiquidModifierExt,
7};
8use crate::motion::{liquid_axis_owns_visual_selection, liquid_visual_index, LiquidMotion};
9use crate::theme::{liquid_colors, liquid_typography, LiquidTypography};
10use cranpose_foundation::PointerId;
11use cranpose_macros::composable;
12use cranpose_services::{default_haptics, HapticFeedback};
13use cranpose_ui::text::{FontWeight, SpanStyle, TextStyle};
14use cranpose_ui::widgets::{
15 Box, BoxSpec, BoxWithConstraints, BoxWithConstraintsScope, Column, ColumnSpec, Row, RowSpec,
16 Text,
17};
18use cranpose_ui::{
19 Brush, Color, CornerRadii, Modifier, PointerEventKind, PointerInputScope, Rect, Size,
20};
21use cranpose_ui_layout::{Alignment, HorizontalAlignment, VerticalAlignment};
22use std::cell::RefCell;
23use std::rc::Rc;
24
25#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
27pub enum LiquidTabIconStyle {
28 #[default]
29 Plain,
30 AppBadge,
31}
32
33#[derive(Clone, Debug, PartialEq)]
35pub struct LiquidTab {
36 pub icon: &'static str,
37 pub label: &'static str,
38 pub icon_style: LiquidTabIconStyle,
39 pub icon_scale: f32,
42}
43
44impl LiquidTab {
45 pub fn new(icon: &'static str, label: &'static str) -> Self {
46 Self {
47 icon,
48 label,
49 icon_style: LiquidTabIconStyle::Plain,
50 icon_scale: 1.0,
51 }
52 }
53
54 pub fn app_badge(icon: &'static str, label: &'static str) -> Self {
55 Self {
56 icon,
57 label,
58 icon_style: LiquidTabIconStyle::AppBadge,
59 icon_scale: 1.0,
60 }
61 }
62
63 pub fn with_icon_scale(mut self, scale: f32) -> Self {
64 self.icon_scale = normalize_icon_scale(scale);
65 self
66 }
67}
68
69fn normalize_icon_scale(scale: f32) -> f32 {
70 if scale.is_finite() {
71 scale.clamp(0.5, 1.5)
72 } else {
73 1.0
74 }
75}
76
77fn tab_base_content_color(colors: crate::theme::LiquidColors) -> Color {
78 colors.label
79}
80
81fn tab_selection_content_color(colors: crate::theme::LiquidColors) -> Color {
82 colors.accent
83}
84
85const BAR_HEIGHT: f32 = 64.0;
86const BLOB_HEIGHT: f32 = 52.0;
87const BLOB_MARGIN: f32 = 8.0;
88const FLIGHT_LENS_PROJECTION_SCALE: f32 = 1.15;
89const TAB_LENS_REST_WIDTH_FACTOR: f32 = 1.16;
90const TAB_WIDTH: f32 = 78.0;
92const TAB_ICON_SIZE: f32 = 32.0;
94const TAB_LABEL_SIZE: f32 = 11.0;
95const TAP_SLOP: f32 = 6.0;
97const ACCESSORY_GAP: f32 = 10.0;
98
99#[derive(Clone, Copy, Debug, PartialEq)]
101pub struct LiquidTabBarSpec {
102 max_tab_width: f32,
103}
104
105impl LiquidTabBarSpec {
106 pub fn new(max_tab_width: f32) -> Self {
107 Self {
108 max_tab_width: if max_tab_width.is_finite() {
109 max_tab_width.max(1.0)
110 } else {
111 TAB_WIDTH
112 },
113 }
114 }
115}
116
117impl Default for LiquidTabBarSpec {
118 fn default() -> Self {
119 Self::new(TAB_WIDTH)
120 }
121}
122
123fn tab_flight_lens_material(foreground: cranpose_ui_graphics::Color) -> Glass {
124 Glass::lens()
125 .no_clip()
126 .tint(neutral_surface_tint(foreground, 0.13, 0.10))
127 .blur_radius(0.0)
128 .refraction_depth(0.26)
129 .refraction_curve(0.25)
130 .fold_depth(6.0)
131 .optical_zoom(1.5)
134 .dispersion(0.24)
135 .lift(0.0)
136 .highlight(0.24)
137}
138
139fn tab_bar_surface_material(foreground: cranpose_ui_graphics::Color) -> Glass {
140 Glass::regular()
141 .tint(neutral_surface_tint(foreground, 0.0, 0.04))
142 .blur_radius(4.0)
143 .saturation(1.12)
147 .lift(neutral_surface_lift(foreground, 0.30, -0.24))
148 .highlight(0.20)
149 .fold_depth(8.0)
154 .adaptive_frost(foreground, 0.28)
155}
156
157fn tab_flight_tint_multiplier(activity: f32) -> f32 {
158 1.0 - 0.25 * activity.clamp(0.0, 1.0)
159}
160
161fn tab_lens_activity_motion() -> cranpose_animation::AnimationType {
162 cranpose_animation::spring(1.0, 900.0)
163}
164
165fn tab_lens_left(pointer_x: f32, tab_width: f32, count: usize, has_accessory: bool) -> f32 {
166 let last_tab = tab_width * count.saturating_sub(1) as f32;
167 let min = if has_accessory { -tab_width * 0.2 } else { 0.0 };
168 let max = if has_accessory {
169 tab_width * (count as f32 - 0.45)
170 } else {
171 last_tab
172 };
173 (pointer_x - tab_width * 0.5).clamp(min, max)
174}
175
176fn tab_visual_selection(
177 selected: usize,
178 lens_position: f32,
179 tab_width: f32,
180 count: usize,
181 direct: bool,
182) -> usize {
183 let selected = selected.min(count.saturating_sub(1));
184 let state_position = tab_width * selected as f32;
185 let lens_owns_selection =
186 liquid_axis_owns_visual_selection(direct, lens_position, state_position, tab_width);
187 liquid_visual_index(
188 selected,
189 lens_position,
190 tab_width,
191 count,
192 lens_owns_selection,
193 )
194}
195
196#[derive(Clone, Copy, Debug, PartialEq)]
197struct AppBadgeGeometry {
198 size: Size,
199 corner_radius: f32,
200 stripe: Rect,
201 glyph: Rect,
202}
203
204fn app_badge_geometry(optical_scale: f32) -> AppBadgeGeometry {
205 let scale = normalize_icon_scale(optical_scale);
206 AppBadgeGeometry {
207 size: Size::new(20.0 * scale, 32.0 * scale),
208 corner_radius: 5.0 * scale,
209 stripe: Rect {
210 x: 7.0 * scale,
211 y: 4.5 * scale,
212 width: 6.0 * scale,
213 height: 1.5 * scale,
214 },
215 glyph: Rect {
216 x: 3.0 * scale,
217 y: 10.0 * scale,
218 width: 14.0 * scale,
219 height: 14.0 * scale,
220 },
221 }
222}
223
224#[composable]
225#[allow(non_snake_case)]
226fn TabIcon(icon: &'static str, style: LiquidTabIconStyle, color: Color, optical_scale: f32) {
227 const FRAME_HEIGHT: f32 = 32.0;
228 Box(
229 Modifier::empty().size(Size::new(TAB_ICON_SIZE, FRAME_HEIGHT)),
230 BoxSpec::default().content_alignment(Alignment::CENTER),
231 move || match style {
232 LiquidTabIconStyle::Plain => {
233 crate::icons::Icon(icon, TAB_ICON_SIZE * optical_scale, color)
234 }
235 LiquidTabIconStyle::AppBadge => {
236 let geometry = app_badge_geometry(optical_scale);
237 Box(
238 Modifier::empty()
239 .size(geometry.size)
240 .draw_behind(move |scope| {
241 scope.draw_round_rect(
242 Brush::solid(color),
243 CornerRadii::uniform(geometry.corner_radius),
244 );
245 scope.draw_rect_at(geometry.stripe, Brush::solid(Color::WHITE));
246 }),
247 BoxSpec::default(),
248 move || {
249 Box(
250 Modifier::empty()
251 .offset(geometry.glyph.x, geometry.glyph.y)
252 .size(Size::new(geometry.glyph.width, geometry.glyph.height)),
253 BoxSpec::default(),
254 move || crate::icons::Icon(icon, geometry.glyph.width, Color::WHITE),
255 );
256 },
257 );
258 }
259 },
260 );
261}
262
263#[derive(Clone, Copy, PartialEq)]
264struct TabCellsSpec {
265 base_color: Color,
266 selected: Option<usize>,
267 selected_color: Color,
268 interactive: bool,
269 selection_only: bool,
270}
271
272fn tab_cell_is_visible(index: usize, selected: Option<usize>, selection_only: bool) -> bool {
273 !selection_only || selected == Some(index)
274}
275
276#[composable]
277#[allow(non_snake_case)]
278fn TabCells(
279 modifier: Modifier,
280 tabs: Rc<Vec<LiquidTab>>,
281 typography: LiquidTypography,
282 tab_width: f32,
283 spec: TabCellsSpec,
284) {
285 Row(modifier, RowSpec::default(), move || {
286 for (index, tab) in tabs.iter().enumerate() {
287 let visible = tab_cell_is_visible(index, spec.selected, spec.selection_only);
288 let color = if spec.selected == Some(index) {
289 spec.selected_color
290 } else {
291 spec.base_color
292 };
293 let label_for_semantics = tab.label;
294 let mut cell = Modifier::empty().size(Size::new(tab_width, BLOB_HEIGHT));
295 if spec.interactive {
296 cell = cell.semantics(move |config| {
297 config.is_button = true;
298 config.is_clickable = true;
299 config.content_description = Some(label_for_semantics.to_string());
300 });
301 }
302 let icon = tab.icon;
303 let icon_style = tab.icon_style;
304 let icon_scale = tab.icon_scale;
305 let label = tab.label;
306 let label_style = TextStyle {
307 span_style: SpanStyle {
308 color: Some(color),
309 font_size: cranpose_ui::text::TextUnit::Sp(TAB_LABEL_SIZE),
310 font_weight: Some(FontWeight::MEDIUM),
311 ..typography.caption1.span_style.clone()
312 },
313 ..typography.caption1.clone()
314 };
315 Box(
316 cell,
317 BoxSpec::default().content_alignment(Alignment::CENTER),
318 move || {
319 if !visible {
320 return;
321 }
322 let label_style = label_style.clone();
323 Column(
324 Modifier::empty(),
325 ColumnSpec::default()
326 .horizontal_alignment(HorizontalAlignment::CenterHorizontally),
327 move || {
328 TabIcon(icon, icon_style, color, icon_scale);
329 Text(label, Modifier::empty(), label_style.clone());
330 },
331 );
332 },
333 );
334 }
335 });
336}
337
338fn tab_lens_node_top(node_height: f32) -> f32 {
339 (BAR_HEIGHT - node_height) * 0.5
340}
341
342fn tab_bar_accessory_gap(has_accessory: bool) -> f32 {
343 if has_accessory {
344 ACCESSORY_GAP
345 } else {
346 0.0
347 }
348}
349
350fn accessory_surfaces_touch(edge_gap: f32) -> bool {
351 edge_gap <= 0.0
352}
353
354fn tab_lens_base_size(tab_width: f32, activity: f32) -> (f32, f32) {
355 let activity = activity.clamp(0.0, 1.0);
356 let ease = activity * activity * (3.0 - 2.0 * activity);
357 let rest_width = tab_width * TAB_LENS_REST_WIDTH_FACTOR;
358 let projection = 1.0 + (FLIGHT_LENS_PROJECTION_SCALE - 1.0) * ease;
359 (rest_width * projection, BLOB_HEIGHT * projection)
360}
361
362#[derive(Clone, Copy, Debug, PartialEq)]
363struct TabFlightGeometry {
364 center: (f32, f32),
365 base_size: Size,
366 pose: crate::dynamics::LiquidPose,
367 lens_position: f32,
368 lens_activity: f32,
369 resting_tint: Color,
370 accessory_center: Option<(f32, f32)>,
371}
372
373#[derive(Clone, Copy, Debug, PartialEq)]
374struct TabFlightNode {
375 origin: (f32, f32),
376 size: Size,
377}
378
379fn tab_flight_dynamics(geometry: TabFlightGeometry, node: TabFlightNode) -> GlassDynamics {
380 let activity = geometry.lens_activity.clamp(0.0, 1.0);
381 let energy = geometry.pose.energy() * activity;
382 let radius = geometry.base_size.height * (0.48 + 0.02 * energy);
383 let glue = 20.0;
384 let shapes = geometry
385 .accessory_center
386 .filter(|(x, _)| {
387 let edge_gap = (*x - geometry.center.0).abs()
388 - geometry.base_size.width * geometry.pose.stretch.max(geometry.pose.ortho) * 0.5
389 - BAR_HEIGHT * 0.5;
390 accessory_surfaces_touch(edge_gap)
391 })
392 .map(|(x, y)| {
393 vec![(
394 x - node.origin.0,
395 y - node.origin.1,
396 BAR_HEIGHT,
397 BAR_HEIGHT,
398 -1.0,
399 )]
400 })
401 .unwrap_or_default();
402 GlassDynamics {
403 morph: Some(GlassMorph {
404 node_size: (node.size.width, node.size.height),
405 primary: (
406 geometry.center.0 - node.origin.0,
407 geometry.center.1 - node.origin.1,
408 geometry.base_size.width,
409 geometry.base_size.height,
410 radius,
411 ),
412 shapes,
413 glue,
414 wobble_amplitude: 1.1 * energy,
415 wobble_phase: geometry.lens_position * 0.045,
416 bulge_amplitude: geometry.pose.bulge_amplitude.min(8.0) * activity,
417 bulge_direction: geometry.pose.bulge_direction,
418 ellipse_blend: 0.0,
419 deformation: Some(crate::material::GlassDeformation::incompressible(
420 geometry.pose.axis,
421 1.0 + (geometry.pose.stretch - 1.0) * activity,
422 )),
423 }),
424 activity: Some(activity),
425 resting_tint: Some(geometry.resting_tint),
426 tint_alpha_multiplier: Some(tab_flight_tint_multiplier(geometry.lens_activity)),
427 ..Default::default()
428 }
429}
430
431#[composable]
433#[allow(non_snake_case)]
434pub fn LiquidTabBar(
435 modifier: Modifier,
436 spec: LiquidTabBarSpec,
437 tabs: Vec<LiquidTab>,
438 selected: usize,
439 on_select: impl Fn(usize) + 'static,
440) {
441 LiquidTabBarLayout(modifier, spec, tabs, selected, on_select, false, || {});
442}
443
444#[composable]
446#[allow(non_snake_case)]
447pub fn LiquidTabBarWithAccessory(
448 modifier: Modifier,
449 spec: LiquidTabBarSpec,
450 tabs: Vec<LiquidTab>,
451 selected: usize,
452 on_select: impl Fn(usize) + 'static,
453 accessory: impl FnMut() + 'static,
454) {
455 LiquidTabBarLayout(modifier, spec, tabs, selected, on_select, true, accessory);
456}
457
458#[composable]
459#[allow(non_snake_case)]
460fn LiquidTabBarLayout(
461 modifier: Modifier,
462 spec: LiquidTabBarSpec,
463 tabs: Vec<LiquidTab>,
464 selected: usize,
465 on_select: impl Fn(usize) + 'static,
466 has_accessory: bool,
467 accessory: impl FnMut() + 'static,
468) {
469 let colors = liquid_colors();
470 let typography = liquid_typography();
471 let count = tabs.len().max(1);
472 let selected = selected.min(count - 1);
473 let on_select: Rc<dyn Fn(usize)> = Rc::new(on_select);
474 let tabs = Rc::new(tabs);
475 let accessory = Rc::new(RefCell::new(accessory));
476
477 Row(
478 modifier,
479 RowSpec::default().vertical_alignment(VerticalAlignment::CenterVertically),
480 move || {
481 let tabs = Rc::clone(&tabs);
482 let typography = typography.clone();
483 let on_select = Rc::clone(&on_select);
484 let accessory = Rc::clone(&accessory);
485
486 let lens_x_outer = cranpose_core::remember(|| {
489 cranpose_core::mutableStateOf((
490 0.0f32,
491 0.0f32,
492 0.0f32,
493 crate::dynamics::LiquidPose::default(),
494 ))
495 })
496 .with(|state| *state);
497 Box(
501 Modifier::empty().height(BAR_HEIGHT),
502 BoxSpec::default(),
503 move || {
504 let tabs = Rc::clone(&tabs);
505 let typography = typography.clone();
506 let on_select = Rc::clone(&on_select);
507 let pill = Modifier::empty()
510 .glass_effect(
511 tab_bar_surface_material(colors.label),
516 )
517 .height(BAR_HEIGHT);
518 Box(pill, BoxSpec::default(), move || {
519 let tabs = Rc::clone(&tabs);
520 let typography = typography.clone();
521 let on_select = Rc::clone(&on_select);
522 BoxWithConstraints(Modifier::empty().padding(BLOB_MARGIN), move |scope| {
523 let tabs = Rc::clone(&tabs);
524 let typography = typography.clone();
525 let on_select = Rc::clone(&on_select);
526 let constrained = scope.constraints().max_width;
527 let tab_width = if constrained.is_finite() && constrained > 1.0 {
528 (constrained / count as f32).min(spec.max_tab_width)
529 } else {
530 spec.max_tab_width
531 };
532
533 let lens_pressed =
536 cranpose_core::remember(|| cranpose_core::mutableStateOf(false))
537 .with(|state| *state);
538 let resting_lens_x = tab_width * selected as f32;
539 let lens_axis =
540 crate::motion::remember_liquid_drag_axis(resting_lens_x);
541 lens_axis.settle_to(resting_lens_x, LiquidMotion::glide());
542 let lens_x = lens_axis.value();
543 let lens_pose = lens_axis.liquid_pose();
544 let lens_in_flight = !lens_axis.is_dragging()
545 && (lens_x - resting_lens_x).abs() > tab_width * 0.15;
546 let lens_raised = lens_pressed.get() || lens_in_flight;
547 let lens_activity_target = if lens_raised { 1.0 } else { 0.0 };
548 let lens_activity_anim = cranpose_animation::animateFloatAsState(
549 lens_activity_target,
550 tab_lens_activity_motion(),
551 "tabbar-lens-activity",
552 );
553 let lens_activity = if lens_raised {
558 1.0
559 } else {
560 lens_activity_anim.get()
561 };
562 let visual_selection = tab_visual_selection(
563 selected,
564 lens_x,
565 tab_width,
566 count,
567 lens_axis.is_dragging(),
568 );
569 TabCells(
577 Modifier::empty(),
578 Rc::clone(&tabs),
579 typography.clone(),
580 tab_width,
581 TabCellsSpec {
582 base_color: tab_base_content_color(colors),
583 selected: Some(visual_selection),
584 selected_color: tab_selection_content_color(colors),
585 interactive: true,
586 selection_only: false,
587 },
588 );
589
590 let row_width = tab_width * count as f32;
592 let gesture = Modifier::empty()
593 .size(Size::new(row_width, BLOB_HEIGHT))
594 .pointer_input(selected, {
595 let on_select = Rc::clone(&on_select);
596 let lens_axis = Rc::clone(&lens_axis);
597 move |scope: PointerInputScope| {
598 let on_select = Rc::clone(&on_select);
599 let lens_axis = Rc::clone(&lens_axis);
600 async move {
601 scope
602 .await_pointer_event_scope(
603 |await_scope| async move {
604 let mut down_x = 0.0f32;
605 let mut active_pointer =
606 Option::<PointerId>::None;
607 let mut moved = false;
608 loop {
609 let event = await_scope
610 .await_pointer_event()
611 .await;
612 match event.kind {
613 PointerEventKind::Down
614 if active_pointer.is_none() =>
615 {
616 active_pointer = Some(event.id);
617 moved = false;
618 down_x = event.position.x;
619 lens_axis.begin(
624 lens_axis.value(),
625 event.time_ms,
626 );
627 lens_pressed.set(true);
628 default_haptics().perform(
629 HapticFeedback::Selection,
630 );
631 event.consume();
632 }
633 PointerEventKind::Move
634 if active_pointer
635 == Some(event.id) =>
636 {
637 moved |= (event.position.x
638 - down_x)
639 .abs()
640 > TAP_SLOP;
641 if moved {
650 lens_axis.move_to(
651 tab_lens_left(
652 event.position.x,
653 tab_width,
654 count,
655 has_accessory,
656 ),
657 event.time_ms,
658 );
659 }
660 event.consume();
661 }
662 PointerEventKind::Up
663 if active_pointer
664 == Some(event.id) =>
665 {
666 active_pointer = None;
667 lens_pressed.set(false);
668 let commit_x = if moved {
669 event.position.x
670 } else {
671 down_x
672 };
673 let index = ((commit_x
674 / tab_width)
675 .floor()
676 as isize)
677 .clamp(
678 0,
679 count as isize - 1,
680 )
681 as usize;
682 lens_axis.release_to(
683 tab_width * index as f32,
684 event.time_ms,
685 LiquidMotion::glide(),
686 );
687 default_haptics().perform(
688 HapticFeedback::ImpactLight,
689 );
690 on_select(index);
691 event.consume();
692 }
693 PointerEventKind::Cancel
694 if active_pointer
695 == Some(event.id) =>
696 {
697 active_pointer = None;
698 lens_pressed.set(false);
699 lens_axis.release_to(
700 resting_lens_x,
701 event.time_ms,
702 LiquidMotion::glide(),
703 );
704 event.consume();
705 }
706 _ => {}
707 }
708 }
709 },
710 )
711 .await;
712 }
713 }
714 });
715 Box(gesture, BoxSpec::default(), || {});
716
717 let published = (lens_x, lens_activity, tab_width, lens_pose);
721 if lens_x_outer.get() != published {
722 lens_x_outer.set(published);
723 }
724 });
725 });
726
727 let (lens_px, lens_activity, lens_tab_w, pose) = lens_x_outer.get();
736 let lens_w =
737 lens_tab_w * TAB_LENS_REST_WIDTH_FACTOR * FLIGHT_LENS_PROJECTION_SCALE;
738 let lens_h = BLOB_HEIGHT * FLIGHT_LENS_PROJECTION_SCALE;
739 let deformation_headroom =
742 crate::dynamics::STRETCH_MAX.max(1.0 / crate::dynamics::STRETCH_MIN);
743 let node_w = lens_w * deformation_headroom + crate::dynamics::BULGE_MAX + 20.0;
744 let node_h = lens_h * deformation_headroom + crate::dynamics::BULGE_MAX + 16.0;
745 let lens_center_x = BLOB_MARGIN + lens_px + lens_tab_w * 0.5;
746 let node_x = lens_center_x - node_w * 0.5;
747 let node_top = tab_lens_node_top(node_h);
748 let pill_w = lens_tab_w * count as f32 + 2.0 * BLOB_MARGIN;
749 let (base_w, base_h) = tab_lens_base_size(lens_tab_w, lens_activity);
750 let geometry = TabFlightGeometry {
751 center: (lens_center_x, BAR_HEIGHT * 0.5),
752 base_size: Size::new(base_w, base_h),
753 pose,
754 lens_position: lens_px,
755 lens_activity,
756 resting_tint: colors.fill,
757 accessory_center: has_accessory.then_some((
758 pill_w + tab_bar_accessory_gap(true) + BAR_HEIGHT * 0.5,
759 BAR_HEIGHT * 0.5,
760 )),
761 };
762 let lens_node = TabFlightNode {
763 origin: (node_x, node_top),
764 size: Size::new(node_w, node_h),
765 };
766
767 let lens_geometry = geometry;
768 let lens = Modifier::empty()
769 .required_size(lens_node.size)
774 .offset(node_x, node_top)
775 .glass_effect_with(tab_flight_lens_material(colors.label), move || {
776 tab_flight_dynamics(lens_geometry, lens_node)
777 });
778 Box(lens, BoxSpec::default(), || {});
779 },
780 );
781
782 if has_accessory {
783 Box(
784 Modifier::empty().width(tab_bar_accessory_gap(true)),
785 BoxSpec::default(),
786 || {},
787 );
788 (accessory.borrow_mut())();
789 }
790 },
791 );
792}
793
794#[composable]
796#[allow(non_snake_case)]
797pub fn LiquidTabBarSearchAccessory(on_click: impl Fn() + 'static) {
798 crate::widgets::GlassIconButton(
800 Modifier::empty(),
801 crate::widgets::GlassButtonSpec::glass(),
802 BAR_HEIGHT * 0.94,
803 on_click,
804 crate::icons::SEARCH,
805 );
806}
807
808#[cfg(test)]
809mod tests {
810 use super::*;
811
812 #[test]
813 fn tab_bar_spec_normalizes_the_maximum_cell_width() {
814 assert_eq!(LiquidTabBarSpec::default().max_tab_width, TAB_WIDTH);
815 assert_eq!(LiquidTabBarSpec::new(85.0).max_tab_width, 85.0);
816 assert_eq!(LiquidTabBarSpec::new(0.0).max_tab_width, 1.0);
817 assert_eq!(LiquidTabBarSpec::new(f32::NAN).max_tab_width, TAB_WIDTH);
818 }
819
820 #[test]
821 fn drag_pointer_centers_the_lens_and_preserves_end_overdrag() {
822 let width = 100.0;
823 assert_eq!(tab_lens_left(50.0, width, 4, true), 0.0);
824 assert_eq!(tab_lens_left(250.0, width, 4, true), 200.0);
825 assert_eq!(tab_lens_left(-100.0, width, 4, true), -20.0);
826 assert_eq!(tab_lens_left(500.0, width, 4, true), 355.0);
827
828 assert_eq!(tab_lens_left(-100.0, width, 4, false), 0.0);
829 assert_eq!(tab_lens_left(500.0, width, 4, false), 300.0);
830 }
831
832 #[test]
833 fn flight_lens_node_is_centered_on_the_bar_axis() {
834 for node_height in [48.0, 64.0, 96.0, 128.0] {
835 let center = tab_lens_node_top(node_height) + node_height * 0.5;
836 assert!((center - BAR_HEIGHT * 0.5).abs() < f32::EPSILON);
837 }
838 }
839
840 #[test]
841 fn liquid_tab_builds_reference_content() {
842 assert_eq!(TAB_ICON_SIZE, 32.0);
843 let tab = LiquidTab::new(crate::icons::STAR, "Discover");
844 assert_eq!(tab.icon, crate::icons::STAR);
845 assert_eq!(tab.label, "Discover");
846 assert_eq!(tab.icon_style, LiquidTabIconStyle::Plain);
847
848 let badge = LiquidTab::app_badge(crate::icons::APPLE, "WWDC");
849 assert_eq!(badge.icon_style, LiquidTabIconStyle::AppBadge);
850
851 let compact = LiquidTab::new(crate::icons::ACCOUNT_CIRCLE, "Account").with_icon_scale(0.72);
852 assert!((compact.icon_scale - 0.72).abs() < f32::EPSILON);
853 assert_eq!(tab.clone().with_icon_scale(f32::NAN).icon_scale, 1.0);
854 assert_eq!(tab.with_icon_scale(2.0).icon_scale, 1.5);
855 }
856
857 #[test]
858 fn app_badge_geometry_honors_the_tab_optical_scale() {
859 let full = app_badge_geometry(1.0);
860 let corrected = app_badge_geometry(0.85);
861 assert_eq!(full.size, Size::new(20.0, 32.0));
862 assert_eq!(corrected.size, Size::new(17.0, 27.2));
863 assert!((corrected.glyph.width - 11.9).abs() < 1.0e-5);
864 assert!((corrected.corner_radius / full.corner_radius - 0.85).abs() < f32::EPSILON);
865 assert!((corrected.stripe.x / full.stripe.x - 0.85).abs() < f32::EPSILON);
866 assert!((corrected.glyph.width / full.glyph.width - 0.85).abs() < f32::EPSILON);
867 }
868
869 #[test]
870 fn base_tab_content_remains_neutral_under_the_moving_selection_layer() {
871 let colors = crate::theme::LiquidColors::light(cranpose_ui_graphics::Color::from_rgb_u8(
872 0, 122, 255,
873 ));
874 assert_eq!(tab_base_content_color(colors), colors.label);
875 assert_eq!(tab_selection_content_color(colors), colors.accent);
876 }
877
878 #[test]
879 fn flight_overlay_accents_only_the_lens_owned_cell() {
880 assert_eq!(
881 tab_visual_selection(3, TAB_WIDTH * 2.5, TAB_WIDTH, 4, true),
882 2
883 );
884 assert!(tab_cell_is_visible(2, Some(2), true));
885 assert!(!tab_cell_is_visible(3, Some(2), true));
886 assert!(tab_cell_is_visible(3, Some(2), false));
887 }
888
889 #[test]
890 fn selection_mask_and_lens_resolve_the_same_global_sdf() {
891 let geometry = TabFlightGeometry {
892 center: (212.0, 32.0),
893 base_size: Size::new(106.0, 64.0),
894 pose: crate::dynamics::LiquidPose::default(),
895 lens_position: 160.0,
896 lens_activity: 1.0,
897 resting_tint: Color::BLACK.with_alpha(0.10),
898 accessory_center: None,
899 };
900 let mask_node = TabFlightNode {
901 origin: (0.0, 0.0),
902 size: Size::new(328.0, 64.0),
903 };
904 let lens_node = TabFlightNode {
905 origin: (132.0, -22.0),
906 size: Size::new(160.0, 108.0),
907 };
908 let mask = tab_flight_dynamics(geometry, mask_node)
909 .morph
910 .expect("selection mask morph");
911 let lens = tab_flight_dynamics(geometry, lens_node)
912 .morph
913 .expect("lens morph");
914 assert_eq!(
915 (
916 mask.primary.0 + mask_node.origin.0,
917 mask.primary.1 + mask_node.origin.1
918 ),
919 geometry.center
920 );
921 assert_eq!(
922 (
923 lens.primary.0 + lens_node.origin.0,
924 lens.primary.1 + lens_node.origin.1
925 ),
926 geometry.center
927 );
928 assert_eq!(
929 (mask.primary.2, mask.primary.3, mask.primary.4),
930 (lens.primary.2, lens.primary.3, lens.primary.4)
931 );
932 assert_eq!(mask.node_size, (328.0, 64.0));
933 assert_eq!(lens.node_size, (160.0, 108.0));
934 }
935
936 #[test]
937 fn unified_bar_has_no_detached_accessory_gap() {
938 assert_eq!(tab_bar_accessory_gap(false), 0.0);
939 assert_eq!(tab_bar_accessory_gap(true), 10.0);
940 }
941
942 #[test]
943 fn flight_lens_only_joins_accessory_after_surface_contact() {
944 assert!(!accessory_surfaces_touch(0.01));
945 assert!(accessory_surfaces_touch(0.0));
946 assert!(accessory_surfaces_touch(-4.0));
947 }
948
949 #[test]
950 fn lens_depth_is_an_isotropic_projection_separate_from_fluid_strain() {
951 let resting = tab_lens_base_size(TAB_WIDTH, 0.0);
952 let raised = tab_lens_base_size(TAB_WIDTH, 1.0);
953 assert_eq!(
954 resting,
955 (TAB_WIDTH * TAB_LENS_REST_WIDTH_FACTOR, BLOB_HEIGHT)
956 );
957 assert!((raised.0 / resting.0 - FLIGHT_LENS_PROJECTION_SCALE).abs() < 0.001);
958 assert!((raised.1 / resting.1 - FLIGHT_LENS_PROJECTION_SCALE).abs() < 0.001);
959 assert!(
960 raised.0 * raised.1 / (resting.0 * resting.1) < 1.38,
961 "depth projection must not masquerade as fluid volume inflation"
962 );
963 }
964
965 #[test]
966 fn tab_grid_matches_the_reference_pitch() {
967 assert_eq!(TAB_WIDTH, 78.0);
968 }
969
970 #[test]
971 fn tab_grid_matches_the_reference_inner_inset() {
972 assert_eq!(BLOB_MARGIN, 8.0);
973 }
974
975 #[test]
976 fn flight_lens_uses_the_clear_wcksrd_contract() {
977 let glass = tab_flight_lens_material(cranpose_ui_graphics::Color::BLACK);
978 let generic_lens = Glass::lens();
979 assert!(glass
980 .lift
981 .is_some_and(|lift| (-0.02..=0.02).contains(&lift)));
982 assert!(glass.refraction_depth < generic_lens.refraction_depth);
983 assert!(glass.refraction_curve < generic_lens.refraction_curve);
984 assert!(glass.dispersion < generic_lens.dispersion);
985 assert_eq!(glass.blur_radius, Some(0.0));
986 assert!(glass.highlight < generic_lens.highlight);
987 assert!(
988 glass.shadow,
989 "the moving lens needs its target-visible SDF contact outline"
990 );
991 assert!(glass
992 .tint
993 .is_some_and(|tint| { tint.r() < 0.05 && (0.125..=0.135).contains(&tint.a()) }));
994 assert_eq!(glass.adaptive_frost, 0.0);
995 }
996
997 #[test]
998 fn flight_lens_retains_neutral_tint_through_direct_motion() {
999 assert_eq!(tab_flight_tint_multiplier(0.0), 1.0);
1000 assert!((tab_flight_tint_multiplier(1.0) - 0.75).abs() < f32::EPSILON);
1001 assert_eq!(tab_flight_tint_multiplier(-1.0), 1.0);
1002 assert!((tab_flight_tint_multiplier(2.0) - 0.75).abs() < f32::EPSILON);
1003 }
1004
1005 #[test]
1006 fn bar_surface_adapts_frost_to_its_foreground() {
1007 let glass = tab_bar_surface_material(cranpose_ui_graphics::Color::BLACK);
1008 assert_eq!(glass.blur_radius, Some(4.0));
1009 assert_eq!(glass.saturation, Some(1.12));
1010 assert_eq!(glass.lift, Some(0.30));
1011 assert_eq!(glass.refraction_depth, 0.34);
1012 assert_eq!(glass.adaptive_frost, 0.28);
1013 }
1014
1015 #[test]
1016 fn bar_surface_lift_tracks_the_local_foreground_polarity() {
1017 let light_surface = tab_bar_surface_material(cranpose_ui_graphics::Color::BLACK);
1018 assert_eq!(light_surface.lift, Some(0.30));
1019
1020 let dark_surface = tab_bar_surface_material(cranpose_ui_graphics::Color::WHITE);
1021 assert_eq!(dark_surface.lift, Some(-0.24));
1022 }
1023
1024 #[test]
1025 fn bar_surface_tint_separates_from_same_polarity_backdrops() {
1026 let light_surface = tab_bar_surface_material(cranpose_ui_graphics::Color::BLACK)
1027 .tint
1028 .expect("bar tint");
1029 assert!(light_surface.r() < 0.05);
1030 assert_eq!(light_surface.a(), 0.0);
1031
1032 let dark_surface = tab_bar_surface_material(cranpose_ui_graphics::Color::WHITE)
1033 .tint
1034 .expect("bar tint");
1035 assert!(dark_surface.r() > 0.95);
1036 assert!((0.03..=0.05).contains(&dark_surface.a()));
1037 }
1038
1039 #[test]
1040 fn arrival_contraction_uses_the_measured_fast_settle() {
1041 let cranpose_animation::AnimationType::Spring(settle) = tab_lens_activity_motion() 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}