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