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