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