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: &'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_flight_tint_multiplier(activity: f32) -> f32 {
232 1.0 - 0.25 * activity.clamp(0.0, 1.0)
233}
234
235#[derive(Clone, Copy, Debug, Default, PartialEq)]
236struct TabGeometry {
237 width: f32,
238 cell_width: f32,
239 pitch: f32,
240}
241
242impl TabGeometry {
243 fn new(allocation: f32, count: usize) -> Self {
244 let width = allocation * count.max(1) as f32;
245 let cell_width = tab_lens_rest_width(allocation).min(width);
246 let pitch = if count > 1 {
247 (width - cell_width) / (count - 1) as f32
248 } else {
249 cell_width
250 };
251 Self {
252 width,
253 cell_width,
254 pitch,
255 }
256 }
257
258 fn drag_left(self, pointer_x: f32, count: usize, has_accessory: bool) -> f32 {
259 let last_tab = self.pitch * count.saturating_sub(1) as f32;
260 let min = if has_accessory {
261 -self.pitch * 0.2
262 } else {
263 0.0
264 };
265 let max = if has_accessory {
266 last_tab + self.pitch * 0.55
267 } else {
268 last_tab
269 };
270 (pointer_x - self.cell_width * 0.5).clamp(min, max)
271 }
272
273 fn optical_pointer_x(self, pointer_x: f32, press: f32, travel: f32) -> f32 {
274 let transform = tab_bar_transform(self.width + 2.0 * BLOB_MARGIN, press, travel);
275 let center = self.width * 0.5;
276 pointer_x + (pointer_x - center) * (transform.scale_x - 1.0) + transform.translation_x
277 }
278
279 fn lens_surface_position(self, position: f32, press: f32, travel: f32) -> f32 {
280 let transform = tab_bar_transform(self.width + 2.0 * BLOB_MARGIN, press, travel);
281 (position + self.cell_width * 0.5 - self.width * 0.5) * transform.scale_x
282 }
283}
284
285pub fn tab_lens_resting_left(selected: usize, tab_pitch: f32, count: usize) -> f32 {
287 tab_pitch * selected.min(count.saturating_sub(1)) as f32
288}
289
290pub fn tab_lens_rest_width(tab_allocation: f32) -> f32 {
293 tab_allocation + TAB_LENS_OVERLAP
294}
295
296#[derive(Clone, Copy, Debug, PartialEq)]
297struct AppBadgeGeometry {
298 size: Size,
299 corner_radius: f32,
300 stripe: Rect,
301 glyph: Rect,
302}
303
304fn app_badge_geometry(optical_scale: f32) -> AppBadgeGeometry {
305 let scale = normalize_icon_scale(optical_scale);
306 AppBadgeGeometry {
307 size: Size::new(20.0 * scale, 32.0 * scale),
308 corner_radius: 5.0 * scale,
309 stripe: Rect {
310 x: 7.0 * scale,
311 y: 4.5 * scale,
312 width: 6.0 * scale,
313 height: 1.5 * scale,
314 },
315 glyph: Rect {
316 x: 3.0 * scale,
317 y: 10.0 * scale,
318 width: 14.0 * scale,
319 height: 14.0 * scale,
320 },
321 }
322}
323
324#[composable]
325fn TabIcon(icon: LiquidTabIcon, style: LiquidTabIconStyle, color: Color, optical_scale: f32) {
326 const FRAME_HEIGHT: f32 = 32.0;
327 Box(
328 Modifier::empty().size(Size::new(TAB_ICON_SIZE, FRAME_HEIGHT)),
329 BoxSpec::default().content_alignment(Alignment::CENTER),
330 move || match style {
331 LiquidTabIconStyle::Plain => {
332 TabGlyph(icon.clone(), TAB_ICON_SIZE * optical_scale, color);
333 }
334 LiquidTabIconStyle::AppBadge => {
335 let icon = icon.clone();
336 let geometry = app_badge_geometry(optical_scale);
337 Box(
338 Modifier::empty()
339 .size(geometry.size)
340 .draw_behind(move |scope| {
341 scope.draw_round_rect(
342 Brush::solid(color),
343 CornerRadii::uniform(geometry.corner_radius),
344 );
345 scope.draw_rect_at(geometry.stripe, Brush::solid(Color::WHITE));
346 }),
347 BoxSpec::default(),
348 move || {
349 let icon = icon.clone();
350 Box(
351 Modifier::empty()
352 .offset(geometry.glyph.x, geometry.glyph.y)
353 .size(Size::new(geometry.glyph.width, geometry.glyph.height)),
354 BoxSpec::default(),
355 move || TabGlyph(icon.clone(), geometry.glyph.width, Color::WHITE),
356 );
357 },
358 );
359 }
360 },
361 );
362}
363
364#[composable]
365fn TabGlyph(icon: LiquidTabIcon, size: f32, color: Color) {
366 match icon {
367 LiquidTabIcon::Vector(path) => crate::icons::Icon(path, None, size, color),
368 LiquidTabIcon::Painter {
369 painter,
370 size: intrinsic,
371 } => {
372 let scale = size / TAB_ICON_SIZE;
373 Image(
374 painter,
375 None,
376 Modifier::empty()
377 .size(Size::new(intrinsic.width * scale, intrinsic.height * scale)),
378 Alignment::CENTER,
379 ContentScale::FillBounds,
380 1.0,
381 Some(cranpose_ui_graphics::ColorFilter::tint(color)),
382 );
383 }
384 }
385}
386
387fn tab_ink_selection(
388 geometry: TabGeometry,
389 position: f32,
390 activity: f32,
391 strain: Size,
392 color: Color,
393) -> super::vibrancy::InkSelection {
394 let (width, height) = tab_lens_base_size(geometry.cell_width, activity);
395 let base = Size::new(width, height);
396 let deformed = tab_lens_deformed_size(base, strain);
397 let width = deformed.width;
398 let height = deformed.height;
399 super::vibrancy::InkSelection {
400 bounds: Rect {
401 x: position + geometry.cell_width * 0.5 - width * 0.5,
402 y: BLOB_HEIGHT * 0.5 - height * 0.5,
403 width,
404 height,
405 },
406 color,
407 content_zoom: 1.0 + 0.16 * activity.clamp(0.0, 1.0),
408 activity: activity.clamp(0.0, 1.0),
409 optical_scale: 1.0,
410 projection: Size::new(deformed.width / base.width, deformed.height / base.height),
411 }
412}
413
414#[derive(Clone, Copy, PartialEq)]
415struct TabCellsSpec {
416 geometry: TabGeometry,
417 base_color: Color,
418 selected: usize,
419 committed_selection: usize,
420}
421
422fn lifted_ink(
424 geometry: TabGeometry,
425 count: usize,
426 selection: super::vibrancy::InkSelection,
427 transform: &cranpose_ui_graphics::GraphicsLayer,
428) -> (super::vibrancy::InkSelection, super::vibrancy::InkGrid) {
429 let size = Size::new(geometry.width + BLOB_MARGIN * 2.0, BAR_HEIGHT);
430 let mut selection = selection;
431 let center = selection.bounds.x + selection.bounds.width * 0.5 + BLOB_MARGIN;
432 selection.bounds.width *= transform.scale_x;
433 selection.bounds.height *= transform.scale_y;
434 selection.optical_scale = transform.scale_x.min(transform.scale_y);
435 selection.bounds.x = (center - size.width * 0.5) * transform.scale_x
436 + size.width * 0.5
437 + transform.translation_x
438 - selection.bounds.width * 0.5;
439 selection.bounds.y = (BAR_HEIGHT - selection.bounds.height) * 0.5;
440 let grid = super::vibrancy::InkGrid {
441 first_center: (
442 (BLOB_MARGIN + geometry.cell_width * 0.5 - size.width * 0.5) * transform.scale_x
443 + size.width * 0.5
444 + transform.translation_x,
445 BAR_HEIGHT * 0.5,
446 ),
447 pitch: geometry.pitch * transform.scale_x,
448 count,
449 };
450 (selection, grid)
451}
452
453#[composable]
454fn TabCells(
455 modifier: Modifier,
456 tabs: Rc<Vec<LiquidTab>>,
457 typography: LiquidTypography,
458 spec: TabCellsSpec,
459 selection: impl Fn() -> super::vibrancy::InkSelection + 'static,
460 transform: impl Fn() -> cranpose_ui_graphics::GraphicsLayer + 'static,
461 on_select: impl Fn(usize) + 'static,
462) {
463 let on_select: Rc<dyn Fn(usize)> = Rc::new(on_select);
464 let transform: Rc<dyn Fn() -> cranpose_ui_graphics::GraphicsLayer> = Rc::new(transform);
465 let geometry = spec.geometry;
466 let size = Size::new(geometry.width + BLOB_MARGIN * 2.0, BAR_HEIGHT);
467 let count = tabs.len();
468 let ink = {
469 let transform = Rc::clone(&transform);
470 move || lifted_ink(geometry, count, selection(), &transform())
471 };
472 super::vibrancy::VibrantContent(modifier, size, spec.base_color, ink, move || {
473 let tabs = Rc::clone(&tabs);
474 let typography = typography.clone();
475 let on_select = Rc::clone(&on_select);
476 let transform = Rc::clone(&transform);
477 Box(
478 Modifier::empty()
479 .size(size)
480 .selectable_group()
481 .role(SemanticsWidgetRole::TabBar)
482 .graphics_layer(move || transform()),
483 BoxSpec::default(),
484 move || {
485 for (index, tab) in tabs.iter().enumerate() {
486 let color = spec.base_color;
487 let label_for_semantics = tab.label;
488 let icon_offset = tab.icon_offset;
489 let on_select = Rc::clone(&on_select);
490 let cell = Modifier::empty()
491 .offset(BLOB_MARGIN + index as f32 * geometry.pitch, BLOB_MARGIN)
492 .size(Size::new(geometry.cell_width, BLOB_HEIGHT))
493 .stable_semantics(super::selection::selection_semantics(
494 label_for_semantics.to_string(),
495 SemanticsWidgetRole::Tab,
496 index,
497 spec.committed_selection,
498 on_select,
499 ))
500 .focusable();
501 let icon = tab.icon.clone();
502 let icon_style = tab.icon_style;
503 let icon_scale = tab.icon_scale;
504 let label = tab.label;
505 let label_style = TextStyle {
506 span_style: SpanStyle {
507 color: Some(color),
508 font_size: cranpose_ui::text::TextUnit::Sp(TAB_LABEL_SIZE),
509 font_weight: Some(if spec.selected == index {
510 FontWeight::SEMI_BOLD
511 } else {
512 FontWeight::MEDIUM
513 }),
514 ..typography.caption1.span_style.clone()
515 },
516 paragraph_style: cranpose_ui::text::ParagraphStyle {
517 line_height: cranpose_ui::text::TextUnit::Sp(12.0),
518 ..typography.caption1.paragraph_style.clone()
519 },
520 };
521 Box(cell, BoxSpec::default(), move || {
522 let label_style = label_style.clone();
523 let icon = icon.clone();
524 Box(
525 Modifier::empty()
526 .offset(
527 (geometry.cell_width - TAB_ICON_SIZE) * 0.5 + icon_offset.0,
528 3.0 + icon_offset.1,
529 )
530 .size(Size::new(TAB_ICON_SIZE, TAB_ICON_SIZE)),
531 BoxSpec::default(),
532 move || TabIcon(icon.clone(), icon_style, color, icon_scale),
533 );
534 Box(
535 Modifier::empty()
536 .offset(0.0, 35.0)
537 .size(Size::new(geometry.cell_width, 12.0)),
538 BoxSpec::default().content_alignment(Alignment::CENTER),
539 move || {
540 Text(label, Modifier::empty(), label_style.clone());
541 },
542 );
543 });
544 }
545 },
546 );
547 });
548}
549
550fn tab_lens_node_top(node_height: f32) -> f32 {
551 (BAR_HEIGHT - node_height) * 0.5
552}
553
554#[derive(Clone, Copy)]
555struct TabBarTouch {
556 position: cranpose_core::MutableState<(f32, f32)>,
557 origin: cranpose_core::MutableState<Option<f32>>,
558 travel: cranpose_core::MutableState<f32>,
559}
560
561impl TabBarTouch {
562 fn pressed(self, down: bool) {
563 if down {
564 self.origin.set(None);
565 self.travel.set(0.0);
566 }
567 }
568
569 fn update(self, x: f32, y: f32, span: f32) {
570 self.position.set((x + BLOB_MARGIN, y + BLOB_MARGIN));
571 if let Some(origin) = self.origin.get() {
572 self.travel
573 .set(((x - origin) / span.max(1.0)).clamp(-1.0, 1.0));
574 } else {
575 self.origin.set(Some(x));
576 }
577 }
578}
579
580#[composable]
581fn remember_tab_bar_touch() -> TabBarTouch {
582 TabBarTouch {
583 position: cranpose_core::remember(|| cranpose_core::mutableStateOf((0.0f32, 0.0f32)))
584 .with(|state| *state),
585 origin: cranpose_core::remember(|| cranpose_core::mutableStateOf(None::<f32>))
586 .with(|state| *state),
587 travel: cranpose_core::remember(|| cranpose_core::mutableStateOf(0.0f32))
588 .with(|state| *state),
589 }
590}
591
592fn tab_bar_transform(width: f32, press: f32, travel: f32) -> cranpose_ui_graphics::GraphicsLayer {
593 let press = press.max(0.0);
594 let travel = travel.clamp(-1.0, 1.0);
595 let rise = 14.14 / width.max(1.0) * press;
596 let strain = 2.63 / width.max(1.0) * travel.abs() * press;
597 cranpose_ui_graphics::GraphicsLayer {
598 scale_x: 1.0 + rise + strain,
599 scale_y: 1.0 + rise - strain,
600 translation_x: 2.756 * travel * press,
601 ..Default::default()
602 }
603}
604
605fn tab_bar_accessory_gap(has_accessory: bool) -> f32 {
606 if has_accessory { ACCESSORY_GAP } else { 0.0 }
607}
608
609fn accessory_surfaces_touch(edge_gap: f32) -> bool {
610 edge_gap <= 0.0
611}
612
613fn tab_lens_base_size(cell_width: f32, activity: f32) -> (f32, f32) {
614 let inflation = FLIGHT_LENS_INFLATION * activity.clamp(0.0, 1.0);
615 (cell_width + inflation, BLOB_HEIGHT + inflation)
616}
617
618#[derive(Clone, Copy, Debug, PartialEq)]
619struct TabFlightGeometry {
620 center: (f32, f32),
621 base_size: Size,
622 strain: Size,
623 lens_position: f32,
624 lens_activity: f32,
625 resting_tint: Color,
626 accessory_center: Option<(f32, f32)>,
627}
628
629#[derive(Clone, Copy, Debug, PartialEq)]
630struct TabFlightNode {
631 origin: (f32, f32),
632 size: Size,
633}
634
635fn tab_lens_deformed_size(base: Size, strain: Size) -> Size {
636 Size::new(
637 base.width * (1.0 + strain.width.clamp(-0.35, 0.35)),
638 base.height * (1.0 + strain.height.clamp(-0.35, 0.35)),
639 )
640}
641
642fn tab_flight_dynamics(geometry: TabFlightGeometry, node: TabFlightNode) -> GlassDynamics {
643 let activity = geometry.lens_activity.clamp(0.0, 1.0);
644 let size = geometry.base_size;
645 let deformed = tab_lens_deformed_size(size, geometry.strain);
646 let radius = size.width.min(size.height) * 0.5;
647 let glue = 20.0;
648 let shapes = geometry
649 .accessory_center
650 .filter(|(x, _)| {
651 let edge_gap = (*x - geometry.center.0).abs() - size.width * 0.5 - BAR_HEIGHT * 0.5;
652 accessory_surfaces_touch(edge_gap)
653 })
654 .map(|(x, y)| {
655 vec![(
656 x - node.origin.0,
657 y - node.origin.1,
658 BAR_HEIGHT,
659 BAR_HEIGHT,
660 -1.0,
661 )]
662 })
663 .unwrap_or_default();
664 GlassDynamics {
665 edge_return_depth_dp: Some(7.0 * activity),
666 optical_projection: Some((deformed.width / size.width, deformed.height / size.height)),
667 morph: Some(GlassMorph {
668 node_size: (node.size.width, node.size.height),
669 primary: (
670 geometry.center.0 - node.origin.0,
671 geometry.center.1 - node.origin.1,
672 size.width,
673 size.height,
674 radius,
675 ),
676 shapes,
677 glue,
678 wobble_amplitude: 0.0,
679 wobble_phase: geometry.lens_position * 0.045,
680 bulge_amplitude: 0.0,
681 bulge_direction: 0.0,
682 ellipse_blend: 0.0,
683 capsule_smoothing_dp: ((size.width - size.height).abs() * 0.5).min(radius * 0.6)
684 * activity,
685 deformation: None,
686 zoom_anchor: (0.0, 0.0),
687 }),
688 activity: Some(1.0),
689 resting_tint: Some(geometry.resting_tint),
690 tint_alpha_multiplier: Some(tab_flight_tint_multiplier(geometry.lens_activity)),
691 ..Default::default()
692 }
693}
694
695pub struct LiquidTabBarScope {
700 tabs: ScopeContent<LiquidTab>,
701}
702
703impl LiquidTabBarScope {
704 pub fn tab(&self, icon: &'static str, label: &'static str) {
706 self.push(LiquidTab::new(icon, label));
707 }
708
709 pub fn app_badge(&self, icon: &'static str, label: &'static str) {
711 self.push(LiquidTab::app_badge(icon, label));
712 }
713
714 pub fn push(&self, tab: LiquidTab) {
717 self.tabs.push(tab);
718 }
719}
720
721fn collect_tabs(content: impl FnOnce(&LiquidTabBarScope)) -> Vec<LiquidTab> {
722 ScopeContent::collect(|tabs| LiquidTabBarScope { tabs }, content)
723}
724
725#[composable]
734pub fn LiquidTabBar(
735 modifier: Modifier,
736 spec: LiquidTabBarSpec,
737 selected: usize,
738 on_select: impl Fn(usize) + 'static,
739 content: impl FnOnce(&LiquidTabBarScope),
740) {
741 LiquidTabBarLayout(
742 modifier,
743 spec,
744 collect_tabs(content),
745 selected,
746 on_select,
747 false,
748 || {},
749 );
750}
751
752#[composable]
754pub fn LiquidTabBarWithAccessory(
755 modifier: Modifier,
756 spec: LiquidTabBarSpec,
757 selected: usize,
758 on_select: impl Fn(usize) + 'static,
759 content: impl FnOnce(&LiquidTabBarScope),
760 accessory: impl FnMut() + 'static,
761) {
762 LiquidTabBarLayout(
763 modifier,
764 spec,
765 collect_tabs(content),
766 selected,
767 on_select,
768 true,
769 accessory,
770 );
771}
772
773#[composable]
774fn LiquidTabBarLayout(
775 modifier: Modifier,
776 spec: LiquidTabBarSpec,
777 tabs: Vec<LiquidTab>,
778 selected: usize,
779 on_select: impl Fn(usize) + 'static,
780 has_accessory: bool,
781 accessory: impl FnMut() + 'static,
782) {
783 let colors = liquid_colors();
784 let typography = liquid_typography();
785 let tint_amount = crate::theme::liquid_glass_tint_amount();
786 let count = tabs.len().max(1);
787 let selected = selected.min(count - 1);
788 let on_select: Rc<dyn Fn(usize)> = Rc::new(on_select);
789 let tabs = Rc::new(tabs);
790 let accessory = Rc::new(RefCell::new(accessory));
791
792 Row(
793 modifier,
794 RowSpec::default().vertical_alignment(VerticalAlignment::CenterVertically),
795 move || {
796 let tabs = Rc::clone(&tabs);
797 let typography = typography.clone();
798 let on_select = Rc::clone(&on_select);
799 let accessory = Rc::clone(&accessory);
800
801 let tab_width = cranpose_core::rememberMutableStateOf(|| 0.0f32);
802 let lens_pressed = cranpose_core::rememberMutableStateOf(|| false);
803 let bar_touch = remember_tab_bar_touch();
804 let contact_motion = super::lens_motion::remember_tab_contact_motion();
805 let bar_press = contact_motion.bar_state();
806 let glow = contact_motion.glow_state();
807 let local_glow_factor = contact_motion.local_glow_factor_state();
808 let travel = cranpose_animation::animateFloatAsState(
809 bar_touch.travel.get(),
810 cranpose_animation::spring(0.85, 650.0),
811 "tabbar-travel",
812 );
813 let motion = remember_tab_bar_motion(bar_press, travel, contact_motion.lens_state());
814 let cells = TabGeometry::new(tab_width.get(), count);
815 let resting_lens_x = tab_lens_resting_left(selected, cells.pitch, count);
816 let visual_index = {
817 let motion = motion.clone();
818 cranpose_core::derivedStateOf(move || {
819 motion.visual_index(selected, cells, count, lens_pressed.get())
820 })
821 .get()
822 };
823 if bar_touch.travel.get_non_reactive().abs() > 0.001 && visual_index != selected {
824 contact_motion.crossed_tab();
825 }
826 let pill_w = cells.width + 2.0 * BLOB_MARGIN;
827 let bar_lift = Modifier::empty()
828 .graphics_layer(move || tab_bar_transform(pill_w, bar_press.get(), travel.get()));
829 Box(
830 Modifier::empty().height(BAR_HEIGHT),
831 BoxSpec::default(),
832 move || {
833 let tabs = Rc::clone(&tabs);
834 let typography = typography.clone();
835 let on_select = Rc::clone(&on_select);
836 let semantic_selection = Rc::clone(&on_select);
837 let contact_motion = Rc::clone(&contact_motion);
838 let published_axis = Rc::clone(&motion.axis);
839 let pill = bar_lift.clone().height(BAR_HEIGHT);
840 Box(pill, BoxSpec::default(), move || {
841 let on_select = Rc::clone(&on_select);
842 let contact_motion = Rc::clone(&contact_motion);
843 let published_axis = Rc::clone(&published_axis);
844 BoxWithConstraints(Modifier::empty(), move |scope| {
845 let on_select = Rc::clone(&on_select);
846 let measured_width =
847 spec.cell_width(scope.constraints().max_width, count);
848 tab_width.set(measured_width);
849
850 let geometry = TabGeometry::new(measured_width, count);
851 Box(
852 Modifier::empty()
853 .glass_effect(
854 surface_material(colors.label).tint_amount(tint_amount),
855 )
856 .size(Size::new(
857 geometry.width + BLOB_MARGIN * 2.0,
858 BAR_HEIGHT,
859 )),
860 BoxSpec::default(),
861 || {},
862 );
863 let resting_lens_x =
864 tab_lens_resting_left(selected, geometry.pitch, count);
865 let lens_axis = crate::motion::remember_liquid_follow_axis(
866 resting_lens_x,
867 cranpose_animation::spring(0.85, 650.0),
868 );
869 if !lens_pressed.get() {
870 lens_axis.settle_to(resting_lens_x, LiquidMotion::glide());
871 }
872 *published_axis.borrow_mut() = Some(Rc::clone(&lens_axis));
873 let row_width = geometry.width;
874 let gesture = Modifier::empty()
875 .offset(BLOB_MARGIN, BLOB_MARGIN)
876 .size(Size::new(row_width, BLOB_HEIGHT))
877 .pointer_input(selected, {
878 let on_select = Rc::clone(&on_select);
879 let lens_axis = Rc::clone(&lens_axis);
880 let contact_motion = Rc::clone(&contact_motion);
881 move |scope: PointerInputScope| {
882 let on_select = Rc::clone(&on_select);
883 let lens_axis = Rc::clone(&lens_axis);
884 let contact_motion = Rc::clone(&contact_motion);
885 crate::motion::liquid_lens_gesture(
886 scope,
887 crate::motion::LiquidLensGesture {
888 axis: lens_axis,
889 cell_width: geometry.pitch,
890 cell_offset: (geometry.cell_width - geometry.pitch)
891 * 0.5,
892 count,
893 tap_slop: TAP_SLOP,
894 drag_left: Rc::new(move |x| {
895 geometry.drag_left(
896 geometry.optical_pointer_x(
897 x,
898 bar_press.get(),
899 travel.get(),
900 ),
901 count,
902 has_accessory,
903 )
904 }),
905 rest_left: Rc::new(move |index| {
906 tab_lens_resting_left(
907 index,
908 geometry.pitch,
909 count,
910 )
911 }),
912 selected,
913 on_pressed: Rc::new(move |down, time| {
914 lens_pressed.set(down);
915 contact_motion.pressed(down, time);
916 bar_touch.pressed(down);
917 }),
918 on_touch: Rc::new(move |x, y| {
919 bar_touch.update(
920 geometry.optical_pointer_x(
921 x,
922 bar_press.get(),
923 travel.get(),
924 ),
925 y,
926 geometry.pitch
927 * count.saturating_sub(1) as f32,
928 );
929 }),
930 on_select,
931 },
932 )
933 }
934 });
935 Box(gesture, BoxSpec::default(), || {});
936 });
937 });
938
939 let drawing = Rc::new(TabLensDrawing {
940 lens: TabLensPlacement::new(cells, resting_lens_x, colors, has_accessory),
941 motion: motion.clone(),
942 colors,
943 held_layers: cranpose_core::remember(|| Rc::new(RefCell::new(None)))
944 .with(Rc::clone),
945 });
946 let lighting = Rc::clone(&drawing);
947 super::tab_lighting::TabLighting(
948 Size::new(pill_w, BAR_HEIGHT),
949 move || lighting.transform(),
950 bar_touch.position,
951 glow,
952 local_glow_factor,
953 );
954 Box(drawing.glass_modifier(0), BoxSpec::default(), || {});
955 let (ink, lift) = (Rc::clone(&drawing), Rc::clone(&drawing));
956 TabCells(
957 Modifier::empty(),
958 Rc::clone(&tabs),
959 typography,
960 TabCellsSpec {
961 geometry: cells,
962 base_color: tab_base_content_color(colors),
963 selected: visual_index,
964 committed_selection: selected,
965 },
966 move || ink.selection(),
967 move || lift.transform(),
968 move |index| semantic_selection(index),
969 );
970 Box(drawing.glass_modifier(1), BoxSpec::default(), || {});
971 },
972 );
973
974 if has_accessory {
975 Box(
976 Modifier::empty().width(tab_bar_accessory_gap(true)),
977 BoxSpec::default(),
978 || {},
979 );
980 (accessory.borrow_mut())();
981 }
982 },
983 );
984}
985
986type TabLensLayers = dyn Fn(f32, GlassDynamics) -> [cranpose_ui_graphics::GraphicsLayer; 2];
989
990type HeldTabLensLayers = (u32, Rc<TabLensLayers>);
992
993#[derive(Clone)]
997struct TabBarMotion {
998 axis: Rc<RefCell<Option<Rc<crate::motion::LiquidDragAxis>>>>,
999 lens_shape: Rc<super::lens_motion::LensShapeMotion>,
1000 bar_press: cranpose_core::State<f32>,
1001 travel: cranpose_core::State<f32>,
1002 lens_activity: cranpose_core::State<f32>,
1003}
1004
1005#[composable]
1008fn remember_tab_bar_motion(
1009 bar_press: cranpose_core::State<f32>,
1010 travel: cranpose_core::State<f32>,
1011 lens_activity: cranpose_core::State<f32>,
1012) -> TabBarMotion {
1013 TabBarMotion {
1014 axis: cranpose_core::remember(|| Rc::new(RefCell::new(None))).with(Rc::clone),
1015 lens_shape: super::lens_motion::remember_lens_shape(super::lens_motion::LensShapeKind::Tab),
1016 bar_press,
1017 travel,
1018 lens_activity,
1019 }
1020}
1021
1022#[derive(Clone, Copy)]
1024struct TabLensFrame {
1025 lens_x: f32,
1026 activity: f32,
1027 strain: Size,
1028 press: f32,
1029 travel: f32,
1030}
1031
1032impl TabBarMotion {
1033 fn lens_x(&self, resting_lens_x: f32) -> f32 {
1035 self.axis
1036 .borrow()
1037 .as_ref()
1038 .map_or(resting_lens_x, |axis| axis.value())
1039 }
1040
1041 fn visual_index(
1044 &self,
1045 selected: usize,
1046 cells: TabGeometry,
1047 count: usize,
1048 pressed: bool,
1049 ) -> usize {
1050 let resting_lens_x = tab_lens_resting_left(selected, cells.pitch, count);
1051 let lens_x = self.lens_x(resting_lens_x);
1052 crate::motion::liquid_visual_index(
1053 selected,
1054 lens_x,
1055 cells.pitch,
1056 count,
1057 crate::motion::liquid_axis_owns_visual_selection(
1058 pressed,
1059 lens_x,
1060 resting_lens_x,
1061 cells.pitch,
1062 ),
1063 )
1064 }
1065
1066 fn frame(&self, cells: TabGeometry, resting_lens_x: f32) -> TabLensFrame {
1070 let press = self.bar_press.get();
1071 let travel = self.travel.get();
1072 let activity = self.lens_activity.get().clamp(0.0, 1.0);
1073 let lens_x = self.lens_x(resting_lens_x);
1074 let strain = self
1075 .lens_shape
1076 .sample(cells.lens_surface_position(lens_x, press, travel));
1077 TabLensFrame {
1078 lens_x,
1079 activity,
1080 strain,
1081 press,
1082 travel,
1083 }
1084 }
1085}
1086
1087#[derive(Clone, Copy)]
1090struct TabLensPlacement {
1091 cells: TabGeometry,
1092 resting_lens_x: f32,
1093 pill_w: f32,
1094 node_size: Size,
1095 node_top: f32,
1096 resting_tint: Color,
1097 accessory_center: Option<(f32, f32)>,
1098}
1099
1100impl TabLensPlacement {
1101 fn new(
1102 cells: TabGeometry,
1103 resting_lens_x: f32,
1104 colors: LiquidColors,
1105 has_accessory: bool,
1106 ) -> Self {
1107 let (lens_w, lens_h) = tab_lens_base_size(cells.cell_width, 1.0);
1108 let deformation_headroom =
1109 crate::dynamics::STRETCH_MAX.max(1.0 / crate::dynamics::STRETCH_MIN);
1110 let node_w = lens_w * deformation_headroom + crate::dynamics::BULGE_MAX + 20.0;
1111 let node_h = lens_h * deformation_headroom + crate::dynamics::BULGE_MAX + 16.0;
1112 let pill_w = cells.width + 2.0 * BLOB_MARGIN;
1113 Self {
1114 cells,
1115 resting_lens_x,
1116 pill_w,
1117 node_size: Size::new(node_w, node_h),
1118 node_top: tab_lens_node_top(node_h),
1119 resting_tint: colors.fill,
1120 accessory_center: has_accessory.then_some((
1121 pill_w + tab_bar_accessory_gap(true) + BAR_HEIGHT * 0.5,
1122 BAR_HEIGHT * 0.5,
1123 )),
1124 }
1125 }
1126
1127 fn center_x(self, frame: TabLensFrame) -> f32 {
1128 BLOB_MARGIN + frame.lens_x + self.cells.cell_width * 0.5
1129 }
1130
1131 fn node(self, frame: TabLensFrame) -> TabFlightNode {
1132 TabFlightNode {
1133 origin: (
1134 self.center_x(frame) - self.node_size.width * 0.5,
1135 self.node_top,
1136 ),
1137 size: self.node_size,
1138 }
1139 }
1140
1141 fn geometry(self, frame: TabLensFrame) -> TabFlightGeometry {
1142 let (base_w, base_h) = tab_lens_base_size(self.cells.cell_width, frame.activity);
1143 TabFlightGeometry {
1144 center: (self.center_x(frame), BAR_HEIGHT * 0.5),
1145 base_size: Size::new(base_w, base_h),
1146 strain: frame.strain,
1147 lens_position: frame.lens_x,
1148 lens_activity: frame.activity,
1149 resting_tint: self.resting_tint,
1150 accessory_center: self.accessory_center,
1151 }
1152 }
1153
1154 fn layer(self, motion: &TabBarMotion) -> cranpose_ui_graphics::GraphicsLayer {
1157 let frame = motion.frame(self.cells, self.resting_lens_x);
1158 let mut layer = tab_bar_transform(self.pill_w, frame.press, frame.travel);
1159 let center_x = self.center_x(frame);
1160 layer.translation_x +=
1161 (center_x - self.pill_w * 0.5) * (layer.scale_x - 1.0) + self.node(frame).origin.0;
1162 layer
1163 }
1164}
1165
1166struct TabLensDrawing {
1169 lens: TabLensPlacement,
1170 motion: TabBarMotion,
1171 colors: LiquidColors,
1172 held_layers: Rc<RefCell<Option<HeldTabLensLayers>>>,
1173}
1174
1175impl TabLensDrawing {
1176 fn transform(&self) -> cranpose_ui_graphics::GraphicsLayer {
1178 tab_bar_transform(
1179 self.lens.pill_w,
1180 self.motion.bar_press.get(),
1181 self.motion.travel.get(),
1182 )
1183 }
1184
1185 fn selection(&self) -> super::vibrancy::InkSelection {
1187 let frame = self.motion.frame(self.lens.cells, self.lens.resting_lens_x);
1188 tab_ink_selection(
1189 self.lens.cells,
1190 frame.lens_x,
1191 frame.activity,
1192 frame.strain,
1193 tab_selection_content_color(self.colors),
1194 )
1195 }
1196
1197 fn layers(&self, activity: f32) -> Rc<TabLensLayers> {
1200 let key = activity.to_bits();
1201 let mut held = self.held_layers.borrow_mut();
1202 if let Some((held_key, layers)) = &*held
1203 && *held_key == key
1204 {
1205 return Rc::clone(layers);
1206 }
1207 let layers: Rc<TabLensLayers> = Rc::new(crate::material::cached_glass_content_layers(
1208 tab_flight_lens_material(self.colors.label, activity).resolve(&self.colors),
1209 ));
1210 *held = Some((key, Rc::clone(&layers)));
1211 layers
1212 }
1213
1214 fn glass_layer(&self, layer: usize) -> cranpose_ui_graphics::GraphicsLayer {
1216 let frame = self.motion.frame(self.lens.cells, self.lens.resting_lens_x);
1217 self.layers(frame.activity)(
1218 cranpose_ui::current_density(),
1219 tab_flight_dynamics(self.lens.geometry(frame), self.lens.node(frame)),
1220 )[layer]
1221 .clone()
1222 }
1223
1224 fn glass_modifier(self: &Rc<Self>, layer: usize) -> Modifier {
1229 let (node, glass) = (Rc::clone(self), Rc::clone(self));
1230 let node_top = self.lens.node_top;
1231 Modifier::empty()
1232 .required_size(self.lens.node_size)
1233 .graphics_layer(move || {
1234 let mut layer = node.lens.layer(&node.motion);
1235 layer.translation_y += node_top;
1236 layer
1237 })
1238 .graphics_layer(move || glass.glass_layer(layer))
1239 }
1240}
1241
1242#[composable]
1244pub fn LiquidTabBarSearchAccessory(on_click: impl Fn() + 'static) {
1245 crate::widgets::GlassIconButton(
1246 Modifier::empty().content_description("Search"),
1247 crate::widgets::GlassButtonSpec::glass(),
1248 BAR_HEIGHT * 0.94,
1249 on_click,
1250 crate::icons::SEARCH,
1251 );
1252}
1253
1254#[cfg(test)]
1255#[path = "tests/tab_bar_tests.rs"]
1256mod tests;