1use crate::material::{neutral_surface_tint, Glass, GlassDynamics, GlassMorph, LiquidModifierExt};
6use crate::motion::LiquidMotion;
7use crate::theme::{liquid_colors, liquid_typography};
8use cranpose_foundation::PointerId;
9use cranpose_macros::composable;
10use cranpose_services::{default_haptics, HapticFeedback};
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::{
17 Brush, Color, CornerRadii, Modifier, PointerEventKind, PointerInputScope, Rect, Size,
18};
19use cranpose_ui_graphics::{GlassProfileCurve, GlassSurfaceProfile};
20use cranpose_ui_layout::{Alignment, HorizontalAlignment, VerticalAlignment};
21use std::cell::RefCell;
22use std::rc::Rc;
23
24#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
26pub enum LiquidTabIconStyle {
27 #[default]
28 Plain,
29 AppBadge,
30}
31
32#[derive(Clone, Debug, PartialEq)]
34pub struct LiquidTab {
35 pub icon: &'static str,
36 pub label: &'static str,
37 pub icon_style: LiquidTabIconStyle,
38 pub icon_scale: f32,
41}
42
43impl LiquidTab {
44 pub fn new(icon: &'static str, label: &'static str) -> Self {
45 Self {
46 icon,
47 label,
48 icon_style: LiquidTabIconStyle::Plain,
49 icon_scale: 1.0,
50 }
51 }
52
53 pub fn app_badge(icon: &'static str, label: &'static str) -> Self {
54 Self {
55 icon,
56 label,
57 icon_style: LiquidTabIconStyle::AppBadge,
58 icon_scale: 1.0,
59 }
60 }
61
62 pub fn with_icon_scale(mut self, scale: f32) -> Self {
63 self.icon_scale = if scale.is_finite() {
64 scale.clamp(0.5, 1.5)
65 } else {
66 1.0
67 };
68 self
69 }
70}
71
72const BAR_HEIGHT: f32 = 64.0;
73const BLOB_HEIGHT: f32 = 52.0;
74const BLOB_MARGIN: f32 = 8.0;
75const FLIGHT_LENS_WIDTH_FACTOR: f32 = 1.17;
76const FLIGHT_LENS_HEIGHT_FACTOR: f32 = 1.0;
77const FLIGHT_LENS_DEPTH_ACTIVE: f32 = 0.0;
78const FLIGHT_LENS_DEPTH_MOTION: f32 = 0.12;
79const FLIGHT_SURFACE_RADIAL_POWER: f32 = 1.5;
80const FLIGHT_SURFACE_AXIS_COUPLING: f32 = 0.78;
81const TAB_WIDTH: f32 = 78.0;
83const TAB_ICON_SIZE: f32 = 32.0;
85const TAB_LABEL_SIZE: f32 = 11.0;
86const TAP_SLOP: f32 = 6.0;
88const ACCESSORY_GAP: f32 = 10.0;
89const VISUAL_HANDOFF_TOLERANCE_IN_TABS: f32 = 0.12;
90
91fn flight_surface_profile() -> GlassSurfaceProfile {
92 let x_profile = GlassProfileCurve::from_points(&[
93 (0.0, 0.50),
94 (0.16, 0.508),
95 (0.34, 0.54),
96 (0.56, 0.44),
97 (0.78, 0.34),
98 (1.0, 0.24),
99 ])
100 .expect("tab flight X-Z profile is valid");
101 let y_profile = GlassProfileCurve::from_points(&[
102 (0.0, 0.50),
103 (0.16, 0.50),
104 (0.30, 0.50),
105 (0.52, 0.515),
106 (0.76, 0.558),
107 (1.0, 0.51),
108 ])
109 .expect("tab flight Y-Z profile is valid");
110 GlassSurfaceProfile::new(x_profile, y_profile, 30.0, FLIGHT_SURFACE_RADIAL_POWER)
111 .and_then(|profile| profile.with_axis_coupling(FLIGHT_SURFACE_AXIS_COUPLING))
112 .expect("tab flight surface profile is coherent")
113}
114
115fn tab_flight_lens_material(
116 foreground: cranpose_ui_graphics::Color,
117 accent: cranpose_ui_graphics::Color,
118) -> Glass {
119 Glass::lens()
120 .no_clip()
121 .tint(neutral_surface_tint(foreground, 0.08, 0.10))
122 .content_recolor(accent, 1.0)
123 .blur_radius(2.0)
124 .lift(0.0)
125 .highlight(0.32)
126 .surface_profile(flight_surface_profile())
127 .sheen(0.12)
128 .chromatic_aberration(1.2)
129 .displacement(55.0)
130}
131
132fn tab_bar_surface_material(foreground: cranpose_ui_graphics::Color) -> Glass {
133 Glass::regular()
134 .tint(neutral_surface_tint(foreground, 0.028, 0.04))
135 .blur_radius(16.0)
136 .saturation(0.95)
137 .lift(0.48)
138 .highlight(0.20)
139 .surface_profile(
140 GlassSurfaceProfile::regular()
141 .with_depth(3.0)
142 .expect("tab bar surface depth is valid"),
143 )
144 .adaptive_frost(foreground, 0.42)
145 .edge_fold(1.0)
146}
147
148fn tab_flight_depth_boost(activity: f32, energy: f32) -> f32 {
149 activity.clamp(0.0, 1.0)
150 * (FLIGHT_LENS_DEPTH_ACTIVE + FLIGHT_LENS_DEPTH_MOTION * energy.clamp(0.0, 1.0))
151}
152
153fn tab_flight_tint_multiplier(activity: f32) -> f32 {
154 1.0 - 0.60 * activity.clamp(0.0, 1.0)
155}
156
157fn tab_lens_activity_motion(active: bool) -> cranpose_animation::AnimationType {
158 if active {
159 LiquidMotion::smooth()
160 } else {
161 cranpose_animation::spring(1.0, 1400.0)
162 }
163}
164
165fn tab_lens_left(pointer_x: f32, tab_width: f32, count: usize) -> f32 {
166 (pointer_x - tab_width * 0.5).clamp(-tab_width * 0.2, tab_width * (count as f32 - 0.45))
167}
168
169fn tab_visual_index(
170 selected: usize,
171 lens_x: f32,
172 tab_width: f32,
173 count: usize,
174 direct: bool,
175) -> usize {
176 if count == 0 {
177 return 0;
178 }
179 let selected = selected.min(count - 1);
180 if !direct || !lens_x.is_finite() || tab_width <= f32::EPSILON {
181 return selected;
182 }
183 (lens_x / tab_width)
184 .round()
185 .clamp(0.0, count.saturating_sub(1) as f32) as usize
186}
187
188fn tab_lens_owns_visual_selection(
189 direct: bool,
190 lens_x: f32,
191 target_x: f32,
192 tab_width: f32,
193) -> bool {
194 direct
195 || (lens_x - target_x).abs()
196 > tab_width.max(f32::EPSILON) * VISUAL_HANDOFF_TOLERANCE_IN_TABS
197}
198
199#[composable]
200#[allow(non_snake_case)]
201fn TabIcon(icon: &'static str, style: LiquidTabIconStyle, color: Color, optical_scale: f32) {
202 const FRAME_HEIGHT: f32 = 32.0;
203 Box(
204 Modifier::empty().size(Size::new(TAB_ICON_SIZE, FRAME_HEIGHT)),
205 BoxSpec::default().content_alignment(Alignment::CENTER),
206 move || match style {
207 LiquidTabIconStyle::Plain => {
208 crate::icons::Icon(icon, TAB_ICON_SIZE * optical_scale, color)
209 }
210 LiquidTabIconStyle::AppBadge => {
211 Box(
212 Modifier::empty()
213 .size(Size::new(20.0, FRAME_HEIGHT))
214 .draw_behind(move |scope| {
215 scope.draw_round_rect(Brush::solid(color), CornerRadii::uniform(5.0));
216 scope.draw_rect_at(
217 Rect {
218 x: 7.0,
219 y: 4.5,
220 width: 6.0,
221 height: 1.5,
222 },
223 Brush::solid(Color::WHITE),
224 );
225 }),
226 BoxSpec::default(),
227 move || {
228 Box(
229 Modifier::empty()
230 .offset(4.0, 11.0)
231 .size(Size::new(12.0, 12.0)),
232 BoxSpec::default(),
233 move || crate::icons::Icon(icon, 12.0, Color::WHITE),
234 );
235 },
236 );
237 }
238 },
239 );
240}
241
242fn tab_lens_node_top(node_height: f32) -> f32 {
243 (BAR_HEIGHT - node_height) * 0.5
244}
245
246fn tab_bar_accessory_gap(has_accessory: bool) -> f32 {
247 if has_accessory {
248 ACCESSORY_GAP
249 } else {
250 0.0
251 }
252}
253
254fn tab_lens_base_size(tab_width: f32, activity: f32) -> (f32, f32) {
255 let activity = activity.clamp(0.0, 1.0);
256 let ease = activity * activity * (3.0 - 2.0 * activity);
257 let rest_width = tab_width * 1.08;
258 let active_width = tab_width * FLIGHT_LENS_WIDTH_FACTOR;
259 (
260 rest_width + (active_width - rest_width) * ease,
261 BLOB_HEIGHT + (BAR_HEIGHT * FLIGHT_LENS_HEIGHT_FACTOR - BLOB_HEIGHT) * ease,
262 )
263}
264
265fn tab_flight_launch_wake(
266 pose: crate::dynamics::LiquidPose,
267 base_height: f32,
268) -> Option<(f32, f32)> {
269 let compression = ((1.0 - pose.stretch) / (1.0 - crate::dynamics::STRETCH_MIN)).clamp(0.0, 1.0);
270 if compression <= 0.03 {
271 return None;
272 }
273 let amplitude = base_height * (0.04 + 0.04 * compression);
274 let trailing = (-pose.axis.0, -pose.axis.1);
275 Some((amplitude, trailing.1.atan2(trailing.0)))
276}
277
278#[composable]
280#[allow(non_snake_case)]
281pub fn LiquidTabBar(
282 modifier: Modifier,
283 tabs: Vec<LiquidTab>,
284 selected: usize,
285 on_select: impl Fn(usize) + 'static,
286) {
287 LiquidTabBarLayout(modifier, tabs, selected, on_select, false, || {});
288}
289
290#[composable]
292#[allow(non_snake_case)]
293pub fn LiquidTabBarWithAccessory(
294 modifier: Modifier,
295 tabs: Vec<LiquidTab>,
296 selected: usize,
297 on_select: impl Fn(usize) + 'static,
298 accessory: impl FnMut() + 'static,
299) {
300 LiquidTabBarLayout(modifier, tabs, selected, on_select, true, accessory);
301}
302
303#[composable]
304#[allow(non_snake_case)]
305fn LiquidTabBarLayout(
306 modifier: Modifier,
307 tabs: Vec<LiquidTab>,
308 selected: usize,
309 on_select: impl Fn(usize) + 'static,
310 has_accessory: bool,
311 accessory: impl FnMut() + 'static,
312) {
313 let colors = liquid_colors();
314 let typography = liquid_typography();
315 let count = tabs.len().max(1);
316 let selected = selected.min(count - 1);
317 let on_select: Rc<dyn Fn(usize)> = Rc::new(on_select);
318 let tabs = Rc::new(tabs);
319 let accessory = Rc::new(RefCell::new(accessory));
320
321 Row(
322 modifier,
323 RowSpec::default().vertical_alignment(VerticalAlignment::CenterVertically),
324 move || {
325 let tabs = Rc::clone(&tabs);
326 let typography = typography.clone();
327 let on_select = Rc::clone(&on_select);
328 let accessory = Rc::clone(&accessory);
329
330 let lens_x_outer = cranpose_core::remember(|| {
333 cranpose_core::mutableStateOf((
334 0.0f32,
335 0.0f32,
336 0.0f32,
337 crate::dynamics::LiquidPose::default(),
338 ))
339 })
340 .with(|state| *state);
341 Box(
345 Modifier::empty().height(BAR_HEIGHT),
346 BoxSpec::default(),
347 move || {
348 let tabs = Rc::clone(&tabs);
349 let typography = typography.clone();
350 let on_select = Rc::clone(&on_select);
351 let pill = Modifier::empty()
354 .glass_effect(
355 tab_bar_surface_material(colors.label),
360 )
361 .height(BAR_HEIGHT);
362 Box(pill, BoxSpec::default(), move || {
363 let tabs = Rc::clone(&tabs);
364 let typography = typography.clone();
365 let on_select = Rc::clone(&on_select);
366 BoxWithConstraints(Modifier::empty().padding(BLOB_MARGIN), move |scope| {
367 let tabs = Rc::clone(&tabs);
368 let typography = typography.clone();
369 let on_select = Rc::clone(&on_select);
370 let constrained = scope.constraints().max_width;
371 let tab_width = if constrained.is_finite() && constrained > 1.0 {
372 (constrained / count as f32).min(TAB_WIDTH)
373 } else {
374 TAB_WIDTH
375 };
376
377 let lens_pressed =
380 cranpose_core::remember(|| cranpose_core::mutableStateOf(false))
381 .with(|state| *state);
382 let resting_lens_x = tab_width * selected as f32;
383 let lens_axis =
384 crate::motion::remember_liquid_drag_axis(resting_lens_x);
385 lens_axis.settle_to(resting_lens_x, LiquidMotion::glide());
386 let lens_x = lens_axis.value();
387 let lens_pose = lens_axis.liquid_pose();
388 let lens_settling = !lens_axis.is_dragging()
389 && (lens_x - resting_lens_x).abs() > tab_width * 0.75;
390 let lens_activity_target = if lens_pressed.get() || lens_settling {
391 1.0
392 } else {
393 0.0
394 };
395 let lens_activity_anim = cranpose_animation::animateFloatAsState(
396 lens_activity_target,
397 tab_lens_activity_motion(lens_activity_target > 0.5),
398 "tabbar-lens-activity",
399 );
400 let lens_activity = move || {
401 if lens_activity_target > 0.5 {
402 1.0
403 } else {
404 lens_activity_anim.get()
405 }
406 };
407
408 let cells_tabs = Rc::clone(&tabs);
409 let visual_index = tab_visual_index(
410 selected,
411 lens_x,
412 tab_width,
413 count,
414 tab_lens_owns_visual_selection(
415 lens_pressed.get(),
416 lens_x,
417 resting_lens_x,
418 tab_width,
419 ),
420 );
421 Row(Modifier::empty(), RowSpec::default(), move || {
422 for (index, tab) in cells_tabs.iter().enumerate() {
423 let color = if index == visual_index {
424 colors.accent
425 } else {
426 colors.label
427 };
428 let label_for_semantics = tab.label;
429 let cell = Modifier::empty()
430 .size(Size::new(tab_width, BLOB_HEIGHT))
431 .semantics(move |config| {
432 config.is_button = true;
433 config.is_clickable = true;
434 config.content_description =
435 Some(label_for_semantics.to_string());
436 });
437 let icon = tab.icon;
438 let icon_style = tab.icon_style;
439 let icon_scale = tab.icon_scale;
440 let label = tab.label;
441 let label_style = TextStyle {
442 span_style: SpanStyle {
443 color: Some(color),
444 font_size: cranpose_ui::text::TextUnit::Sp(
445 TAB_LABEL_SIZE,
446 ),
447 font_weight: Some(FontWeight::MEDIUM),
448 ..typography.caption1.span_style.clone()
449 },
450 ..typography.caption1.clone()
451 };
452 Box(
453 cell,
454 BoxSpec::default().content_alignment(Alignment::CENTER),
455 move || {
456 let label_style = label_style.clone();
457 Column(
458 Modifier::empty(),
459 ColumnSpec::default().horizontal_alignment(
460 HorizontalAlignment::CenterHorizontally,
461 ),
462 move || {
463 TabIcon(icon, icon_style, color, icon_scale);
464 Text(
465 label,
466 Modifier::empty(),
467 label_style.clone(),
468 );
469 },
470 );
471 },
472 );
473 }
474 });
475
476 let row_width = tab_width * count as f32;
478 let gesture = Modifier::empty()
479 .size(Size::new(row_width, BLOB_HEIGHT))
480 .pointer_input(selected, {
481 let on_select = Rc::clone(&on_select);
482 let lens_axis = Rc::clone(&lens_axis);
483 move |scope: PointerInputScope| {
484 let on_select = Rc::clone(&on_select);
485 let lens_axis = Rc::clone(&lens_axis);
486 async move {
487 scope
488 .await_pointer_event_scope(
489 |await_scope| async move {
490 let mut down_x = 0.0f32;
491 let mut active_pointer =
492 Option::<PointerId>::None;
493 let mut moved = false;
494 loop {
495 let event = await_scope
496 .await_pointer_event()
497 .await;
498 match event.kind {
499 PointerEventKind::Down
500 if active_pointer.is_none() =>
501 {
502 active_pointer = Some(event.id);
503 moved = false;
504 down_x = event.position.x;
505 lens_axis.begin(
506 tab_lens_left(
507 event.position.x,
508 tab_width,
509 count,
510 ),
511 event.time_ms,
512 );
513 lens_pressed.set(true);
514 default_haptics().perform(
515 HapticFeedback::Selection,
516 );
517 event.consume();
518 }
519 PointerEventKind::Move
520 if active_pointer
521 == Some(event.id) =>
522 {
523 moved |= (event.position.x
524 - down_x)
525 .abs()
526 > TAP_SLOP;
527 lens_axis.move_to(
528 tab_lens_left(
529 event.position.x,
530 tab_width,
531 count,
532 ),
533 event.time_ms,
534 );
535 event.consume();
536 }
537 PointerEventKind::Up
538 if active_pointer
539 == Some(event.id) =>
540 {
541 active_pointer = None;
542 lens_pressed.set(false);
543 let commit_x = if moved {
544 event.position.x
545 } else {
546 down_x
547 };
548 let index = ((commit_x
549 / tab_width)
550 .floor()
551 as isize)
552 .clamp(
553 0,
554 count as isize - 1,
555 )
556 as usize;
557 lens_axis.release_to(
558 tab_width * index as f32,
559 event.time_ms,
560 LiquidMotion::glide(),
561 );
562 default_haptics().perform(
563 HapticFeedback::ImpactLight,
564 );
565 on_select(index);
566 event.consume();
567 }
568 PointerEventKind::Cancel
569 if active_pointer
570 == Some(event.id) =>
571 {
572 active_pointer = None;
573 lens_pressed.set(false);
574 lens_axis.release_to(
575 resting_lens_x,
576 event.time_ms,
577 LiquidMotion::glide(),
578 );
579 event.consume();
580 }
581 _ => {}
582 }
583 }
584 },
585 )
586 .await;
587 }
588 }
589 });
590 Box(gesture, BoxSpec::default(), || {});
591
592 let published = (lens_x, lens_activity(), tab_width, lens_pose);
596 if lens_x_outer.get() != published {
597 lens_x_outer.set(published);
598 }
599 });
600 });
601
602 let (lens_px, lens_activity, lens_tab_w, pose) = lens_x_outer.get();
611 {
612 let lens_w = lens_tab_w * FLIGHT_LENS_WIDTH_FACTOR;
613 let lens_h = BAR_HEIGHT * FLIGHT_LENS_HEIGHT_FACTOR;
614 let deformation_headroom =
617 crate::dynamics::STRETCH_MAX.max(1.0 / crate::dynamics::STRETCH_MIN);
618 let node_w =
619 lens_w * deformation_headroom + crate::dynamics::BULGE_MAX + 20.0;
620 let node_h =
621 lens_h * deformation_headroom + crate::dynamics::BULGE_MAX + 16.0;
622 let lens_center_x = BLOB_MARGIN + lens_px + lens_tab_w * 0.5;
623 let node_x = lens_center_x - node_w * 0.5;
624 let pill_w = lens_tab_w * count as f32 + 2.0 * BLOB_MARGIN;
625 let accessory_cx =
627 pill_w + tab_bar_accessory_gap(has_accessory) + BAR_HEIGHT * 0.5
628 - node_x;
629 let accessory_cy = node_h * 0.5;
630 let lens = Modifier::empty()
631 .required_size(Size::new(node_w, node_h))
636 .offset(node_x, tab_lens_node_top(node_h))
637 .glass_effect_with(
638 tab_flight_lens_material(colors.label, colors.accent),
639 move || {
640 let (base_w, base_h) =
641 tab_lens_base_size(lens_tab_w, lens_activity);
642 let energy = pose.energy();
643 let radius = base_h * (0.48 + 0.02 * energy);
647 let glue = 20.0;
653 let edge_gap = (accessory_cx - node_w * 0.5).abs()
654 - base_w * pose.stretch.max(pose.ortho) * 0.5
655 - BAR_HEIGHT * 0.5;
656 let shapes = if edge_gap < 10.0 {
659 vec![(
660 accessory_cx,
661 accessory_cy,
662 BAR_HEIGHT,
663 BAR_HEIGHT,
664 -1.0,
665 )]
666 } else {
667 Vec::new()
668 };
669 let (bulge_amplitude, bulge_direction) =
670 tab_flight_launch_wake(pose, base_h).unwrap_or((
671 pose.bulge_amplitude.min(8.0),
672 pose.bulge_direction,
673 ));
674 GlassDynamics {
675 morph: Some(GlassMorph {
676 node_size: (node_w, node_h),
677 primary: (
678 node_w * 0.5,
679 node_h * 0.5,
680 base_w,
681 base_h,
682 radius,
683 ),
684 shapes,
685 glue,
686 wobble_amplitude: 1.1 * energy,
691 wobble_phase: lens_px * 0.045,
692 bulge_amplitude,
693 bulge_direction,
694 ellipse_blend: 0.0,
695 deformation: Some(pose.deformation()),
696 }),
697 surface_depth_boost: tab_flight_depth_boost(
698 lens_activity,
699 energy,
700 ),
701 tint_alpha_multiplier: Some(tab_flight_tint_multiplier(
702 lens_activity,
703 )),
704 optical_strength: 0.18 + 0.67 * lens_activity,
705 ..Default::default()
706 }
707 },
708 );
709 Box(lens, BoxSpec::default(), || {});
710 }
711 },
712 );
713
714 if has_accessory {
715 Box(
716 Modifier::empty().width(tab_bar_accessory_gap(true)),
717 BoxSpec::default(),
718 || {},
719 );
720 (accessory.borrow_mut())();
721 }
722 },
723 );
724}
725
726#[composable]
728#[allow(non_snake_case)]
729pub fn LiquidTabBarSearchAccessory(on_click: impl Fn() + 'static) {
730 crate::widgets::GlassIconButton(
732 Modifier::empty(),
733 crate::widgets::GlassButtonSpec::glass(),
734 BAR_HEIGHT * 0.94,
735 on_click,
736 crate::icons::SEARCH,
737 );
738}
739
740#[cfg(test)]
741mod tests {
742 use super::*;
743
744 #[test]
745 fn drag_pointer_centers_the_lens_and_preserves_end_overdrag() {
746 let width = 100.0;
747 assert_eq!(tab_lens_left(50.0, width, 4), 0.0);
748 assert_eq!(tab_lens_left(250.0, width, 4), 200.0);
749 assert_eq!(tab_lens_left(-100.0, width, 4), -20.0);
750 assert_eq!(tab_lens_left(500.0, width, 4), 355.0);
751 }
752
753 #[test]
754 fn flight_lens_node_is_centered_on_the_bar_axis() {
755 for node_height in [48.0, 64.0, 96.0, 128.0] {
756 let center = tab_lens_node_top(node_height) + node_height * 0.5;
757 assert!((center - BAR_HEIGHT * 0.5).abs() < f32::EPSILON);
758 }
759 }
760
761 #[test]
762 fn liquid_tab_builds_reference_content() {
763 assert_eq!(TAB_ICON_SIZE, 32.0);
764 let tab = LiquidTab::new(crate::icons::STAR, "Discover");
765 assert_eq!(tab.icon, crate::icons::STAR);
766 assert_eq!(tab.label, "Discover");
767 assert_eq!(tab.icon_style, LiquidTabIconStyle::Plain);
768
769 let badge = LiquidTab::app_badge(crate::icons::APPLE, "WWDC");
770 assert_eq!(badge.icon_style, LiquidTabIconStyle::AppBadge);
771
772 let compact = LiquidTab::new(crate::icons::ACCOUNT_CIRCLE, "Account").with_icon_scale(0.72);
773 assert!((compact.icon_scale - 0.72).abs() < f32::EPSILON);
774 assert_eq!(tab.clone().with_icon_scale(f32::NAN).icon_scale, 1.0);
775 assert_eq!(tab.with_icon_scale(2.0).icon_scale, 1.5);
776 }
777
778 #[test]
779 fn direct_drag_selects_the_visual_tab_under_the_lens() {
780 assert_eq!(tab_visual_index(2, 0.0, TAB_WIDTH, 4, false), 2);
781 assert_eq!(tab_visual_index(0, 2.0 * TAB_WIDTH, TAB_WIDTH, 4, true), 2);
782 assert_eq!(tab_visual_index(0, 99.0 * TAB_WIDTH, TAB_WIDTH, 4, true), 3);
783 assert!(tab_lens_owns_visual_selection(
784 false, TAB_WIDTH, 0.0, TAB_WIDTH
785 ));
786 assert!(!tab_lens_owns_visual_selection(
787 false,
788 TAB_WIDTH * 0.05,
789 0.0,
790 TAB_WIDTH
791 ));
792 }
793
794 #[test]
795 fn unified_bar_has_no_detached_accessory_gap() {
796 assert_eq!(tab_bar_accessory_gap(false), 0.0);
797 assert_eq!(tab_bar_accessory_gap(true), 10.0);
798 }
799
800 #[test]
801 fn one_lens_morphs_between_rest_and_full_flight_footprints() {
802 let width = TAB_WIDTH * FLIGHT_LENS_WIDTH_FACTOR;
803 let height = BAR_HEIGHT * FLIGHT_LENS_HEIGHT_FACTOR;
804 assert!(
805 (1.165..=1.175).contains(&(width / TAB_WIDTH)),
806 "the undeformed optic must reach the target width after physical flight stretch: {width}"
807 );
808 assert!(
809 (0.98..=1.02).contains(&(height / BAR_HEIGHT)),
810 "the moving optic shares the target bar's 64dp vertical footprint: {height}"
811 );
812 assert_eq!(
813 tab_lens_base_size(TAB_WIDTH, 0.0),
814 (TAB_WIDTH * 1.08, BLOB_HEIGHT)
815 );
816 assert_eq!(tab_lens_base_size(TAB_WIDTH, 1.0), (width, height));
817 }
818
819 #[test]
820 fn tab_grid_matches_the_reference_pitch() {
821 assert_eq!(TAB_WIDTH, 78.0);
822 }
823
824 #[test]
825 fn tab_grid_matches_the_reference_inner_inset() {
826 assert_eq!(BLOB_MARGIN, 8.0);
827 }
828
829 #[test]
830 fn flight_lens_couples_principal_profiles_into_rounded_side_lobes() {
831 let coupling = flight_surface_profile().axis_coupling();
832 assert!(
833 (0.70..=0.85).contains(&coupling),
834 "the target's rounded side lobes require both principal profiles off-axis; full toric isolation leaves upright icon columns, got {coupling}"
835 );
836 }
837
838 #[test]
839 fn flight_lens_uses_clear_target_range_optics() {
840 let accent = cranpose_ui_graphics::Color::rgb(0.0, 0.48, 1.0);
841 let glass = tab_flight_lens_material(cranpose_ui_graphics::Color::BLACK, accent);
842 assert!(glass
843 .lift
844 .is_some_and(|lift| (-0.02..=0.02).contains(&lift)));
845 assert!((1.1..=1.3).contains(&glass.chromatic_aberration));
846 assert_eq!(glass.blur_radius, Some(2.0));
847 assert!((0.28..=0.36).contains(&glass.highlight));
848 assert!((52.0..=58.0).contains(&glass.displacement));
849 assert!((29.0..=31.0).contains(&glass.surface_profile.depth()));
850 assert_eq!(glass.surface_profile.radial_power(), 1.5);
851 assert_eq!(glass.surface_profile.axis_coupling(), 0.78);
852 assert!(
853 glass.shadow,
854 "the moving lens needs its target-visible SDF contact outline"
855 );
856 assert!(glass
857 .tint
858 .is_some_and(|tint| { tint.r() < 0.05 && (0.075..=0.085).contains(&tint.a()) }));
859 assert!(glass
860 .sheen
861 .is_some_and(|sheen| (0.08..=0.16).contains(&sheen)));
862 assert!(glass.content_recolor.is_some_and(|(color, strength)| {
863 color == accent && (0.95..=1.0).contains(&strength)
864 }));
865 assert!(
866 (-9.0..=-5.0).contains(
867 &(glass.surface_profile.y_profile().evaluate(0.94).1
868 * glass.surface_profile.depth())
869 ),
870 "the short-axis outer return must form the target's deep crown ridge"
871 );
872 let depth = glass.surface_profile.depth();
873 let x_return_inner = glass.surface_profile.x_profile().evaluate(0.453).1 * depth;
874 let x_return_mid = glass.surface_profile.x_profile().evaluate(0.705).1 * depth;
875 let y_inner_shoulder = glass.surface_profile.y_profile().evaluate(0.22).1 * depth;
876 let y_shoulder = glass.surface_profile.y_profile().evaluate(0.65).1 * depth;
877 let y_outer = glass.surface_profile.y_profile().evaluate(0.90).1 * depth;
878 assert!(
879 (-19.0..=-15.0).contains(&x_return_inner)
880 && (-15.0..=-11.0).contains(&x_return_mid)
881 && y_inner_shoulder.abs() <= 0.5
882 && y_shoulder > 0.0
883 && (-9.0..=-5.0).contains(&y_outer),
884 "the long-axis return must allocate broad side lobes while the short axis magnifies through its inner shoulder and keeps its crown return: x=({x_return_inner}, {x_return_mid}), y=({y_inner_shoulder}, {y_shoulder}, {y_outer})"
885 );
886 assert_eq!(
887 glass
888 .surface_profile
889 .y_profile()
890 .knots()
891 .last()
892 .map(|knot| knot.height()),
893 Some(0.51)
894 );
895 assert_eq!(glass.surface_profile, flight_surface_profile());
896 let curve = glass.surface_profile.x_profile();
897 let epsilon = 0.001;
898 let center_curvature = (curve.evaluate(epsilon).1 - curve.evaluate(0.0).1) / epsilon;
899 let half_width = TAB_WIDTH * FLIGHT_LENS_WIDTH_FACTOR * 0.5;
900 let bend = 1.0 - 1.0 / 1.5;
901 let active_optical_strength = 0.18 + 0.67;
902 let source_scale = 1.0
903 - center_curvature
904 * glass.surface_profile.depth()
905 * bend
906 * glass.displacement
907 * active_optical_strength
908 / (half_width * half_width);
909 let magnification = 1.0 / source_scale;
910 assert!(
911 (1.20..=1.35).contains(&magnification),
912 "the authored surface must physically magnify the tab content, got {magnification}x"
913 );
914 let moving_depth = glass.surface_profile.depth() * (1.0 + tab_flight_depth_boost(1.0, 1.0));
915 let flight_strain = 1.07;
916 let moving_half_width = half_width * flight_strain;
917 let moving_source_scale = 1.0
918 - center_curvature * moving_depth * bend * glass.displacement * active_optical_strength
919 / (moving_half_width * moving_half_width);
920 let moving_magnification = 1.0 / moving_source_scale;
921 assert!(
922 (1.15..=1.35).contains(&moving_magnification),
923 "the fully resolved moving optic must stay in the measured target range, got {moving_magnification}x"
924 );
925 let horizontal_mapping = |normalized_x: f32| {
926 normalized_x
927 - curve.evaluate(normalized_x).1
928 * moving_depth
929 * bend
930 * glass.displacement
931 * active_optical_strength
932 / (moving_half_width * moving_half_width)
933 };
934 assert!(
935 (0.58..=0.61).contains(&horizontal_mapping(0.466))
936 && (0.79..=0.82).contains(&horizontal_mapping(0.705)),
937 "the long-axis return must map the source badge into the target lobe bounds: inner={}, mid={}",
938 horizontal_mapping(0.466),
939 horizontal_mapping(0.705)
940 );
941 for step in 1..=49 {
942 let normalized_x = step as f32 * 0.02;
943 let source_scale = (horizontal_mapping(normalized_x + epsilon)
944 - horizontal_mapping(normalized_x - epsilon))
945 / (2.0 * epsilon);
946 assert!(
947 source_scale > 0.05,
948 "the full target lens must remain a single monotonic image at x={normalized_x}, got {source_scale}"
949 );
950 }
951 let vertical_mapping = |normalized_y: f32| {
952 let gradient = glass
953 .surface_profile
954 .sample_normalized((0.68, normalized_y))
955 .gradient
956 .1;
957 let half_height = BAR_HEIGHT * FLIGHT_LENS_HEIGHT_FACTOR * 0.5;
958 let normalized_gain =
959 moving_depth * glass.displacement * bend * active_optical_strength
960 / (half_height * half_height);
961 normalized_y - gradient * normalized_gain
962 };
963 let vertical_source_scale =
964 (vertical_mapping(0.22 + epsilon) - vertical_mapping(0.22 - epsilon)) / (2.0 * epsilon);
965 assert!(
966 (0.95..=1.05).contains(&vertical_source_scale),
967 "the side-band vertical Jacobian must preserve the target glyph's native proportions, got {vertical_source_scale}"
968 );
969 assert!(
970 (0.49..=0.51).contains(&vertical_mapping(0.58)),
971 "the outer Y-Z zone must expand the source badge into the target lobe height, got {}",
972 vertical_mapping(0.58)
973 );
974 for step in 1..=35 {
975 let normalized_y = step as f32 * 0.02;
976 let source_scale = (vertical_mapping(normalized_y + epsilon)
977 - vertical_mapping(normalized_y - epsilon))
978 / (2.0 * epsilon);
979 assert!(
980 source_scale > 0.05,
981 "the visible biconic interior must stay fold-free at y={normalized_y}, got {source_scale}"
982 );
983 }
984 assert_eq!(tab_flight_depth_boost(0.0, 1.0), 0.0);
985 let still = tab_flight_depth_boost(1.0, 0.0);
986 let cruise = tab_flight_depth_boost(1.0, 1.0);
987 assert_eq!(still, 0.0);
988 assert!((0.11..=0.13).contains(&cruise));
989 }
990
991 #[test]
992 fn flight_lens_retains_neutral_tint_through_direct_motion() {
993 assert_eq!(tab_flight_tint_multiplier(0.0), 1.0);
994 assert!((tab_flight_tint_multiplier(1.0) - 0.4).abs() < f32::EPSILON);
995 assert_eq!(tab_flight_tint_multiplier(-1.0), 1.0);
996 assert!((tab_flight_tint_multiplier(2.0) - 0.4).abs() < f32::EPSILON);
997 }
998
999 #[test]
1000 fn bar_surface_frosts_color_inside_a_bright_folded_body() {
1001 let glass = tab_bar_surface_material(cranpose_ui_graphics::Color::BLACK);
1002 assert_eq!(glass.blur_radius, Some(16.0));
1003 assert_eq!(glass.saturation, Some(0.95));
1004 assert_eq!(glass.lift, Some(0.48));
1005 assert_eq!(glass.edge_fold, 1.0);
1006 assert!((2.8..=3.2).contains(&glass.surface_profile.depth()));
1007 }
1008
1009 #[test]
1010 fn bar_surface_tint_separates_from_same_polarity_backdrops() {
1011 let light_surface = tab_bar_surface_material(cranpose_ui_graphics::Color::BLACK)
1012 .tint
1013 .expect("bar tint");
1014 assert!(light_surface.r() < 0.05);
1015 assert!((0.02..=0.04).contains(&light_surface.a()));
1016
1017 let dark_surface = tab_bar_surface_material(cranpose_ui_graphics::Color::WHITE)
1018 .tint
1019 .expect("bar tint");
1020 assert!(dark_surface.r() > 0.95);
1021 assert!((0.03..=0.05).contains(&dark_surface.a()));
1022 }
1023
1024 #[test]
1025 fn arrival_contraction_uses_the_measured_fast_settle() {
1026 let cranpose_animation::AnimationType::Spring(settle) = tab_lens_activity_motion(false)
1027 else {
1028 panic!("arrival contraction must use a spring");
1029 };
1030 assert_eq!(settle.damping_ratio, 1.0);
1031 assert!((1300.0..=1500.0).contains(&settle.stiffness));
1032 }
1033
1034 #[test]
1035 fn launch_inertia_drives_one_trailing_perimeter_lobe() {
1036 let launch = crate::dynamics::LiquidPose {
1037 stretch: 0.84,
1038 ortho: 1.0 / 0.84,
1039 axis: (-1.0, 0.0),
1040 ..Default::default()
1041 };
1042 let (amplitude, direction) = tab_flight_launch_wake(launch, 72.0)
1043 .expect("accelerating fluid needs a trailing inertia lobe");
1044 assert!((3.0..=7.0).contains(&litude));
1045 assert!(
1046 direction.abs() < 1e-4,
1047 "leftward flight must trail to the right"
1048 );
1049
1050 let cruise = crate::dynamics::LiquidPose {
1051 stretch: 1.02,
1052 ortho: 1.0 / 1.02,
1053 axis: (-1.0, 0.0),
1054 ..Default::default()
1055 };
1056 assert!(tab_flight_launch_wake(cruise, 72.0).is_none());
1057 }
1058}