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::{LiquidColors, LiquidTypography, liquid_colors, liquid_typography},
21 widgets::{content_scope::ScopeContent, glass_surface::surface_material},
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: cranpose_ui::SharedText,
53 pub icon_style: LiquidTabIconStyle,
54 pub icon_scale: f32,
57 pub icon_offset: (f32, f32),
59}
60
61impl LiquidTab {
62 pub fn new(icon: &'static str, label: impl Into<cranpose_ui::SharedText>) -> Self {
64 Self {
65 icon: LiquidTabIcon::Vector(icon),
66 label: label.into(),
67 icon_style: LiquidTabIconStyle::Plain,
68 icon_scale: 1.0,
69 icon_offset: (0.0, 0.0),
70 }
71 }
72
73 pub fn from_painter(
75 painter: Painter,
76 size: Size,
77 label: impl Into<cranpose_ui::SharedText>,
78 ) -> Self {
79 Self {
80 icon: LiquidTabIcon::Painter { painter, size },
81 label: label.into(),
82 icon_style: LiquidTabIconStyle::Plain,
83 icon_scale: 1.0,
84 icon_offset: (0.0, 0.0),
85 }
86 }
87
88 pub fn app_badge(icon: &'static str, label: impl Into<cranpose_ui::SharedText>) -> Self {
90 Self {
91 icon: LiquidTabIcon::Vector(icon),
92 label: label.into(),
93 icon_style: LiquidTabIconStyle::AppBadge,
94 icon_scale: 1.0,
95 icon_offset: (0.0, 0.0),
96 }
97 }
98
99 pub fn with_icon_scale(mut self, scale: f32) -> Self {
100 self.icon_scale = normalize_icon_scale(scale);
101 self
102 }
103
104 pub fn with_icon_offset(mut self, x: f32, y: f32) -> Self {
106 self.icon_offset = (
107 if x.is_finite() { x } else { 0.0 },
108 if y.is_finite() { y } else { 0.0 },
109 );
110 self
111 }
112}
113
114fn normalize_icon_scale(scale: f32) -> f32 {
115 if scale.is_finite() {
116 scale.clamp(0.5, 1.5)
117 } else {
118 1.0
119 }
120}
121
122fn tab_base_content_color(colors: crate::theme::LiquidColors) -> Color {
123 colors.label
124}
125
126fn tab_selection_content_color(colors: crate::theme::LiquidColors) -> Color {
127 colors.accent
128}
129
130const BAR_HEIGHT: f32 = 62.0;
131const BLOB_HEIGHT: f32 = 54.0;
132const BLOB_MARGIN: f32 = 4.0;
133const FLIGHT_LENS_INFLATION: f32 = 16.0;
134const TAB_LENS_OVERLAP: f32 = 7.0;
135const TAB_WIDTH: f32 = 78.0;
136const TAB_ICON_SIZE: f32 = 32.0;
137const TAB_LABEL_SIZE: f32 = 10.0;
138const TAP_SLOP: f32 = 6.0;
139const ACCESSORY_GAP: f32 = 10.0;
140
141#[derive(Clone, Copy, Debug, PartialEq)]
143pub struct LiquidTabBarSpec {
144 max_tab_width: f32,
145}
146
147impl LiquidTabBarSpec {
148 pub fn new(max_tab_width: f32) -> Self {
150 Self {
151 max_tab_width: if max_tab_width.is_finite() {
152 max_tab_width.max(1.0)
153 } else {
154 TAB_WIDTH
155 },
156 }
157 }
158
159 fn cell_width(self, available_width: f32, count: usize) -> f32 {
160 let constrained = available_width - BLOB_MARGIN * 2.0;
161 if constrained.is_finite() && constrained > 1.0 {
162 (constrained / count as f32).min(self.max_tab_width)
163 } else {
164 self.max_tab_width
165 }
166 }
167}
168
169impl Default for LiquidTabBarSpec {
170 fn default() -> Self {
171 Self::new(TAB_WIDTH)
172 }
173}
174
175fn tab_flight_lens_material(foreground: cranpose_ui_graphics::Color, activity: f32) -> Glass {
176 let activity = activity.clamp(0.0, 1.0);
177 Glass::lens()
178 .face_lighting(false)
179 .key_fill(GlassKeyFill {
180 height_dp: 1.0,
181 curvature: 0.7 + 0.1 * activity,
182 angle_radians: std::f32::consts::FRAC_PI_4,
183 saturation: 1.5,
184 luma_gain: 0.4367,
185 offset: 1.125,
186 scale_with_surface: true,
187 })
188 .no_clip()
189 .face_response(GlassFaceResponse {
190 gain: 1.0 - 0.03 * activity,
191 start_dp: 1.0 / 3.0,
192 end_dp: 2.0 / 3.0,
193 illumination: 0.0,
194 })
195 .tint(neutral_surface_tint(
196 foreground,
197 0.07333333 * (1.0 - activity),
198 0.1440678 * (1.0 - activity),
199 ))
200 .blur_radius(0.0)
201 .backdrop_blur(4.0, 1.0 - activity)
202 .edge_refraction(9.0 * activity)
203 .refraction_depth_dp(36.0)
204 .refraction_curve(0.25)
205 .optical_zoom(1.0)
206 .meniscus_absorption(0.0)
207 .inner_shadow(GlassShadow::new(
208 Color::BLACK.with_alpha(0.12 * activity),
209 3.0,
210 7.0,
211 0.0,
212 ))
213 .fold_depth(0.0)
214 .edge_spectrum(crate::material::GlassSpectrum {
215 angle_radians: std::f32::consts::FRAC_PI_2
216 - std::f32::consts::PI * 7.0 / 12.0 * activity,
217 step_dp: (4.068 / (112.24138 / 36.0)) / (std::f32::consts::PI / 12.0).cos()
218 * (-5.0 + 8.684211 * activity)
219 / 3.6842105,
220 vertical_scale: 0.8265,
221 opacity_near: activity,
222 opacity_far: 1.0 - activity,
223 fade_depth_dp: 14.0 * activity,
224 extent_dp: 38.88889 * activity,
225 })
226 .dispersion(0.0)
227 .lift(0.0)
228 .highlight(activity)
229 .rim_reflection(activity)
230 .shadow_style(GlassShadow::new(
231 cranpose_ui_graphics::Color::BLACK.with_alpha(0.1 * activity),
232 8.0,
233 7.0,
234 0.0,
235 ))
236}
237
238fn tab_flight_tint_multiplier(activity: f32) -> f32 {
239 1.0 - 0.25 * activity.clamp(0.0, 1.0)
240}
241
242#[derive(Clone, Copy, Debug, Default, PartialEq)]
243struct TabGeometry {
244 width: f32,
245 cell_width: f32,
246 pitch: f32,
247}
248
249impl TabGeometry {
250 fn new(allocation: f32, count: usize) -> Self {
251 let width = allocation * count.max(1) as f32;
252 let cell_width = tab_lens_rest_width(allocation).min(width);
253 let pitch = if count > 1 {
254 (width - cell_width) / (count - 1) as f32
255 } else {
256 cell_width
257 };
258 Self {
259 width,
260 cell_width,
261 pitch,
262 }
263 }
264
265 fn drag_left(self, pointer_x: f32, count: usize, has_accessory: bool) -> f32 {
266 let last_tab = self.pitch * count.saturating_sub(1) as f32;
267 let min = if has_accessory {
268 -self.pitch * 0.2
269 } else {
270 0.0
271 };
272 let max = if has_accessory {
273 last_tab + self.pitch * 0.55
274 } else {
275 last_tab
276 };
277 (pointer_x - self.cell_width * 0.5).clamp(min, max)
278 }
279
280 fn optical_pointer_x(self, pointer_x: f32, press: f32, travel: f32) -> f32 {
281 let transform = tab_bar_transform(self.width + 2.0 * BLOB_MARGIN, press, travel);
282 let center = self.width * 0.5;
283 pointer_x + (pointer_x - center) * (transform.scale_x - 1.0) + transform.translation_x
284 }
285
286 fn lens_surface_position(self, position: f32, press: f32, travel: f32) -> f32 {
287 let transform = tab_bar_transform(self.width + 2.0 * BLOB_MARGIN, press, travel);
288 (position + self.cell_width * 0.5 - self.width * 0.5) * transform.scale_x
289 }
290}
291
292pub fn tab_lens_resting_left(selected: usize, tab_pitch: f32, count: usize) -> f32 {
294 tab_pitch * selected.min(count.saturating_sub(1)) as f32
295}
296
297pub fn tab_lens_rest_width(tab_allocation: f32) -> f32 {
300 tab_allocation + TAB_LENS_OVERLAP
301}
302
303#[derive(Clone, Copy, Debug, PartialEq)]
304struct AppBadgeGeometry {
305 size: Size,
306 corner_radius: f32,
307 stripe: Rect,
308 glyph: Rect,
309}
310
311fn app_badge_geometry(optical_scale: f32) -> AppBadgeGeometry {
312 let scale = normalize_icon_scale(optical_scale);
313 AppBadgeGeometry {
314 size: Size::new(20.0 * scale, 32.0 * scale),
315 corner_radius: 5.0 * scale,
316 stripe: Rect {
317 x: 7.0 * scale,
318 y: 4.5 * scale,
319 width: 6.0 * scale,
320 height: 1.5 * scale,
321 },
322 glyph: Rect {
323 x: 3.0 * scale,
324 y: 10.0 * scale,
325 width: 14.0 * scale,
326 height: 14.0 * scale,
327 },
328 }
329}
330
331#[composable]
332fn TabIcon(icon: LiquidTabIcon, style: LiquidTabIconStyle, color: Color, optical_scale: f32) {
333 const FRAME_HEIGHT: f32 = 32.0;
334 Box(
335 Modifier::empty().size(Size::new(TAB_ICON_SIZE, FRAME_HEIGHT)),
336 BoxSpec::default().content_alignment(Alignment::CENTER),
337 move || match style {
338 LiquidTabIconStyle::Plain => {
339 TabGlyph(icon.clone(), TAB_ICON_SIZE * optical_scale, color);
340 }
341 LiquidTabIconStyle::AppBadge => {
342 let icon = icon.clone();
343 let geometry = app_badge_geometry(optical_scale);
344 Box(
345 Modifier::empty()
346 .size(geometry.size)
347 .draw_behind(move |scope| {
348 scope.draw_round_rect(
349 Brush::solid(color),
350 CornerRadii::uniform(geometry.corner_radius),
351 );
352 scope.draw_rect_at(geometry.stripe, Brush::solid(Color::WHITE));
353 }),
354 BoxSpec::default(),
355 move || {
356 let icon = icon.clone();
357 Box(
358 Modifier::empty()
359 .offset(geometry.glyph.x, geometry.glyph.y)
360 .size(Size::new(geometry.glyph.width, geometry.glyph.height)),
361 BoxSpec::default(),
362 move || TabGlyph(icon.clone(), geometry.glyph.width, Color::WHITE),
363 );
364 },
365 );
366 }
367 },
368 );
369}
370
371#[composable]
372fn TabGlyph(icon: LiquidTabIcon, size: f32, color: Color) {
373 match icon {
374 LiquidTabIcon::Vector(path) => crate::icons::Icon(path, None, size, color),
375 LiquidTabIcon::Painter {
376 painter,
377 size: intrinsic,
378 } => {
379 let scale = size / TAB_ICON_SIZE;
380 Image(
381 painter,
382 None,
383 Modifier::empty()
384 .size(Size::new(intrinsic.width * scale, intrinsic.height * scale)),
385 Alignment::CENTER,
386 ContentScale::FillBounds,
387 1.0,
388 Some(cranpose_ui_graphics::ColorFilter::tint(color)),
389 );
390 }
391 }
392}
393
394fn tab_ink_selection(
395 geometry: TabGeometry,
396 position: f32,
397 activity: f32,
398 strain: Size,
399 color: Color,
400) -> super::vibrancy::InkSelection {
401 let (width, height) = tab_lens_base_size(geometry.cell_width, activity);
402 let base = Size::new(width, height);
403 let deformed = tab_lens_deformed_size(base, strain);
404 let width = deformed.width;
405 let height = deformed.height;
406 super::vibrancy::InkSelection {
407 bounds: Rect {
408 x: position + geometry.cell_width * 0.5 - width * 0.5,
409 y: BLOB_HEIGHT * 0.5 - height * 0.5,
410 width,
411 height,
412 },
413 color,
414 content_zoom: 1.0 + 0.16 * activity.clamp(0.0, 1.0),
415 activity: activity.clamp(0.0, 1.0),
416 optical_scale: 1.0,
417 projection: Size::new(deformed.width / base.width, deformed.height / base.height),
418 }
419}
420
421#[derive(Clone, Copy, PartialEq)]
422struct TabCellsSpec {
423 geometry: TabGeometry,
424 base_color: Color,
425 selected: usize,
426 committed_selection: usize,
427}
428
429fn lifted_ink(
431 geometry: TabGeometry,
432 count: usize,
433 selection: super::vibrancy::InkSelection,
434 transform: &cranpose_ui_graphics::GraphicsLayer,
435) -> (super::vibrancy::InkSelection, super::vibrancy::InkGrid) {
436 let size = Size::new(geometry.width + BLOB_MARGIN * 2.0, BAR_HEIGHT);
437 let mut selection = selection;
438 let center = selection.bounds.x + selection.bounds.width * 0.5 + BLOB_MARGIN;
439 selection.bounds.width *= transform.scale_x;
440 selection.bounds.height *= transform.scale_y;
441 selection.optical_scale = transform.scale_x.min(transform.scale_y);
442 selection.bounds.x = (center - size.width * 0.5) * transform.scale_x
443 + size.width * 0.5
444 + transform.translation_x
445 - selection.bounds.width * 0.5;
446 selection.bounds.y = (BAR_HEIGHT - selection.bounds.height) * 0.5;
447 let grid = super::vibrancy::InkGrid {
448 first_center: (
449 (BLOB_MARGIN + geometry.cell_width * 0.5 - size.width * 0.5) * transform.scale_x
450 + size.width * 0.5
451 + transform.translation_x,
452 BAR_HEIGHT * 0.5,
453 ),
454 pitch: geometry.pitch * transform.scale_x,
455 count,
456 };
457 (selection, grid)
458}
459
460#[composable]
461fn TabCells(
462 modifier: Modifier,
463 tabs: Rc<Vec<LiquidTab>>,
464 typography: LiquidTypography,
465 spec: TabCellsSpec,
466 selection: impl Fn() -> super::vibrancy::InkSelection + 'static,
467 transform: impl Fn() -> cranpose_ui_graphics::GraphicsLayer + 'static,
468 on_select: impl Fn(usize) + 'static,
469) {
470 let on_select: Rc<dyn Fn(usize)> = Rc::new(on_select);
471 let transform: Rc<dyn Fn() -> cranpose_ui_graphics::GraphicsLayer> = Rc::new(transform);
472 let geometry = spec.geometry;
473 let size = Size::new(geometry.width + BLOB_MARGIN * 2.0, BAR_HEIGHT);
474 let count = tabs.len();
475 let ink = {
476 let transform = Rc::clone(&transform);
477 move || lifted_ink(geometry, count, selection(), &transform())
478 };
479 super::vibrancy::VibrantContent(modifier, size, spec.base_color, ink, move || {
480 let tabs = Rc::clone(&tabs);
481 let typography = typography.clone();
482 let on_select = Rc::clone(&on_select);
483 let transform = Rc::clone(&transform);
484 Box(
485 Modifier::empty()
486 .size(size)
487 .selectable_group()
488 .role(SemanticsWidgetRole::TabBar)
489 .graphics_layer(move || transform()),
490 BoxSpec::default(),
491 move || {
492 for (index, tab) in tabs.iter().enumerate() {
493 let color = spec.base_color;
494 let icon_offset = tab.icon_offset;
495 let on_select = Rc::clone(&on_select);
496 let cell = Modifier::empty()
497 .offset(BLOB_MARGIN + index as f32 * geometry.pitch, BLOB_MARGIN)
498 .size(Size::new(geometry.cell_width, BLOB_HEIGHT))
499 .stable_semantics(super::selection::selection_semantics(
500 tab.label.to_string(),
501 SemanticsWidgetRole::Tab,
502 index,
503 spec.committed_selection,
504 on_select,
505 ))
506 .focusable();
507 let icon = tab.icon.clone();
508 let icon_style = tab.icon_style;
509 let icon_scale = tab.icon_scale;
510 let label = tab.label.clone();
511 let label_style = TextStyle {
512 span_style: SpanStyle {
513 color: Some(color),
514 font_size: cranpose_ui::text::TextUnit::Sp(TAB_LABEL_SIZE),
515 font_weight: Some(if spec.selected == index {
516 FontWeight::SEMI_BOLD
517 } else {
518 FontWeight::MEDIUM
519 }),
520 ..typography.caption1.span_style.clone()
521 },
522 paragraph_style: cranpose_ui::text::ParagraphStyle {
523 line_height: cranpose_ui::text::TextUnit::Sp(12.0),
524 line_height_style: Some(cranpose_ui::text::LineHeightStyle {
525 alignment: cranpose_ui::text::LineHeightAlignment::Center,
526 trim: cranpose_ui::text::LineHeightTrim::None,
527 mode: cranpose_ui::text::LineHeightMode::Fixed,
528 }),
529 ..typography.caption1.paragraph_style.clone()
530 },
531 };
532 Box(cell, BoxSpec::default(), move || {
533 let label = label.clone();
534 let label_style = label_style.clone();
535 let icon = icon.clone();
536 Box(
537 Modifier::empty()
538 .offset(
539 (geometry.cell_width - TAB_ICON_SIZE) * 0.5 + icon_offset.0,
540 3.0 + icon_offset.1,
541 )
542 .size(Size::new(TAB_ICON_SIZE, TAB_ICON_SIZE)),
543 BoxSpec::default(),
544 move || TabIcon(icon.clone(), icon_style, color, icon_scale),
545 );
546 Box(
547 Modifier::empty()
548 .offset(0.0, 35.0)
549 .size(Size::new(geometry.cell_width, 12.0)),
550 BoxSpec::default().content_alignment(Alignment::CENTER),
551 move || {
552 Text(label.clone(), Modifier::empty(), label_style.clone());
553 },
554 );
555 });
556 }
557 },
558 );
559 });
560}
561
562fn tab_lens_node_top(node_height: f32) -> f32 {
563 (BAR_HEIGHT - node_height) * 0.5
564}
565
566#[derive(Clone, Copy)]
567struct TabBarTouch {
568 position: cranpose_core::MutableState<(f32, f32)>,
569 origin: cranpose_core::MutableState<Option<f32>>,
570 travel: cranpose_core::MutableState<f32>,
571}
572
573impl TabBarTouch {
574 fn pressed(self, down: bool) {
575 if down {
576 self.origin.set(None);
577 self.travel.set(0.0);
578 }
579 }
580
581 fn update(self, x: f32, y: f32, span: f32) {
582 self.position.set((x + BLOB_MARGIN, y + BLOB_MARGIN));
583 if let Some(origin) = self.origin.get() {
584 self.travel
585 .set(((x - origin) / span.max(1.0)).clamp(-1.0, 1.0));
586 } else {
587 self.origin.set(Some(x));
588 }
589 }
590}
591
592#[composable]
593fn remember_tab_bar_touch() -> TabBarTouch {
594 TabBarTouch {
595 position: cranpose_core::remember(|| cranpose_core::mutableStateOf((0.0f32, 0.0f32)))
596 .with(|state| *state),
597 origin: cranpose_core::remember(|| cranpose_core::mutableStateOf(None::<f32>))
598 .with(|state| *state),
599 travel: cranpose_core::remember(|| cranpose_core::mutableStateOf(0.0f32))
600 .with(|state| *state),
601 }
602}
603
604fn tab_bar_transform(width: f32, press: f32, travel: f32) -> cranpose_ui_graphics::GraphicsLayer {
605 let press = press.max(0.0);
606 let travel = travel.clamp(-1.0, 1.0);
607 let rise = 14.14 / width.max(1.0) * press;
608 let strain = 2.63 / width.max(1.0) * travel.abs() * press;
609 cranpose_ui_graphics::GraphicsLayer {
610 scale_x: 1.0 + rise + strain,
611 scale_y: 1.0 + rise - strain,
612 translation_x: 2.756 * travel * press,
613 ..Default::default()
614 }
615}
616
617fn tab_bar_accessory_gap(has_accessory: bool) -> f32 {
618 if has_accessory { ACCESSORY_GAP } else { 0.0 }
619}
620
621fn accessory_surfaces_touch(edge_gap: f32) -> bool {
622 edge_gap <= 0.0
623}
624
625fn tab_lens_base_size(cell_width: f32, activity: f32) -> (f32, f32) {
626 let inflation = FLIGHT_LENS_INFLATION * activity.clamp(0.0, 1.0);
627 (cell_width + inflation, BLOB_HEIGHT + inflation)
628}
629
630#[derive(Clone, Copy, Debug, PartialEq)]
631struct TabFlightGeometry {
632 center: (f32, f32),
633 base_size: Size,
634 strain: Size,
635 lens_position: f32,
636 lens_activity: f32,
637 resting_tint: Color,
638 accessory_center: Option<(f32, f32)>,
639}
640
641#[derive(Clone, Copy, Debug, PartialEq)]
642struct TabFlightNode {
643 origin: (f32, f32),
644 size: Size,
645}
646
647fn tab_lens_deformed_size(base: Size, strain: Size) -> Size {
648 Size::new(
649 base.width * (1.0 + strain.width.clamp(-0.35, 0.35)),
650 base.height * (1.0 + strain.height.clamp(-0.35, 0.35)),
651 )
652}
653
654fn tab_flight_dynamics(geometry: TabFlightGeometry, node: TabFlightNode) -> GlassDynamics {
655 let activity = geometry.lens_activity.clamp(0.0, 1.0);
656 let size = geometry.base_size;
657 let deformed = tab_lens_deformed_size(size, geometry.strain);
658 let radius = size.width.min(size.height) * 0.5;
659 let glue = 20.0;
660 let shapes = geometry
661 .accessory_center
662 .filter(|(x, _)| {
663 let edge_gap = (*x - geometry.center.0).abs() - size.width * 0.5 - BAR_HEIGHT * 0.5;
664 accessory_surfaces_touch(edge_gap)
665 })
666 .map(|(x, y)| {
667 vec![(
668 x - node.origin.0,
669 y - node.origin.1,
670 BAR_HEIGHT,
671 BAR_HEIGHT,
672 -1.0,
673 )]
674 })
675 .unwrap_or_default();
676 GlassDynamics {
677 edge_return_depth_dp: Some(7.0 * activity),
678 optical_projection: Some((deformed.width / size.width, deformed.height / size.height)),
679 morph: Some(GlassMorph {
680 node_size: (node.size.width, node.size.height),
681 primary: (
682 geometry.center.0 - node.origin.0,
683 geometry.center.1 - node.origin.1,
684 size.width,
685 size.height,
686 radius,
687 ),
688 shapes,
689 glue,
690 wobble_amplitude: 0.0,
691 wobble_phase: geometry.lens_position * 0.045,
692 bulge_amplitude: 0.0,
693 bulge_direction: 0.0,
694 ellipse_blend: 0.0,
695 capsule_smoothing_dp: ((size.width - size.height).abs() * 0.5).min(radius * 0.6)
696 * activity,
697 deformation: None,
698 zoom_anchor: (0.0, 0.0),
699 }),
700 activity: Some(1.0),
701 resting_tint: Some(geometry.resting_tint),
702 tint_alpha_multiplier: Some(tab_flight_tint_multiplier(geometry.lens_activity)),
703 ..Default::default()
704 }
705}
706
707pub struct LiquidTabBarScope {
712 tabs: ScopeContent<LiquidTab>,
713}
714
715impl LiquidTabBarScope {
716 pub fn tab(&self, icon: &'static str, label: impl Into<cranpose_ui::SharedText>) {
718 self.push(LiquidTab::new(icon, label));
719 }
720
721 pub fn app_badge(&self, icon: &'static str, label: impl Into<cranpose_ui::SharedText>) {
723 self.push(LiquidTab::app_badge(icon, label));
724 }
725
726 pub fn push(&self, tab: LiquidTab) {
729 self.tabs.push(tab);
730 }
731}
732
733fn collect_tabs(content: impl FnOnce(&LiquidTabBarScope)) -> Vec<LiquidTab> {
734 ScopeContent::collect(|tabs| LiquidTabBarScope { tabs }, content)
735}
736
737#[composable]
746pub fn LiquidTabBar(
747 modifier: Modifier,
748 spec: LiquidTabBarSpec,
749 selected: usize,
750 on_select: impl Fn(usize) + 'static,
751 content: impl FnOnce(&LiquidTabBarScope),
752) {
753 LiquidTabBarLayout(
754 modifier,
755 spec,
756 collect_tabs(content),
757 selected,
758 on_select,
759 false,
760 || {},
761 );
762}
763
764#[composable]
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]
786fn LiquidTabBarLayout(
787 modifier: Modifier,
788 spec: LiquidTabBarSpec,
789 mut tabs: Vec<LiquidTab>,
790 selected: usize,
791 on_select: impl Fn(usize) + 'static,
792 has_accessory: bool,
793 accessory: impl FnMut() + 'static,
794) {
795 let colors = liquid_colors();
796 let typography = liquid_typography();
797 let tint_amount = crate::theme::liquid_glass_tint_amount();
798 let count = tabs.len().max(1);
799 let selected = selected.min(count - 1);
800 let rtl = cranpose_ui::layout_direction().is_rtl();
801 if rtl {
802 tabs.reverse();
803 }
804 let selected = if rtl { count - 1 - selected } else { selected };
805 let on_select: Rc<dyn Fn(usize)> =
806 Rc::new(move |index| on_select(if rtl { count - 1 - index } else { index }));
807 let tabs = Rc::new(tabs);
808 let accessory = Rc::new(RefCell::new(accessory));
809
810 Row(
811 modifier,
812 RowSpec::default().vertical_alignment(VerticalAlignment::CenterVertically),
813 move || {
814 let tabs = Rc::clone(&tabs);
815 let typography = typography.clone();
816 let on_select = Rc::clone(&on_select);
817 let accessory = Rc::clone(&accessory);
818
819 let tab_width = cranpose_core::rememberMutableStateOf(|| 0.0f32);
820 let lens_pressed = cranpose_core::rememberMutableStateOf(|| false);
821 let bar_touch = remember_tab_bar_touch();
822 let contact_motion = super::lens_motion::remember_tab_contact_motion();
823 let bar_press = contact_motion.bar_state();
824 let glow = contact_motion.glow_state();
825 let local_glow_factor = contact_motion.local_glow_factor_state();
826 let travel = cranpose_animation::animateFloatAsState(
827 bar_touch.travel.get(),
828 cranpose_animation::spring(0.85, 650.0),
829 "tabbar-travel",
830 );
831 let motion = remember_tab_bar_motion(bar_press, travel, contact_motion.lens_state());
832 let cells = TabGeometry::new(tab_width.get(), count);
833 let resting_lens_x = tab_lens_resting_left(selected, cells.pitch, count);
834 let visual_index = {
835 let motion = motion.clone();
836 cranpose_core::derivedStateOf(move || {
837 motion.visual_index(selected, cells, count, lens_pressed.get())
838 })
839 .get()
840 };
841 if bar_touch.travel.get_non_reactive().abs() > 0.001 && visual_index != selected {
842 contact_motion.crossed_tab();
843 }
844 let pill_w = cells.width + 2.0 * BLOB_MARGIN;
845 let bar_lift = Modifier::empty()
846 .graphics_layer(move || tab_bar_transform(pill_w, bar_press.get(), travel.get()));
847 cranpose_ui::ProvideLayoutDirection(cranpose_ui::LayoutDirection::Ltr, || {
848 Box(
849 Modifier::empty().height(BAR_HEIGHT),
850 BoxSpec::default(),
851 move || {
852 let tabs = Rc::clone(&tabs);
853 let typography = typography.clone();
854 let on_select = Rc::clone(&on_select);
855 let semantic_selection = Rc::clone(&on_select);
856 let contact_motion = Rc::clone(&contact_motion);
857 let published_axis = Rc::clone(&motion.axis);
858 let pill = bar_lift.clone().height(BAR_HEIGHT);
859 Box(pill, BoxSpec::default(), move || {
860 let on_select = Rc::clone(&on_select);
861 let contact_motion = Rc::clone(&contact_motion);
862 let published_axis = Rc::clone(&published_axis);
863 BoxWithConstraints(Modifier::empty(), move |scope| {
864 let on_select = Rc::clone(&on_select);
865 let measured_width =
866 spec.cell_width(scope.constraints().max_width, count);
867 tab_width.set(measured_width);
868
869 let geometry = TabGeometry::new(measured_width, count);
870 Box(
871 Modifier::empty()
872 .glass_effect(
873 surface_material(colors.label).tint_amount(tint_amount),
874 )
875 .size(Size::new(
876 geometry.width + BLOB_MARGIN * 2.0,
877 BAR_HEIGHT,
878 )),
879 BoxSpec::default(),
880 || {},
881 );
882 let resting_lens_x =
883 tab_lens_resting_left(selected, geometry.pitch, count);
884 let lens_axis = crate::motion::remember_liquid_follow_axis(
885 resting_lens_x,
886 cranpose_animation::spring(0.85, 650.0),
887 );
888 if !lens_pressed.get() {
889 lens_axis.settle_to(resting_lens_x, LiquidMotion::glide());
890 }
891 *published_axis.borrow_mut() = Some(Rc::clone(&lens_axis));
892 let gesture = TabGesture {
893 geometry,
894 count,
895 selected,
896 has_accessory,
897 lens_axis,
898 contact_motion: Rc::clone(&contact_motion),
899 on_select,
900 bar_press,
901 travel,
902 lens_pressed,
903 bar_touch,
904 }
905 .modifier();
906 Box(gesture, BoxSpec::default(), || {});
907 });
908 });
909
910 let drawing = Rc::new(TabLensDrawing {
911 lens: TabLensPlacement::new(
912 cells,
913 resting_lens_x,
914 colors,
915 has_accessory,
916 ),
917 motion: motion.clone(),
918 colors,
919 held_layers: cranpose_core::remember(|| Rc::new(RefCell::new(None)))
920 .with(Rc::clone),
921 });
922 let lighting = Rc::clone(&drawing);
923 super::tab_lighting::TabLighting(
924 Size::new(pill_w, BAR_HEIGHT),
925 move || lighting.transform(),
926 bar_touch.position,
927 glow,
928 local_glow_factor,
929 );
930 Box(drawing.glass_modifier(0), BoxSpec::default(), || {});
931 let (ink, lift) = (Rc::clone(&drawing), Rc::clone(&drawing));
932 TabCells(
933 Modifier::empty(),
934 Rc::clone(&tabs),
935 typography,
936 TabCellsSpec {
937 geometry: cells,
938 base_color: tab_base_content_color(colors),
939 selected: visual_index,
940 committed_selection: selected,
941 },
942 move || ink.selection(),
943 move || lift.transform(),
944 move |index| semantic_selection(index),
945 );
946 Box(drawing.glass_modifier(1), BoxSpec::default(), || {});
947 },
948 );
949 });
950
951 if has_accessory {
952 Box(
953 Modifier::empty().width(tab_bar_accessory_gap(true)),
954 BoxSpec::default(),
955 || {},
956 );
957 (accessory.borrow_mut())();
958 }
959 },
960 );
961}
962
963struct TabGesture {
964 geometry: TabGeometry,
965 count: usize,
966 selected: usize,
967 has_accessory: bool,
968 lens_axis: Rc<crate::motion::LiquidDragAxis>,
969 contact_motion: Rc<super::lens_motion::TabContactMotion>,
970 on_select: Rc<dyn Fn(usize)>,
971 bar_press: cranpose_core::State<f32>,
972 travel: cranpose_core::State<f32>,
973 lens_pressed: cranpose_core::MutableState<bool>,
974 bar_touch: TabBarTouch,
975}
976
977impl TabGesture {
978 fn modifier(self) -> Modifier {
979 let Self {
980 geometry,
981 count,
982 selected,
983 has_accessory,
984 lens_axis,
985 contact_motion,
986 on_select,
987 bar_press,
988 travel,
989 lens_pressed,
990 bar_touch,
991 } = self;
992 Modifier::empty()
993 .offset(BLOB_MARGIN, BLOB_MARGIN)
994 .size(Size::new(geometry.width, BLOB_HEIGHT))
995 .pointer_input(selected, move |scope: PointerInputScope| {
996 let contact_motion = Rc::clone(&contact_motion);
997 crate::motion::liquid_lens_gesture(
998 scope,
999 crate::motion::LiquidLensGesture {
1000 axis: Rc::clone(&lens_axis),
1001 cell_width: geometry.pitch,
1002 cell_offset: (geometry.cell_width - geometry.pitch) * 0.5,
1003 count,
1004 tap_slop: TAP_SLOP,
1005 drag_left: Rc::new(move |x| {
1006 geometry.drag_left(
1007 geometry.optical_pointer_x(x, bar_press.get(), travel.get()),
1008 count,
1009 has_accessory,
1010 )
1011 }),
1012 rest_left: Rc::new(move |index| {
1013 tab_lens_resting_left(index, geometry.pitch, count)
1014 }),
1015 selected,
1016 on_pressed: Rc::new(move |down, time| {
1017 lens_pressed.set(down);
1018 contact_motion.pressed(down, time);
1019 bar_touch.pressed(down);
1020 }),
1021 on_touch: Rc::new(move |x, y| {
1022 bar_touch.update(
1023 geometry.optical_pointer_x(x, bar_press.get(), travel.get()),
1024 y,
1025 geometry.pitch * count.saturating_sub(1) as f32,
1026 );
1027 }),
1028 on_select: Rc::clone(&on_select),
1029 },
1030 )
1031 })
1032 }
1033}
1034
1035type TabLensLayers = dyn Fn(f32, GlassDynamics) -> [cranpose_ui_graphics::GraphicsLayer; 2];
1038
1039type HeldTabLensLayers = (u32, Rc<TabLensLayers>);
1041
1042#[derive(Clone)]
1046struct TabBarMotion {
1047 axis: Rc<RefCell<Option<Rc<crate::motion::LiquidDragAxis>>>>,
1048 lens_shape: Rc<super::lens_motion::LensShapeMotion>,
1049 bar_press: cranpose_core::State<f32>,
1050 travel: cranpose_core::State<f32>,
1051 lens_activity: cranpose_core::State<f32>,
1052}
1053
1054#[composable]
1057fn remember_tab_bar_motion(
1058 bar_press: cranpose_core::State<f32>,
1059 travel: cranpose_core::State<f32>,
1060 lens_activity: cranpose_core::State<f32>,
1061) -> TabBarMotion {
1062 TabBarMotion {
1063 axis: cranpose_core::remember(|| Rc::new(RefCell::new(None))).with(Rc::clone),
1064 lens_shape: super::lens_motion::remember_lens_shape(super::lens_motion::LensShapeKind::Tab),
1065 bar_press,
1066 travel,
1067 lens_activity,
1068 }
1069}
1070
1071#[derive(Clone, Copy)]
1073struct TabLensFrame {
1074 lens_x: f32,
1075 activity: f32,
1076 strain: Size,
1077 press: f32,
1078 travel: f32,
1079}
1080
1081impl TabBarMotion {
1082 fn lens_x(&self, resting_lens_x: f32) -> f32 {
1084 self.axis
1085 .borrow()
1086 .as_ref()
1087 .map_or(resting_lens_x, |axis| axis.value())
1088 }
1089
1090 fn visual_index(
1093 &self,
1094 selected: usize,
1095 cells: TabGeometry,
1096 count: usize,
1097 pressed: bool,
1098 ) -> usize {
1099 let resting_lens_x = tab_lens_resting_left(selected, cells.pitch, count);
1100 let lens_x = self.lens_x(resting_lens_x);
1101 crate::motion::liquid_visual_index(
1102 selected,
1103 lens_x,
1104 cells.pitch,
1105 count,
1106 crate::motion::liquid_axis_owns_visual_selection(
1107 pressed,
1108 lens_x,
1109 resting_lens_x,
1110 cells.pitch,
1111 ),
1112 )
1113 }
1114
1115 fn frame(&self, cells: TabGeometry, resting_lens_x: f32) -> TabLensFrame {
1119 let press = self.bar_press.get();
1120 let travel = self.travel.get();
1121 let activity = self.lens_activity.get().clamp(0.0, 1.0);
1122 let lens_x = self.lens_x(resting_lens_x);
1123 let strain = self
1124 .lens_shape
1125 .sample(cells.lens_surface_position(lens_x, press, travel));
1126 TabLensFrame {
1127 lens_x,
1128 activity,
1129 strain,
1130 press,
1131 travel,
1132 }
1133 }
1134}
1135
1136#[derive(Clone, Copy)]
1139struct TabLensPlacement {
1140 cells: TabGeometry,
1141 resting_lens_x: f32,
1142 pill_w: f32,
1143 node_size: Size,
1144 node_top: f32,
1145 resting_tint: Color,
1146 accessory_center: Option<(f32, f32)>,
1147}
1148
1149impl TabLensPlacement {
1150 fn new(
1151 cells: TabGeometry,
1152 resting_lens_x: f32,
1153 colors: LiquidColors,
1154 has_accessory: bool,
1155 ) -> Self {
1156 let (lens_w, lens_h) = tab_lens_base_size(cells.cell_width, 1.0);
1157 let deformation_headroom =
1158 crate::dynamics::STRETCH_MAX.max(1.0 / crate::dynamics::STRETCH_MIN);
1159 let node_w = lens_w * deformation_headroom + crate::dynamics::BULGE_MAX + 20.0;
1160 let node_h = lens_h * deformation_headroom + crate::dynamics::BULGE_MAX + 16.0;
1161 let pill_w = cells.width + 2.0 * BLOB_MARGIN;
1162 Self {
1163 cells,
1164 resting_lens_x,
1165 pill_w,
1166 node_size: Size::new(node_w, node_h),
1167 node_top: tab_lens_node_top(node_h),
1168 resting_tint: colors.fill,
1169 accessory_center: has_accessory.then_some((
1170 pill_w + tab_bar_accessory_gap(true) + BAR_HEIGHT * 0.5,
1171 BAR_HEIGHT * 0.5,
1172 )),
1173 }
1174 }
1175
1176 fn center_x(self, frame: TabLensFrame) -> f32 {
1177 BLOB_MARGIN + frame.lens_x + self.cells.cell_width * 0.5
1178 }
1179
1180 fn node(self, frame: TabLensFrame) -> TabFlightNode {
1181 TabFlightNode {
1182 origin: (
1183 self.center_x(frame) - self.node_size.width * 0.5,
1184 self.node_top,
1185 ),
1186 size: self.node_size,
1187 }
1188 }
1189
1190 fn geometry(self, frame: TabLensFrame) -> TabFlightGeometry {
1191 let (base_w, base_h) = tab_lens_base_size(self.cells.cell_width, frame.activity);
1192 TabFlightGeometry {
1193 center: (self.center_x(frame), BAR_HEIGHT * 0.5),
1194 base_size: Size::new(base_w, base_h),
1195 strain: frame.strain,
1196 lens_position: frame.lens_x,
1197 lens_activity: frame.activity,
1198 resting_tint: self.resting_tint,
1199 accessory_center: self.accessory_center,
1200 }
1201 }
1202
1203 fn layer(self, motion: &TabBarMotion) -> cranpose_ui_graphics::GraphicsLayer {
1206 let frame = motion.frame(self.cells, self.resting_lens_x);
1207 let mut layer = tab_bar_transform(self.pill_w, frame.press, frame.travel);
1208 let center_x = self.center_x(frame);
1209 layer.translation_x +=
1210 (center_x - self.pill_w * 0.5) * (layer.scale_x - 1.0) + self.node(frame).origin.0;
1211 layer
1212 }
1213}
1214
1215struct TabLensDrawing {
1218 lens: TabLensPlacement,
1219 motion: TabBarMotion,
1220 colors: LiquidColors,
1221 held_layers: Rc<RefCell<Option<HeldTabLensLayers>>>,
1222}
1223
1224impl TabLensDrawing {
1225 fn transform(&self) -> cranpose_ui_graphics::GraphicsLayer {
1227 tab_bar_transform(
1228 self.lens.pill_w,
1229 self.motion.bar_press.get(),
1230 self.motion.travel.get(),
1231 )
1232 }
1233
1234 fn selection(&self) -> super::vibrancy::InkSelection {
1236 let frame = self.motion.frame(self.lens.cells, self.lens.resting_lens_x);
1237 tab_ink_selection(
1238 self.lens.cells,
1239 frame.lens_x,
1240 frame.activity,
1241 frame.strain,
1242 tab_selection_content_color(self.colors),
1243 )
1244 }
1245
1246 fn layers(&self, activity: f32) -> Rc<TabLensLayers> {
1249 let key = activity.to_bits();
1250 let mut held = self.held_layers.borrow_mut();
1251 if let Some((held_key, layers)) = &*held
1252 && *held_key == key
1253 {
1254 return Rc::clone(layers);
1255 }
1256 let layers: Rc<TabLensLayers> = Rc::new(crate::material::cached_glass_content_layers(
1257 tab_flight_lens_material(self.colors.label, activity).resolve(&self.colors),
1258 ));
1259 *held = Some((key, Rc::clone(&layers)));
1260 layers
1261 }
1262
1263 fn glass_layer(&self, layer: usize) -> cranpose_ui_graphics::GraphicsLayer {
1265 let frame = self.motion.frame(self.lens.cells, self.lens.resting_lens_x);
1266 self.layers(frame.activity)(
1267 cranpose_ui::current_density(),
1268 tab_flight_dynamics(self.lens.geometry(frame), self.lens.node(frame)),
1269 )[layer]
1270 .clone()
1271 }
1272
1273 fn glass_modifier(self: &Rc<Self>, layer: usize) -> Modifier {
1278 let (node, glass) = (Rc::clone(self), Rc::clone(self));
1279 let node_top = self.lens.node_top;
1280 Modifier::empty()
1281 .required_size(self.lens.node_size)
1282 .graphics_layer(move || {
1283 let mut layer = node.lens.layer(&node.motion);
1284 layer.translation_y += node_top;
1285 layer
1286 })
1287 .graphics_layer(move || glass.glass_layer(layer))
1288 }
1289}
1290
1291#[composable]
1293pub fn LiquidTabBarSearchAccessory(on_click: impl Fn() + 'static) {
1294 crate::widgets::GlassIconButton(
1295 Modifier::empty().content_description(cranpose_ui::UiString::Search.resolve()),
1296 crate::widgets::GlassButtonSpec::glass(),
1297 BAR_HEIGHT * 0.94,
1298 on_click,
1299 crate::icons::SEARCH,
1300 );
1301}
1302
1303#[cfg(test)]
1304#[path = "tests/tab_bar_tests.rs"]
1305mod tests;