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, 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 .graphics_layer_value(transform.clone()),
495 BoxSpec::default(),
496 move || {
497 for (index, tab) in tabs.iter().enumerate() {
498 let color = spec.base_color;
499 let label_for_semantics = tab.label;
500 let icon_offset = tab.icon_offset;
501 let cell = Modifier::empty()
502 .offset(BLOB_MARGIN + index as f32 * geometry.pitch, BLOB_MARGIN)
503 .size(Size::new(geometry.cell_width, BLOB_HEIGHT))
504 .semantics(move |config| {
505 config.role = Some(SemanticsWidgetRole::Tab);
506 config.is_clickable = true;
507 config.selected = Some(index == spec.committed_selection);
508 config.content_description = Some(label_for_semantics.to_string());
509 });
510 let icon = tab.icon.clone();
511 let icon_style = tab.icon_style;
512 let icon_scale = tab.icon_scale;
513 let label = tab.label;
514 let label_style = TextStyle {
515 span_style: SpanStyle {
516 color: Some(color),
517 font_size: cranpose_ui::text::TextUnit::Sp(TAB_LABEL_SIZE),
518 font_weight: Some(if spec.selected == index {
519 FontWeight::SEMI_BOLD
520 } else {
521 FontWeight::MEDIUM
522 }),
523 ..typography.caption1.span_style.clone()
524 },
525 paragraph_style: cranpose_ui::text::ParagraphStyle {
526 line_height: cranpose_ui::text::TextUnit::Sp(12.0),
527 ..typography.caption1.paragraph_style.clone()
528 },
529 };
530 Box(cell, BoxSpec::default(), move || {
531 let label_style = label_style.clone();
532 let icon = icon.clone();
533 Box(
534 Modifier::empty()
535 .offset(
536 (geometry.cell_width - TAB_ICON_SIZE) * 0.5 + icon_offset.0,
537 3.0 + icon_offset.1,
538 )
539 .size(Size::new(TAB_ICON_SIZE, TAB_ICON_SIZE)),
540 BoxSpec::default(),
541 move || TabIcon(icon.clone(), icon_style, color, icon_scale),
542 );
543 Box(
544 Modifier::empty()
545 .offset(0.0, 35.0)
546 .size(Size::new(geometry.cell_width, 12.0)),
547 BoxSpec::default().content_alignment(Alignment::CENTER),
548 move || {
549 Text(label, Modifier::empty(), label_style.clone());
550 },
551 );
552 });
553 }
554 },
555 );
556 },
557 );
558}
559
560fn tab_lens_node_top(node_height: f32) -> f32 {
561 (BAR_HEIGHT - node_height) * 0.5
562}
563
564#[derive(Clone, Copy)]
565struct TabBarTouch {
566 position: cranpose_core::MutableState<(f32, f32)>,
567 origin: cranpose_core::MutableState<Option<f32>>,
568 travel: cranpose_core::MutableState<f32>,
569}
570
571impl TabBarTouch {
572 fn pressed(self, down: bool) {
573 if down {
574 self.origin.set(None);
575 self.travel.set(0.0);
576 }
577 }
578
579 fn update(self, x: f32, y: f32, span: f32) {
580 self.position.set((x + BLOB_MARGIN, y + BLOB_MARGIN));
581 if let Some(origin) = self.origin.get() {
582 self.travel
583 .set(((x - origin) / span.max(1.0)).clamp(-1.0, 1.0));
584 } else {
585 self.origin.set(Some(x));
586 }
587 }
588}
589
590#[composable]
591fn remember_tab_bar_touch() -> TabBarTouch {
592 TabBarTouch {
593 position: cranpose_core::remember(|| cranpose_core::mutableStateOf((0.0f32, 0.0f32)))
594 .with(|state| *state),
595 origin: cranpose_core::remember(|| cranpose_core::mutableStateOf(None::<f32>))
596 .with(|state| *state),
597 travel: cranpose_core::remember(|| cranpose_core::mutableStateOf(0.0f32))
598 .with(|state| *state),
599 }
600}
601
602fn tab_bar_transform(width: f32, press: f32, travel: f32) -> cranpose_ui_graphics::GraphicsLayer {
603 let press = press.max(0.0);
604 let travel = travel.clamp(-1.0, 1.0);
605 let rise = 14.14 / width.max(1.0) * press;
606 let strain = 2.63 / width.max(1.0) * travel.abs() * press;
607 cranpose_ui_graphics::GraphicsLayer {
608 scale_x: 1.0 + rise + strain,
609 scale_y: 1.0 + rise - strain,
610 translation_x: 2.756 * travel * press,
611 ..Default::default()
612 }
613}
614
615fn tab_bar_accessory_gap(has_accessory: bool) -> f32 {
616 if has_accessory { ACCESSORY_GAP } else { 0.0 }
617}
618
619fn accessory_surfaces_touch(edge_gap: f32) -> bool {
620 edge_gap <= 0.0
621}
622
623fn tab_lens_base_size(cell_width: f32, activity: f32) -> (f32, f32) {
624 let inflation = FLIGHT_LENS_INFLATION * activity.clamp(0.0, 1.0);
625 (cell_width + inflation, BLOB_HEIGHT + inflation)
626}
627
628#[derive(Clone, Copy, Debug, PartialEq)]
629struct TabFlightGeometry {
630 center: (f32, f32),
631 base_size: Size,
632 strain: Size,
633 lens_position: f32,
634 lens_activity: f32,
635 resting_tint: Color,
636 accessory_center: Option<(f32, f32)>,
637}
638
639#[derive(Clone, Copy, Debug, PartialEq)]
640struct TabFlightNode {
641 origin: (f32, f32),
642 size: Size,
643}
644
645fn tab_lens_deformed_size(base: Size, strain: Size) -> Size {
646 Size::new(
647 base.width * (1.0 + strain.width.clamp(-0.35, 0.35)),
648 base.height * (1.0 + strain.height.clamp(-0.35, 0.35)),
649 )
650}
651
652fn tab_flight_dynamics(geometry: TabFlightGeometry, node: TabFlightNode) -> GlassDynamics {
653 let activity = geometry.lens_activity.clamp(0.0, 1.0);
654 let size = geometry.base_size;
655 let deformed = tab_lens_deformed_size(size, geometry.strain);
656 let radius = size.width.min(size.height) * 0.5;
657 let glue = 20.0;
658 let shapes = geometry
659 .accessory_center
660 .filter(|(x, _)| {
661 let edge_gap = (*x - geometry.center.0).abs() - size.width * 0.5 - BAR_HEIGHT * 0.5;
662 accessory_surfaces_touch(edge_gap)
663 })
664 .map(|(x, y)| {
665 vec![(
666 x - node.origin.0,
667 y - node.origin.1,
668 BAR_HEIGHT,
669 BAR_HEIGHT,
670 -1.0,
671 )]
672 })
673 .unwrap_or_default();
674 GlassDynamics {
675 edge_return_depth_dp: Some(7.0 * activity),
676 optical_projection: Some((deformed.width / size.width, deformed.height / size.height)),
677 morph: Some(GlassMorph {
678 node_size: (node.size.width, node.size.height),
679 primary: (
680 geometry.center.0 - node.origin.0,
681 geometry.center.1 - node.origin.1,
682 size.width,
683 size.height,
684 radius,
685 ),
686 shapes,
687 glue,
688 wobble_amplitude: 0.0,
689 wobble_phase: geometry.lens_position * 0.045,
690 bulge_amplitude: 0.0,
691 bulge_direction: 0.0,
692 ellipse_blend: 0.0,
693 capsule_smoothing_dp: ((size.width - size.height).abs() * 0.5).min(radius * 0.6)
694 * activity,
695 deformation: None,
696 zoom_anchor: (0.0, 0.0),
697 }),
698 activity: Some(1.0),
699 resting_tint: Some(geometry.resting_tint),
700 tint_alpha_multiplier: Some(tab_flight_tint_multiplier(geometry.lens_activity)),
701 ..Default::default()
702 }
703}
704
705pub struct LiquidTabBarScope {
710 tabs: ScopeContent<LiquidTab>,
711}
712
713impl LiquidTabBarScope {
714 pub fn tab(&self, icon: &'static str, label: &'static str) {
716 self.push(LiquidTab::new(icon, label));
717 }
718
719 pub fn app_badge(&self, icon: &'static str, label: &'static str) {
721 self.push(LiquidTab::app_badge(icon, label));
722 }
723
724 pub fn push(&self, tab: LiquidTab) {
727 self.tabs.push(tab);
728 }
729}
730
731fn collect_tabs(content: impl FnOnce(&LiquidTabBarScope)) -> Vec<LiquidTab> {
732 ScopeContent::collect(|tabs| LiquidTabBarScope { tabs }, content)
733}
734
735#[composable]
744#[allow(non_snake_case)]
745pub fn LiquidTabBar(
746 modifier: Modifier,
747 spec: LiquidTabBarSpec,
748 selected: usize,
749 on_select: impl Fn(usize) + 'static,
750 content: impl FnOnce(&LiquidTabBarScope),
751) {
752 LiquidTabBarLayout(
753 modifier,
754 spec,
755 collect_tabs(content),
756 selected,
757 on_select,
758 false,
759 || {},
760 );
761}
762
763#[composable]
765#[allow(non_snake_case)]
766pub fn LiquidTabBarWithAccessory(
767 modifier: Modifier,
768 spec: LiquidTabBarSpec,
769 selected: usize,
770 on_select: impl Fn(usize) + 'static,
771 content: impl FnOnce(&LiquidTabBarScope),
772 accessory: impl FnMut() + 'static,
773) {
774 LiquidTabBarLayout(
775 modifier,
776 spec,
777 collect_tabs(content),
778 selected,
779 on_select,
780 true,
781 accessory,
782 );
783}
784
785#[composable]
786#[allow(non_snake_case)]
787fn LiquidTabBarLayout(
788 modifier: Modifier,
789 spec: LiquidTabBarSpec,
790 tabs: Vec<LiquidTab>,
791 selected: usize,
792 on_select: impl Fn(usize) + 'static,
793 has_accessory: bool,
794 accessory: impl FnMut() + 'static,
795) {
796 let colors = liquid_colors();
797 let typography = liquid_typography();
798 let tint_amount = crate::theme::liquid_glass_tint_amount();
799 let count = tabs.len().max(1);
800 let selected = selected.min(count - 1);
801 let on_select: Rc<dyn Fn(usize)> = Rc::new(on_select);
802 let tabs = Rc::new(tabs);
803 let accessory = Rc::new(RefCell::new(accessory));
804
805 Row(
806 modifier,
807 RowSpec::default().vertical_alignment(VerticalAlignment::CenterVertically),
808 move || {
809 let tabs = Rc::clone(&tabs);
810 let typography = typography.clone();
811 let on_select = Rc::clone(&on_select);
812 let accessory = Rc::clone(&accessory);
813
814 let lens_x_outer = cranpose_core::remember(move || {
815 cranpose_core::mutableStateOf((
816 0.0f32,
817 0.0f32,
818 0.0f32,
819 Size::new(0.0, 0.0),
820 selected,
821 ))
822 })
823 .with(|state| *state);
824 let bar_touch = remember_tab_bar_touch();
825 let contact_motion = super::tab_motion::remember_tab_contact_motion();
826 let bar_press = contact_motion.bar_state();
827 let glow = contact_motion.glow_state();
828 let local_glow_factor = contact_motion.local_glow_factor_state();
829 let travel = cranpose_animation::animateFloatAsState(
830 bar_touch.travel.get(),
831 cranpose_animation::spring(0.85, 650.0),
832 "tabbar-travel",
833 );
834 let bar_lift = Modifier::empty().graphics_layer(move || {
835 let width = lens_x_outer.get().2 * count as f32 + 2.0 * BLOB_MARGIN;
836 tab_bar_transform(width, bar_press.get(), travel.get())
837 });
838 Box(
839 Modifier::empty().height(BAR_HEIGHT),
840 BoxSpec::default(),
841 move || {
842 let tabs = Rc::clone(&tabs);
843 let typography = typography.clone();
844 let on_select = Rc::clone(&on_select);
845 let contact_motion = Rc::clone(&contact_motion);
846 let pill = bar_lift.clone().height(BAR_HEIGHT);
847 Box(pill, BoxSpec::default(), move || {
848 let on_select = Rc::clone(&on_select);
849 let contact_motion = Rc::clone(&contact_motion);
850 BoxWithConstraints(Modifier::empty(), move |scope| {
851 let on_select = Rc::clone(&on_select);
852 let constrained = scope.constraints().max_width - BLOB_MARGIN * 2.0;
853 let tab_width = if constrained.is_finite() && constrained > 1.0 {
854 (constrained / count as f32).min(spec.max_tab_width)
855 } else {
856 spec.max_tab_width
857 };
858
859 let geometry = TabGeometry::new(tab_width, count);
860 Box(
861 Modifier::empty()
862 .glass_effect(
863 tab_bar_surface_material(colors.label)
864 .tint_amount(tint_amount),
865 )
866 .size(Size::new(
867 geometry.width + BLOB_MARGIN * 2.0,
868 BAR_HEIGHT,
869 )),
870 BoxSpec::default(),
871 || {},
872 );
873 let lens_pressed =
874 cranpose_core::remember(|| cranpose_core::mutableStateOf(false))
875 .with(|state| *state);
876 let resting_lens_x =
877 tab_lens_resting_left(selected, geometry.pitch, count);
878 let lens_axis = crate::motion::remember_liquid_follow_axis(
879 resting_lens_x,
880 cranpose_animation::spring(0.85, 650.0),
881 );
882 if !lens_pressed.get() {
883 lens_axis.settle_to(resting_lens_x, LiquidMotion::glide());
884 }
885 let lens_x = lens_axis.value();
886 let lens_shape = super::tab_motion::remember_tab_lens_shape();
887 let strain = lens_shape.sample(geometry.lens_surface_position(
888 lens_x,
889 bar_press.get(),
890 travel.get(),
891 ));
892 let lens_activity_anim = contact_motion.lens_state();
893 let lens_activity = lens_activity_anim.get().clamp(0.0, 1.0);
894 let visual_index = crate::motion::liquid_visual_index(
895 selected,
896 lens_x,
897 geometry.pitch,
898 count,
899 crate::motion::liquid_axis_owns_visual_selection(
900 lens_pressed.get(),
901 lens_x,
902 resting_lens_x,
903 geometry.pitch,
904 ),
905 );
906 if bar_touch.travel.get().abs() > 0.001 && visual_index != selected {
907 contact_motion.crossed_tab();
908 }
909 let row_width = geometry.width;
910 let gesture = Modifier::empty()
911 .offset(BLOB_MARGIN, BLOB_MARGIN)
912 .size(Size::new(row_width, BLOB_HEIGHT))
913 .pointer_input(selected, {
914 let on_select = Rc::clone(&on_select);
915 let lens_axis = Rc::clone(&lens_axis);
916 let contact_motion = Rc::clone(&contact_motion);
917 move |scope: PointerInputScope| {
918 let on_select = Rc::clone(&on_select);
919 let lens_axis = Rc::clone(&lens_axis);
920 let contact_motion = Rc::clone(&contact_motion);
921 crate::motion::liquid_lens_gesture(
922 scope,
923 crate::motion::LiquidLensGesture {
924 axis: lens_axis,
925 cell_width: geometry.pitch,
926 cell_offset: (geometry.cell_width - geometry.pitch)
927 * 0.5,
928 count,
929 tap_slop: TAP_SLOP,
930 drag_left: Rc::new(move |x| {
931 geometry.drag_left(
932 geometry.optical_pointer_x(
933 x,
934 bar_press.get(),
935 travel.get(),
936 ),
937 count,
938 has_accessory,
939 )
940 }),
941 rest_left: Rc::new(move |index| {
942 tab_lens_resting_left(
943 index,
944 geometry.pitch,
945 count,
946 )
947 }),
948 selected,
949 on_pressed: Rc::new(move |down, time| {
950 lens_pressed.set(down);
951 contact_motion.pressed(down, time);
952 bar_touch.pressed(down);
953 }),
954 on_touch: Rc::new(move |x, y| {
955 bar_touch.update(
956 geometry.optical_pointer_x(
957 x,
958 bar_press.get(),
959 travel.get(),
960 ),
961 y,
962 geometry.pitch
963 * count.saturating_sub(1) as f32,
964 );
965 }),
966 on_select,
967 },
968 )
969 }
970 });
971 Box(gesture, BoxSpec::default(), || {});
972
973 let published =
974 (lens_x, lens_activity, tab_width, strain, visual_index);
975 if lens_x_outer.get() != published {
976 lens_x_outer.set(published);
977 }
978 });
979 });
980
981 let (lens_px, lens_activity, lens_tab_w, strain, visual_index) =
982 lens_x_outer.get();
983 let cells = TabGeometry::new(lens_tab_w, count);
984 let (lens_w, lens_h) = tab_lens_base_size(cells.cell_width, 1.0);
985 let deformation_headroom =
986 crate::dynamics::STRETCH_MAX.max(1.0 / crate::dynamics::STRETCH_MIN);
987 let node_w = lens_w * deformation_headroom + crate::dynamics::BULGE_MAX + 20.0;
988 let node_h = lens_h * deformation_headroom + crate::dynamics::BULGE_MAX + 16.0;
989 let pill_w = cells.width + 2.0 * BLOB_MARGIN;
990 let transform = tab_bar_transform(pill_w, bar_press.get(), travel.get());
991 let local_center_x = BLOB_MARGIN + lens_px + cells.cell_width * 0.5;
992 let lens_center_x = local_center_x;
993 let node_x = lens_center_x - node_w * 0.5;
994 let node_top = tab_lens_node_top(node_h);
995 let (base_w, base_h) = tab_lens_base_size(cells.cell_width, lens_activity);
996 let geometry = TabFlightGeometry {
997 center: (lens_center_x, BAR_HEIGHT * 0.5),
998 base_size: Size::new(base_w, base_h),
999 strain,
1000 lens_position: lens_px,
1001 lens_activity,
1002 resting_tint: colors.fill,
1003 accessory_center: has_accessory.then_some((
1004 pill_w + tab_bar_accessory_gap(true) + BAR_HEIGHT * 0.5,
1005 BAR_HEIGHT * 0.5,
1006 )),
1007 };
1008 let lens_node = TabFlightNode {
1009 origin: (node_x, node_top),
1010 size: Size::new(node_w, node_h),
1011 };
1012
1013 let lens_geometry = geometry;
1014 let mut lens_transform = transform.clone();
1015 lens_transform.translation_x +=
1016 (local_center_x - pill_w * 0.5) * (transform.scale_x - 1.0);
1017 let layers = Rc::new(crate::material::cached_glass_content_layers(
1018 tab_flight_lens_material(colors.label, lens_activity).resolve(&colors),
1019 ));
1020 let lens_node_modifier = Modifier::empty()
1021 .required_size(lens_node.size)
1022 .offset(node_x, node_top)
1023 .graphics_layer_value(lens_transform);
1024 let background_layers = Rc::clone(&layers);
1025 let lens = lens_node_modifier.clone().graphics_layer(move || {
1026 background_layers(
1027 cranpose_ui::current_density(),
1028 tab_flight_dynamics(lens_geometry, lens_node),
1029 )[0]
1030 .clone()
1031 });
1032 super::tab_lighting::TabLighting(
1033 Size::new(pill_w, BAR_HEIGHT),
1034 transform.clone(),
1035 bar_touch.position,
1036 glow,
1037 local_glow_factor,
1038 );
1039 Box(lens, BoxSpec::default(), || {});
1040 TabCells(
1041 Modifier::empty(),
1042 Rc::clone(&tabs),
1043 typography.clone(),
1044 cells,
1045 TabCellsSpec {
1046 base_color: tab_base_content_color(colors),
1047 selection: tab_ink_selection(
1048 cells,
1049 lens_px,
1050 lens_activity,
1051 strain,
1052 tab_selection_content_color(colors),
1053 ),
1054 selected: visual_index,
1055 committed_selection: selected,
1056 },
1057 transform,
1058 );
1059 Box(
1060 lens_node_modifier.graphics_layer(move || {
1061 layers(
1062 cranpose_ui::current_density(),
1063 tab_flight_dynamics(lens_geometry, lens_node),
1064 )[1]
1065 .clone()
1066 }),
1067 BoxSpec::default(),
1068 || {},
1069 );
1070 },
1071 );
1072
1073 if has_accessory {
1074 Box(
1075 Modifier::empty().width(tab_bar_accessory_gap(true)),
1076 BoxSpec::default(),
1077 || {},
1078 );
1079 (accessory.borrow_mut())();
1080 }
1081 },
1082 );
1083}
1084
1085#[composable]
1087#[allow(non_snake_case)]
1088pub fn LiquidTabBarSearchAccessory(on_click: impl Fn() + 'static) {
1089 crate::widgets::GlassIconButton(
1090 Modifier::empty(),
1091 crate::widgets::GlassButtonSpec::glass(),
1092 BAR_HEIGHT * 0.94,
1093 on_click,
1094 crate::icons::SEARCH,
1095 );
1096}
1097
1098#[cfg(test)]
1099mod tests {
1100 use super::*;
1101 use crate::widgets::tab_motion::tab_lens_activity_motion;
1102
1103 #[test]
1104 fn tab_bar_spec_normalizes_the_maximum_cell_width() {
1105 assert_eq!(LiquidTabBarSpec::default().max_tab_width, TAB_WIDTH);
1106 assert_eq!(LiquidTabBarSpec::new(85.0).max_tab_width, 85.0);
1107 assert_eq!(LiquidTabBarSpec::new(0.0).max_tab_width, 1.0);
1108 assert_eq!(LiquidTabBarSpec::new(f32::NAN).max_tab_width, TAB_WIDTH);
1109 }
1110
1111 #[test]
1112 fn drag_pointer_centers_the_lens_and_preserves_end_overdrag() {
1113 let geometry = TabGeometry {
1114 width: 400.0,
1115 cell_width: 100.0,
1116 pitch: 100.0,
1117 };
1118 assert_eq!(geometry.drag_left(50.0, 4, true), 0.0);
1119 assert_eq!(geometry.drag_left(250.0, 4, true), 200.0);
1120 assert_eq!(geometry.drag_left(-100.0, 4, true), -20.0);
1121 assert_eq!(geometry.drag_left(500.0, 4, true), 355.0);
1122
1123 assert_eq!(geometry.drag_left(-100.0, 4, false), 0.0);
1124 assert_eq!(geometry.drag_left(500.0, 4, false), 300.0);
1125 }
1126
1127 #[test]
1128 fn resting_lens_centers_on_its_cell_and_stays_inside_the_pill() {
1129 let tab = 78.0;
1130 assert_eq!(tab_lens_resting_left(0, tab, 5), 0.0);
1131 assert_eq!(tab_lens_resting_left(1, tab, 5), tab);
1132 assert_eq!(tab_lens_resting_left(3, tab, 5), 3.0 * tab);
1133 assert_eq!(tab_lens_resting_left(4, tab, 5), 4.0 * tab);
1134 assert_eq!(tab_lens_resting_left(9, tab, 5), 4.0 * tab);
1135 }
1136
1137 #[test]
1138 fn flight_deformation_keeps_native_optics_in_the_unscaled_capsule() {
1139 let geometry = TabFlightGeometry {
1140 center: (80.0, 54.0),
1141 base_size: Size::new(111.0, 70.0),
1142 strain: Size::new(14.0 / 111.0, -14.0 / 70.0),
1143 lens_position: 0.0,
1144 lens_activity: 1.0,
1145 resting_tint: Color::TRANSPARENT,
1146 accessory_center: None,
1147 };
1148 let dynamics = tab_flight_dynamics(
1149 geometry,
1150 TabFlightNode {
1151 origin: (0.0, 0.0),
1152 size: Size::new(160.0, 108.0),
1153 },
1154 );
1155 let shape = dynamics.morph.unwrap().primary;
1156 assert_eq!((shape.2, shape.3, shape.4), (111.0, 70.0, 35.0));
1157 }
1158
1159 #[test]
1160 fn released_bubble_keeps_its_elastic_rebound() {
1161 let base = Size::new(104.5, 54.0);
1162 let strain = Size::new(0.017416525, -0.024551005);
1163 let actual = tab_lens_deformed_size(base, strain);
1164 assert!((actual.width - 106.32003).abs() < 0.0001);
1165 assert!((actual.height - 52.67425).abs() < 0.0001);
1166 }
1167
1168 #[test]
1169 fn flight_lens_node_is_centered_on_the_bar_axis() {
1170 for node_height in [48.0, 64.0, 96.0, 128.0] {
1171 let center = tab_lens_node_top(node_height) + node_height * 0.5;
1172 assert!((center - BAR_HEIGHT * 0.5).abs() < f32::EPSILON);
1173 }
1174 }
1175
1176 #[test]
1177 fn liquid_tab_builds_reference_content() {
1178 assert_eq!(TAB_ICON_SIZE, 32.0);
1179 let tab = LiquidTab::new(crate::icons::STAR, "Discover");
1180 assert_eq!(tab.icon, LiquidTabIcon::Vector(crate::icons::STAR));
1181 assert_eq!(tab.label, "Discover");
1182 assert_eq!(tab.icon_style, LiquidTabIconStyle::Plain);
1183
1184 let badge = LiquidTab::app_badge(crate::icons::APPLE, "WWDC");
1185 assert_eq!(badge.icon_style, LiquidTabIconStyle::AppBadge);
1186
1187 let compact = LiquidTab::new(crate::icons::ACCOUNT_CIRCLE, "Account").with_icon_scale(0.72);
1188 assert!((compact.icon_scale - 0.72).abs() < f32::EPSILON);
1189 assert_eq!(tab.clone().with_icon_scale(f32::NAN).icon_scale, 1.0);
1190 assert_eq!(tab.with_icon_scale(2.0).icon_scale, 1.5);
1191 }
1192
1193 #[test]
1194 fn artwork_offsets_preserve_the_tab_and_reject_nonfinite_coordinates() {
1195 let tab = LiquidTab::new(crate::icons::STAR, "Discover");
1196 let shifted = tab.clone().with_icon_offset(-1.0 / 3.0, 1.0);
1197 assert_eq!(shifted.icon_offset, (-1.0 / 3.0, 1.0));
1198 assert_eq!(shifted.icon, tab.icon);
1199 assert_eq!(shifted.label, tab.label);
1200 assert_eq!(
1201 tab.clone().with_icon_offset(f32::NAN, 1.0).icon_offset,
1202 (0.0, 1.0)
1203 );
1204 assert_eq!(
1205 tab.with_icon_offset(2.0, f32::INFINITY).icon_offset,
1206 (2.0, 0.0)
1207 );
1208 }
1209
1210 #[test]
1211 fn template_tab_keeps_artwork_size_and_label() {
1212 let bitmap = cranpose_ui_graphics::ImageBitmap::from_rgba8(1, 1, vec![0, 0, 0, 255])
1213 .expect("template pixel");
1214 let painter = Painter::from_bitmap(bitmap);
1215 let size = Size::new(24.0, 28.0);
1216 let tab = LiquidTab::from_painter(painter.clone(), size, "Saved");
1217 assert_eq!(tab.icon, LiquidTabIcon::Painter { painter, size });
1218 assert_eq!(tab.label, "Saved");
1219 assert_eq!(tab.icon_style, LiquidTabIconStyle::Plain);
1220 }
1221
1222 #[test]
1223 fn app_badge_geometry_honors_the_tab_optical_scale() {
1224 let full = app_badge_geometry(1.0);
1225 let corrected = app_badge_geometry(0.85);
1226 assert_eq!(full.size, Size::new(20.0, 32.0));
1227 assert_eq!(corrected.size, Size::new(17.0, 27.2));
1228 assert!((corrected.glyph.width - 11.9).abs() < 1.0e-5);
1229 assert!((corrected.corner_radius / full.corner_radius - 0.85).abs() < f32::EPSILON);
1230 assert!((corrected.stripe.x / full.stripe.x - 0.85).abs() < f32::EPSILON);
1231 assert!((corrected.glyph.width / full.glyph.width - 0.85).abs() < f32::EPSILON);
1232 }
1233
1234 #[test]
1235 fn base_tab_content_remains_neutral_under_the_moving_selection_layer() {
1236 let colors = crate::theme::LiquidColors::light(cranpose_ui_graphics::Color::from_rgb_u8(
1237 0, 122, 255,
1238 ));
1239 assert_eq!(tab_base_content_color(colors), colors.label);
1240 assert_eq!(tab_selection_content_color(colors), colors.accent);
1241 }
1242
1243 #[test]
1244 fn tab_deformation_preserves_independent_native_axis_motion() {
1245 let base = Size::new(120.5, 70.0);
1246 let strain = Size::new(0.1820888, -0.2760726);
1247 let deformed = tab_lens_deformed_size(base, strain);
1248 assert!((deformed.width - 142.4417).abs() < 0.001);
1249 assert!((deformed.height - 50.67492).abs() < 0.001);
1250 assert_eq!(tab_lens_deformed_size(base, Size::new(0.0, 0.0)), base);
1251 }
1252
1253 #[test]
1254 fn selection_mask_and_lens_resolve_the_same_global_sdf() {
1255 let geometry = TabFlightGeometry {
1256 center: (212.0, 32.0),
1257 base_size: Size::new(106.0, 64.0),
1258 strain: Size::new(0.0, 0.0),
1259 lens_position: 160.0,
1260 lens_activity: 1.0,
1261 resting_tint: Color::BLACK.with_alpha(0.10),
1262 accessory_center: None,
1263 };
1264 let mask_node = TabFlightNode {
1265 origin: (0.0, 0.0),
1266 size: Size::new(328.0, 64.0),
1267 };
1268 let lens_node = TabFlightNode {
1269 origin: (132.0, -22.0),
1270 size: Size::new(160.0, 108.0),
1271 };
1272 let mask = tab_flight_dynamics(geometry, mask_node)
1273 .morph
1274 .expect("selection mask morph");
1275 let lens = tab_flight_dynamics(geometry, lens_node)
1276 .morph
1277 .expect("lens morph");
1278 assert_eq!(
1279 (
1280 mask.primary.0 + mask_node.origin.0,
1281 mask.primary.1 + mask_node.origin.1
1282 ),
1283 geometry.center
1284 );
1285 assert_eq!(
1286 (
1287 lens.primary.0 + lens_node.origin.0,
1288 lens.primary.1 + lens_node.origin.1
1289 ),
1290 geometry.center
1291 );
1292 assert_eq!(
1293 (mask.primary.2, mask.primary.3, mask.primary.4),
1294 (lens.primary.2, lens.primary.3, lens.primary.4)
1295 );
1296 assert_eq!(mask.node_size, (328.0, 64.0));
1297 assert_eq!(lens.node_size, (160.0, 108.0));
1298 }
1299
1300 #[test]
1301 fn unified_bar_has_no_detached_accessory_gap() {
1302 assert_eq!(tab_bar_accessory_gap(false), 0.0);
1303 assert_eq!(tab_bar_accessory_gap(true), 10.0);
1304 }
1305
1306 #[test]
1307 fn flight_lens_only_joins_accessory_after_surface_contact() {
1308 assert!(!accessory_surfaces_touch(0.01));
1309 assert!(accessory_surfaces_touch(0.0));
1310 assert!(accessory_surfaces_touch(-4.0));
1311 }
1312
1313 #[test]
1314 fn lens_contact_swell_matches_the_raised_reference() {
1315 for (cell, raised_width) in [(95.0, 111.0), (104.5, 120.5)] {
1316 assert_eq!(tab_lens_base_size(cell, 0.0), (cell, 54.0));
1317 let raised = tab_lens_base_size(cell, 1.0);
1318 assert!((raised.0 - raised_width).abs() < 0.001);
1319 assert!((raised.1 - 70.0).abs() < 0.001);
1320 }
1321 }
1322
1323 #[test]
1324 fn held_bar_grows_about_its_center_and_strains_with_travel() {
1325 let rest = tab_bar_transform(360.0, 0.0, 1.0);
1326 assert_eq!(rest.scale_x, 1.0);
1327 assert_eq!(rest.scale_y, 1.0);
1328 assert_eq!(rest.translation_x, 0.0);
1329 for travel in [-1.0, 0.0, 1.0] {
1330 let layer = tab_bar_transform(360.0, 1.0, travel);
1331 assert_eq!(layer.translation_y, 0.0);
1332 assert!((360.0 * layer.scale_x - (374.14 + 2.63 * travel.abs())).abs() < 1e-3);
1333 assert!((62.0 * layer.scale_y - (64.4352 - 0.45294 * travel.abs())).abs() < 1e-3);
1334 assert!((layer.translation_x - travel * 2.756).abs() < 1e-3);
1335 }
1336 for width in [360.0, 398.0] {
1337 let layer = tab_bar_transform(width, 1.0, 0.0);
1338 assert!((width * (layer.scale_x - 1.0) - 14.14).abs() < 0.001);
1339 }
1340 }
1341
1342 #[test]
1343 fn tab_grid_matches_the_reference_pitch() {
1344 assert_eq!(TAB_WIDTH, 78.0);
1345 }
1346
1347 #[test]
1348 fn tab_grid_matches_the_reference_inner_inset() {
1349 assert_eq!(BLOB_MARGIN, 4.0);
1350 assert_eq!(BLOB_HEIGHT + 2.0 * BLOB_MARGIN, BAR_HEIGHT);
1351 }
1352
1353 #[test]
1354 fn flight_lens_uses_the_measured_sequential_warps() {
1355 let glass = tab_flight_lens_material(cranpose_ui_graphics::Color::BLACK, 1.0);
1356 let generic_lens = Glass::lens();
1357 assert!(glass.lift.is_some_and(|lift| (0.0..=0.15).contains(&lift)));
1358 assert_eq!(glass.refraction_depth_dp, Some(36.0));
1359 assert_eq!(
1360 glass.refraction,
1361 crate::material::GlassRefraction::EdgeLens { reach_dp: 9.0 }
1362 );
1363 assert!(glass.refraction_curve < generic_lens.refraction_curve);
1364 assert!(glass.dispersion * 0.3 < generic_lens.dispersion);
1365 assert_eq!(glass.blur_radius, Some(0.0));
1366 assert_eq!(glass.backdrop_blur, Some((4.0, 0.0)));
1367 assert_eq!(glass.highlight, 1.0);
1368 assert_eq!(glass.key_fill.map(|light| light.curvature), Some(0.8));
1369 assert!(
1370 glass.shadow,
1371 "the moving lens needs its target-visible SDF contact outline"
1372 );
1373 assert!(glass.tint.is_some_and(|tint| tint.a() == 0.0));
1374 assert_eq!(glass.face_response.unwrap().gain, 0.97);
1375 assert_eq!(glass.meniscus_absorption, 0.0);
1376 assert_eq!(glass.adaptive_frost, 0.0);
1377 }
1378
1379 #[test]
1380 fn resting_lens_does_not_blend_two_different_text_projections() {
1381 let rest = tab_flight_lens_material(Color::BLACK, 0.0);
1382 let held = tab_flight_lens_material(Color::BLACK, 1.0);
1383 assert_eq!(rest.optical_zoom, 1.0);
1384 assert_eq!(rest.backdrop_blur, Some((4.0, 1.0)));
1385 assert_eq!(rest.refraction_depth_dp, Some(36.0));
1386 assert_eq!(rest.fold_depth, 0.0);
1387 assert_eq!(rest.highlight, 0.0);
1388 assert_eq!(rest.meniscus_absorption, 0.0);
1389 assert_eq!(rest.shadow_style.unwrap().color.a(), 0.0);
1390 assert_eq!(held.optical_zoom, 1.0);
1391 assert_eq!(rest.refraction_depth_dp, held.refraction_depth_dp);
1392 let dynamics = tab_flight_dynamics(
1393 TabFlightGeometry {
1394 center: (50.0, 31.0),
1395 base_size: Size::new(95.0, 54.0),
1396 strain: Size::new(0.0, 0.0),
1397 lens_position: 0.0,
1398 lens_activity: 0.0,
1399 resting_tint: Color::TRANSPARENT,
1400 accessory_center: None,
1401 },
1402 TabFlightNode {
1403 origin: (0.0, 0.0),
1404 size: Size::new(140.0, 100.0),
1405 },
1406 );
1407 assert_eq!(dynamics.activity, Some(1.0));
1408 }
1409
1410 #[test]
1411 fn flight_lens_retains_neutral_tint_through_direct_motion() {
1412 assert_eq!(tab_flight_tint_multiplier(0.0), 1.0);
1413 assert!((tab_flight_tint_multiplier(1.0) - 0.75).abs() < f32::EPSILON);
1414 assert_eq!(tab_flight_tint_multiplier(-1.0), 1.0);
1415 assert!((tab_flight_tint_multiplier(2.0) - 0.75).abs() < f32::EPSILON);
1416 }
1417
1418 #[test]
1419 fn bar_surface_adapts_tone_to_its_foreground() {
1420 let glass = tab_bar_surface_material(cranpose_ui_graphics::Color::BLACK);
1421 assert_eq!(glass.blur_radius, Some(6.0));
1422 assert_eq!(glass.saturation, Some(1.0));
1423 assert_eq!(glass.lift, Some(0.0));
1424 assert_eq!(glass.refraction_depth, 0.0);
1425 assert_eq!(glass.refraction_depth_dp, Some(15.5));
1426 assert_eq!(
1427 glass.refraction,
1428 crate::material::GlassRefraction::Surface { reach_dp: 31.0 }
1429 );
1430 assert_eq!(glass.transmission_refraction, 1.0);
1431 assert_eq!(glass.adaptive_frost, 0.0);
1432 assert!(glass.adaptive_tone);
1433 }
1434
1435 #[test]
1436 fn bar_surface_keeps_its_refraction_after_release() {
1437 let glass = tab_bar_surface_material(Color::BLACK);
1438 assert_eq!(glass.refraction_depth_dp, Some(15.5));
1439 assert_eq!(glass.face_response.unwrap().illumination, 0.0);
1440 }
1441
1442 #[test]
1443 fn bar_surface_tone_tracks_the_local_foreground_polarity() {
1444 for foreground in [Color::BLACK, Color::WHITE] {
1445 let surface = tab_bar_surface_material(foreground);
1446 assert!(surface.adaptive_tone);
1447 assert_eq!(surface.foreground, Some(foreground));
1448 assert_eq!(surface.lift, Some(0.0));
1449 }
1450 }
1451
1452 #[test]
1453 fn bar_surface_tone_keeps_the_face_tint_neutral() {
1454 for foreground in [Color::BLACK, Color::WHITE] {
1455 assert_eq!(
1456 tab_bar_surface_material(foreground).tint,
1457 Some(Color::TRANSPARENT)
1458 );
1459 }
1460 }
1461
1462 #[test]
1463 fn contact_growth_follows_the_native_presentation_clock() {
1464 let mut samples = 0;
1465 for pressed in [false, true] {
1466 for route in 0..4 {
1467 let runtime = cranpose_core::Runtime::new(std::sync::Arc::new(
1468 cranpose_core::DefaultScheduler,
1469 ));
1470 let mut activity = cranpose_animation::Animatable::new(
1471 if pressed { 0.0 } else { 1.0 },
1472 runtime.handle(),
1473 );
1474 activity.animate_to_at(
1475 f32::from(pressed),
1476 tab_lens_activity_motion(pressed),
1477 1_000_000_000,
1478 );
1479 let mut error = 0.0;
1480 let mut count = 0;
1481 for line in include_str!("../../tests/fixtures/native_tab_contact.csv")
1482 .lines()
1483 .skip(1)
1484 {
1485 let sample = line
1486 .split(',')
1487 .map(|v| v.parse::<f64>().expect("contact sample"))
1488 .collect::<Vec<_>>();
1489 if sample[0] != f64::from(pressed) || sample[1] != f64::from(route) {
1490 continue;
1491 }
1492 runtime
1493 .handle()
1494 .drain_frame_callbacks(1_000_000_000 + (sample[2] * 1e9) as u64);
1495 error += (activity.state().value().clamp(0.0, 1.0) - sample[3] as f32).powi(2);
1496 count += 1;
1497 }
1498 assert!(count >= 30, "every native route must execute");
1499 let rms = (error / count as f32).sqrt();
1500 assert!(
1501 rms < 0.025,
1502 "native contact progress pressed={pressed} route={route} RMS: {rms}"
1503 );
1504 samples += count;
1505 }
1506 }
1507 assert!(samples >= 300);
1508 }
1509
1510 #[test]
1511 fn a_scope_declares_destinations_in_order() {
1512 let tabs = collect_tabs(|scope| {
1513 scope.tab("M0 0", "Discover");
1514 scope.app_badge("M1 1", "WWDC");
1515 scope.push(LiquidTab::new("M2 2", "Account").with_icon_scale(0.95));
1516 });
1517
1518 assert_eq!(tabs.len(), 3);
1519 assert_eq!(tabs[0].label, "Discover");
1520 assert_eq!(tabs[0].icon_style, LiquidTabIconStyle::Plain);
1521 assert_eq!(tabs[0].icon_scale, 1.0);
1522 assert_eq!(tabs[1].icon_style, LiquidTabIconStyle::AppBadge);
1523 assert_eq!(tabs[2].icon_scale, 0.95);
1524 }
1525
1526 #[test]
1527 fn a_bar_with_no_destinations_declares_none() {
1528 assert!(collect_tabs(|_| {}).is_empty());
1529 }
1530
1531 #[test]
1532 fn native_cells_overlap_without_crossing_the_strip_ends() {
1533 let geometry = TabGeometry::new(88.0, 4);
1534 assert_eq!(geometry.width, 352.0);
1535 assert!((geometry.cell_width - 95.0).abs() < 1e-4);
1536 for (index, center) in [72.5, 158.16667, 243.83333, 329.5].into_iter().enumerate() {
1537 let actual =
1538 25.0 + tab_lens_resting_left(index, geometry.pitch, 4) + geometry.cell_width * 0.5;
1539 assert!((actual - center).abs() < 1e-4);
1540 }
1541 for count in [0, 1, 2, 4, 8] {
1542 for allocation in [1.0, 24.0, 78.0, 120.0, 400.0] {
1543 let geometry = TabGeometry::new(allocation, count);
1544 let last = tab_lens_resting_left(count, geometry.pitch, count);
1545 assert!((last + geometry.cell_width - geometry.width).abs() < 1e-3);
1546 }
1547 }
1548 }
1549
1550 #[test]
1551 fn selection_width_uses_the_destination_allocation() {
1552 for (allocation, native_width) in [(88.0, 95.0), (97.5, 104.5)] {
1553 assert!((tab_lens_rest_width(allocation) - native_width).abs() < 1e-4);
1554 }
1555 }
1556 #[test]
1557 fn held_drag_coordinates_follow_the_lifted_cell_in_both_directions() {
1558 let geometry = TabGeometry::new(88.0, 4);
1559 for (travel, physical_x) in [(1.0, 304.66666), (-1.0, 47.333332)] {
1560 let transform = tab_bar_transform(360.0, 1.0, travel);
1561 let center = geometry.width * 0.5;
1562 let local_x =
1563 (physical_x - center - transform.translation_x) / transform.scale_x + center;
1564 let optical_x = geometry.optical_pointer_x(local_x, 1.0, travel);
1565 assert!(
1566 (optical_x - physical_x).abs() < 0.001,
1567 "pointer drift: {}",
1568 optical_x - physical_x
1569 );
1570 assert_eq!(
1571 geometry.optical_pointer_x(physical_x, 0.0, travel),
1572 physical_x
1573 );
1574 }
1575 }
1576 #[test]
1577 fn ink_selection_uses_the_same_bounded_shape_before_face_magnification() {
1578 let geometry = TabGeometry::new(88.0, 4);
1579 for activity in [0.0, 0.5, 1.0] {
1580 for value in [-1000.0, -0.1, 0.0, 0.1, 1000.0] {
1581 let strain = Size::new(value, -value * 1.5);
1582 let selected = tab_ink_selection(geometry, 120.0, activity, strain, Color::BLUE);
1583 let (width, height) = tab_lens_base_size(geometry.cell_width, activity);
1584 let size = tab_lens_deformed_size(Size::new(width, height), strain);
1585 assert_eq!(selected.content_zoom, 1.0 + 0.16 * activity);
1586 assert!((selected.bounds.width - size.width).abs() < 0.0001);
1587 assert!((selected.bounds.height - size.height).abs() < 0.0001);
1588 assert!((selected.bounds.x + selected.bounds.width * 0.5 - 167.5).abs() < 0.0001);
1589 assert!(selected.bounds.width > 0.0 && selected.bounds.height > 0.0);
1590 }
1591 }
1592 }
1593}