1use std::{cell::RefCell, rc::Rc};
2
3use cranpose_macros::composable;
4use cranpose_ui::{
5 Brush, Color, CornerRadii, Modifier, PointerInputScope, Rect, SemanticsWidgetRole, Size,
6 text::{FontWeight, SpanStyle, TextStyle},
7 widgets::{
8 Box, BoxSpec, BoxWithConstraints, BoxWithConstraintsScope, Column, ColumnSpec, Row,
9 RowSpec, Text,
10 },
11};
12use cranpose_ui_layout::{Alignment, HorizontalAlignment, VerticalAlignment};
13
14use crate::{
15 material::{
16 Glass, GlassDynamics, GlassMorph, GlassShadow, LiquidModifierExt, neutral_surface_lift,
17 neutral_surface_tint,
18 },
19 motion::LiquidMotion,
20 theme::{LiquidTypography, liquid_colors, liquid_typography},
21 widgets::content_scope::ScopeContent,
22};
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 = normalize_icon_scale(scale);
64 self
65 }
66}
67
68fn normalize_icon_scale(scale: f32) -> f32 {
69 if scale.is_finite() {
70 scale.clamp(0.5, 1.5)
71 } else {
72 1.0
73 }
74}
75
76fn tab_base_content_color(colors: crate::theme::LiquidColors) -> Color {
77 colors.label
78}
79
80fn tab_selection_content_color(colors: crate::theme::LiquidColors) -> Color {
81 colors.accent
82}
83
84const BAR_HEIGHT: f32 = 64.0;
85const BLOB_HEIGHT: f32 = 56.0;
86const BLOB_MARGIN: f32 = 4.0;
87const FLIGHT_LENS_HEIGHT_PROJECTION: f32 = 68.0 / BLOB_HEIGHT;
88const FLIGHT_ELLIPSE_BLEND: f32 = 0.25;
89const TAB_LENS_REST_WIDTH_FACTOR: f32 = 1.10;
90const TAB_STRAIN_RESPONSE: f32 = 0.30;
91const TAB_WIDTH: f32 = 78.0;
92const TAB_ICON_SIZE: f32 = 32.0;
93const TAB_LABEL_SIZE: f32 = 11.0;
94const TAP_SLOP: f32 = 6.0;
95const ACCESSORY_GAP: f32 = 10.0;
96
97#[derive(Clone, Copy, Debug, PartialEq)]
99pub struct LiquidTabBarSpec {
100 max_tab_width: f32,
101}
102
103impl LiquidTabBarSpec {
104 pub fn new(max_tab_width: f32) -> Self {
105 Self {
106 max_tab_width: if max_tab_width.is_finite() {
107 max_tab_width.max(1.0)
108 } else {
109 TAB_WIDTH
110 },
111 }
112 }
113}
114
115impl Default for LiquidTabBarSpec {
116 fn default() -> Self {
117 Self::new(TAB_WIDTH)
118 }
119}
120
121fn tab_flight_lens_material(foreground: cranpose_ui_graphics::Color, accent: Color) -> Glass {
122 Glass::lens()
123 .no_clip()
124 .tint(neutral_surface_tint(foreground, 0.06, 0.05))
125 .ink_recolor(accent, 0.85)
126 .blur_radius(0.0)
127 .refraction_depth(1.0)
128 .refraction_curve(0.25)
129 .optical_zoom(1.22)
130 .fold_depth(2.5)
131 .dispersion(0.9)
132 .lift(0.05)
133 .highlight(0.18)
134 .shadow_style(GlassShadow::new(
135 cranpose_ui_graphics::Color::BLACK.with_alpha(0.14),
136 12.0,
137 4.0,
138 -2.0,
139 ))
140}
141
142fn tab_bar_surface_material(foreground: cranpose_ui_graphics::Color) -> Glass {
143 Glass::regular()
144 .tint(neutral_surface_tint(foreground, 0.0, 0.04))
145 .blur_radius(9.0)
146 .saturation(1.15)
147 .lift(neutral_surface_lift(foreground, 0.60, -0.24))
148 .highlight(0.20)
149 .fold_depth(8.0)
150 .adaptive_frost(foreground, 0.28)
151}
152
153fn tab_flight_tint_multiplier(activity: f32) -> f32 {
154 1.0 - 0.25 * activity.clamp(0.0, 1.0)
155}
156
157fn tab_lens_activity_motion(raised: bool) -> cranpose_animation::AnimationType {
158 if raised {
159 cranpose_animation::spring(0.9, 1400.0)
160 } else {
161 cranpose_animation::spring(1.0, 900.0)
162 }
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
176pub fn tab_lens_resting_left(selected: usize, tab_width: f32, count: usize) -> f32 {
183 tab_width * selected.min(count.saturating_sub(1)) as f32
184}
185
186pub fn tab_lens_rest_width(tab_width: f32) -> f32 {
196 let overhang = (tab_width * (TAB_LENS_REST_WIDTH_FACTOR - 1.0) * 0.5).min(BLOB_MARGIN);
197 tab_width + 2.0 * overhang
198}
199
200#[derive(Clone, Copy, Debug, PartialEq)]
201struct AppBadgeGeometry {
202 size: Size,
203 corner_radius: f32,
204 stripe: Rect,
205 glyph: Rect,
206}
207
208fn app_badge_geometry(optical_scale: f32) -> AppBadgeGeometry {
209 let scale = normalize_icon_scale(optical_scale);
210 AppBadgeGeometry {
211 size: Size::new(20.0 * scale, 32.0 * scale),
212 corner_radius: 5.0 * scale,
213 stripe: Rect {
214 x: 7.0 * scale,
215 y: 4.5 * scale,
216 width: 6.0 * scale,
217 height: 1.5 * scale,
218 },
219 glyph: Rect {
220 x: 3.0 * scale,
221 y: 10.0 * scale,
222 width: 14.0 * scale,
223 height: 14.0 * scale,
224 },
225 }
226}
227
228#[composable]
229#[allow(non_snake_case)]
230fn TabIcon(icon: &'static str, style: LiquidTabIconStyle, color: Color, optical_scale: f32) {
231 const FRAME_HEIGHT: f32 = 32.0;
232 Box(
233 Modifier::empty().size(Size::new(TAB_ICON_SIZE, FRAME_HEIGHT)),
234 BoxSpec::default().content_alignment(Alignment::CENTER),
235 move || match style {
236 LiquidTabIconStyle::Plain => {
237 crate::icons::Icon(icon, TAB_ICON_SIZE * optical_scale, color)
238 }
239 LiquidTabIconStyle::AppBadge => {
240 let geometry = app_badge_geometry(optical_scale);
241 Box(
242 Modifier::empty()
243 .size(geometry.size)
244 .draw_behind(move |scope| {
245 scope.draw_round_rect(
246 Brush::solid(color),
247 CornerRadii::uniform(geometry.corner_radius),
248 );
249 scope.draw_rect_at(geometry.stripe, Brush::solid(Color::WHITE));
250 }),
251 BoxSpec::default(),
252 move || {
253 Box(
254 Modifier::empty()
255 .offset(geometry.glyph.x, geometry.glyph.y)
256 .size(Size::new(geometry.glyph.width, geometry.glyph.height)),
257 BoxSpec::default(),
258 move || crate::icons::Icon(icon, geometry.glyph.width, Color::WHITE),
259 );
260 },
261 );
262 }
263 },
264 );
265}
266
267#[derive(Clone, Copy, PartialEq)]
268struct TabCellsSpec {
269 base_color: Color,
270 selected: Option<usize>,
271 selected_color: Color,
272 interactive: bool,
273 selection_only: bool,
274}
275
276fn tab_cell_is_visible(index: usize, selected: Option<usize>, selection_only: bool) -> bool {
277 !selection_only || selected == Some(index)
278}
279
280#[composable]
281#[allow(non_snake_case)]
282fn TabCells(
283 modifier: Modifier,
284 tabs: Rc<Vec<LiquidTab>>,
285 typography: LiquidTypography,
286 tab_width: f32,
287 spec: TabCellsSpec,
288) {
289 Row(modifier, RowSpec::default(), move || {
290 for (index, tab) in tabs.iter().enumerate() {
291 let visible = tab_cell_is_visible(index, spec.selected, spec.selection_only);
292 let color = if spec.selected == Some(index) {
293 spec.selected_color
294 } else {
295 spec.base_color
296 };
297 let label_for_semantics = tab.label;
298 let mut cell = Modifier::empty().size(Size::new(tab_width, BLOB_HEIGHT));
299 if spec.interactive {
300 cell = cell.semantics(move |config| {
301 config.role = Some(SemanticsWidgetRole::Button);
302 config.is_clickable = true;
303 config.content_description = Some(label_for_semantics.to_string());
304 });
305 }
306 let icon = tab.icon;
307 let icon_style = tab.icon_style;
308 let icon_scale = tab.icon_scale;
309 let label = tab.label;
310 let label_style = TextStyle {
311 span_style: SpanStyle {
312 color: Some(color),
313 font_size: cranpose_ui::text::TextUnit::Sp(TAB_LABEL_SIZE),
314 font_weight: Some(FontWeight::MEDIUM),
315 ..typography.caption1.span_style.clone()
316 },
317 ..typography.caption1.clone()
318 };
319 Box(
320 cell,
321 BoxSpec::default().content_alignment(Alignment::CENTER),
322 move || {
323 if !visible {
324 return;
325 }
326 let label_style = label_style.clone();
327 Column(
328 Modifier::empty(),
329 ColumnSpec::default()
330 .horizontal_alignment(HorizontalAlignment::CenterHorizontally),
331 move || {
332 TabIcon(icon, icon_style, color, icon_scale);
333 Text(label, Modifier::empty(), label_style.clone());
334 },
335 );
336 },
337 );
338 }
339 });
340}
341
342fn tab_lens_node_top(node_height: f32) -> f32 {
343 (BAR_HEIGHT - node_height) * 0.5
344}
345
346fn tab_bar_accessory_gap(has_accessory: bool) -> f32 {
347 if has_accessory { ACCESSORY_GAP } else { 0.0 }
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_lens_rest_width(tab_width);
358 let projection = 1.0 + (FLIGHT_LENS_HEIGHT_PROJECTION - 1.0) * ease;
359 (rest_width, 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 effective_stretch =
388 1.0 + (geometry.pose.stretch.max(geometry.pose.ortho) - 1.0) * TAB_STRAIN_RESPONSE;
389 let edge_gap = (*x - geometry.center.0).abs()
390 - geometry.base_size.width * effective_stretch * 0.5
391 - BAR_HEIGHT * 0.5;
392 accessory_surfaces_touch(edge_gap)
393 })
394 .map(|(x, y)| {
395 vec![(
396 x - node.origin.0,
397 y - node.origin.1,
398 BAR_HEIGHT,
399 BAR_HEIGHT,
400 -1.0,
401 )]
402 })
403 .unwrap_or_default();
404 GlassDynamics {
405 morph: Some(GlassMorph {
406 node_size: (node.size.width, node.size.height),
407 primary: (
408 geometry.center.0 - node.origin.0,
409 geometry.center.1 - node.origin.1,
410 geometry.base_size.width,
411 geometry.base_size.height,
412 radius,
413 ),
414 shapes,
415 glue,
416 wobble_amplitude: 0.5 * energy,
417 wobble_phase: geometry.lens_position * 0.045,
418 bulge_amplitude: geometry.pose.bulge_amplitude.min(8.0) * activity,
419 bulge_direction: geometry.pose.bulge_direction,
420 ellipse_blend: FLIGHT_ELLIPSE_BLEND,
421 deformation: Some(crate::material::GlassDeformation::incompressible(
422 geometry.pose.axis,
423 1.0 + (geometry.pose.stretch - 1.0) * activity * TAB_STRAIN_RESPONSE,
424 )),
425 zoom_anchor: (0.0, 0.0),
426 }),
427 activity: Some(activity),
428 press_depth: Some(0.3 + 0.7 * activity),
429 resting_tint: Some(geometry.resting_tint),
430 tint_alpha_multiplier: Some(tab_flight_tint_multiplier(geometry.lens_activity)),
431 ..Default::default()
432 }
433}
434
435pub struct LiquidTabBarScope {
440 tabs: ScopeContent<LiquidTab>,
441}
442
443impl LiquidTabBarScope {
444 pub fn tab(&self, icon: &'static str, label: &'static str) {
446 self.push(LiquidTab::new(icon, label));
447 }
448
449 pub fn app_badge(&self, icon: &'static str, label: &'static str) {
451 self.push(LiquidTab::app_badge(icon, label));
452 }
453
454 pub fn push(&self, tab: LiquidTab) {
457 self.tabs.push(tab);
458 }
459}
460
461fn collect_tabs(content: impl FnOnce(&LiquidTabBarScope)) -> Vec<LiquidTab> {
462 ScopeContent::collect(|tabs| LiquidTabBarScope { tabs }, content)
463}
464
465#[composable]
474#[allow(non_snake_case)]
475pub fn LiquidTabBar(
476 modifier: Modifier,
477 spec: LiquidTabBarSpec,
478 selected: usize,
479 on_select: impl Fn(usize) + 'static,
480 content: impl FnOnce(&LiquidTabBarScope),
481) {
482 LiquidTabBarLayout(
483 modifier,
484 spec,
485 collect_tabs(content),
486 selected,
487 on_select,
488 false,
489 || {},
490 );
491}
492
493#[composable]
495#[allow(non_snake_case)]
496pub fn LiquidTabBarWithAccessory(
497 modifier: Modifier,
498 spec: LiquidTabBarSpec,
499 selected: usize,
500 on_select: impl Fn(usize) + 'static,
501 content: impl FnOnce(&LiquidTabBarScope),
502 accessory: impl FnMut() + 'static,
503) {
504 LiquidTabBarLayout(
505 modifier,
506 spec,
507 collect_tabs(content),
508 selected,
509 on_select,
510 true,
511 accessory,
512 );
513}
514
515#[composable]
516#[allow(non_snake_case)]
517fn LiquidTabBarLayout(
518 modifier: Modifier,
519 spec: LiquidTabBarSpec,
520 tabs: Vec<LiquidTab>,
521 selected: usize,
522 on_select: impl Fn(usize) + 'static,
523 has_accessory: bool,
524 accessory: impl FnMut() + 'static,
525) {
526 let colors = liquid_colors();
527 let typography = liquid_typography();
528 let count = tabs.len().max(1);
529 let selected = selected.min(count - 1);
530 let on_select: Rc<dyn Fn(usize)> = Rc::new(on_select);
531 let tabs = Rc::new(tabs);
532 let accessory = Rc::new(RefCell::new(accessory));
533
534 Row(
535 modifier,
536 RowSpec::default().vertical_alignment(VerticalAlignment::CenterVertically),
537 move || {
538 let tabs = Rc::clone(&tabs);
539 let typography = typography.clone();
540 let on_select = Rc::clone(&on_select);
541 let accessory = Rc::clone(&accessory);
542
543 let lens_x_outer = cranpose_core::remember(|| {
544 cranpose_core::mutableStateOf((
545 0.0f32,
546 0.0f32,
547 0.0f32,
548 crate::dynamics::LiquidPose::default(),
549 ))
550 })
551 .with(|state| *state);
552 let bar_touch =
553 cranpose_core::remember(|| cranpose_core::mutableStateOf((0.0f32, 0.0f32)))
554 .with(|state| *state);
555 let bar_held = cranpose_core::remember(|| cranpose_core::mutableStateOf(false))
556 .with(|state| *state);
557 let bar_press = cranpose_animation::animateFloatAsState(
558 if bar_held.get() { 1.0 } else { 0.0 },
559 cranpose_animation::spring(1.0, 600.0),
560 "tabbar-hold-press",
561 );
562 let bar_lift = Modifier::empty().graphics_layer(move || {
563 let press = bar_press.get().clamp(0.0, 1.0);
564 let rise = 1.0 + 0.03 * press;
565 cranpose_ui_graphics::GraphicsLayer {
566 scale_x: rise,
567 scale_y: rise,
568 translation_y: -2.5 * press,
569 ..Default::default()
570 }
571 });
572 Box(
573 bar_lift.then(Modifier::empty().height(BAR_HEIGHT)),
574 BoxSpec::default(),
575 move || {
576 let tabs = Rc::clone(&tabs);
577 let typography = typography.clone();
578 let on_select = Rc::clone(&on_select);
579 let pill = Modifier::empty()
580 .glass_effect_with(tab_bar_surface_material(colors.label), move || {
581 let press = bar_press.get().clamp(0.0, 1.0);
582 let (touch_x, touch_y) = bar_touch.get();
583 GlassDynamics {
584 highlight_boost: 0.45 * press,
585 saturation_boost: 0.12 * press,
586 touch: (press > 0.01).then_some((touch_x, touch_y, press)),
587 press_depth: Some(press),
588 ..Default::default()
589 }
590 })
591 .height(BAR_HEIGHT);
592 Box(pill, BoxSpec::default(), move || {
593 let tabs = Rc::clone(&tabs);
594 let typography = typography.clone();
595 let on_select = Rc::clone(&on_select);
596 BoxWithConstraints(Modifier::empty().padding(BLOB_MARGIN), move |scope| {
597 let tabs = Rc::clone(&tabs);
598 let typography = typography.clone();
599 let on_select = Rc::clone(&on_select);
600 let constrained = scope.constraints().max_width;
601 let tab_width = if constrained.is_finite() && constrained > 1.0 {
602 (constrained / count as f32).min(spec.max_tab_width)
603 } else {
604 spec.max_tab_width
605 };
606
607 let lens_pressed =
608 cranpose_core::remember(|| cranpose_core::mutableStateOf(false))
609 .with(|state| *state);
610 let resting_lens_x = tab_lens_resting_left(selected, tab_width, count);
611 let lens_axis =
612 crate::motion::remember_liquid_drag_axis(resting_lens_x);
613 if !lens_pressed.get() {
614 lens_axis.settle_to(resting_lens_x, LiquidMotion::glide());
615 }
616 let lens_x = lens_axis.value();
617 let lens_pose = lens_axis.liquid_pose();
618 let lens_in_flight = !lens_axis.is_dragging()
619 && (lens_x - resting_lens_x).abs() > tab_width * 0.15;
620 let lens_raised = lens_pressed.get() || lens_in_flight;
621 let lens_activity_target = if lens_raised { 1.0 } else { 0.0 };
622 let lens_activity_anim = cranpose_animation::animateFloatAsState(
623 lens_activity_target,
624 tab_lens_activity_motion(lens_raised),
625 "tabbar-lens-activity",
626 );
627 let lens_activity = lens_activity_anim.get();
628 let visual_index = crate::motion::liquid_visual_index(
629 selected,
630 lens_x,
631 tab_width,
632 count,
633 crate::motion::liquid_axis_owns_visual_selection(
634 lens_pressed.get(),
635 lens_x,
636 resting_lens_x,
637 tab_width,
638 ),
639 );
640 TabCells(
641 Modifier::empty(),
642 Rc::clone(&tabs),
643 typography.clone(),
644 tab_width,
645 TabCellsSpec {
646 base_color: tab_base_content_color(colors),
647 selected: Some(visual_index),
648 selected_color: tab_selection_content_color(colors),
649 interactive: true,
650 selection_only: false,
651 },
652 );
653
654 let row_width = tab_width * count as f32;
655 let gesture = Modifier::empty()
656 .size(Size::new(row_width, BLOB_HEIGHT))
657 .pointer_input(selected, {
658 let on_select = Rc::clone(&on_select);
659 let lens_axis = Rc::clone(&lens_axis);
660 move |scope: PointerInputScope| {
661 let on_select = Rc::clone(&on_select);
662 let lens_axis = Rc::clone(&lens_axis);
663 crate::motion::liquid_lens_gesture(
664 scope,
665 crate::motion::LiquidLensGesture {
666 axis: lens_axis,
667 cell_width: tab_width,
668 count,
669 tap_slop: TAP_SLOP,
670 drag_left: Rc::new(move |x| {
671 tab_lens_left(
672 x,
673 tab_width,
674 count,
675 has_accessory,
676 )
677 }),
678 rest_left: Rc::new(move |index| {
679 tab_lens_resting_left(index, tab_width, count)
680 }),
681 selected,
682 on_pressed: Rc::new(move |down| {
683 lens_pressed.set(down);
684 bar_held.set(down);
685 }),
686 on_touch: Rc::new(move |x, y| {
687 bar_touch
688 .set((x + BLOB_MARGIN, y + BLOB_MARGIN));
689 }),
690 on_select,
691 },
692 )
693 }
694 });
695 Box(gesture, BoxSpec::default(), || {});
696
697 let published = (lens_x, lens_activity, tab_width, lens_pose);
698 if lens_x_outer.get() != published {
699 lens_x_outer.set(published);
700 }
701 });
702 });
703
704 let (lens_px, lens_activity, lens_tab_w, pose) = lens_x_outer.get();
705 let lens_w = tab_lens_rest_width(lens_tab_w);
706 let lens_h = BLOB_HEIGHT * FLIGHT_LENS_HEIGHT_PROJECTION;
707 let deformation_headroom =
708 crate::dynamics::STRETCH_MAX.max(1.0 / crate::dynamics::STRETCH_MIN);
709 let node_w = lens_w * deformation_headroom + crate::dynamics::BULGE_MAX + 20.0;
710 let node_h = lens_h * deformation_headroom + crate::dynamics::BULGE_MAX + 16.0;
711 let lens_center_x = BLOB_MARGIN + lens_px + lens_tab_w * 0.5;
712 let node_x = lens_center_x - node_w * 0.5;
713 let node_top = tab_lens_node_top(node_h);
714 let pill_w = lens_tab_w * count as f32 + 2.0 * BLOB_MARGIN;
715 let (base_w, base_h) = tab_lens_base_size(lens_tab_w, lens_activity);
716 let geometry = TabFlightGeometry {
717 center: (lens_center_x, BAR_HEIGHT * 0.5),
718 base_size: Size::new(base_w, base_h),
719 pose,
720 lens_position: lens_px,
721 lens_activity,
722 resting_tint: colors.fill,
723 accessory_center: has_accessory.then_some((
724 pill_w + tab_bar_accessory_gap(true) + BAR_HEIGHT * 0.5,
725 BAR_HEIGHT * 0.5,
726 )),
727 };
728 let lens_node = TabFlightNode {
729 origin: (node_x, node_top),
730 size: Size::new(node_w, node_h),
731 };
732
733 let lens_geometry = geometry;
734 let lens = Modifier::empty()
735 .required_size(lens_node.size)
736 .offset(node_x, node_top)
737 .glass_effect_with(
738 tab_flight_lens_material(
739 colors.label,
740 tab_selection_content_color(colors),
741 ),
742 move || tab_flight_dynamics(lens_geometry, lens_node),
743 );
744 Box(lens, BoxSpec::default(), || {});
745 },
746 );
747
748 if has_accessory {
749 Box(
750 Modifier::empty().width(tab_bar_accessory_gap(true)),
751 BoxSpec::default(),
752 || {},
753 );
754 (accessory.borrow_mut())();
755 }
756 },
757 );
758}
759
760#[composable]
762#[allow(non_snake_case)]
763pub fn LiquidTabBarSearchAccessory(on_click: impl Fn() + 'static) {
764 crate::widgets::GlassIconButton(
765 Modifier::empty(),
766 crate::widgets::GlassButtonSpec::glass(),
767 BAR_HEIGHT * 0.94,
768 on_click,
769 crate::icons::SEARCH,
770 );
771}
772
773#[cfg(test)]
774mod tests {
775 use super::*;
776
777 #[test]
778 fn tab_bar_spec_normalizes_the_maximum_cell_width() {
779 assert_eq!(LiquidTabBarSpec::default().max_tab_width, TAB_WIDTH);
780 assert_eq!(LiquidTabBarSpec::new(85.0).max_tab_width, 85.0);
781 assert_eq!(LiquidTabBarSpec::new(0.0).max_tab_width, 1.0);
782 assert_eq!(LiquidTabBarSpec::new(f32::NAN).max_tab_width, TAB_WIDTH);
783 }
784
785 #[test]
786 fn drag_pointer_centers_the_lens_and_preserves_end_overdrag() {
787 let width = 100.0;
788 assert_eq!(tab_lens_left(50.0, width, 4, true), 0.0);
789 assert_eq!(tab_lens_left(250.0, width, 4, true), 200.0);
790 assert_eq!(tab_lens_left(-100.0, width, 4, true), -20.0);
791 assert_eq!(tab_lens_left(500.0, width, 4, true), 355.0);
792
793 assert_eq!(tab_lens_left(-100.0, width, 4, false), 0.0);
794 assert_eq!(tab_lens_left(500.0, width, 4, false), 300.0);
795 }
796
797 #[test]
798 fn resting_lens_centers_on_its_cell_and_stays_inside_the_pill() {
799 let tab = 78.0;
800 assert_eq!(tab_lens_resting_left(0, tab, 5), 0.0);
801 assert_eq!(tab_lens_resting_left(1, tab, 5), tab);
802 assert_eq!(tab_lens_resting_left(3, tab, 5), 3.0 * tab);
803 assert_eq!(tab_lens_resting_left(4, tab, 5), 4.0 * tab);
804 assert_eq!(tab_lens_resting_left(9, tab, 5), 4.0 * tab);
805 let overhang = (tab_lens_rest_width(tab) - tab) * 0.5;
806 assert!(overhang <= BLOB_MARGIN + 1.0e-4);
807 }
808
809 #[test]
810 fn flight_lens_node_is_centered_on_the_bar_axis() {
811 for node_height in [48.0, 64.0, 96.0, 128.0] {
812 let center = tab_lens_node_top(node_height) + node_height * 0.5;
813 assert!((center - BAR_HEIGHT * 0.5).abs() < f32::EPSILON);
814 }
815 }
816
817 #[test]
818 fn liquid_tab_builds_reference_content() {
819 assert_eq!(TAB_ICON_SIZE, 32.0);
820 let tab = LiquidTab::new(crate::icons::STAR, "Discover");
821 assert_eq!(tab.icon, crate::icons::STAR);
822 assert_eq!(tab.label, "Discover");
823 assert_eq!(tab.icon_style, LiquidTabIconStyle::Plain);
824
825 let badge = LiquidTab::app_badge(crate::icons::APPLE, "WWDC");
826 assert_eq!(badge.icon_style, LiquidTabIconStyle::AppBadge);
827
828 let compact = LiquidTab::new(crate::icons::ACCOUNT_CIRCLE, "Account").with_icon_scale(0.72);
829 assert!((compact.icon_scale - 0.72).abs() < f32::EPSILON);
830 assert_eq!(tab.clone().with_icon_scale(f32::NAN).icon_scale, 1.0);
831 assert_eq!(tab.with_icon_scale(2.0).icon_scale, 1.5);
832 }
833
834 #[test]
835 fn app_badge_geometry_honors_the_tab_optical_scale() {
836 let full = app_badge_geometry(1.0);
837 let corrected = app_badge_geometry(0.85);
838 assert_eq!(full.size, Size::new(20.0, 32.0));
839 assert_eq!(corrected.size, Size::new(17.0, 27.2));
840 assert!((corrected.glyph.width - 11.9).abs() < 1.0e-5);
841 assert!((corrected.corner_radius / full.corner_radius - 0.85).abs() < f32::EPSILON);
842 assert!((corrected.stripe.x / full.stripe.x - 0.85).abs() < f32::EPSILON);
843 assert!((corrected.glyph.width / full.glyph.width - 0.85).abs() < f32::EPSILON);
844 }
845
846 #[test]
847 fn base_tab_content_remains_neutral_under_the_moving_selection_layer() {
848 let colors = crate::theme::LiquidColors::light(cranpose_ui_graphics::Color::from_rgb_u8(
849 0, 122, 255,
850 ));
851 assert_eq!(tab_base_content_color(colors), colors.label);
852 assert_eq!(tab_selection_content_color(colors), colors.accent);
853 }
854
855 #[test]
856 fn selection_mask_and_lens_resolve_the_same_global_sdf() {
857 let geometry = TabFlightGeometry {
858 center: (212.0, 32.0),
859 base_size: Size::new(106.0, 64.0),
860 pose: crate::dynamics::LiquidPose::default(),
861 lens_position: 160.0,
862 lens_activity: 1.0,
863 resting_tint: Color::BLACK.with_alpha(0.10),
864 accessory_center: None,
865 };
866 let mask_node = TabFlightNode {
867 origin: (0.0, 0.0),
868 size: Size::new(328.0, 64.0),
869 };
870 let lens_node = TabFlightNode {
871 origin: (132.0, -22.0),
872 size: Size::new(160.0, 108.0),
873 };
874 let mask = tab_flight_dynamics(geometry, mask_node)
875 .morph
876 .expect("selection mask morph");
877 let lens = tab_flight_dynamics(geometry, lens_node)
878 .morph
879 .expect("lens morph");
880 assert_eq!(
881 (
882 mask.primary.0 + mask_node.origin.0,
883 mask.primary.1 + mask_node.origin.1
884 ),
885 geometry.center
886 );
887 assert_eq!(
888 (
889 lens.primary.0 + lens_node.origin.0,
890 lens.primary.1 + lens_node.origin.1
891 ),
892 geometry.center
893 );
894 assert_eq!(
895 (mask.primary.2, mask.primary.3, mask.primary.4),
896 (lens.primary.2, lens.primary.3, lens.primary.4)
897 );
898 assert_eq!(mask.node_size, (328.0, 64.0));
899 assert_eq!(lens.node_size, (160.0, 108.0));
900 }
901
902 #[test]
903 fn unified_bar_has_no_detached_accessory_gap() {
904 assert_eq!(tab_bar_accessory_gap(false), 0.0);
905 assert_eq!(tab_bar_accessory_gap(true), 10.0);
906 }
907
908 #[test]
909 fn flight_lens_only_joins_accessory_after_surface_contact() {
910 assert!(!accessory_surfaces_touch(0.01));
911 assert!(accessory_surfaces_touch(0.0));
912 assert!(accessory_surfaces_touch(-4.0));
913 }
914
915 #[test]
916 fn lens_contact_swell_is_vertical_only() {
917 let resting = tab_lens_base_size(TAB_WIDTH, 0.0);
918 let raised = tab_lens_base_size(TAB_WIDTH, 1.0);
919 assert_eq!(
920 resting,
921 (TAB_WIDTH * TAB_LENS_REST_WIDTH_FACTOR, BLOB_HEIGHT)
922 );
923 assert_eq!(raised.0, resting.0);
924 assert!((raised.1 / resting.1 - FLIGHT_LENS_HEIGHT_PROJECTION).abs() < 0.001);
925 assert!((raised.1 - 68.0).abs() < 0.5);
926 }
927
928 #[test]
929 fn tab_grid_matches_the_reference_pitch() {
930 assert_eq!(TAB_WIDTH, 78.0);
931 }
932
933 #[test]
934 fn tab_grid_matches_the_reference_inner_inset() {
935 assert_eq!(BLOB_MARGIN, 4.0);
936 assert_eq!(BLOB_HEIGHT + 2.0 * BLOB_MARGIN, BAR_HEIGHT);
937 }
938
939 #[test]
940 fn flight_lens_uses_the_clear_wcksrd_contract() {
941 let glass = tab_flight_lens_material(
942 cranpose_ui_graphics::Color::BLACK,
943 cranpose_ui_graphics::Color::from_rgb_u8(0, 122, 255),
944 );
945 let generic_lens = Glass::lens();
946 assert!(glass.lift.is_some_and(|lift| (0.0..=0.15).contains(&lift)));
947 assert_eq!(glass.refraction_depth, 1.0);
948 assert!(glass.refraction_curve < generic_lens.refraction_curve);
949 assert!(glass.dispersion * 0.3 < generic_lens.dispersion);
950 assert_eq!(glass.blur_radius, Some(0.0));
951 assert!(glass.highlight < generic_lens.highlight);
952 assert!(
953 glass.shadow,
954 "the moving lens needs its target-visible SDF contact outline"
955 );
956 assert!(
957 glass
958 .tint
959 .is_some_and(|tint| { tint.r() < 0.05 && (0.055..=0.065).contains(&tint.a()) })
960 );
961 assert_eq!(glass.adaptive_frost, 0.0);
962 }
963
964 #[test]
965 fn flight_lens_retains_neutral_tint_through_direct_motion() {
966 assert_eq!(tab_flight_tint_multiplier(0.0), 1.0);
967 assert!((tab_flight_tint_multiplier(1.0) - 0.75).abs() < f32::EPSILON);
968 assert_eq!(tab_flight_tint_multiplier(-1.0), 1.0);
969 assert!((tab_flight_tint_multiplier(2.0) - 0.75).abs() < f32::EPSILON);
970 }
971
972 #[test]
973 fn bar_surface_adapts_frost_to_its_foreground() {
974 let glass = tab_bar_surface_material(cranpose_ui_graphics::Color::BLACK);
975 assert_eq!(glass.blur_radius, Some(9.0));
976 assert_eq!(glass.saturation, Some(1.15));
977 assert_eq!(glass.lift, Some(0.60));
978 assert_eq!(glass.refraction_depth, 0.0);
979 assert_eq!(glass.transmission_refraction, 0.0);
980 assert_eq!(glass.adaptive_frost, 0.28);
981 }
982
983 #[test]
984 fn bar_surface_lift_tracks_the_local_foreground_polarity() {
985 let light_surface = tab_bar_surface_material(cranpose_ui_graphics::Color::BLACK);
986 assert_eq!(light_surface.lift, Some(0.60));
987
988 let dark_surface = tab_bar_surface_material(cranpose_ui_graphics::Color::WHITE);
989 assert_eq!(dark_surface.lift, Some(-0.24));
990 }
991
992 #[test]
993 fn bar_surface_tint_separates_from_same_polarity_backdrops() {
994 let light_surface = tab_bar_surface_material(cranpose_ui_graphics::Color::BLACK)
995 .tint
996 .expect("bar tint");
997 assert!(light_surface.r() < 0.05);
998 assert_eq!(light_surface.a(), 0.0);
999
1000 let dark_surface = tab_bar_surface_material(cranpose_ui_graphics::Color::WHITE)
1001 .tint
1002 .expect("bar tint");
1003 assert!(dark_surface.r() > 0.95);
1004 assert!((0.03..=0.05).contains(&dark_surface.a()));
1005 }
1006
1007 #[test]
1008 fn contact_rises_continuously_and_returns_on_the_measured_settle() {
1009 let cranpose_animation::AnimationType::Spring(rise) = tab_lens_activity_motion(true) else {
1010 panic!("contact rise must use a spring");
1011 };
1012 assert_eq!(rise.stiffness, 1400.0);
1013 let cranpose_animation::AnimationType::Spring(settle) = tab_lens_activity_motion(false)
1014 else {
1015 panic!("arrival contraction must use a spring");
1016 };
1017 assert_eq!(settle.damping_ratio, 1.0);
1018 assert_eq!(settle.stiffness, 900.0);
1019 }
1020
1021 #[test]
1022 fn a_scope_declares_destinations_in_order() {
1023 let tabs = collect_tabs(|scope| {
1024 scope.tab("M0 0", "Discover");
1025 scope.app_badge("M1 1", "WWDC");
1026 scope.push(LiquidTab::new("M2 2", "Account").with_icon_scale(0.95));
1027 });
1028
1029 assert_eq!(tabs.len(), 3);
1030 assert_eq!(tabs[0].label, "Discover");
1031 assert_eq!(tabs[0].icon_style, LiquidTabIconStyle::Plain);
1032 assert_eq!(tabs[0].icon_scale, 1.0);
1033 assert_eq!(tabs[1].icon_style, LiquidTabIconStyle::AppBadge);
1034 assert_eq!(tabs[2].icon_scale, 0.95);
1035 }
1036
1037 #[test]
1038 fn a_bar_with_no_destinations_declares_none() {
1039 assert!(collect_tabs(|_| {}).is_empty());
1040 }
1041
1042 #[test]
1043 fn a_resting_lens_overhangs_its_cell_within_the_legal_margin() {
1044 for tab_width in [24.0_f32, 44.0, 78.0, 80.0, 120.0, 400.0] {
1045 let width = tab_lens_rest_width(tab_width);
1046 assert!(
1047 width > tab_width,
1048 "the bubble reads wider than its cell ({width} vs {tab_width})"
1049 );
1050 let overhang = (width - tab_width) * 0.5;
1051 assert!(
1052 overhang <= BLOB_MARGIN + 1.0e-4,
1053 "an end cell's overhang ({overhang}) at a {tab_width}dp cell \
1054 has to stay inside the pill inset ({BLOB_MARGIN})"
1055 );
1056 }
1057 }
1058
1059 #[test]
1060 fn a_reference_width_cell_keeps_the_measured_rest_proportion() {
1061 for tab_width in [24.0_f32, 44.0, 78.0, 80.0] {
1062 assert!(
1063 (tab_lens_rest_width(tab_width) - tab_width * TAB_LENS_REST_WIDTH_FACTOR).abs()
1064 < 1.0e-4,
1065 "a {tab_width}dp cell still rests at the measured 1.10 factor"
1066 );
1067 }
1068 assert!(
1069 tab_lens_rest_width(200.0) < 200.0 * TAB_LENS_REST_WIDTH_FACTOR,
1070 "past the inset the margin governs, not the factor"
1071 );
1072 }
1073
1074 #[test]
1075 fn a_resting_lens_stays_centered_on_the_cell_it_rests_in() {
1076 let tab_width = 72.0;
1077 let width = tab_lens_rest_width(tab_width);
1078 for selected in 0..4 {
1079 let left = tab_lens_resting_left(selected, tab_width, 4);
1080 let cell_center = left + tab_width * 0.5;
1081 let lens_center = left - (width - tab_width) * 0.5 + width * 0.5;
1082 assert!(
1083 (lens_center - cell_center).abs() < 1e-4,
1084 "cell {selected}: lens center {lens_center} vs cell center {cell_center}"
1085 );
1086 }
1087 }
1088}