1use crate::material::{Glass, GlassDynamics, GlassMorph, LiquidModifierExt, LiquidShape};
5use crate::motion::{liquid_press_scale, LiquidMotion};
6use crate::theme::{liquid_colors, liquid_typography};
7use crate::widgets::content_scope::ScopeContent;
8use cranpose_animation::{AnimationSpec, AnimationType, Easing};
9use cranpose_core::{mutableStateOf, remember};
10use cranpose_macros::composable;
11use cranpose_services::{default_haptics, HapticFeedback};
12use cranpose_ui::rememberMutableInteractionSource;
13use cranpose_ui::text::TextStyle;
14use cranpose_ui::widgets::{Box, BoxSpec, Text};
15use cranpose_ui::{Modifier, PointerEventKind, PointerInputScope, SemanticsWidgetRole, Size};
16use cranpose_ui_graphics::{Color, GraphicsLayer};
17use cranpose_ui_layout::Alignment;
18use std::cell::{Cell, RefCell};
19use std::rc::Rc;
20
21const ICON_BACKPLATE_DIAMETER_RATIO: f32 = 0.50;
22const ICON_BACKPLATE_GLYPH_RATIO: f32 = 0.28;
23const TAP_EXIT_SLOP: f32 = 12.0;
26
27fn with_button_semantics(modifier: Modifier) -> Modifier {
28 modifier.semantics(|config| {
29 config.role = Some(SemanticsWidgetRole::Button);
30 config.is_clickable = true;
31 })
32}
33
34#[derive(Clone, Copy, Debug, PartialEq, Eq, Default)]
36pub enum GlassButtonStyle {
37 #[default]
39 Glass,
40 Prominent,
42 Plain,
44 Destructive,
46}
47
48#[derive(Clone, Debug, Default, PartialEq)]
50pub struct GlassButtonSpec {
51 pub style: GlassButtonStyle,
52 pub glass: Option<Glass>,
54 pub content_color: Option<Color>,
56 pub icon_backplate: Option<Color>,
59}
60
61impl GlassButtonSpec {
62 pub fn glass() -> Self {
63 Self::default()
64 }
65
66 pub fn prominent() -> Self {
67 Self {
68 style: GlassButtonStyle::Prominent,
69 ..Self::default()
70 }
71 }
72
73 pub fn plain() -> Self {
74 Self {
75 style: GlassButtonStyle::Plain,
76 ..Self::default()
77 }
78 }
79
80 pub fn destructive() -> Self {
81 Self {
82 style: GlassButtonStyle::Destructive,
83 ..Self::default()
84 }
85 }
86
87 pub fn with_glass(mut self, glass: Glass) -> Self {
88 self.glass = Some(glass);
89 self
90 }
91
92 pub fn with_content_color(mut self, color: Color) -> Self {
93 self.content_color = Some(color);
94 self
95 }
96
97 pub fn with_icon_backplate(mut self, color: Color) -> Self {
98 self.icon_backplate = Some(color);
99 self
100 }
101
102 pub fn content_color(&self, colors: &crate::theme::LiquidColors) -> Color {
104 if let Some(color) = self.content_color {
105 return color;
106 }
107 match self.style {
108 GlassButtonStyle::Glass => colors.accent,
109 GlassButtonStyle::Prominent => colors.on_accent,
110 GlassButtonStyle::Plain => colors.accent,
111 GlassButtonStyle::Destructive => colors.destructive,
112 }
113 }
114
115 pub fn icon_color(&self, colors: &crate::theme::LiquidColors) -> Color {
118 if let Some(color) = self.content_color {
119 return color;
120 }
121 match self.style {
122 GlassButtonStyle::Glass | GlassButtonStyle::Plain => colors.label,
123 GlassButtonStyle::Prominent => colors.on_accent,
124 GlassButtonStyle::Destructive => colors.destructive,
125 }
126 }
127
128 fn resolve_material(
129 &self,
130 colors: &crate::theme::LiquidColors,
131 foreground: Color,
132 ) -> Option<Glass> {
133 if let Some(glass) = &self.glass {
134 return Some(if glass.foreground.is_some() {
135 glass.clone()
136 } else {
137 glass
138 .clone()
139 .adaptive_frost(foreground, glass.adaptive_frost)
140 });
141 }
142 match self.style {
143 GlassButtonStyle::Glass => Some(Glass::regular().adaptive_frost(foreground, 0.65)),
144 GlassButtonStyle::Prominent => Some(
145 Glass::regular()
146 .tint(colors.accent.with_alpha(0.75))
147 .adaptive_frost(foreground, 0.65),
148 ),
149 GlassButtonStyle::Plain | GlassButtonStyle::Destructive => None,
150 }
151 }
152}
153
154#[derive(Clone)]
156pub struct GlassIconButtonGroupItem {
157 spec: GlassButtonSpec,
158 icon_path: &'static str,
159 content_description: String,
160 on_click: Rc<RefCell<dyn FnMut()>>,
161}
162
163impl PartialEq for GlassIconButtonGroupItem {
164 fn eq(&self, other: &Self) -> bool {
165 self.spec == other.spec
166 && self.icon_path == other.icon_path
167 && self.content_description == other.content_description
168 && Rc::ptr_eq(&self.on_click, &other.on_click)
169 }
170}
171
172impl GlassIconButtonGroupItem {
173 pub fn new(
174 icon_path: &'static str,
175 content_description: impl Into<String>,
176 on_click: impl FnMut() + 'static,
177 ) -> Self {
178 Self {
179 spec: GlassButtonSpec::glass(),
180 icon_path,
181 content_description: content_description.into(),
182 on_click: Rc::new(RefCell::new(on_click)),
183 }
184 }
185
186 pub fn with_spec(mut self, spec: GlassButtonSpec) -> Self {
187 self.spec = spec;
188 self
189 }
190}
191
192pub struct GlassIconButtonGroupScope {
197 items: ScopeContent<GlassIconButtonGroupItem>,
198}
199
200impl GlassIconButtonGroupScope {
201 pub fn action(
203 &self,
204 icon_path: &'static str,
205 content_description: impl Into<String>,
206 on_click: impl FnMut() + 'static,
207 ) {
208 self.push(GlassIconButtonGroupItem::new(
209 icon_path,
210 content_description,
211 on_click,
212 ));
213 }
214
215 pub fn push(&self, item: GlassIconButtonGroupItem) {
218 self.items.push(item);
219 }
220}
221
222fn collect_items(
224 content: impl FnOnce(&GlassIconButtonGroupScope),
225) -> Vec<GlassIconButtonGroupItem> {
226 ScopeContent::collect(|items| GlassIconButtonGroupScope { items }, content)
227}
228
229#[derive(Clone, Copy, Debug, PartialEq)]
231pub struct GlassIconButtonGroupSpec {
232 diameter: f32,
233 spacing: f32,
234 pressed_scale: f32,
235 glue_radius: f32,
236}
237
238impl GlassIconButtonGroupSpec {
239 pub fn new(diameter: f32) -> Self {
240 Self {
241 diameter: diameter.max(1.0),
242 ..Self::default()
243 }
244 }
245
246 pub fn with_spacing(mut self, spacing: f32) -> Self {
247 self.spacing = spacing.max(0.0);
248 self
249 }
250
251 pub fn with_pressed_scale(mut self, pressed_scale: f32) -> Self {
252 self.pressed_scale = pressed_scale.max(1.0);
253 self
254 }
255
256 pub fn with_glue_radius(mut self, glue_radius: f32) -> Self {
257 self.glue_radius = glue_radius.max(0.0);
258 self
259 }
260}
261
262impl Default for GlassIconButtonGroupSpec {
263 fn default() -> Self {
264 Self {
265 diameter: 44.0,
266 spacing: 8.0,
267 pressed_scale: 1.55,
270 glue_radius: 12.0,
271 }
272 }
273}
274
275fn merge_glyph_alpha(merge: f32) -> f32 {
279 let m = merge.clamp(0.0, 1.0);
280 if m <= 0.0 {
281 return 1.0;
282 }
283 let fade_out = 1.0 - ((m - 0.02) / 0.10).clamp(0.0, 1.0);
284 let fade_in = ((m - 0.78) / 0.18).clamp(0.0, 1.0);
285 fade_out.max(fade_in)
286}
287
288fn icon_group_width(count: usize, spec: GlassIconButtonGroupSpec) -> f32 {
289 if count == 0 {
290 0.0
291 } else {
292 spec.diameter * count as f32 + spec.spacing * count.saturating_sub(1) as f32
293 }
294}
295
296fn icon_group_item_at(
297 x: f32,
298 y: f32,
299 count: usize,
300 spec: GlassIconButtonGroupSpec,
301) -> Option<usize> {
302 if !(0.0..=spec.diameter).contains(&y) {
303 return None;
304 }
305 let pitch = spec.diameter + spec.spacing;
306 let index = (x / pitch).floor() as isize;
307 if index < 0 || index as usize >= count {
308 return None;
309 }
310 let local_x = x - index as f32 * pitch;
311 (local_x <= spec.diameter).then_some(index as usize)
312}
313
314fn icon_group_neighbor_shapes(
315 count: usize,
316 active: usize,
317 spec: GlassIconButtonGroupSpec,
318 pad: f32,
319 center_y: f32,
320) -> Vec<(f32, f32, f32, f32, f32)> {
321 let pitch = spec.diameter + spec.spacing;
322 let contact_diameter = spec.diameter * 0.36;
323 let contact_offset = spec.diameter * 0.38;
324 (0..count)
325 .filter(|index| index.abs_diff(active) == 1)
326 .map(|index| {
327 let toward_active = if index < active { 1.0 } else { -1.0 };
328 (
329 pad + index as f32 * pitch + spec.diameter * 0.5 + toward_active * contact_offset,
330 center_y,
331 contact_diameter,
332 contact_diameter,
333 -1.0,
334 )
335 })
336 .collect()
337}
338
339#[composable]
342#[allow(non_snake_case)]
343pub fn GlassButton(
344 modifier: Modifier,
345 spec: GlassButtonSpec,
346 on_click: impl Fn() + 'static,
347 content: impl FnMut() + 'static,
348) {
349 let colors = liquid_colors();
350 let interaction = rememberMutableInteractionSource();
351 let (pressed_modifier, pressed, content_alpha) =
352 liquid_press_scale(Modifier::empty(), interaction, 1.18);
353
354 let material = spec.resolve_material(&colors, spec.content_color(&colors));
355 let mut base = Modifier::empty();
356 if let Some(glass) = material {
357 let pressed_for_glass = pressed;
358 let glow_size = cranpose_core::remember(|| {
363 std::rc::Rc::new(std::cell::Cell::new(cranpose_ui_graphics::Size {
364 width: 0.0,
365 height: 0.0,
366 }))
367 })
368 .with(std::rc::Rc::clone);
369 let glow_size_for_glass = std::rc::Rc::clone(&glow_size);
370 base = base
371 .report_size(std::rc::Rc::clone(&glow_size))
372 .glass_effect_with(glass, move || {
373 let size = glow_size_for_glass.get();
374 let press = if pressed_for_glass.get() { 1.0 } else { 0.0 };
375 let dynamics = GlassDynamics::default();
376 if size.width > 0.0 && size.height > 0.0 {
377 dynamics.touched_up(press, None, (size.width * 0.5, size.height * 0.5))
378 } else {
379 dynamics
380 }
381 });
382 }
383
384 let on_click = Rc::new(RefCell::new(on_click));
385 let base = with_button_semantics(
386 base.press_interaction_source(interaction)
387 .clickable(move |_point| {
388 default_haptics().perform(HapticFeedback::ImpactLight);
389 (on_click.borrow_mut())();
390 })
391 .padding_symmetric(16.0, 10.0),
392 );
393
394 let content_layer = Modifier::empty().graphics_layer(move || GraphicsLayer {
396 alpha: content_alpha.get().clamp(0.0, 1.0),
397 ..Default::default()
398 });
399 let content = Rc::new(RefCell::new(content));
400 let button = base.then(modifier);
401 Box(pressed_modifier, BoxSpec::default(), move || {
402 let content = Rc::clone(&content);
403 let content_layer = content_layer.clone();
404 Box(
405 button.clone(),
406 BoxSpec::default().content_alignment(Alignment::CENTER),
407 move || {
408 let content = Rc::clone(&content);
409 Box(
410 content_layer.clone(),
411 BoxSpec::default().content_alignment(Alignment::CENTER),
412 move || (content.borrow_mut())(),
413 );
414 },
415 );
416 });
417}
418
419#[composable]
421#[allow(non_snake_case)]
422pub fn GlassButtonLabel(text: impl Into<String>, spec: GlassButtonSpec) {
423 let typography = liquid_typography();
424 let color = spec.content_color(&liquid_colors());
425 let style = TextStyle {
426 span_style: cranpose_ui::text::SpanStyle {
427 color: Some(color),
428 ..typography.headline.span_style.clone()
429 },
430 ..typography.headline.clone()
431 };
432 Text(text.into(), Modifier::empty(), style);
433}
434
435#[composable]
436#[allow(non_snake_case)]
437pub(crate) fn GlassIconForeground(spec: GlassButtonSpec, diameter: f32, icon_path: &'static str) {
438 let colors = liquid_colors();
439 let icon_color = spec.icon_color(&colors);
440 if let Some(backplate) = spec.icon_backplate {
441 let backplate_diameter = diameter * ICON_BACKPLATE_DIAMETER_RATIO;
442 Box(
443 Modifier::empty()
444 .size(Size::new(backplate_diameter, backplate_diameter))
445 .draw_behind(move |scope| {
446 scope.draw_circle(
447 cranpose_ui_graphics::Brush::solid(backplate),
448 cranpose_ui_graphics::Point::new(
449 backplate_diameter * 0.5,
450 backplate_diameter * 0.5,
451 ),
452 backplate_diameter * 0.5,
453 );
454 }),
455 BoxSpec::default().content_alignment(Alignment::CENTER),
456 move || {
457 crate::icons::Icon(icon_path, diameter * ICON_BACKPLATE_GLYPH_RATIO, icon_color);
458 },
459 );
460 } else {
461 crate::icons::Icon(icon_path, diameter * 0.5, icon_color);
462 }
463}
464
465#[composable]
467#[allow(non_snake_case)]
468pub fn GlassIconButton(
469 modifier: Modifier,
470 spec: GlassButtonSpec,
471 diameter: f32,
472 on_click: impl Fn() + 'static,
473 icon_path: &'static str,
474) {
475 GlassIconButtonWithForegroundAlpha(modifier, spec, diameter, 1.0, on_click, icon_path);
476}
477
478#[composable]
479#[allow(non_snake_case)]
480pub(crate) fn GlassIconButtonWithForegroundAlpha(
481 modifier: Modifier,
482 spec: GlassButtonSpec,
483 diameter: f32,
484 foreground_alpha: f32,
485 on_click: impl Fn() + 'static,
486 icon_path: &'static str,
487) {
488 let colors = liquid_colors();
489 let interaction = rememberMutableInteractionSource();
490 let (pressed_modifier, pressed, content_alpha) =
491 liquid_press_scale(Modifier::empty(), interaction, 1.20);
492
493 let material = spec
494 .resolve_material(&colors, spec.icon_color(&colors))
495 .map(|glass| glass.shape(LiquidShape::Circle));
496 let mut base = Modifier::empty();
497 if let Some(glass) = material {
498 let pressed_for_glass = pressed;
499 let half = diameter * 0.5;
500 base = base.glass_effect_with(glass, move || {
501 let press = if pressed_for_glass.get() { 1.0 } else { 0.0 };
502 GlassDynamics::default().touched_up(press, None, (half, half))
503 });
504 }
505
506 let on_click = Rc::new(RefCell::new(on_click));
507 let base = base
508 .press_interaction_source(interaction)
509 .clickable(move |_point| {
510 default_haptics().perform(HapticFeedback::ImpactLight);
511 (on_click.borrow_mut())();
512 })
513 .size(Size::new(diameter, diameter));
514
515 let content_layer = Modifier::empty().graphics_layer(move || GraphicsLayer {
517 alpha: content_alpha.get().clamp(0.0, 1.0) * foreground_alpha.clamp(0.0, 1.0),
518 ..Default::default()
519 });
520 let button = base.then(modifier);
521 Box(pressed_modifier, BoxSpec::default(), move || {
522 let foreground_spec = spec.clone();
523 let content_layer = content_layer.clone();
524 Box(
525 button.clone(),
526 BoxSpec::default().content_alignment(Alignment::CENTER),
527 move || {
528 let foreground_spec = foreground_spec.clone();
529 Box(
530 content_layer.clone(),
531 BoxSpec::default().content_alignment(Alignment::CENTER),
532 move || GlassIconForeground(foreground_spec.clone(), diameter, icon_path),
533 );
534 },
535 );
536 });
537}
538
539#[composable]
543#[allow(non_snake_case)]
544pub fn GlassIconButtonGroup(
545 modifier: Modifier,
546 spec: GlassIconButtonGroupSpec,
547 content: impl FnOnce(&GlassIconButtonGroupScope),
548) {
549 let items = collect_items(content);
550 let count = items.len();
551 if count == 0 {
552 return;
553 }
554
555 let colors = liquid_colors();
556 let live_items: Rc<RefCell<Vec<GlassIconButtonGroupItem>>> =
557 remember(|| Rc::new(RefCell::new(Vec::new()))).with(Rc::clone);
558 let ghosts: Rc<RefCell<Vec<(GlassIconButtonGroupItem, usize, f32)>>> =
562 remember(|| Rc::new(RefCell::new(Vec::new()))).with(Rc::clone);
563 let ghost_fade = remember(|| {
564 let runtime = cranpose_core::with_current_composer(|composer| composer.runtime_handle());
565 Rc::new(RefCell::new(cranpose_animation::Animatable::new(
566 0.0f32, runtime,
567 )))
568 })
569 .with(Rc::clone);
570 let active = remember(|| mutableStateOf(None::<usize>)).with(|state| *state);
571 let drag_x = remember(|| mutableStateOf(None::<f32>)).with(|state| *state);
574 let last_active = remember(|| Rc::new(Cell::new(0usize))).with(Rc::clone);
575 if let Some(index) = active.get() {
576 last_active.set(index.min(count - 1));
577 }
578 let press_progress = cranpose_animation::animateFloatAsState(
579 if active.get().is_some() { 1.0 } else { 0.0 },
580 LiquidMotion::snappy(),
581 "glass-icon-group-press",
582 );
583 let press_orphaned = remember(|| Rc::new(Cell::new(false))).with(Rc::clone);
586
587 {
588 let member_stays = |member: &GlassIconButtonGroupItem| {
594 items.iter().any(|item| {
595 item.icon_path == member.icon_path
596 && item.content_description == member.content_description
597 })
598 };
599 let previous = live_items.borrow();
600 if !previous.is_empty() {
601 let leavers: Vec<(GlassIconButtonGroupItem, usize, f32)> = previous
606 .iter()
607 .enumerate()
608 .filter(|(_, member)| !member_stays(member))
609 .map(|(index, member)| {
610 let departure = if index == last_active.get() {
611 press_orphaned.set(true);
614 press_progress.get().clamp(0.0, 1.0)
615 } else {
616 0.0
617 };
618 (member.clone(), index, departure)
619 })
620 .collect();
621 if !leavers.is_empty() {
622 *ghosts.borrow_mut() = leavers;
623 let mut fade = ghost_fade.borrow_mut();
624 fade.snapTo(1.0);
625 fade.animateTo(
626 0.0,
627 AnimationType::Tween(AnimationSpec::tween(250, Easing::EaseIn)),
628 );
629 }
630 }
631 }
632 let ghost_alpha = ghost_fade.borrow().state();
633 if !ghosts.borrow().is_empty() && ghost_alpha.get() <= 0.01 {
634 ghosts.borrow_mut().clear();
635 }
636 *live_items.borrow_mut() = items;
637 let render_items = live_items.borrow().clone();
638
639 let ghost_slots = ghosts
644 .borrow()
645 .iter()
646 .map(|(_, index, _)| index + 1)
647 .max()
648 .unwrap_or(0);
649 let layout_count = count.max(ghost_slots);
650 let merge = if layout_count > count {
658 (1.0 - ghost_alpha.get().clamp(0.0, 1.0)).clamp(0.0, 1.0)
659 } else {
660 0.0
661 };
662 let full_width = icon_group_width(layout_count, spec);
663 let settled_width = icon_group_width(count, spec);
664 let width = settled_width + (full_width - settled_width) * (1.0 - merge);
665 let ghost_trailing_shift = width - full_width;
666 let gesture_items = Rc::clone(&live_items);
667 let gesture_press_orphaned = Rc::clone(&press_orphaned);
668 let gesture = Modifier::empty()
669 .size(Size::new(width, spec.diameter))
670 .pointer_input(count, move |scope: PointerInputScope| {
671 let gesture_items = Rc::clone(&gesture_items);
672 let gesture_press_orphaned = Rc::clone(&gesture_press_orphaned);
673 async move {
674 scope
675 .await_pointer_event_scope(|await_scope| async move {
676 let mut down_index = None::<usize>;
677 loop {
678 let event = await_scope.await_pointer_event().await;
679 match event.kind {
680 PointerEventKind::Down if down_index.is_none() => {
681 down_index = icon_group_item_at(
682 event.position.x,
683 event.position.y,
684 gesture_items.borrow().len(),
685 spec,
686 );
687 if let Some(index) = down_index {
688 active.set(Some(index));
689 drag_x.set(Some(event.position.x));
690 gesture_press_orphaned.set(false);
691 default_haptics().perform(HapticFeedback::Selection);
692 event.consume();
693 }
694 }
695 PointerEventKind::Move if down_index.is_some() => {
696 drag_x.set(Some(event.position.x));
697 event.consume();
698 }
699 PointerEventKind::Up => {
700 let pressed_index = down_index.take();
701 active.set(None);
702 drag_x.set(None);
703 if let Some(index) = pressed_index {
704 let count = gesture_items.borrow().len();
713 let released_inside = (-TAP_EXIT_SLOP
714 ..=spec.diameter + TAP_EXIT_SLOP)
715 .contains(&event.position.y)
716 && (-TAP_EXIT_SLOP
717 ..=icon_group_width(count, spec) + TAP_EXIT_SLOP)
718 .contains(&event.position.x);
719 if released_inside {
720 let on_click = gesture_items
721 .borrow()
722 .get(index)
723 .map(|item| Rc::clone(&item.on_click));
724 if let Some(on_click) = on_click {
725 default_haptics()
726 .perform(HapticFeedback::ImpactLight);
727 (on_click.borrow_mut())();
728 }
729 }
730 event.consume();
731 }
732 }
733 PointerEventKind::Cancel if down_index.take().is_some() => {
734 active.set(None);
735 drag_x.set(None);
736 event.consume();
737 }
738 _ => {}
739 }
740 }
741 })
742 .await;
743 }
744 })
745 .then(modifier);
746
747 Box(gesture, BoxSpec::default(), move || {
748 let pitch = spec.diameter + spec.spacing;
749 let active_index = last_active.get().min(count - 1);
750
751 let pad = spec.glue_radius + spec.diameter * (spec.pressed_scale - 1.0) * 0.5 + 4.0;
758 let node_width = width + pad * 2.0;
759 let node_height = spec.diameter * spec.pressed_scale + pad * 2.0;
760 let shared_progress = press_progress;
761 let shared_orphaned = Rc::clone(&press_orphaned);
762 let shared_last_active = Rc::clone(&last_active);
763 let union_tint = render_items
764 .get(active_index)
765 .and_then(|item| {
766 item.spec
767 .resolve_material(&colors, item.spec.icon_color(&colors))
768 })
769 .and_then(|material| material.tint)
770 .map(|tint| tint.with_alpha(0.85))
774 .unwrap_or(Color::WHITE.with_alpha(0.035));
775 let shared = Modifier::empty()
776 .required_size(Size::new(node_width, node_height))
777 .offset(-pad, (spec.diameter - node_height) * 0.5)
778 .glass_effect_with(
779 Glass::regular()
783 .blur_radius(0.0)
784 .tint(union_tint)
785 .lift(0.0)
786 .highlight(0.48)
787 .shadow(false)
788 .no_clip(),
789 move || {
790 let progress = if shared_orphaned.get() {
791 0.0
792 } else {
793 shared_progress.get().clamp(0.0, 1.0)
794 };
795 let active_index = shared_last_active.get().min(count - 1);
796 let center_y = node_height * 0.5;
797 let rest_center = active_index as f32 * pitch + spec.diameter * 0.5;
798 let ridden = drag_x
804 .get()
805 .map(|x| x.clamp(rest_center - pitch * 0.35, rest_center + pitch * 0.35))
806 .unwrap_or(rest_center);
807 let center_x = pad + rest_center + (ridden - rest_center) * progress;
808 let diameter = spec.diameter * (1.0 + (spec.pressed_scale - 1.0) * progress);
809 let touch = drag_x.get().map(|x| {
813 (
814 pad + x.clamp(0.0, node_width - 2.0 * pad),
815 center_y,
816 progress,
817 )
818 });
819 GlassDynamics {
820 activity: Some(progress),
821 touch,
822 morph: Some(GlassMorph {
823 node_size: (node_width, node_height),
824 primary: (center_x, center_y, diameter, diameter, -1.0),
825 shapes: icon_group_neighbor_shapes(
826 count,
827 active_index,
828 spec,
829 pad,
830 center_y,
831 ),
832 glue: spec.glue_radius * progress,
833 ..Default::default()
834 }),
835 ..Default::default()
836 }
837 },
838 );
839 Box(shared, BoxSpec::default(), || {});
840
841 for (index, item) in render_items.iter().enumerate() {
843 let x = index as f32 * pitch;
844 let surface_progress = press_progress;
845 let item_is_active = index == active_index;
846 let scale_orphaned = Rc::clone(&press_orphaned);
847 let outer = Modifier::empty()
848 .size(Size::new(spec.diameter, spec.diameter))
849 .offset(x, 0.0);
850 let rest_center = index as f32 * pitch + spec.diameter * 0.5;
851 let scale_layer = Modifier::empty().graphics_layer(move || {
852 let progress = if item_is_active && !scale_orphaned.get() {
853 surface_progress.get().clamp(0.0, 1.0)
854 } else {
855 0.0
856 };
857 let scale = 1.0 + (spec.pressed_scale - 1.0) * progress;
858 let ridden = if item_is_active {
859 drag_x
860 .get()
861 .map(|x| x.clamp(rest_center - pitch * 0.35, rest_center + pitch * 0.35))
862 .unwrap_or(rest_center)
863 } else {
864 rest_center
865 };
866 GraphicsLayer {
867 translation_x: (ridden - rest_center) * progress,
868 scale_x: scale,
869 scale_y: scale,
870 ..Default::default()
871 }
872 });
873 let mut surface = Modifier::empty().size(Size::new(spec.diameter, spec.diameter));
874 if let Some(material) = item
875 .spec
876 .resolve_material(&colors, item.spec.icon_color(&colors))
877 {
878 let surface_progress = press_progress;
879 let dynamics_orphaned = Rc::clone(&press_orphaned);
880 surface =
881 surface.glass_effect_with(material.shape(LiquidShape::Circle), move || {
882 let progress = if dynamics_orphaned.get() {
883 0.0
884 } else {
885 surface_progress.get().clamp(0.0, 1.0)
886 };
887 GlassDynamics {
893 highlight_boost: if item_is_active { 0.60 * progress } else { 0.0 },
894 saturation_boost: if item_is_active { 1.6 * progress } else { 0.0 },
895 tint_alpha_multiplier: item_is_active.then_some(1.0 + 0.85 * progress),
896 ..Default::default()
897 }
898 });
899 }
900 Box(outer, BoxSpec::default(), move || {
901 let surface = surface.clone();
902 Box(scale_layer.clone(), BoxSpec::default(), move || {
903 Box(surface.clone(), BoxSpec::default(), || {});
904 });
905 });
906 }
907
908 for (index, item) in render_items.iter().enumerate() {
911 let x = index as f32 * pitch;
912 let item_is_active = index == active_index;
913 let foreground_progress = press_progress;
914 let foreground_orphaned = Rc::clone(&press_orphaned);
915 let foreground_spec = item.spec.clone();
916 let icon_path = item.icon_path;
917 let description = item.content_description.clone();
918 let merge_alpha = merge_glyph_alpha(merge);
922 let foreground = Modifier::empty()
923 .size(Size::new(spec.diameter, spec.diameter))
924 .offset(x, 0.0)
925 .graphics_layer(move || GraphicsLayer {
926 alpha: merge_alpha
927 * if item_is_active && !foreground_orphaned.get() {
928 1.0 - foreground_progress.get().clamp(0.0, 1.0)
929 } else {
930 1.0
931 },
932 ..Default::default()
933 })
934 .semantics(move |config| {
935 config.role = Some(SemanticsWidgetRole::Button);
936 config.is_clickable = true;
937 config.content_description = Some(description.clone());
938 });
939 Box(
940 foreground,
941 BoxSpec::default().content_alignment(Alignment::CENTER),
942 move || {
943 GlassIconForeground(foreground_spec.clone(), spec.diameter, icon_path);
944 },
945 );
946 }
947
948 for (ghost, ghost_index, departure) in ghosts.borrow().iter() {
952 let x = *ghost_index as f32 * pitch + ghost_trailing_shift;
955 let fade = ghost_alpha;
956 let departure = *departure;
957 let ghost_layer = Modifier::empty()
958 .size(Size::new(spec.diameter, spec.diameter))
959 .offset(x, 0.0)
960 .graphics_layer(move || {
961 let alpha = fade.get().clamp(0.0, 1.0);
962 let scale =
967 1.0 + (spec.pressed_scale - 1.0) * departure * (0.55 + 0.45 * alpha);
968 GraphicsLayer {
969 alpha,
970 scale_x: scale,
971 scale_y: scale,
972 render_effect: (departure > 0.05 && alpha < 0.98).then(|| {
973 cranpose_ui_graphics::RenderEffect::blur(
974 (1.0 - alpha) * 9.0 * departure,
975 )
976 }),
977 ..Default::default()
978 }
979 });
980 let ghost_spec = ghost.spec.clone();
981 let ghost_icon = ghost.icon_path;
982 let surface = ghost
983 .spec
984 .resolve_material(&colors, ghost.spec.icon_color(&colors))
985 .map(|material| {
986 Modifier::empty()
987 .size(Size::new(spec.diameter, spec.diameter))
988 .glass_effect_with(material.shape(LiquidShape::Circle), move || {
989 GlassDynamics {
993 highlight_boost: 0.60 * departure,
994 saturation_boost: 1.6 * departure,
995 tint_alpha_multiplier: Some(1.0 + 0.85 * departure),
996 ..Default::default()
997 }
998 })
999 });
1000 let glyph_alpha = 1.0 - departure;
1001 Box(
1002 ghost_layer,
1003 BoxSpec::default().content_alignment(Alignment::CENTER),
1004 move || {
1005 if let Some(surface) = surface.clone() {
1006 Box(surface, BoxSpec::default(), || {});
1007 }
1008 let glyph_layer = Modifier::empty().graphics_layer(move || GraphicsLayer {
1011 alpha: glyph_alpha,
1012 ..Default::default()
1013 });
1014 let glyph_spec = ghost_spec.clone();
1015 Box(
1016 glyph_layer,
1017 BoxSpec::default().content_alignment(Alignment::CENTER),
1018 move || {
1019 GlassIconForeground(glyph_spec.clone(), spec.diameter, ghost_icon);
1020 },
1021 );
1022 },
1023 );
1024 }
1025 });
1026}
1027
1028#[cfg(test)]
1029mod tests {
1030 use super::*;
1031
1032 #[test]
1033 fn glass_buttons_expose_button_semantics_to_every_platform_bridge() {
1034 let semantics =
1035 cranpose_ui::collect_semantics_from_modifier(&with_button_semantics(Modifier::empty()))
1036 .expect("glass button semantics");
1037 assert_eq!(semantics.role, Some(SemanticsWidgetRole::Button));
1038 assert!(semantics.is_clickable);
1039 }
1040
1041 #[test]
1042 fn icon_backplate_colors_only_the_compact_foreground_core() {
1043 let blue = Color::from_rgb_u8(0, 122, 255);
1044 let spec = GlassButtonSpec::glass()
1045 .with_icon_backplate(blue)
1046 .with_content_color(Color::WHITE);
1047 assert_eq!(spec.style, GlassButtonStyle::Glass);
1048 assert_eq!(spec.icon_backplate, Some(blue));
1049 assert_eq!(spec.content_color, Some(Color::WHITE));
1050 assert!(spec.glass.is_none());
1051 assert!((0.49..=0.51).contains(&ICON_BACKPLATE_DIAMETER_RATIO));
1052 assert!((0.27..=0.29).contains(&ICON_BACKPLATE_GLYPH_RATIO));
1053
1054 let colors = crate::theme::LiquidColors::light(blue);
1055 let material = GlassButtonSpec::glass()
1056 .resolve_material(&colors, colors.label)
1057 .expect("glass button material");
1058 assert_eq!(material.tint, None);
1059 assert_eq!(material.resolve(&colors).tint, colors.glass_tint);
1060 }
1061
1062 #[test]
1063 fn neutral_button_tint_comes_from_the_theme_not_foreground_polarity() {
1064 let accent = Color::from_rgb_u8(0, 122, 255);
1065 for colors in [
1066 crate::theme::LiquidColors::light(accent),
1067 crate::theme::LiquidColors::dark(accent),
1068 ] {
1069 let material = GlassButtonSpec::glass()
1070 .resolve_material(&colors, colors.label)
1071 .expect("glass button material");
1072 assert_eq!(material.tint, None);
1073 assert_eq!(material.resolve(&colors).tint, colors.glass_tint);
1074 }
1075 }
1076
1077 #[test]
1078 fn icon_button_group_builders_and_hit_regions_preserve_member_gaps() {
1079 let spec = GlassIconButtonGroupSpec::new(44.0)
1080 .with_spacing(8.0)
1081 .with_pressed_scale(1.2)
1082 .with_glue_radius(12.0);
1083 assert_eq!(icon_group_width(2, spec), 96.0);
1084 assert_eq!(icon_group_item_at(22.0, 22.0, 2, spec), Some(0));
1085 assert_eq!(icon_group_item_at(48.0, 22.0, 2, spec), None);
1086 assert_eq!(icon_group_item_at(74.0, 22.0, 2, spec), Some(1));
1087 assert_eq!(icon_group_item_at(22.0, 50.0, 2, spec), None);
1088
1089 let shapes = icon_group_neighbor_shapes(4, 2, spec, 16.0, 38.0);
1090 assert_eq!(shapes.len(), 2);
1091 assert!((shapes[0].0 - (16.0 + 52.0 + 22.0 + 44.0 * 0.38)).abs() < 1e-5);
1092 assert!((shapes[1].0 - (16.0 + 156.0 + 22.0 - 44.0 * 0.38)).abs() < 1e-5);
1093 assert_eq!(shapes[0].2, 44.0 * 0.36);
1094
1095 let item = GlassIconButtonGroupItem::new("M0 0", "Confirm", || {})
1096 .with_spec(GlassButtonSpec::prominent());
1097 assert_eq!(item.content_description, "Confirm");
1098 assert_eq!(item.spec.style, GlassButtonStyle::Prominent);
1099 }
1100
1101 #[test]
1104 fn a_scope_declares_actions_in_order_with_their_callbacks() {
1105 let fired = Rc::new(Cell::new(0usize));
1106 let more = Rc::clone(&fired);
1107 let confirm = Rc::clone(&fired);
1108 let items = collect_items(|scope| {
1109 scope.action("M0 0", "More", move || more.set(1));
1110 scope.push(
1111 GlassIconButtonGroupItem::new("M1 1", "Confirm", move || confirm.set(2))
1112 .with_spec(GlassButtonSpec::prominent()),
1113 );
1114 });
1115
1116 assert_eq!(items.len(), 2);
1117 assert_eq!(items[0].content_description, "More");
1118 assert_eq!(items[0].spec.style, GlassButtonStyle::Glass);
1119 assert_eq!(items[1].spec.style, GlassButtonStyle::Prominent);
1120
1121 (items[0].on_click.borrow_mut())();
1122 assert_eq!(fired.get(), 1);
1123 (items[1].on_click.borrow_mut())();
1124 assert_eq!(fired.get(), 2);
1125 }
1126
1127 #[test]
1128 fn a_group_with_no_actions_declares_none() {
1129 assert!(collect_items(|_| {}).is_empty());
1130 }
1131}