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