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