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, ContentScale, Image, Painter,
9 Row, RowSpec, Text,
10 },
11};
12use cranpose_ui_layout::{Alignment, VerticalAlignment};
13
14use crate::{
15 material::{
16 Glass, GlassDynamics, GlassFaceResponse, GlassKeyFill, GlassMorph, GlassShadow,
17 LiquidModifierExt, 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 enum LiquidTabIcon {
35 Vector(&'static str),
37 Painter {
39 painter: Painter,
41 size: Size,
43 },
44}
45
46#[derive(Clone, Debug, PartialEq)]
48pub struct LiquidTab {
49 pub icon: LiquidTabIcon,
51 pub label: &'static str,
52 pub icon_style: LiquidTabIconStyle,
53 pub icon_scale: f32,
56 pub icon_offset: (f32, f32),
58}
59
60impl LiquidTab {
61 pub fn new(icon: &'static str, label: &'static str) -> Self {
62 Self {
63 icon: LiquidTabIcon::Vector(icon),
64 label,
65 icon_style: LiquidTabIconStyle::Plain,
66 icon_scale: 1.0,
67 icon_offset: (0.0, 0.0),
68 }
69 }
70
71 pub fn from_painter(painter: Painter, size: Size, label: &'static str) -> Self {
73 Self {
74 icon: LiquidTabIcon::Painter { painter, size },
75 label,
76 icon_style: LiquidTabIconStyle::Plain,
77 icon_scale: 1.0,
78 icon_offset: (0.0, 0.0),
79 }
80 }
81
82 pub fn app_badge(icon: &'static str, label: &'static str) -> Self {
83 Self {
84 icon: LiquidTabIcon::Vector(icon),
85 label,
86 icon_style: LiquidTabIconStyle::AppBadge,
87 icon_scale: 1.0,
88 icon_offset: (0.0, 0.0),
89 }
90 }
91
92 pub fn with_icon_scale(mut self, scale: f32) -> Self {
93 self.icon_scale = normalize_icon_scale(scale);
94 self
95 }
96
97 pub fn with_icon_offset(mut self, x: f32, y: f32) -> Self {
99 self.icon_offset = (
100 if x.is_finite() { x } else { 0.0 },
101 if y.is_finite() { y } else { 0.0 },
102 );
103 self
104 }
105}
106
107fn normalize_icon_scale(scale: f32) -> f32 {
108 if scale.is_finite() {
109 scale.clamp(0.5, 1.5)
110 } else {
111 1.0
112 }
113}
114
115fn tab_base_content_color(colors: crate::theme::LiquidColors) -> Color {
116 colors.label
117}
118
119fn tab_selection_content_color(colors: crate::theme::LiquidColors) -> Color {
120 colors.accent
121}
122
123const BAR_HEIGHT: f32 = 62.0;
124const BLOB_HEIGHT: f32 = 54.0;
125const BLOB_MARGIN: f32 = 4.0;
126const FLIGHT_LENS_INFLATION: f32 = 16.0;
127const TAB_LENS_OVERLAP: f32 = 7.0;
128const TAB_WIDTH: f32 = 78.0;
129const TAB_ICON_SIZE: f32 = 32.0;
130const TAB_LABEL_SIZE: f32 = 10.0;
131const TAP_SLOP: f32 = 6.0;
132const ACCESSORY_GAP: f32 = 10.0;
133
134#[derive(Clone, Copy, Debug, PartialEq)]
136pub struct LiquidTabBarSpec {
137 max_tab_width: f32,
138}
139
140impl LiquidTabBarSpec {
141 pub fn new(max_tab_width: f32) -> Self {
143 Self {
144 max_tab_width: if max_tab_width.is_finite() {
145 max_tab_width.max(1.0)
146 } else {
147 TAB_WIDTH
148 },
149 }
150 }
151}
152
153impl Default for LiquidTabBarSpec {
154 fn default() -> Self {
155 Self::new(TAB_WIDTH)
156 }
157}
158
159fn tab_flight_lens_material(foreground: cranpose_ui_graphics::Color, activity: f32) -> Glass {
160 let activity = activity.clamp(0.0, 1.0);
161 Glass::lens()
162 .face_lighting(false)
163 .key_fill(GlassKeyFill {
164 height_dp: 1.0,
165 curvature: 0.7 + 0.1 * activity,
166 angle_radians: std::f32::consts::FRAC_PI_4,
167 saturation: 1.5,
168 luma_gain: 0.4367,
169 offset: 1.125,
170 scale_with_surface: true,
171 })
172 .no_clip()
173 .face_response(GlassFaceResponse {
174 gain: 1.0 - 0.03 * activity,
175 start_dp: 1.0 / 3.0,
176 end_dp: 2.0 / 3.0,
177 illumination: 0.0,
178 })
179 .tint(neutral_surface_tint(
180 foreground,
181 0.07333333 * (1.0 - activity),
182 0.1440678 * (1.0 - activity),
183 ))
184 .blur_radius(0.0)
185 .backdrop_blur(4.0, 1.0 - activity)
186 .edge_refraction(9.0 * activity)
187 .refraction_depth_dp(36.0)
188 .refraction_curve(0.25)
189 .optical_zoom(1.0)
190 .meniscus_absorption(0.0)
191 .inner_shadow(GlassShadow::new(
192 Color::BLACK.with_alpha(0.12 * activity),
193 3.0,
194 7.0,
195 0.0,
196 ))
197 .fold_depth(0.0)
198 .edge_spectrum(crate::material::GlassSpectrum {
199 angle_radians: std::f32::consts::FRAC_PI_2
200 - std::f32::consts::PI * 7.0 / 12.0 * activity,
201 step_dp: (4.068 / (112.24138 / 36.0)) / (std::f32::consts::PI / 12.0).cos()
202 * (-5.0 + 8.684211 * activity)
203 / 3.6842105,
204 vertical_scale: 0.8265,
205 opacity_near: activity,
206 opacity_far: 1.0 - activity,
207 fade_depth_dp: 14.0 * activity,
208 extent_dp: 38.88889 * activity,
209 })
210 .dispersion(0.0)
211 .lift(0.0)
212 .highlight(activity)
213 .rim_reflection(activity)
214 .shadow_style(GlassShadow::new(
215 cranpose_ui_graphics::Color::BLACK.with_alpha(0.1 * activity),
216 8.0,
217 7.0,
218 0.0,
219 ))
220}
221
222fn tab_bar_surface_material(foreground: cranpose_ui_graphics::Color) -> Glass {
223 Glass::regular()
224 .face_lighting(false)
225 .key_fill(GlassKeyFill {
226 height_dp: 1.0,
227 curvature: 0.7,
228 angle_radians: std::f32::consts::FRAC_PI_4,
229 saturation: 1.5,
230 luma_gain: 0.1,
231 offset: 0.9,
232 scale_with_surface: false,
233 })
234 .face_response(GlassFaceResponse {
235 gain: 0.97,
236 start_dp: 1.0 / 3.0,
237 end_dp: 2.0 / 3.0,
238 illumination: 0.0,
239 })
240 .tint(Color::TRANSPARENT)
241 .blur_radius(6.0)
242 .surface_refraction(31.0)
243 .refraction_depth_dp(15.5)
244 .transmission_refraction(1.0)
245 .saturation(1.0)
246 .lift(0.0)
247 .contrast(1.0)
248 .highlight(1.0)
249 .adaptive_frost(foreground, 0.0)
250 .adaptive_tone(foreground)
251 .shadow_style(GlassShadow::new(
252 Color::BLACK.with_alpha(0.162),
253 32.0,
254 8.0,
255 1.5,
256 ))
257}
258
259fn tab_flight_tint_multiplier(activity: f32) -> f32 {
260 1.0 - 0.25 * activity.clamp(0.0, 1.0)
261}
262
263#[derive(Clone, Copy, Debug, Default, PartialEq)]
264struct TabGeometry {
265 width: f32,
266 cell_width: f32,
267 pitch: f32,
268}
269
270impl TabGeometry {
271 fn new(allocation: f32, count: usize) -> Self {
272 let width = allocation * count.max(1) as f32;
273 let cell_width = tab_lens_rest_width(allocation).min(width);
274 let pitch = if count > 1 {
275 (width - cell_width) / (count - 1) as f32
276 } else {
277 cell_width
278 };
279 Self {
280 width,
281 cell_width,
282 pitch,
283 }
284 }
285
286 fn drag_left(self, pointer_x: f32, count: usize, has_accessory: bool) -> f32 {
287 let last_tab = self.pitch * count.saturating_sub(1) as f32;
288 let min = if has_accessory {
289 -self.pitch * 0.2
290 } else {
291 0.0
292 };
293 let max = if has_accessory {
294 last_tab + self.pitch * 0.55
295 } else {
296 last_tab
297 };
298 (pointer_x - self.cell_width * 0.5).clamp(min, max)
299 }
300
301 fn optical_pointer_x(self, pointer_x: f32, press: f32, travel: f32) -> f32 {
302 let transform = tab_bar_transform(self.width + 2.0 * BLOB_MARGIN, press, travel);
303 let center = self.width * 0.5;
304 pointer_x + (pointer_x - center) * (transform.scale_x - 1.0) + transform.translation_x
305 }
306
307 fn lens_surface_position(self, position: f32, press: f32, travel: f32) -> f32 {
308 let transform = tab_bar_transform(self.width + 2.0 * BLOB_MARGIN, press, travel);
309 (position + self.cell_width * 0.5 - self.width * 0.5) * transform.scale_x
310 }
311}
312
313pub fn tab_lens_resting_left(selected: usize, tab_pitch: f32, count: usize) -> f32 {
315 tab_pitch * selected.min(count.saturating_sub(1)) as f32
316}
317
318pub fn tab_lens_rest_width(tab_allocation: f32) -> f32 {
321 tab_allocation + TAB_LENS_OVERLAP
322}
323
324#[derive(Clone, Copy, Debug, PartialEq)]
325struct AppBadgeGeometry {
326 size: Size,
327 corner_radius: f32,
328 stripe: Rect,
329 glyph: Rect,
330}
331
332fn app_badge_geometry(optical_scale: f32) -> AppBadgeGeometry {
333 let scale = normalize_icon_scale(optical_scale);
334 AppBadgeGeometry {
335 size: Size::new(20.0 * scale, 32.0 * scale),
336 corner_radius: 5.0 * scale,
337 stripe: Rect {
338 x: 7.0 * scale,
339 y: 4.5 * scale,
340 width: 6.0 * scale,
341 height: 1.5 * scale,
342 },
343 glyph: Rect {
344 x: 3.0 * scale,
345 y: 10.0 * scale,
346 width: 14.0 * scale,
347 height: 14.0 * scale,
348 },
349 }
350}
351
352#[composable]
353#[allow(non_snake_case)]
354fn TabIcon(icon: LiquidTabIcon, style: LiquidTabIconStyle, color: Color, optical_scale: f32) {
355 const FRAME_HEIGHT: f32 = 32.0;
356 Box(
357 Modifier::empty().size(Size::new(TAB_ICON_SIZE, FRAME_HEIGHT)),
358 BoxSpec::default().content_alignment(Alignment::CENTER),
359 move || match style {
360 LiquidTabIconStyle::Plain => {
361 TabGlyph(icon.clone(), TAB_ICON_SIZE * optical_scale, color)
362 }
363 LiquidTabIconStyle::AppBadge => {
364 let icon = icon.clone();
365 let geometry = app_badge_geometry(optical_scale);
366 Box(
367 Modifier::empty()
368 .size(geometry.size)
369 .draw_behind(move |scope| {
370 scope.draw_round_rect(
371 Brush::solid(color),
372 CornerRadii::uniform(geometry.corner_radius),
373 );
374 scope.draw_rect_at(geometry.stripe, Brush::solid(Color::WHITE));
375 }),
376 BoxSpec::default(),
377 move || {
378 let icon = icon.clone();
379 Box(
380 Modifier::empty()
381 .offset(geometry.glyph.x, geometry.glyph.y)
382 .size(Size::new(geometry.glyph.width, geometry.glyph.height)),
383 BoxSpec::default(),
384 move || TabGlyph(icon.clone(), geometry.glyph.width, Color::WHITE),
385 );
386 },
387 );
388 }
389 },
390 );
391}
392
393#[composable]
394fn TabGlyph(icon: LiquidTabIcon, size: f32, color: Color) {
395 match icon {
396 LiquidTabIcon::Vector(path) => crate::icons::Icon(path, None, size, color),
397 LiquidTabIcon::Painter {
398 painter,
399 size: intrinsic,
400 } => {
401 let scale = size / TAB_ICON_SIZE;
402 Image(
403 painter,
404 None,
405 Modifier::empty()
406 .size(Size::new(intrinsic.width * scale, intrinsic.height * scale)),
407 Alignment::CENTER,
408 ContentScale::FillBounds,
409 1.0,
410 Some(cranpose_ui_graphics::ColorFilter::tint(color)),
411 );
412 }
413 }
414}
415
416fn tab_ink_selection(
417 geometry: TabGeometry,
418 position: f32,
419 activity: f32,
420 strain: Size,
421 color: Color,
422) -> super::vibrancy::InkSelection {
423 let (width, height) = tab_lens_base_size(geometry.cell_width, activity);
424 let base = Size::new(width, height);
425 let deformed = tab_lens_deformed_size(base, strain);
426 let width = deformed.width;
427 let height = deformed.height;
428 super::vibrancy::InkSelection {
429 bounds: Rect {
430 x: position + geometry.cell_width * 0.5 - width * 0.5,
431 y: BLOB_HEIGHT * 0.5 - height * 0.5,
432 width,
433 height,
434 },
435 color,
436 content_zoom: 1.0 + 0.16 * activity.clamp(0.0, 1.0),
437 activity: activity.clamp(0.0, 1.0),
438 optical_scale: 1.0,
439 projection: Size::new(deformed.width / base.width, deformed.height / base.height),
440 }
441}
442
443#[derive(Clone, Copy, PartialEq)]
444struct TabCellsSpec {
445 base_color: Color,
446 selection: super::vibrancy::InkSelection,
447 selected: usize,
448 committed_selection: usize,
449}
450
451#[composable]
452#[allow(non_snake_case)]
453fn TabCells(
454 modifier: Modifier,
455 tabs: Rc<Vec<LiquidTab>>,
456 typography: LiquidTypography,
457 geometry: TabGeometry,
458 spec: TabCellsSpec,
459 transform: cranpose_ui_graphics::GraphicsLayer,
460) {
461 let size = Size::new(geometry.width + BLOB_MARGIN * 2.0, BAR_HEIGHT);
462 let mut selection = spec.selection;
463 let center = selection.bounds.x + selection.bounds.width * 0.5 + BLOB_MARGIN;
464 selection.bounds.width *= transform.scale_x;
465 selection.bounds.height *= transform.scale_y;
466 selection.optical_scale = transform.scale_x.min(transform.scale_y);
467 selection.bounds.x = (center - size.width * 0.5) * transform.scale_x
468 + size.width * 0.5
469 + transform.translation_x
470 - selection.bounds.width * 0.5;
471 selection.bounds.y = (BAR_HEIGHT - selection.bounds.height) * 0.5;
472 let grid = super::vibrancy::InkGrid {
473 first_center: (
474 (BLOB_MARGIN + geometry.cell_width * 0.5 - size.width * 0.5) * transform.scale_x
475 + size.width * 0.5
476 + transform.translation_x,
477 BAR_HEIGHT * 0.5,
478 ),
479 pitch: geometry.pitch * transform.scale_x,
480 count: tabs.len(),
481 };
482 super::vibrancy::VibrantContent(
483 modifier,
484 size,
485 spec.base_color,
486 selection,
487 grid,
488 move || {
489 let tabs = Rc::clone(&tabs);
490 let typography = typography.clone();
491 Box(
492 Modifier::empty()
493 .size(size)
494 .selectable_group()
495 .role(SemanticsWidgetRole::TabBar)
496 .graphics_layer_value(transform.clone()),
497 BoxSpec::default(),
498 move || {
499 for (index, tab) in tabs.iter().enumerate() {
500 let color = spec.base_color;
501 let label_for_semantics = tab.label;
502 let icon_offset = tab.icon_offset;
503 let cell = Modifier::empty()
504 .offset(BLOB_MARGIN + index as f32 * geometry.pitch, BLOB_MARGIN)
505 .size(Size::new(geometry.cell_width, BLOB_HEIGHT))
506 .semantics(move |config| {
507 config.role = Some(SemanticsWidgetRole::Tab);
508 config.is_clickable = true;
509 config.selected = Some(index == spec.committed_selection);
510 config.content_description = Some(label_for_semantics.to_string());
511 })
512 .focusable();
513 let icon = tab.icon.clone();
514 let icon_style = tab.icon_style;
515 let icon_scale = tab.icon_scale;
516 let label = tab.label;
517 let label_style = TextStyle {
518 span_style: SpanStyle {
519 color: Some(color),
520 font_size: cranpose_ui::text::TextUnit::Sp(TAB_LABEL_SIZE),
521 font_weight: Some(if spec.selected == index {
522 FontWeight::SEMI_BOLD
523 } else {
524 FontWeight::MEDIUM
525 }),
526 ..typography.caption1.span_style.clone()
527 },
528 paragraph_style: cranpose_ui::text::ParagraphStyle {
529 line_height: cranpose_ui::text::TextUnit::Sp(12.0),
530 ..typography.caption1.paragraph_style.clone()
531 },
532 };
533 Box(cell, BoxSpec::default(), move || {
534 let label_style = label_style.clone();
535 let icon = icon.clone();
536 Box(
537 Modifier::empty()
538 .offset(
539 (geometry.cell_width - TAB_ICON_SIZE) * 0.5 + icon_offset.0,
540 3.0 + icon_offset.1,
541 )
542 .size(Size::new(TAB_ICON_SIZE, TAB_ICON_SIZE)),
543 BoxSpec::default(),
544 move || TabIcon(icon.clone(), icon_style, color, icon_scale),
545 );
546 Box(
547 Modifier::empty()
548 .offset(0.0, 35.0)
549 .size(Size::new(geometry.cell_width, 12.0)),
550 BoxSpec::default().content_alignment(Alignment::CENTER),
551 move || {
552 Text(label, Modifier::empty(), label_style.clone());
553 },
554 );
555 });
556 }
557 },
558 );
559 },
560 );
561}
562
563fn tab_lens_node_top(node_height: f32) -> f32 {
564 (BAR_HEIGHT - node_height) * 0.5
565}
566
567#[derive(Clone, Copy)]
568struct TabBarTouch {
569 position: cranpose_core::MutableState<(f32, f32)>,
570 origin: cranpose_core::MutableState<Option<f32>>,
571 travel: cranpose_core::MutableState<f32>,
572}
573
574impl TabBarTouch {
575 fn pressed(self, down: bool) {
576 if down {
577 self.origin.set(None);
578 self.travel.set(0.0);
579 }
580 }
581
582 fn update(self, x: f32, y: f32, span: f32) {
583 self.position.set((x + BLOB_MARGIN, y + BLOB_MARGIN));
584 if let Some(origin) = self.origin.get() {
585 self.travel
586 .set(((x - origin) / span.max(1.0)).clamp(-1.0, 1.0));
587 } else {
588 self.origin.set(Some(x));
589 }
590 }
591}
592
593#[composable]
594fn remember_tab_bar_touch() -> TabBarTouch {
595 TabBarTouch {
596 position: cranpose_core::remember(|| cranpose_core::mutableStateOf((0.0f32, 0.0f32)))
597 .with(|state| *state),
598 origin: cranpose_core::remember(|| cranpose_core::mutableStateOf(None::<f32>))
599 .with(|state| *state),
600 travel: cranpose_core::remember(|| cranpose_core::mutableStateOf(0.0f32))
601 .with(|state| *state),
602 }
603}
604
605fn tab_bar_transform(width: f32, press: f32, travel: f32) -> cranpose_ui_graphics::GraphicsLayer {
606 let press = press.max(0.0);
607 let travel = travel.clamp(-1.0, 1.0);
608 let rise = 14.14 / width.max(1.0) * press;
609 let strain = 2.63 / width.max(1.0) * travel.abs() * press;
610 cranpose_ui_graphics::GraphicsLayer {
611 scale_x: 1.0 + rise + strain,
612 scale_y: 1.0 + rise - strain,
613 translation_x: 2.756 * travel * press,
614 ..Default::default()
615 }
616}
617
618fn tab_bar_accessory_gap(has_accessory: bool) -> f32 {
619 if has_accessory { ACCESSORY_GAP } else { 0.0 }
620}
621
622fn accessory_surfaces_touch(edge_gap: f32) -> bool {
623 edge_gap <= 0.0
624}
625
626fn tab_lens_base_size(cell_width: f32, activity: f32) -> (f32, f32) {
627 let inflation = FLIGHT_LENS_INFLATION * activity.clamp(0.0, 1.0);
628 (cell_width + inflation, BLOB_HEIGHT + inflation)
629}
630
631#[derive(Clone, Copy, Debug, PartialEq)]
632struct TabFlightGeometry {
633 center: (f32, f32),
634 base_size: Size,
635 strain: Size,
636 lens_position: f32,
637 lens_activity: f32,
638 resting_tint: Color,
639 accessory_center: Option<(f32, f32)>,
640}
641
642#[derive(Clone, Copy, Debug, PartialEq)]
643struct TabFlightNode {
644 origin: (f32, f32),
645 size: Size,
646}
647
648fn tab_lens_deformed_size(base: Size, strain: Size) -> Size {
649 Size::new(
650 base.width * (1.0 + strain.width.clamp(-0.35, 0.35)),
651 base.height * (1.0 + strain.height.clamp(-0.35, 0.35)),
652 )
653}
654
655fn tab_flight_dynamics(geometry: TabFlightGeometry, node: TabFlightNode) -> GlassDynamics {
656 let activity = geometry.lens_activity.clamp(0.0, 1.0);
657 let size = geometry.base_size;
658 let deformed = tab_lens_deformed_size(size, geometry.strain);
659 let radius = size.width.min(size.height) * 0.5;
660 let glue = 20.0;
661 let shapes = geometry
662 .accessory_center
663 .filter(|(x, _)| {
664 let edge_gap = (*x - geometry.center.0).abs() - size.width * 0.5 - BAR_HEIGHT * 0.5;
665 accessory_surfaces_touch(edge_gap)
666 })
667 .map(|(x, y)| {
668 vec![(
669 x - node.origin.0,
670 y - node.origin.1,
671 BAR_HEIGHT,
672 BAR_HEIGHT,
673 -1.0,
674 )]
675 })
676 .unwrap_or_default();
677 GlassDynamics {
678 edge_return_depth_dp: Some(7.0 * activity),
679 optical_projection: Some((deformed.width / size.width, deformed.height / size.height)),
680 morph: Some(GlassMorph {
681 node_size: (node.size.width, node.size.height),
682 primary: (
683 geometry.center.0 - node.origin.0,
684 geometry.center.1 - node.origin.1,
685 size.width,
686 size.height,
687 radius,
688 ),
689 shapes,
690 glue,
691 wobble_amplitude: 0.0,
692 wobble_phase: geometry.lens_position * 0.045,
693 bulge_amplitude: 0.0,
694 bulge_direction: 0.0,
695 ellipse_blend: 0.0,
696 capsule_smoothing_dp: ((size.width - size.height).abs() * 0.5).min(radius * 0.6)
697 * activity,
698 deformation: None,
699 zoom_anchor: (0.0, 0.0),
700 }),
701 activity: Some(1.0),
702 resting_tint: Some(geometry.resting_tint),
703 tint_alpha_multiplier: Some(tab_flight_tint_multiplier(geometry.lens_activity)),
704 ..Default::default()
705 }
706}
707
708pub struct LiquidTabBarScope {
713 tabs: ScopeContent<LiquidTab>,
714}
715
716impl LiquidTabBarScope {
717 pub fn tab(&self, icon: &'static str, label: &'static str) {
719 self.push(LiquidTab::new(icon, label));
720 }
721
722 pub fn app_badge(&self, icon: &'static str, label: &'static str) {
724 self.push(LiquidTab::app_badge(icon, label));
725 }
726
727 pub fn push(&self, tab: LiquidTab) {
730 self.tabs.push(tab);
731 }
732}
733
734fn collect_tabs(content: impl FnOnce(&LiquidTabBarScope)) -> Vec<LiquidTab> {
735 ScopeContent::collect(|tabs| LiquidTabBarScope { tabs }, content)
736}
737
738#[composable]
747#[allow(non_snake_case)]
748pub fn LiquidTabBar(
749 modifier: Modifier,
750 spec: LiquidTabBarSpec,
751 selected: usize,
752 on_select: impl Fn(usize) + 'static,
753 content: impl FnOnce(&LiquidTabBarScope),
754) {
755 LiquidTabBarLayout(
756 modifier,
757 spec,
758 collect_tabs(content),
759 selected,
760 on_select,
761 false,
762 || {},
763 );
764}
765
766#[composable]
768#[allow(non_snake_case)]
769pub fn LiquidTabBarWithAccessory(
770 modifier: Modifier,
771 spec: LiquidTabBarSpec,
772 selected: usize,
773 on_select: impl Fn(usize) + 'static,
774 content: impl FnOnce(&LiquidTabBarScope),
775 accessory: impl FnMut() + 'static,
776) {
777 LiquidTabBarLayout(
778 modifier,
779 spec,
780 collect_tabs(content),
781 selected,
782 on_select,
783 true,
784 accessory,
785 );
786}
787
788#[composable]
789#[allow(non_snake_case)]
790fn LiquidTabBarLayout(
791 modifier: Modifier,
792 spec: LiquidTabBarSpec,
793 tabs: Vec<LiquidTab>,
794 selected: usize,
795 on_select: impl Fn(usize) + 'static,
796 has_accessory: bool,
797 accessory: impl FnMut() + 'static,
798) {
799 let colors = liquid_colors();
800 let typography = liquid_typography();
801 let tint_amount = crate::theme::liquid_glass_tint_amount();
802 let count = tabs.len().max(1);
803 let selected = selected.min(count - 1);
804 let on_select: Rc<dyn Fn(usize)> = Rc::new(on_select);
805 let tabs = Rc::new(tabs);
806 let accessory = Rc::new(RefCell::new(accessory));
807
808 Row(
809 modifier,
810 RowSpec::default().vertical_alignment(VerticalAlignment::CenterVertically),
811 move || {
812 let tabs = Rc::clone(&tabs);
813 let typography = typography.clone();
814 let on_select = Rc::clone(&on_select);
815 let accessory = Rc::clone(&accessory);
816
817 let lens_x_outer = cranpose_core::remember(move || {
818 cranpose_core::mutableStateOf((
819 0.0f32,
820 0.0f32,
821 0.0f32,
822 Size::new(0.0, 0.0),
823 selected,
824 ))
825 })
826 .with(|state| *state);
827 let bar_touch = remember_tab_bar_touch();
828 let contact_motion = super::tab_motion::remember_tab_contact_motion();
829 let bar_press = contact_motion.bar_state();
830 let glow = contact_motion.glow_state();
831 let local_glow_factor = contact_motion.local_glow_factor_state();
832 let travel = cranpose_animation::animateFloatAsState(
833 bar_touch.travel.get(),
834 cranpose_animation::spring(0.85, 650.0),
835 "tabbar-travel",
836 );
837 let bar_lift = Modifier::empty().graphics_layer(move || {
838 let width = lens_x_outer.get().2 * count as f32 + 2.0 * BLOB_MARGIN;
839 tab_bar_transform(width, bar_press.get(), travel.get())
840 });
841 Box(
842 Modifier::empty().height(BAR_HEIGHT),
843 BoxSpec::default(),
844 move || {
845 let tabs = Rc::clone(&tabs);
846 let typography = typography.clone();
847 let on_select = Rc::clone(&on_select);
848 let contact_motion = Rc::clone(&contact_motion);
849 let pill = bar_lift.clone().height(BAR_HEIGHT);
850 Box(pill, BoxSpec::default(), move || {
851 let on_select = Rc::clone(&on_select);
852 let contact_motion = Rc::clone(&contact_motion);
853 BoxWithConstraints(Modifier::empty(), move |scope| {
854 let on_select = Rc::clone(&on_select);
855 let constrained = scope.constraints().max_width - BLOB_MARGIN * 2.0;
856 let tab_width = if constrained.is_finite() && constrained > 1.0 {
857 (constrained / count as f32).min(spec.max_tab_width)
858 } else {
859 spec.max_tab_width
860 };
861
862 let geometry = TabGeometry::new(tab_width, count);
863 Box(
864 Modifier::empty()
865 .glass_effect(
866 tab_bar_surface_material(colors.label)
867 .tint_amount(tint_amount),
868 )
869 .size(Size::new(
870 geometry.width + BLOB_MARGIN * 2.0,
871 BAR_HEIGHT,
872 )),
873 BoxSpec::default(),
874 || {},
875 );
876 let lens_pressed =
877 cranpose_core::remember(|| cranpose_core::mutableStateOf(false))
878 .with(|state| *state);
879 let resting_lens_x =
880 tab_lens_resting_left(selected, geometry.pitch, count);
881 let lens_axis = crate::motion::remember_liquid_follow_axis(
882 resting_lens_x,
883 cranpose_animation::spring(0.85, 650.0),
884 );
885 if !lens_pressed.get() {
886 lens_axis.settle_to(resting_lens_x, LiquidMotion::glide());
887 }
888 let lens_x = lens_axis.value();
889 let lens_shape = super::tab_motion::remember_tab_lens_shape();
890 let strain = lens_shape.sample(geometry.lens_surface_position(
891 lens_x,
892 bar_press.get(),
893 travel.get(),
894 ));
895 let lens_activity_anim = contact_motion.lens_state();
896 let lens_activity = lens_activity_anim.get().clamp(0.0, 1.0);
897 let visual_index = crate::motion::liquid_visual_index(
898 selected,
899 lens_x,
900 geometry.pitch,
901 count,
902 crate::motion::liquid_axis_owns_visual_selection(
903 lens_pressed.get(),
904 lens_x,
905 resting_lens_x,
906 geometry.pitch,
907 ),
908 );
909 if bar_touch.travel.get().abs() > 0.001 && visual_index != selected {
910 contact_motion.crossed_tab();
911 }
912 let row_width = geometry.width;
913 let gesture = Modifier::empty()
914 .offset(BLOB_MARGIN, BLOB_MARGIN)
915 .size(Size::new(row_width, BLOB_HEIGHT))
916 .pointer_input(selected, {
917 let on_select = Rc::clone(&on_select);
918 let lens_axis = Rc::clone(&lens_axis);
919 let contact_motion = Rc::clone(&contact_motion);
920 move |scope: PointerInputScope| {
921 let on_select = Rc::clone(&on_select);
922 let lens_axis = Rc::clone(&lens_axis);
923 let contact_motion = Rc::clone(&contact_motion);
924 crate::motion::liquid_lens_gesture(
925 scope,
926 crate::motion::LiquidLensGesture {
927 axis: lens_axis,
928 cell_width: geometry.pitch,
929 cell_offset: (geometry.cell_width - geometry.pitch)
930 * 0.5,
931 count,
932 tap_slop: TAP_SLOP,
933 drag_left: Rc::new(move |x| {
934 geometry.drag_left(
935 geometry.optical_pointer_x(
936 x,
937 bar_press.get(),
938 travel.get(),
939 ),
940 count,
941 has_accessory,
942 )
943 }),
944 rest_left: Rc::new(move |index| {
945 tab_lens_resting_left(
946 index,
947 geometry.pitch,
948 count,
949 )
950 }),
951 selected,
952 on_pressed: Rc::new(move |down, time| {
953 lens_pressed.set(down);
954 contact_motion.pressed(down, time);
955 bar_touch.pressed(down);
956 }),
957 on_touch: Rc::new(move |x, y| {
958 bar_touch.update(
959 geometry.optical_pointer_x(
960 x,
961 bar_press.get(),
962 travel.get(),
963 ),
964 y,
965 geometry.pitch
966 * count.saturating_sub(1) as f32,
967 );
968 }),
969 on_select,
970 },
971 )
972 }
973 });
974 Box(gesture, BoxSpec::default(), || {});
975
976 let published =
977 (lens_x, lens_activity, tab_width, strain, visual_index);
978 if lens_x_outer.get() != published {
979 lens_x_outer.set(published);
980 }
981 });
982 });
983
984 let (lens_px, lens_activity, lens_tab_w, strain, visual_index) =
985 lens_x_outer.get();
986 let cells = TabGeometry::new(lens_tab_w, count);
987 let (lens_w, lens_h) = tab_lens_base_size(cells.cell_width, 1.0);
988 let deformation_headroom =
989 crate::dynamics::STRETCH_MAX.max(1.0 / crate::dynamics::STRETCH_MIN);
990 let node_w = lens_w * deformation_headroom + crate::dynamics::BULGE_MAX + 20.0;
991 let node_h = lens_h * deformation_headroom + crate::dynamics::BULGE_MAX + 16.0;
992 let pill_w = cells.width + 2.0 * BLOB_MARGIN;
993 let transform = tab_bar_transform(pill_w, bar_press.get(), travel.get());
994 let local_center_x = BLOB_MARGIN + lens_px + cells.cell_width * 0.5;
995 let lens_center_x = local_center_x;
996 let node_x = lens_center_x - node_w * 0.5;
997 let node_top = tab_lens_node_top(node_h);
998 let (base_w, base_h) = tab_lens_base_size(cells.cell_width, lens_activity);
999 let geometry = TabFlightGeometry {
1000 center: (lens_center_x, BAR_HEIGHT * 0.5),
1001 base_size: Size::new(base_w, base_h),
1002 strain,
1003 lens_position: lens_px,
1004 lens_activity,
1005 resting_tint: colors.fill,
1006 accessory_center: has_accessory.then_some((
1007 pill_w + tab_bar_accessory_gap(true) + BAR_HEIGHT * 0.5,
1008 BAR_HEIGHT * 0.5,
1009 )),
1010 };
1011 let lens_node = TabFlightNode {
1012 origin: (node_x, node_top),
1013 size: Size::new(node_w, node_h),
1014 };
1015
1016 let lens_geometry = geometry;
1017 let mut lens_transform = transform.clone();
1018 lens_transform.translation_x +=
1019 (local_center_x - pill_w * 0.5) * (transform.scale_x - 1.0);
1020 let layers = Rc::new(crate::material::cached_glass_content_layers(
1021 tab_flight_lens_material(colors.label, lens_activity).resolve(&colors),
1022 ));
1023 let lens_node_modifier = Modifier::empty()
1024 .required_size(lens_node.size)
1025 .offset(node_x, node_top)
1026 .graphics_layer_value(lens_transform);
1027 let background_layers = Rc::clone(&layers);
1028 let lens = lens_node_modifier.clone().graphics_layer(move || {
1029 background_layers(
1030 cranpose_ui::current_density(),
1031 tab_flight_dynamics(lens_geometry, lens_node),
1032 )[0]
1033 .clone()
1034 });
1035 super::tab_lighting::TabLighting(
1036 Size::new(pill_w, BAR_HEIGHT),
1037 transform.clone(),
1038 bar_touch.position,
1039 glow,
1040 local_glow_factor,
1041 );
1042 Box(lens, BoxSpec::default(), || {});
1043 TabCells(
1044 Modifier::empty(),
1045 Rc::clone(&tabs),
1046 typography.clone(),
1047 cells,
1048 TabCellsSpec {
1049 base_color: tab_base_content_color(colors),
1050 selection: tab_ink_selection(
1051 cells,
1052 lens_px,
1053 lens_activity,
1054 strain,
1055 tab_selection_content_color(colors),
1056 ),
1057 selected: visual_index,
1058 committed_selection: selected,
1059 },
1060 transform,
1061 );
1062 Box(
1063 lens_node_modifier.graphics_layer(move || {
1064 layers(
1065 cranpose_ui::current_density(),
1066 tab_flight_dynamics(lens_geometry, lens_node),
1067 )[1]
1068 .clone()
1069 }),
1070 BoxSpec::default(),
1071 || {},
1072 );
1073 },
1074 );
1075
1076 if has_accessory {
1077 Box(
1078 Modifier::empty().width(tab_bar_accessory_gap(true)),
1079 BoxSpec::default(),
1080 || {},
1081 );
1082 (accessory.borrow_mut())();
1083 }
1084 },
1085 );
1086}
1087
1088#[composable]
1090#[allow(non_snake_case)]
1091pub fn LiquidTabBarSearchAccessory(on_click: impl Fn() + 'static) {
1092 crate::widgets::GlassIconButton(
1093 Modifier::empty().content_description("Search"),
1094 crate::widgets::GlassButtonSpec::glass(),
1095 BAR_HEIGHT * 0.94,
1096 on_click,
1097 crate::icons::SEARCH,
1098 );
1099}
1100
1101#[cfg(test)]
1102mod tests {
1103 use super::*;
1104 use crate::widgets::tab_motion::tab_lens_activity_motion;
1105
1106 #[test]
1107 fn tab_bar_spec_normalizes_the_maximum_cell_width() {
1108 assert_eq!(LiquidTabBarSpec::default().max_tab_width, TAB_WIDTH);
1109 assert_eq!(LiquidTabBarSpec::new(85.0).max_tab_width, 85.0);
1110 assert_eq!(LiquidTabBarSpec::new(0.0).max_tab_width, 1.0);
1111 assert_eq!(LiquidTabBarSpec::new(f32::NAN).max_tab_width, TAB_WIDTH);
1112 }
1113
1114 #[test]
1115 fn drag_pointer_centers_the_lens_and_preserves_end_overdrag() {
1116 let geometry = TabGeometry {
1117 width: 400.0,
1118 cell_width: 100.0,
1119 pitch: 100.0,
1120 };
1121 assert_eq!(geometry.drag_left(50.0, 4, true), 0.0);
1122 assert_eq!(geometry.drag_left(250.0, 4, true), 200.0);
1123 assert_eq!(geometry.drag_left(-100.0, 4, true), -20.0);
1124 assert_eq!(geometry.drag_left(500.0, 4, true), 355.0);
1125
1126 assert_eq!(geometry.drag_left(-100.0, 4, false), 0.0);
1127 assert_eq!(geometry.drag_left(500.0, 4, false), 300.0);
1128 }
1129
1130 #[test]
1131 fn resting_lens_centers_on_its_cell_and_stays_inside_the_pill() {
1132 let tab = 78.0;
1133 assert_eq!(tab_lens_resting_left(0, tab, 5), 0.0);
1134 assert_eq!(tab_lens_resting_left(1, tab, 5), tab);
1135 assert_eq!(tab_lens_resting_left(3, tab, 5), 3.0 * tab);
1136 assert_eq!(tab_lens_resting_left(4, tab, 5), 4.0 * tab);
1137 assert_eq!(tab_lens_resting_left(9, tab, 5), 4.0 * tab);
1138 }
1139
1140 #[test]
1141 fn flight_deformation_keeps_native_optics_in_the_unscaled_capsule() {
1142 let geometry = TabFlightGeometry {
1143 center: (80.0, 54.0),
1144 base_size: Size::new(111.0, 70.0),
1145 strain: Size::new(14.0 / 111.0, -14.0 / 70.0),
1146 lens_position: 0.0,
1147 lens_activity: 1.0,
1148 resting_tint: Color::TRANSPARENT,
1149 accessory_center: None,
1150 };
1151 let dynamics = tab_flight_dynamics(
1152 geometry,
1153 TabFlightNode {
1154 origin: (0.0, 0.0),
1155 size: Size::new(160.0, 108.0),
1156 },
1157 );
1158 let shape = dynamics.morph.unwrap().primary;
1159 assert_eq!((shape.2, shape.3, shape.4), (111.0, 70.0, 35.0));
1160 }
1161
1162 #[test]
1163 fn released_bubble_keeps_its_elastic_rebound() {
1164 let base = Size::new(104.5, 54.0);
1165 let strain = Size::new(0.017416525, -0.024551005);
1166 let actual = tab_lens_deformed_size(base, strain);
1167 assert!((actual.width - 106.32003).abs() < 0.0001);
1168 assert!((actual.height - 52.67425).abs() < 0.0001);
1169 }
1170
1171 #[test]
1172 fn flight_lens_node_is_centered_on_the_bar_axis() {
1173 for node_height in [48.0, 64.0, 96.0, 128.0] {
1174 let center = tab_lens_node_top(node_height) + node_height * 0.5;
1175 assert!((center - BAR_HEIGHT * 0.5).abs() < f32::EPSILON);
1176 }
1177 }
1178
1179 #[test]
1180 fn liquid_tab_builds_reference_content() {
1181 assert_eq!(TAB_ICON_SIZE, 32.0);
1182 let tab = LiquidTab::new(crate::icons::STAR, "Discover");
1183 assert_eq!(tab.icon, LiquidTabIcon::Vector(crate::icons::STAR));
1184 assert_eq!(tab.label, "Discover");
1185 assert_eq!(tab.icon_style, LiquidTabIconStyle::Plain);
1186
1187 let badge = LiquidTab::app_badge(crate::icons::APPLE, "WWDC");
1188 assert_eq!(badge.icon_style, LiquidTabIconStyle::AppBadge);
1189
1190 let compact = LiquidTab::new(crate::icons::ACCOUNT_CIRCLE, "Account").with_icon_scale(0.72);
1191 assert!((compact.icon_scale - 0.72).abs() < f32::EPSILON);
1192 assert_eq!(tab.clone().with_icon_scale(f32::NAN).icon_scale, 1.0);
1193 assert_eq!(tab.with_icon_scale(2.0).icon_scale, 1.5);
1194 }
1195
1196 #[test]
1197 fn artwork_offsets_preserve_the_tab_and_reject_nonfinite_coordinates() {
1198 let tab = LiquidTab::new(crate::icons::STAR, "Discover");
1199 let shifted = tab.clone().with_icon_offset(-1.0 / 3.0, 1.0);
1200 assert_eq!(shifted.icon_offset, (-1.0 / 3.0, 1.0));
1201 assert_eq!(shifted.icon, tab.icon);
1202 assert_eq!(shifted.label, tab.label);
1203 assert_eq!(
1204 tab.clone().with_icon_offset(f32::NAN, 1.0).icon_offset,
1205 (0.0, 1.0)
1206 );
1207 assert_eq!(
1208 tab.with_icon_offset(2.0, f32::INFINITY).icon_offset,
1209 (2.0, 0.0)
1210 );
1211 }
1212
1213 #[test]
1214 fn template_tab_keeps_artwork_size_and_label() {
1215 let bitmap = cranpose_ui_graphics::ImageBitmap::from_rgba8(1, 1, vec![0, 0, 0, 255])
1216 .expect("template pixel");
1217 let painter = Painter::from_bitmap(bitmap);
1218 let size = Size::new(24.0, 28.0);
1219 let tab = LiquidTab::from_painter(painter.clone(), size, "Saved");
1220 assert_eq!(tab.icon, LiquidTabIcon::Painter { painter, size });
1221 assert_eq!(tab.label, "Saved");
1222 assert_eq!(tab.icon_style, LiquidTabIconStyle::Plain);
1223 }
1224
1225 #[test]
1226 fn app_badge_geometry_honors_the_tab_optical_scale() {
1227 let full = app_badge_geometry(1.0);
1228 let corrected = app_badge_geometry(0.85);
1229 assert_eq!(full.size, Size::new(20.0, 32.0));
1230 assert_eq!(corrected.size, Size::new(17.0, 27.2));
1231 assert!((corrected.glyph.width - 11.9).abs() < 1.0e-5);
1232 assert!((corrected.corner_radius / full.corner_radius - 0.85).abs() < f32::EPSILON);
1233 assert!((corrected.stripe.x / full.stripe.x - 0.85).abs() < f32::EPSILON);
1234 assert!((corrected.glyph.width / full.glyph.width - 0.85).abs() < f32::EPSILON);
1235 }
1236
1237 #[test]
1238 fn base_tab_content_remains_neutral_under_the_moving_selection_layer() {
1239 let colors = crate::theme::LiquidColors::light(cranpose_ui_graphics::Color::from_rgb_u8(
1240 0, 122, 255,
1241 ));
1242 assert_eq!(tab_base_content_color(colors), colors.label);
1243 assert_eq!(tab_selection_content_color(colors), colors.accent);
1244 }
1245
1246 #[test]
1247 fn tab_deformation_preserves_independent_native_axis_motion() {
1248 let base = Size::new(120.5, 70.0);
1249 let strain = Size::new(0.1820888, -0.2760726);
1250 let deformed = tab_lens_deformed_size(base, strain);
1251 assert!((deformed.width - 142.4417).abs() < 0.001);
1252 assert!((deformed.height - 50.67492).abs() < 0.001);
1253 assert_eq!(tab_lens_deformed_size(base, Size::new(0.0, 0.0)), base);
1254 }
1255
1256 #[test]
1257 fn selection_mask_and_lens_resolve_the_same_global_sdf() {
1258 let geometry = TabFlightGeometry {
1259 center: (212.0, 32.0),
1260 base_size: Size::new(106.0, 64.0),
1261 strain: Size::new(0.0, 0.0),
1262 lens_position: 160.0,
1263 lens_activity: 1.0,
1264 resting_tint: Color::BLACK.with_alpha(0.10),
1265 accessory_center: None,
1266 };
1267 let mask_node = TabFlightNode {
1268 origin: (0.0, 0.0),
1269 size: Size::new(328.0, 64.0),
1270 };
1271 let lens_node = TabFlightNode {
1272 origin: (132.0, -22.0),
1273 size: Size::new(160.0, 108.0),
1274 };
1275 let mask = tab_flight_dynamics(geometry, mask_node)
1276 .morph
1277 .expect("selection mask morph");
1278 let lens = tab_flight_dynamics(geometry, lens_node)
1279 .morph
1280 .expect("lens morph");
1281 assert_eq!(
1282 (
1283 mask.primary.0 + mask_node.origin.0,
1284 mask.primary.1 + mask_node.origin.1
1285 ),
1286 geometry.center
1287 );
1288 assert_eq!(
1289 (
1290 lens.primary.0 + lens_node.origin.0,
1291 lens.primary.1 + lens_node.origin.1
1292 ),
1293 geometry.center
1294 );
1295 assert_eq!(
1296 (mask.primary.2, mask.primary.3, mask.primary.4),
1297 (lens.primary.2, lens.primary.3, lens.primary.4)
1298 );
1299 assert_eq!(mask.node_size, (328.0, 64.0));
1300 assert_eq!(lens.node_size, (160.0, 108.0));
1301 }
1302
1303 #[test]
1304 fn unified_bar_has_no_detached_accessory_gap() {
1305 assert_eq!(tab_bar_accessory_gap(false), 0.0);
1306 assert_eq!(tab_bar_accessory_gap(true), 10.0);
1307 }
1308
1309 #[test]
1310 fn flight_lens_only_joins_accessory_after_surface_contact() {
1311 assert!(!accessory_surfaces_touch(0.01));
1312 assert!(accessory_surfaces_touch(0.0));
1313 assert!(accessory_surfaces_touch(-4.0));
1314 }
1315
1316 #[test]
1317 fn lens_contact_swell_matches_the_raised_reference() {
1318 for (cell, raised_width) in [(95.0, 111.0), (104.5, 120.5)] {
1319 assert_eq!(tab_lens_base_size(cell, 0.0), (cell, 54.0));
1320 let raised = tab_lens_base_size(cell, 1.0);
1321 assert!((raised.0 - raised_width).abs() < 0.001);
1322 assert!((raised.1 - 70.0).abs() < 0.001);
1323 }
1324 }
1325
1326 #[test]
1327 fn held_bar_grows_about_its_center_and_strains_with_travel() {
1328 let rest = tab_bar_transform(360.0, 0.0, 1.0);
1329 assert_eq!(rest.scale_x, 1.0);
1330 assert_eq!(rest.scale_y, 1.0);
1331 assert_eq!(rest.translation_x, 0.0);
1332 for travel in [-1.0, 0.0, 1.0] {
1333 let layer = tab_bar_transform(360.0, 1.0, travel);
1334 assert_eq!(layer.translation_y, 0.0);
1335 assert!((360.0 * layer.scale_x - (374.14 + 2.63 * travel.abs())).abs() < 1e-3);
1336 assert!((62.0 * layer.scale_y - (64.4352 - 0.45294 * travel.abs())).abs() < 1e-3);
1337 assert!((layer.translation_x - travel * 2.756).abs() < 1e-3);
1338 }
1339 for width in [360.0, 398.0] {
1340 let layer = tab_bar_transform(width, 1.0, 0.0);
1341 assert!((width * (layer.scale_x - 1.0) - 14.14).abs() < 0.001);
1342 }
1343 }
1344
1345 #[test]
1346 fn tab_grid_matches_the_reference_pitch() {
1347 assert_eq!(TAB_WIDTH, 78.0);
1348 }
1349
1350 #[test]
1351 fn tab_grid_matches_the_reference_inner_inset() {
1352 assert_eq!(BLOB_MARGIN, 4.0);
1353 assert_eq!(BLOB_HEIGHT + 2.0 * BLOB_MARGIN, BAR_HEIGHT);
1354 }
1355
1356 #[test]
1357 fn flight_lens_uses_the_measured_sequential_warps() {
1358 let glass = tab_flight_lens_material(cranpose_ui_graphics::Color::BLACK, 1.0);
1359 let generic_lens = Glass::lens();
1360 assert!(glass.lift.is_some_and(|lift| (0.0..=0.15).contains(&lift)));
1361 assert_eq!(glass.refraction_depth_dp, Some(36.0));
1362 assert_eq!(
1363 glass.refraction,
1364 crate::material::GlassRefraction::EdgeLens { reach_dp: 9.0 }
1365 );
1366 assert!(glass.refraction_curve < generic_lens.refraction_curve);
1367 assert!(glass.dispersion * 0.3 < generic_lens.dispersion);
1368 assert_eq!(glass.blur_radius, Some(0.0));
1369 assert_eq!(glass.backdrop_blur, Some((4.0, 0.0)));
1370 assert_eq!(glass.highlight, 1.0);
1371 assert_eq!(glass.key_fill.map(|light| light.curvature), Some(0.8));
1372 assert!(
1373 glass.shadow,
1374 "the moving lens needs its target-visible SDF contact outline"
1375 );
1376 assert!(glass.tint.is_some_and(|tint| tint.a() == 0.0));
1377 assert_eq!(glass.face_response.unwrap().gain, 0.97);
1378 assert_eq!(glass.meniscus_absorption, 0.0);
1379 assert_eq!(glass.adaptive_frost, 0.0);
1380 }
1381
1382 #[test]
1383 fn resting_lens_does_not_blend_two_different_text_projections() {
1384 let rest = tab_flight_lens_material(Color::BLACK, 0.0);
1385 let held = tab_flight_lens_material(Color::BLACK, 1.0);
1386 assert_eq!(rest.optical_zoom, 1.0);
1387 assert_eq!(rest.backdrop_blur, Some((4.0, 1.0)));
1388 assert_eq!(rest.refraction_depth_dp, Some(36.0));
1389 assert_eq!(rest.fold_depth, 0.0);
1390 assert_eq!(rest.highlight, 0.0);
1391 assert_eq!(rest.meniscus_absorption, 0.0);
1392 assert_eq!(rest.shadow_style.unwrap().color.a(), 0.0);
1393 assert_eq!(held.optical_zoom, 1.0);
1394 assert_eq!(rest.refraction_depth_dp, held.refraction_depth_dp);
1395 let dynamics = tab_flight_dynamics(
1396 TabFlightGeometry {
1397 center: (50.0, 31.0),
1398 base_size: Size::new(95.0, 54.0),
1399 strain: Size::new(0.0, 0.0),
1400 lens_position: 0.0,
1401 lens_activity: 0.0,
1402 resting_tint: Color::TRANSPARENT,
1403 accessory_center: None,
1404 },
1405 TabFlightNode {
1406 origin: (0.0, 0.0),
1407 size: Size::new(140.0, 100.0),
1408 },
1409 );
1410 assert_eq!(dynamics.activity, Some(1.0));
1411 }
1412
1413 #[test]
1414 fn flight_lens_retains_neutral_tint_through_direct_motion() {
1415 assert_eq!(tab_flight_tint_multiplier(0.0), 1.0);
1416 assert!((tab_flight_tint_multiplier(1.0) - 0.75).abs() < f32::EPSILON);
1417 assert_eq!(tab_flight_tint_multiplier(-1.0), 1.0);
1418 assert!((tab_flight_tint_multiplier(2.0) - 0.75).abs() < f32::EPSILON);
1419 }
1420
1421 #[test]
1422 fn bar_surface_adapts_tone_to_its_foreground() {
1423 let glass = tab_bar_surface_material(cranpose_ui_graphics::Color::BLACK);
1424 assert_eq!(glass.blur_radius, Some(6.0));
1425 assert_eq!(glass.saturation, Some(1.0));
1426 assert_eq!(glass.lift, Some(0.0));
1427 assert_eq!(glass.refraction_depth, 0.0);
1428 assert_eq!(glass.refraction_depth_dp, Some(15.5));
1429 assert_eq!(
1430 glass.refraction,
1431 crate::material::GlassRefraction::Surface { reach_dp: 31.0 }
1432 );
1433 assert_eq!(glass.transmission_refraction, 1.0);
1434 assert_eq!(glass.adaptive_frost, 0.0);
1435 assert!(glass.adaptive_tone);
1436 }
1437
1438 #[test]
1439 fn bar_surface_keeps_its_refraction_after_release() {
1440 let glass = tab_bar_surface_material(Color::BLACK);
1441 assert_eq!(glass.refraction_depth_dp, Some(15.5));
1442 assert_eq!(glass.face_response.unwrap().illumination, 0.0);
1443 }
1444
1445 #[test]
1446 fn bar_surface_tone_tracks_the_local_foreground_polarity() {
1447 for foreground in [Color::BLACK, Color::WHITE] {
1448 let surface = tab_bar_surface_material(foreground);
1449 assert!(surface.adaptive_tone);
1450 assert_eq!(surface.foreground, Some(foreground));
1451 assert_eq!(surface.lift, Some(0.0));
1452 }
1453 }
1454
1455 #[test]
1456 fn bar_surface_tone_keeps_the_face_tint_neutral() {
1457 for foreground in [Color::BLACK, Color::WHITE] {
1458 assert_eq!(
1459 tab_bar_surface_material(foreground).tint,
1460 Some(Color::TRANSPARENT)
1461 );
1462 }
1463 }
1464
1465 #[test]
1466 fn contact_growth_follows_the_native_presentation_clock() {
1467 let mut samples = 0;
1468 for pressed in [false, true] {
1469 for route in 0..4 {
1470 let runtime = cranpose_core::Runtime::new(std::sync::Arc::new(
1471 cranpose_core::DefaultScheduler,
1472 ));
1473 let mut activity = cranpose_animation::Animatable::new(
1474 if pressed { 0.0 } else { 1.0 },
1475 runtime.handle(),
1476 );
1477 activity.animate_to_at(
1478 f32::from(pressed),
1479 tab_lens_activity_motion(pressed),
1480 1_000_000_000,
1481 );
1482 let mut error = 0.0;
1483 let mut count = 0;
1484 for line in include_str!("../../tests/fixtures/native_tab_contact.csv")
1485 .lines()
1486 .skip(1)
1487 {
1488 let sample = line
1489 .split(',')
1490 .map(|v| v.parse::<f64>().expect("contact sample"))
1491 .collect::<Vec<_>>();
1492 if sample[0] != f64::from(pressed) || sample[1] != f64::from(route) {
1493 continue;
1494 }
1495 runtime
1496 .handle()
1497 .drain_frame_callbacks(1_000_000_000 + (sample[2] * 1e9) as u64);
1498 error += (activity.state().value().clamp(0.0, 1.0) - sample[3] as f32).powi(2);
1499 count += 1;
1500 }
1501 assert!(count >= 30, "every native route must execute");
1502 let rms = (error / count as f32).sqrt();
1503 assert!(
1504 rms < 0.025,
1505 "native contact progress pressed={pressed} route={route} RMS: {rms}"
1506 );
1507 samples += count;
1508 }
1509 }
1510 assert!(samples >= 300);
1511 }
1512
1513 #[test]
1514 fn a_scope_declares_destinations_in_order() {
1515 let tabs = collect_tabs(|scope| {
1516 scope.tab("M0 0", "Discover");
1517 scope.app_badge("M1 1", "WWDC");
1518 scope.push(LiquidTab::new("M2 2", "Account").with_icon_scale(0.95));
1519 });
1520
1521 assert_eq!(tabs.len(), 3);
1522 assert_eq!(tabs[0].label, "Discover");
1523 assert_eq!(tabs[0].icon_style, LiquidTabIconStyle::Plain);
1524 assert_eq!(tabs[0].icon_scale, 1.0);
1525 assert_eq!(tabs[1].icon_style, LiquidTabIconStyle::AppBadge);
1526 assert_eq!(tabs[2].icon_scale, 0.95);
1527 }
1528
1529 #[test]
1530 fn a_bar_with_no_destinations_declares_none() {
1531 assert!(collect_tabs(|_| {}).is_empty());
1532 }
1533
1534 #[test]
1535 fn native_cells_overlap_without_crossing_the_strip_ends() {
1536 let geometry = TabGeometry::new(88.0, 4);
1537 assert_eq!(geometry.width, 352.0);
1538 assert!((geometry.cell_width - 95.0).abs() < 1e-4);
1539 for (index, center) in [72.5, 158.16667, 243.83333, 329.5].into_iter().enumerate() {
1540 let actual =
1541 25.0 + tab_lens_resting_left(index, geometry.pitch, 4) + geometry.cell_width * 0.5;
1542 assert!((actual - center).abs() < 1e-4);
1543 }
1544 for count in [0, 1, 2, 4, 8] {
1545 for allocation in [1.0, 24.0, 78.0, 120.0, 400.0] {
1546 let geometry = TabGeometry::new(allocation, count);
1547 let last = tab_lens_resting_left(count, geometry.pitch, count);
1548 assert!((last + geometry.cell_width - geometry.width).abs() < 1e-3);
1549 }
1550 }
1551 }
1552
1553 #[test]
1554 fn selection_width_uses_the_destination_allocation() {
1555 for (allocation, native_width) in [(88.0, 95.0), (97.5, 104.5)] {
1556 assert!((tab_lens_rest_width(allocation) - native_width).abs() < 1e-4);
1557 }
1558 }
1559 #[test]
1560 fn held_drag_coordinates_follow_the_lifted_cell_in_both_directions() {
1561 let geometry = TabGeometry::new(88.0, 4);
1562 for (travel, physical_x) in [(1.0, 304.66666), (-1.0, 47.333332)] {
1563 let transform = tab_bar_transform(360.0, 1.0, travel);
1564 let center = geometry.width * 0.5;
1565 let local_x =
1566 (physical_x - center - transform.translation_x) / transform.scale_x + center;
1567 let optical_x = geometry.optical_pointer_x(local_x, 1.0, travel);
1568 assert!(
1569 (optical_x - physical_x).abs() < 0.001,
1570 "pointer drift: {}",
1571 optical_x - physical_x
1572 );
1573 assert_eq!(
1574 geometry.optical_pointer_x(physical_x, 0.0, travel),
1575 physical_x
1576 );
1577 }
1578 }
1579 #[test]
1580 fn ink_selection_uses_the_same_bounded_shape_before_face_magnification() {
1581 let geometry = TabGeometry::new(88.0, 4);
1582 for activity in [0.0, 0.5, 1.0] {
1583 for value in [-1000.0, -0.1, 0.0, 0.1, 1000.0] {
1584 let strain = Size::new(value, -value * 1.5);
1585 let selected = tab_ink_selection(geometry, 120.0, activity, strain, Color::BLUE);
1586 let (width, height) = tab_lens_base_size(geometry.cell_width, activity);
1587 let size = tab_lens_deformed_size(Size::new(width, height), strain);
1588 assert_eq!(selected.content_zoom, 1.0 + 0.16 * activity);
1589 assert!((selected.bounds.width - size.width).abs() < 0.0001);
1590 assert!((selected.bounds.height - size.height).abs() < 0.0001);
1591 assert!((selected.bounds.x + selected.bounds.width * 0.5 - 167.5).abs() < 0.0001);
1592 assert!(selected.bounds.width > 0.0 && selected.bounds.height > 0.0);
1593 }
1594 }
1595 }
1596}