1use crate::material::{Glass, GlassDynamics, GlassMorph, LiquidModifierExt, LiquidShape};
5use crate::motion::{liquid_press_scale, LiquidMotion};
6use crate::theme::{liquid_colors, liquid_typography};
7use cranpose_animation::{AnimationSpec, AnimationType, Easing};
8use cranpose_core::{mutableStateOf, remember};
9use cranpose_macros::composable;
10use cranpose_services::{default_haptics, HapticFeedback};
11use cranpose_ui::rememberMutableInteractionSource;
12use cranpose_ui::text::TextStyle;
13use cranpose_ui::widgets::{Box, BoxSpec, Text};
14use cranpose_ui::{Modifier, PointerEventKind, PointerInputScope, Size};
15use cranpose_ui_graphics::{Color, GraphicsLayer};
16use cranpose_ui_layout::Alignment;
17use std::cell::{Cell, RefCell};
18use std::rc::Rc;
19
20const ICON_BACKPLATE_DIAMETER_RATIO: f32 = 0.50;
21const ICON_BACKPLATE_GLYPH_RATIO: f32 = 0.28;
22
23fn with_button_semantics(modifier: Modifier) -> Modifier {
24 modifier.semantics(|config| {
25 config.is_button = true;
26 config.is_clickable = true;
27 })
28}
29
30#[derive(Clone, Copy, Debug, PartialEq, Eq, Default)]
32pub enum GlassButtonStyle {
33 #[default]
35 Glass,
36 Prominent,
38 Plain,
40 Destructive,
42}
43
44#[derive(Clone, Debug, Default, PartialEq)]
46pub struct GlassButtonSpec {
47 pub style: GlassButtonStyle,
48 pub glass: Option<Glass>,
50 pub content_color: Option<Color>,
52 pub icon_backplate: Option<Color>,
55}
56
57impl GlassButtonSpec {
58 pub fn glass() -> Self {
59 Self::default()
60 }
61
62 pub fn prominent() -> Self {
63 Self {
64 style: GlassButtonStyle::Prominent,
65 ..Self::default()
66 }
67 }
68
69 pub fn plain() -> Self {
70 Self {
71 style: GlassButtonStyle::Plain,
72 ..Self::default()
73 }
74 }
75
76 pub fn destructive() -> Self {
77 Self {
78 style: GlassButtonStyle::Destructive,
79 ..Self::default()
80 }
81 }
82
83 pub fn with_glass(mut self, glass: Glass) -> Self {
84 self.glass = Some(glass);
85 self
86 }
87
88 pub fn with_content_color(mut self, color: Color) -> Self {
89 self.content_color = Some(color);
90 self
91 }
92
93 pub fn with_icon_backplate(mut self, color: Color) -> Self {
94 self.icon_backplate = Some(color);
95 self
96 }
97
98 pub fn content_color(&self, colors: &crate::theme::LiquidColors) -> Color {
100 if let Some(color) = self.content_color {
101 return color;
102 }
103 match self.style {
104 GlassButtonStyle::Glass => colors.accent,
105 GlassButtonStyle::Prominent => colors.on_accent,
106 GlassButtonStyle::Plain => colors.accent,
107 GlassButtonStyle::Destructive => colors.destructive,
108 }
109 }
110
111 pub fn icon_color(&self, colors: &crate::theme::LiquidColors) -> Color {
114 if let Some(color) = self.content_color {
115 return color;
116 }
117 match self.style {
118 GlassButtonStyle::Glass | GlassButtonStyle::Plain => colors.label,
119 GlassButtonStyle::Prominent => colors.on_accent,
120 GlassButtonStyle::Destructive => colors.destructive,
121 }
122 }
123
124 fn resolve_material(
125 &self,
126 colors: &crate::theme::LiquidColors,
127 foreground: Color,
128 ) -> Option<Glass> {
129 if let Some(glass) = &self.glass {
130 return Some(if glass.foreground.is_some() {
131 glass.clone()
132 } else {
133 glass
134 .clone()
135 .adaptive_frost(foreground, glass.adaptive_frost)
136 });
137 }
138 match self.style {
139 GlassButtonStyle::Glass => Some(Glass::regular().adaptive_frost(foreground, 0.65)),
140 GlassButtonStyle::Prominent => Some(
141 Glass::regular()
142 .tint(colors.accent.with_alpha(0.75))
143 .adaptive_frost(foreground, 0.65),
144 ),
145 GlassButtonStyle::Plain | GlassButtonStyle::Destructive => None,
146 }
147 }
148}
149
150#[derive(Clone)]
152pub struct GlassIconButtonGroupItem {
153 spec: GlassButtonSpec,
154 icon_path: &'static str,
155 content_description: String,
156 on_click: Rc<RefCell<dyn FnMut()>>,
157}
158
159impl PartialEq for GlassIconButtonGroupItem {
160 fn eq(&self, other: &Self) -> bool {
161 self.spec == other.spec
162 && self.icon_path == other.icon_path
163 && self.content_description == other.content_description
164 && Rc::ptr_eq(&self.on_click, &other.on_click)
165 }
166}
167
168impl GlassIconButtonGroupItem {
169 pub fn new(
170 icon_path: &'static str,
171 content_description: impl Into<String>,
172 on_click: impl FnMut() + 'static,
173 ) -> Self {
174 Self {
175 spec: GlassButtonSpec::glass(),
176 icon_path,
177 content_description: content_description.into(),
178 on_click: Rc::new(RefCell::new(on_click)),
179 }
180 }
181
182 pub fn with_spec(mut self, spec: GlassButtonSpec) -> Self {
183 self.spec = spec;
184 self
185 }
186}
187
188#[derive(Clone, Copy, Debug, PartialEq)]
190pub struct GlassIconButtonGroupSpec {
191 diameter: f32,
192 spacing: f32,
193 pressed_scale: f32,
194 glue_radius: f32,
195}
196
197impl GlassIconButtonGroupSpec {
198 pub fn new(diameter: f32) -> Self {
199 Self {
200 diameter: diameter.max(1.0),
201 ..Self::default()
202 }
203 }
204
205 pub fn with_spacing(mut self, spacing: f32) -> Self {
206 self.spacing = spacing.max(0.0);
207 self
208 }
209
210 pub fn with_pressed_scale(mut self, pressed_scale: f32) -> Self {
211 self.pressed_scale = pressed_scale.max(1.0);
212 self
213 }
214
215 pub fn with_glue_radius(mut self, glue_radius: f32) -> Self {
216 self.glue_radius = glue_radius.max(0.0);
217 self
218 }
219}
220
221impl Default for GlassIconButtonGroupSpec {
222 fn default() -> Self {
223 Self {
224 diameter: 44.0,
225 spacing: 8.0,
226 pressed_scale: 1.45,
227 glue_radius: 12.0,
228 }
229 }
230}
231
232fn icon_group_width(count: usize, spec: GlassIconButtonGroupSpec) -> f32 {
233 if count == 0 {
234 0.0
235 } else {
236 spec.diameter * count as f32 + spec.spacing * count.saturating_sub(1) as f32
237 }
238}
239
240fn icon_group_item_at(
241 x: f32,
242 y: f32,
243 count: usize,
244 spec: GlassIconButtonGroupSpec,
245) -> Option<usize> {
246 if !(0.0..=spec.diameter).contains(&y) {
247 return None;
248 }
249 let pitch = spec.diameter + spec.spacing;
250 let index = (x / pitch).floor() as isize;
251 if index < 0 || index as usize >= count {
252 return None;
253 }
254 let local_x = x - index as f32 * pitch;
255 (local_x <= spec.diameter).then_some(index as usize)
256}
257
258fn icon_group_neighbor_shapes(
259 count: usize,
260 active: usize,
261 spec: GlassIconButtonGroupSpec,
262 pad: f32,
263 center_y: f32,
264) -> Vec<(f32, f32, f32, f32, f32)> {
265 let pitch = spec.diameter + spec.spacing;
266 let contact_diameter = spec.diameter * 0.36;
267 let contact_offset = spec.diameter * 0.38;
268 (0..count)
269 .filter(|index| index.abs_diff(active) == 1)
270 .map(|index| {
271 let toward_active = if index < active { 1.0 } else { -1.0 };
272 (
273 pad + index as f32 * pitch + spec.diameter * 0.5 + toward_active * contact_offset,
274 center_y,
275 contact_diameter,
276 contact_diameter,
277 -1.0,
278 )
279 })
280 .collect()
281}
282
283#[composable]
286#[allow(non_snake_case)]
287pub fn GlassButton(
288 modifier: Modifier,
289 spec: GlassButtonSpec,
290 on_click: impl Fn() + 'static,
291 content: impl FnMut() + 'static,
292) {
293 let colors = liquid_colors();
294 let interaction = rememberMutableInteractionSource();
295 let (pressed_modifier, pressed, content_alpha) =
296 liquid_press_scale(Modifier::empty(), interaction.clone(), 1.18);
297
298 let material = spec.resolve_material(&colors, spec.content_color(&colors));
299 let mut base = Modifier::empty();
300 if let Some(glass) = material {
301 let pressed_for_glass = pressed;
302 let glow_size = cranpose_core::remember(|| {
307 std::rc::Rc::new(std::cell::Cell::new(cranpose_ui_graphics::Size {
308 width: 0.0,
309 height: 0.0,
310 }))
311 })
312 .with(std::rc::Rc::clone);
313 let glow_size_for_glass = std::rc::Rc::clone(&glow_size);
314 base = base
315 .report_size(std::rc::Rc::clone(&glow_size))
316 .glass_effect_with(glass, move || {
317 let size = glow_size_for_glass.get();
318 GlassDynamics {
319 highlight_boost: if pressed_for_glass.get() { 0.85 } else { 0.0 },
320 touch: (pressed_for_glass.get() && size.width > 0.0 && size.height > 0.0)
321 .then_some((size.width * 0.5, size.height * 0.5, 1.0)),
322 ..Default::default()
323 }
324 });
325 }
326
327 let on_click = Rc::new(RefCell::new(on_click));
328 let base = with_button_semantics(
329 base.press_interaction_source(interaction)
330 .clickable(move |_point| {
331 default_haptics().perform(HapticFeedback::ImpactLight);
332 (on_click.borrow_mut())();
333 })
334 .padding_symmetric(16.0, 10.0),
335 );
336
337 let content_layer = Modifier::empty().graphics_layer(move || GraphicsLayer {
339 alpha: content_alpha.get().clamp(0.0, 1.0),
340 ..Default::default()
341 });
342 let content = Rc::new(RefCell::new(content));
343 let button = base.then(modifier);
344 Box(pressed_modifier, BoxSpec::default(), move || {
345 let content = Rc::clone(&content);
346 let content_layer = content_layer.clone();
347 Box(
348 button.clone(),
349 BoxSpec::default().content_alignment(Alignment::CENTER),
350 move || {
351 let content = Rc::clone(&content);
352 Box(
353 content_layer.clone(),
354 BoxSpec::default().content_alignment(Alignment::CENTER),
355 move || (content.borrow_mut())(),
356 );
357 },
358 );
359 });
360}
361
362#[composable]
364#[allow(non_snake_case)]
365pub fn GlassButtonLabel(text: impl Into<String>, spec: GlassButtonSpec) {
366 let typography = liquid_typography();
367 let color = spec.content_color(&liquid_colors());
368 let style = TextStyle {
369 span_style: cranpose_ui::text::SpanStyle {
370 color: Some(color),
371 ..typography.headline.span_style.clone()
372 },
373 ..typography.headline.clone()
374 };
375 Text(text.into(), Modifier::empty(), style);
376}
377
378#[composable]
379#[allow(non_snake_case)]
380pub(crate) fn GlassIconForeground(spec: GlassButtonSpec, diameter: f32, icon_path: &'static str) {
381 let colors = liquid_colors();
382 let icon_color = spec.icon_color(&colors);
383 if let Some(backplate) = spec.icon_backplate {
384 let backplate_diameter = diameter * ICON_BACKPLATE_DIAMETER_RATIO;
385 Box(
386 Modifier::empty()
387 .size(Size::new(backplate_diameter, backplate_diameter))
388 .draw_behind(move |scope| {
389 scope.draw_circle(
390 cranpose_ui_graphics::Brush::solid(backplate),
391 cranpose_ui_graphics::Point::new(
392 backplate_diameter * 0.5,
393 backplate_diameter * 0.5,
394 ),
395 backplate_diameter * 0.5,
396 );
397 }),
398 BoxSpec::default().content_alignment(Alignment::CENTER),
399 move || {
400 crate::icons::Icon(icon_path, diameter * ICON_BACKPLATE_GLYPH_RATIO, icon_color);
401 },
402 );
403 } else {
404 crate::icons::Icon(icon_path, diameter * 0.5, icon_color);
405 }
406}
407
408#[composable]
410#[allow(non_snake_case)]
411pub fn GlassIconButton(
412 modifier: Modifier,
413 spec: GlassButtonSpec,
414 diameter: f32,
415 on_click: impl Fn() + 'static,
416 icon_path: &'static str,
417) {
418 GlassIconButtonWithForegroundAlpha(modifier, spec, diameter, 1.0, on_click, icon_path);
419}
420
421#[composable]
422#[allow(non_snake_case)]
423pub(crate) fn GlassIconButtonWithForegroundAlpha(
424 modifier: Modifier,
425 spec: GlassButtonSpec,
426 diameter: f32,
427 foreground_alpha: f32,
428 on_click: impl Fn() + 'static,
429 icon_path: &'static str,
430) {
431 let colors = liquid_colors();
432 let interaction = rememberMutableInteractionSource();
433 let (pressed_modifier, pressed, content_alpha) =
434 liquid_press_scale(Modifier::empty(), interaction.clone(), 1.20);
435
436 let material = spec
437 .resolve_material(&colors, spec.icon_color(&colors))
438 .map(|glass| glass.shape(LiquidShape::Circle));
439 let mut base = Modifier::empty();
440 if let Some(glass) = material {
441 let pressed_for_glass = pressed;
442 base = base.glass_effect_with(glass, move || GlassDynamics {
443 highlight_boost: if pressed_for_glass.get() { 0.85 } else { 0.0 },
444 ..Default::default()
445 });
446 }
447
448 let on_click = Rc::new(RefCell::new(on_click));
449 let base = base
450 .press_interaction_source(interaction)
451 .clickable(move |_point| {
452 default_haptics().perform(HapticFeedback::ImpactLight);
453 (on_click.borrow_mut())();
454 })
455 .size(Size::new(diameter, diameter));
456
457 let content_layer = Modifier::empty().graphics_layer(move || GraphicsLayer {
459 alpha: content_alpha.get().clamp(0.0, 1.0) * foreground_alpha.clamp(0.0, 1.0),
460 ..Default::default()
461 });
462 let button = base.then(modifier);
463 Box(pressed_modifier, BoxSpec::default(), move || {
464 let foreground_spec = spec.clone();
465 let content_layer = content_layer.clone();
466 Box(
467 button.clone(),
468 BoxSpec::default().content_alignment(Alignment::CENTER),
469 move || {
470 let foreground_spec = foreground_spec.clone();
471 Box(
472 content_layer.clone(),
473 BoxSpec::default().content_alignment(Alignment::CENTER),
474 move || GlassIconForeground(foreground_spec.clone(), diameter, icon_path),
475 );
476 },
477 );
478 });
479}
480
481#[composable]
485#[allow(non_snake_case)]
486pub fn GlassIconButtonGroup(
487 modifier: Modifier,
488 spec: GlassIconButtonGroupSpec,
489 items: Vec<GlassIconButtonGroupItem>,
490) {
491 let count = items.len();
492 if count == 0 {
493 return;
494 }
495
496 let colors = liquid_colors();
497 let live_items: Rc<RefCell<Vec<GlassIconButtonGroupItem>>> =
498 remember(|| Rc::new(RefCell::new(Vec::new()))).with(Rc::clone);
499 let ghosts: Rc<RefCell<Vec<(GlassIconButtonGroupItem, usize)>>> =
503 remember(|| Rc::new(RefCell::new(Vec::new()))).with(Rc::clone);
504 let ghost_fade = remember(|| {
505 let runtime = cranpose_core::with_current_composer(|composer| composer.runtime_handle());
506 Rc::new(RefCell::new(cranpose_animation::Animatable::new(
507 0.0f32, runtime,
508 )))
509 })
510 .with(Rc::clone);
511 {
512 let previous = live_items.borrow();
513 if !previous.is_empty() && *previous != items {
514 let leavers: Vec<(GlassIconButtonGroupItem, usize)> = previous
515 .iter()
516 .enumerate()
517 .filter(|(_, member)| !items.contains(member))
518 .map(|(index, member)| (member.clone(), index))
519 .collect();
520 if !leavers.is_empty() {
521 *ghosts.borrow_mut() = leavers;
522 let mut fade = ghost_fade.borrow_mut();
523 fade.snapTo(1.0);
524 fade.animateTo(
525 0.0,
526 AnimationType::Tween(AnimationSpec::tween(250, Easing::EaseIn)),
527 );
528 }
529 }
530 }
531 let ghost_alpha = ghost_fade.borrow().state();
532 if !ghosts.borrow().is_empty() && ghost_alpha.get() <= 0.01 {
533 ghosts.borrow_mut().clear();
534 }
535 *live_items.borrow_mut() = items;
536 let render_items = live_items.borrow().clone();
537
538 let active = remember(|| mutableStateOf(None::<usize>)).with(|state| *state);
539 let drag_x = remember(|| mutableStateOf(None::<f32>)).with(|state| *state);
542 let last_active = remember(|| Rc::new(Cell::new(0usize))).with(Rc::clone);
543 if let Some(index) = active.get() {
544 last_active.set(index.min(count - 1));
545 }
546 let press_progress = cranpose_animation::animateFloatAsState(
547 if active.get().is_some() { 1.0 } else { 0.0 },
548 LiquidMotion::snappy(),
549 "glass-icon-group-press",
550 );
551
552 let width = icon_group_width(count, spec);
553 let gesture_items = Rc::clone(&live_items);
554 let gesture = Modifier::empty()
555 .size(Size::new(width, spec.diameter))
556 .pointer_input(count, move |scope: PointerInputScope| {
557 let gesture_items = Rc::clone(&gesture_items);
558 async move {
559 scope
560 .await_pointer_event_scope(|await_scope| async move {
561 let mut down_index = None::<usize>;
562 loop {
563 let event = await_scope.await_pointer_event().await;
564 match event.kind {
565 PointerEventKind::Down if down_index.is_none() => {
566 down_index = icon_group_item_at(
567 event.position.x,
568 event.position.y,
569 gesture_items.borrow().len(),
570 spec,
571 );
572 if let Some(index) = down_index {
573 active.set(Some(index));
574 drag_x.set(Some(event.position.x));
575 default_haptics().perform(HapticFeedback::Selection);
576 event.consume();
577 }
578 }
579 PointerEventKind::Move if down_index.is_some() => {
580 drag_x.set(Some(event.position.x));
581 event.consume();
582 }
583 PointerEventKind::Up => {
584 let pressed_index = down_index.take();
585 active.set(None);
586 drag_x.set(None);
587 if let Some(index) = pressed_index {
588 let released_index = icon_group_item_at(
589 event.position.x,
590 event.position.y,
591 gesture_items.borrow().len(),
592 spec,
593 );
594 if released_index == Some(index) {
595 let on_click = gesture_items
596 .borrow()
597 .get(index)
598 .map(|item| Rc::clone(&item.on_click));
599 if let Some(on_click) = on_click {
600 default_haptics()
601 .perform(HapticFeedback::ImpactLight);
602 (on_click.borrow_mut())();
603 }
604 }
605 event.consume();
606 }
607 }
608 PointerEventKind::Cancel if down_index.take().is_some() => {
609 active.set(None);
610 drag_x.set(None);
611 event.consume();
612 }
613 _ => {}
614 }
615 }
616 })
617 .await;
618 }
619 })
620 .then(modifier);
621
622 Box(gesture, BoxSpec::default(), move || {
623 let pitch = spec.diameter + spec.spacing;
624 let active_index = last_active.get().min(count - 1);
625
626 let pad = spec.glue_radius + spec.diameter * (spec.pressed_scale - 1.0) * 0.5 + 4.0;
633 let node_width = width + pad * 2.0;
634 let node_height = spec.diameter * spec.pressed_scale + pad * 2.0;
635 let shared_progress = press_progress;
636 let shared_last_active = Rc::clone(&last_active);
637 let union_tint = render_items
638 .get(active_index)
639 .and_then(|item| {
640 item.spec
641 .resolve_material(&colors, item.spec.icon_color(&colors))
642 })
643 .and_then(|material| material.tint)
644 .map(|tint| tint.with_alpha(0.45))
645 .unwrap_or(Color::WHITE.with_alpha(0.035));
646 let shared = Modifier::empty()
647 .required_size(Size::new(node_width, node_height))
648 .offset(-pad, (spec.diameter - node_height) * 0.5)
649 .glass_effect_with(
650 Glass::regular()
654 .blur_radius(0.0)
655 .tint(union_tint)
656 .lift(0.0)
657 .highlight(0.48)
658 .shadow(false)
659 .no_clip(),
660 move || {
661 let progress = shared_progress.get().clamp(0.0, 1.0);
662 let active_index = shared_last_active.get().min(count - 1);
663 let center_y = node_height * 0.5;
664 let rest_center = active_index as f32 * pitch + spec.diameter * 0.5;
665 let ridden = drag_x
671 .get()
672 .map(|x| x.clamp(rest_center - pitch * 0.35, rest_center + pitch * 0.35))
673 .unwrap_or(rest_center);
674 let center_x = pad + rest_center + (ridden - rest_center) * progress;
675 let diameter = spec.diameter * (1.0 + (spec.pressed_scale - 1.0) * progress);
676 let touch = drag_x.get().map(|x| {
680 (
681 pad + x.clamp(0.0, node_width - 2.0 * pad),
682 center_y,
683 progress,
684 )
685 });
686 GlassDynamics {
687 activity: Some(progress),
688 touch,
689 morph: Some(GlassMorph {
690 node_size: (node_width, node_height),
691 primary: (center_x, center_y, diameter, diameter, -1.0),
692 shapes: icon_group_neighbor_shapes(
693 count,
694 active_index,
695 spec,
696 pad,
697 center_y,
698 ),
699 glue: spec.glue_radius * progress,
700 ..Default::default()
701 }),
702 ..Default::default()
703 }
704 },
705 );
706 Box(shared, BoxSpec::default(), || {});
707
708 for (index, item) in render_items.iter().enumerate() {
710 let x = index as f32 * pitch;
711 let surface_progress = press_progress;
712 let item_is_active = index == active_index;
713 let outer = Modifier::empty()
714 .size(Size::new(spec.diameter, spec.diameter))
715 .offset(x, 0.0);
716 let rest_center = index as f32 * pitch + spec.diameter * 0.5;
717 let scale_layer = Modifier::empty().graphics_layer(move || {
718 let progress = if item_is_active {
719 surface_progress.get().clamp(0.0, 1.0)
720 } else {
721 0.0
722 };
723 let scale = 1.0 + (spec.pressed_scale - 1.0) * progress;
724 let ridden = if item_is_active {
725 drag_x
726 .get()
727 .map(|x| x.clamp(rest_center - pitch * 0.35, rest_center + pitch * 0.35))
728 .unwrap_or(rest_center)
729 } else {
730 rest_center
731 };
732 GraphicsLayer {
733 translation_x: (ridden - rest_center) * progress,
734 scale_x: scale,
735 scale_y: scale,
736 ..Default::default()
737 }
738 });
739 let mut surface = Modifier::empty().size(Size::new(spec.diameter, spec.diameter));
740 if let Some(material) = item
741 .spec
742 .resolve_material(&colors, item.spec.icon_color(&colors))
743 {
744 let surface_progress = press_progress;
745 surface =
746 surface.glass_effect_with(material.shape(LiquidShape::Circle), move || {
747 let progress = surface_progress.get().clamp(0.0, 1.0);
748 GlassDynamics {
752 highlight_boost: if item_is_active { 0.60 * progress } else { 0.0 },
753 saturation_boost: if item_is_active { 0.85 * progress } else { 0.0 },
754 ..Default::default()
755 }
756 });
757 }
758 Box(outer, BoxSpec::default(), move || {
759 let surface = surface.clone();
760 Box(scale_layer.clone(), BoxSpec::default(), move || {
761 Box(surface.clone(), BoxSpec::default(), || {});
762 });
763 });
764 }
765
766 for (index, item) in render_items.iter().enumerate() {
769 let x = index as f32 * pitch;
770 let item_is_active = index == active_index;
771 let foreground_progress = press_progress;
772 let foreground_spec = item.spec.clone();
773 let icon_path = item.icon_path;
774 let description = item.content_description.clone();
775 let foreground = Modifier::empty()
776 .size(Size::new(spec.diameter, spec.diameter))
777 .offset(x, 0.0)
778 .graphics_layer(move || GraphicsLayer {
779 alpha: if item_is_active {
780 1.0 - foreground_progress.get().clamp(0.0, 1.0)
781 } else {
782 1.0
783 },
784 ..Default::default()
785 })
786 .semantics(move |config| {
787 config.is_button = true;
788 config.is_clickable = true;
789 config.content_description = Some(description.clone());
790 });
791 Box(
792 foreground,
793 BoxSpec::default().content_alignment(Alignment::CENTER),
794 move || {
795 GlassIconForeground(foreground_spec.clone(), spec.diameter, icon_path);
796 },
797 );
798 }
799
800 for (ghost, ghost_index) in ghosts.borrow().iter() {
804 let x = *ghost_index as f32 * pitch;
805 let fade = ghost_alpha;
806 let ghost_layer = Modifier::empty()
807 .size(Size::new(spec.diameter, spec.diameter))
808 .offset(x, 0.0)
809 .graphics_layer(move || GraphicsLayer {
810 alpha: fade.get().clamp(0.0, 1.0),
811 ..Default::default()
812 });
813 let ghost_spec = ghost.spec.clone();
814 let ghost_icon = ghost.icon_path;
815 let surface = ghost
816 .spec
817 .resolve_material(&colors, ghost.spec.icon_color(&colors))
818 .map(|material| {
819 Modifier::empty()
820 .size(Size::new(spec.diameter, spec.diameter))
821 .glass_effect(material.shape(LiquidShape::Circle))
822 });
823 Box(
824 ghost_layer,
825 BoxSpec::default().content_alignment(Alignment::CENTER),
826 move || {
827 if let Some(surface) = surface.clone() {
828 Box(surface, BoxSpec::default(), || {});
829 }
830 GlassIconForeground(ghost_spec.clone(), spec.diameter, ghost_icon);
831 },
832 );
833 }
834 });
835}
836
837#[cfg(test)]
838mod tests {
839 use super::*;
840
841 #[test]
842 fn glass_buttons_expose_button_semantics_to_every_platform_bridge() {
843 let semantics =
844 cranpose_ui::collect_semantics_from_modifier(&with_button_semantics(Modifier::empty()))
845 .expect("glass button semantics");
846 assert!(semantics.is_button);
847 assert!(semantics.is_clickable);
848 }
849
850 #[test]
851 fn icon_backplate_colors_only_the_compact_foreground_core() {
852 let blue = Color::from_rgb_u8(0, 122, 255);
853 let spec = GlassButtonSpec::glass()
854 .with_icon_backplate(blue)
855 .with_content_color(Color::WHITE);
856 assert_eq!(spec.style, GlassButtonStyle::Glass);
857 assert_eq!(spec.icon_backplate, Some(blue));
858 assert_eq!(spec.content_color, Some(Color::WHITE));
859 assert!(spec.glass.is_none());
860 assert!((0.49..=0.51).contains(&ICON_BACKPLATE_DIAMETER_RATIO));
861 assert!((0.27..=0.29).contains(&ICON_BACKPLATE_GLYPH_RATIO));
862
863 let colors = crate::theme::LiquidColors::light(blue);
864 let material = GlassButtonSpec::glass()
865 .resolve_material(&colors, colors.label)
866 .expect("glass button material");
867 assert_eq!(material.tint, None);
868 assert_eq!(material.resolve(&colors).tint, colors.glass_tint);
869 }
870
871 #[test]
872 fn neutral_button_tint_comes_from_the_theme_not_foreground_polarity() {
873 let accent = Color::from_rgb_u8(0, 122, 255);
874 for colors in [
875 crate::theme::LiquidColors::light(accent),
876 crate::theme::LiquidColors::dark(accent),
877 ] {
878 let material = GlassButtonSpec::glass()
879 .resolve_material(&colors, colors.label)
880 .expect("glass button material");
881 assert_eq!(material.tint, None);
882 assert_eq!(material.resolve(&colors).tint, colors.glass_tint);
883 }
884 }
885
886 #[test]
887 fn icon_button_group_builders_and_hit_regions_preserve_member_gaps() {
888 let spec = GlassIconButtonGroupSpec::new(44.0)
889 .with_spacing(8.0)
890 .with_pressed_scale(1.2)
891 .with_glue_radius(12.0);
892 assert_eq!(icon_group_width(2, spec), 96.0);
893 assert_eq!(icon_group_item_at(22.0, 22.0, 2, spec), Some(0));
894 assert_eq!(icon_group_item_at(48.0, 22.0, 2, spec), None);
895 assert_eq!(icon_group_item_at(74.0, 22.0, 2, spec), Some(1));
896 assert_eq!(icon_group_item_at(22.0, 50.0, 2, spec), None);
897
898 let shapes = icon_group_neighbor_shapes(4, 2, spec, 16.0, 38.0);
899 assert_eq!(shapes.len(), 2);
900 assert!((shapes[0].0 - (16.0 + 52.0 + 22.0 + 44.0 * 0.38)).abs() < 1e-5);
901 assert!((shapes[1].0 - (16.0 + 156.0 + 22.0 - 44.0 * 0.38)).abs() < 1e-5);
902 assert_eq!(shapes[0].2, 44.0 * 0.36);
903
904 let item = GlassIconButtonGroupItem::new("M0 0", "Confirm", || {})
905 .with_spec(GlassButtonSpec::prominent());
906 assert_eq!(item.content_description, "Confirm");
907 assert_eq!(item.spec.style, GlassButtonStyle::Prominent);
908 }
909}