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.55,
229 glue_radius: 12.0,
230 }
231 }
232}
233
234fn icon_group_width(count: usize, spec: GlassIconButtonGroupSpec) -> f32 {
235 if count == 0 {
236 0.0
237 } else {
238 spec.diameter * count as f32 + spec.spacing * count.saturating_sub(1) as f32
239 }
240}
241
242fn icon_group_item_at(
243 x: f32,
244 y: f32,
245 count: usize,
246 spec: GlassIconButtonGroupSpec,
247) -> Option<usize> {
248 if !(0.0..=spec.diameter).contains(&y) {
249 return None;
250 }
251 let pitch = spec.diameter + spec.spacing;
252 let index = (x / pitch).floor() as isize;
253 if index < 0 || index as usize >= count {
254 return None;
255 }
256 let local_x = x - index as f32 * pitch;
257 (local_x <= spec.diameter).then_some(index as usize)
258}
259
260fn icon_group_neighbor_shapes(
261 count: usize,
262 active: usize,
263 spec: GlassIconButtonGroupSpec,
264 pad: f32,
265 center_y: f32,
266) -> Vec<(f32, f32, f32, f32, f32)> {
267 let pitch = spec.diameter + spec.spacing;
268 let contact_diameter = spec.diameter * 0.36;
269 let contact_offset = spec.diameter * 0.38;
270 (0..count)
271 .filter(|index| index.abs_diff(active) == 1)
272 .map(|index| {
273 let toward_active = if index < active { 1.0 } else { -1.0 };
274 (
275 pad + index as f32 * pitch + spec.diameter * 0.5 + toward_active * contact_offset,
276 center_y,
277 contact_diameter,
278 contact_diameter,
279 -1.0,
280 )
281 })
282 .collect()
283}
284
285#[composable]
288#[allow(non_snake_case)]
289pub fn GlassButton(
290 modifier: Modifier,
291 spec: GlassButtonSpec,
292 on_click: impl Fn() + 'static,
293 content: impl FnMut() + 'static,
294) {
295 let colors = liquid_colors();
296 let interaction = rememberMutableInteractionSource();
297 let (pressed_modifier, pressed, content_alpha) =
298 liquid_press_scale(Modifier::empty(), interaction.clone(), 1.18);
299
300 let material = spec.resolve_material(&colors, spec.content_color(&colors));
301 let mut base = Modifier::empty();
302 if let Some(glass) = material {
303 let pressed_for_glass = pressed;
304 let glow_size = cranpose_core::remember(|| {
309 std::rc::Rc::new(std::cell::Cell::new(cranpose_ui_graphics::Size {
310 width: 0.0,
311 height: 0.0,
312 }))
313 })
314 .with(std::rc::Rc::clone);
315 let glow_size_for_glass = std::rc::Rc::clone(&glow_size);
316 base = base
317 .report_size(std::rc::Rc::clone(&glow_size))
318 .glass_effect_with(glass, move || {
319 let size = glow_size_for_glass.get();
320 GlassDynamics {
321 highlight_boost: if pressed_for_glass.get() { 0.85 } else { 0.0 },
322 touch: (pressed_for_glass.get() && size.width > 0.0 && size.height > 0.0)
323 .then_some((size.width * 0.5, size.height * 0.5, 1.0)),
324 ..Default::default()
325 }
326 });
327 }
328
329 let on_click = Rc::new(RefCell::new(on_click));
330 let base = with_button_semantics(
331 base.press_interaction_source(interaction)
332 .clickable(move |_point| {
333 default_haptics().perform(HapticFeedback::ImpactLight);
334 (on_click.borrow_mut())();
335 })
336 .padding_symmetric(16.0, 10.0),
337 );
338
339 let content_layer = Modifier::empty().graphics_layer(move || GraphicsLayer {
341 alpha: content_alpha.get().clamp(0.0, 1.0),
342 ..Default::default()
343 });
344 let content = Rc::new(RefCell::new(content));
345 let button = base.then(modifier);
346 Box(pressed_modifier, BoxSpec::default(), move || {
347 let content = Rc::clone(&content);
348 let content_layer = content_layer.clone();
349 Box(
350 button.clone(),
351 BoxSpec::default().content_alignment(Alignment::CENTER),
352 move || {
353 let content = Rc::clone(&content);
354 Box(
355 content_layer.clone(),
356 BoxSpec::default().content_alignment(Alignment::CENTER),
357 move || (content.borrow_mut())(),
358 );
359 },
360 );
361 });
362}
363
364#[composable]
366#[allow(non_snake_case)]
367pub fn GlassButtonLabel(text: impl Into<String>, spec: GlassButtonSpec) {
368 let typography = liquid_typography();
369 let color = spec.content_color(&liquid_colors());
370 let style = TextStyle {
371 span_style: cranpose_ui::text::SpanStyle {
372 color: Some(color),
373 ..typography.headline.span_style.clone()
374 },
375 ..typography.headline.clone()
376 };
377 Text(text.into(), Modifier::empty(), style);
378}
379
380#[composable]
381#[allow(non_snake_case)]
382pub(crate) fn GlassIconForeground(spec: GlassButtonSpec, diameter: f32, icon_path: &'static str) {
383 let colors = liquid_colors();
384 let icon_color = spec.icon_color(&colors);
385 if let Some(backplate) = spec.icon_backplate {
386 let backplate_diameter = diameter * ICON_BACKPLATE_DIAMETER_RATIO;
387 Box(
388 Modifier::empty()
389 .size(Size::new(backplate_diameter, backplate_diameter))
390 .draw_behind(move |scope| {
391 scope.draw_circle(
392 cranpose_ui_graphics::Brush::solid(backplate),
393 cranpose_ui_graphics::Point::new(
394 backplate_diameter * 0.5,
395 backplate_diameter * 0.5,
396 ),
397 backplate_diameter * 0.5,
398 );
399 }),
400 BoxSpec::default().content_alignment(Alignment::CENTER),
401 move || {
402 crate::icons::Icon(icon_path, diameter * ICON_BACKPLATE_GLYPH_RATIO, icon_color);
403 },
404 );
405 } else {
406 crate::icons::Icon(icon_path, diameter * 0.5, icon_color);
407 }
408}
409
410#[composable]
412#[allow(non_snake_case)]
413pub fn GlassIconButton(
414 modifier: Modifier,
415 spec: GlassButtonSpec,
416 diameter: f32,
417 on_click: impl Fn() + 'static,
418 icon_path: &'static str,
419) {
420 GlassIconButtonWithForegroundAlpha(modifier, spec, diameter, 1.0, on_click, icon_path);
421}
422
423#[composable]
424#[allow(non_snake_case)]
425pub(crate) fn GlassIconButtonWithForegroundAlpha(
426 modifier: Modifier,
427 spec: GlassButtonSpec,
428 diameter: f32,
429 foreground_alpha: f32,
430 on_click: impl Fn() + 'static,
431 icon_path: &'static str,
432) {
433 let colors = liquid_colors();
434 let interaction = rememberMutableInteractionSource();
435 let (pressed_modifier, pressed, content_alpha) =
436 liquid_press_scale(Modifier::empty(), interaction.clone(), 1.20);
437
438 let material = spec
439 .resolve_material(&colors, spec.icon_color(&colors))
440 .map(|glass| glass.shape(LiquidShape::Circle));
441 let mut base = Modifier::empty();
442 if let Some(glass) = material {
443 let pressed_for_glass = pressed;
444 base = base.glass_effect_with(glass, move || GlassDynamics {
445 highlight_boost: if pressed_for_glass.get() { 0.85 } else { 0.0 },
446 ..Default::default()
447 });
448 }
449
450 let on_click = Rc::new(RefCell::new(on_click));
451 let base = base
452 .press_interaction_source(interaction)
453 .clickable(move |_point| {
454 default_haptics().perform(HapticFeedback::ImpactLight);
455 (on_click.borrow_mut())();
456 })
457 .size(Size::new(diameter, diameter));
458
459 let content_layer = Modifier::empty().graphics_layer(move || GraphicsLayer {
461 alpha: content_alpha.get().clamp(0.0, 1.0) * foreground_alpha.clamp(0.0, 1.0),
462 ..Default::default()
463 });
464 let button = base.then(modifier);
465 Box(pressed_modifier, BoxSpec::default(), move || {
466 let foreground_spec = spec.clone();
467 let content_layer = content_layer.clone();
468 Box(
469 button.clone(),
470 BoxSpec::default().content_alignment(Alignment::CENTER),
471 move || {
472 let foreground_spec = foreground_spec.clone();
473 Box(
474 content_layer.clone(),
475 BoxSpec::default().content_alignment(Alignment::CENTER),
476 move || GlassIconForeground(foreground_spec.clone(), diameter, icon_path),
477 );
478 },
479 );
480 });
481}
482
483#[composable]
487#[allow(non_snake_case)]
488pub fn GlassIconButtonGroup(
489 modifier: Modifier,
490 spec: GlassIconButtonGroupSpec,
491 items: Vec<GlassIconButtonGroupItem>,
492) {
493 let count = items.len();
494 if count == 0 {
495 return;
496 }
497
498 let colors = liquid_colors();
499 let live_items: Rc<RefCell<Vec<GlassIconButtonGroupItem>>> =
500 remember(|| Rc::new(RefCell::new(Vec::new()))).with(Rc::clone);
501 let ghosts: Rc<RefCell<Vec<(GlassIconButtonGroupItem, usize)>>> =
505 remember(|| Rc::new(RefCell::new(Vec::new()))).with(Rc::clone);
506 let ghost_fade = remember(|| {
507 let runtime = cranpose_core::with_current_composer(|composer| composer.runtime_handle());
508 Rc::new(RefCell::new(cranpose_animation::Animatable::new(
509 0.0f32, runtime,
510 )))
511 })
512 .with(Rc::clone);
513 {
514 let previous = live_items.borrow();
515 if !previous.is_empty() && *previous != items {
516 let leavers: Vec<(GlassIconButtonGroupItem, usize)> = previous
517 .iter()
518 .enumerate()
519 .filter(|(_, member)| !items.contains(member))
520 .map(|(index, member)| (member.clone(), index))
521 .collect();
522 if !leavers.is_empty() {
523 *ghosts.borrow_mut() = leavers;
524 let mut fade = ghost_fade.borrow_mut();
525 fade.snapTo(1.0);
526 fade.animateTo(
527 0.0,
528 AnimationType::Tween(AnimationSpec::tween(250, Easing::EaseIn)),
529 );
530 }
531 }
532 }
533 let ghost_alpha = ghost_fade.borrow().state();
534 if !ghosts.borrow().is_empty() && ghost_alpha.get() <= 0.01 {
535 ghosts.borrow_mut().clear();
536 }
537 *live_items.borrow_mut() = items;
538 let render_items = live_items.borrow().clone();
539
540 let active = remember(|| mutableStateOf(None::<usize>)).with(|state| *state);
541 let drag_x = remember(|| mutableStateOf(None::<f32>)).with(|state| *state);
544 let last_active = remember(|| Rc::new(Cell::new(0usize))).with(Rc::clone);
545 if let Some(index) = active.get() {
546 last_active.set(index.min(count - 1));
547 }
548 let press_progress = cranpose_animation::animateFloatAsState(
549 if active.get().is_some() { 1.0 } else { 0.0 },
550 LiquidMotion::snappy(),
551 "glass-icon-group-press",
552 );
553
554 let width = icon_group_width(count, spec);
555 let gesture_items = Rc::clone(&live_items);
556 let gesture = Modifier::empty()
557 .size(Size::new(width, spec.diameter))
558 .pointer_input(count, move |scope: PointerInputScope| {
559 let gesture_items = Rc::clone(&gesture_items);
560 async move {
561 scope
562 .await_pointer_event_scope(|await_scope| async move {
563 let mut down_index = None::<usize>;
564 loop {
565 let event = await_scope.await_pointer_event().await;
566 match event.kind {
567 PointerEventKind::Down if down_index.is_none() => {
568 down_index = icon_group_item_at(
569 event.position.x,
570 event.position.y,
571 gesture_items.borrow().len(),
572 spec,
573 );
574 if let Some(index) = down_index {
575 active.set(Some(index));
576 drag_x.set(Some(event.position.x));
577 default_haptics().perform(HapticFeedback::Selection);
578 event.consume();
579 }
580 }
581 PointerEventKind::Move if down_index.is_some() => {
582 drag_x.set(Some(event.position.x));
583 event.consume();
584 }
585 PointerEventKind::Up => {
586 let pressed_index = down_index.take();
587 active.set(None);
588 drag_x.set(None);
589 if let Some(index) = pressed_index {
590 let released_index = icon_group_item_at(
591 event.position.x,
592 event.position.y,
593 gesture_items.borrow().len(),
594 spec,
595 );
596 if released_index == Some(index) {
597 let on_click = gesture_items
598 .borrow()
599 .get(index)
600 .map(|item| Rc::clone(&item.on_click));
601 if let Some(on_click) = on_click {
602 default_haptics()
603 .perform(HapticFeedback::ImpactLight);
604 (on_click.borrow_mut())();
605 }
606 }
607 event.consume();
608 }
609 }
610 PointerEventKind::Cancel if down_index.take().is_some() => {
611 active.set(None);
612 drag_x.set(None);
613 event.consume();
614 }
615 _ => {}
616 }
617 }
618 })
619 .await;
620 }
621 })
622 .then(modifier);
623
624 Box(gesture, BoxSpec::default(), move || {
625 let pitch = spec.diameter + spec.spacing;
626 let active_index = last_active.get().min(count - 1);
627
628 let pad = spec.glue_radius + spec.diameter * (spec.pressed_scale - 1.0) * 0.5 + 4.0;
635 let node_width = width + pad * 2.0;
636 let node_height = spec.diameter * spec.pressed_scale + pad * 2.0;
637 let shared_progress = press_progress;
638 let shared_last_active = Rc::clone(&last_active);
639 let union_tint = render_items
640 .get(active_index)
641 .and_then(|item| {
642 item.spec
643 .resolve_material(&colors, item.spec.icon_color(&colors))
644 })
645 .and_then(|material| material.tint)
646 .map(|tint| tint.with_alpha(0.45))
647 .unwrap_or(Color::WHITE.with_alpha(0.035));
648 let shared = Modifier::empty()
649 .required_size(Size::new(node_width, node_height))
650 .offset(-pad, (spec.diameter - node_height) * 0.5)
651 .glass_effect_with(
652 Glass::regular()
656 .blur_radius(0.0)
657 .tint(union_tint)
658 .lift(0.0)
659 .highlight(0.48)
660 .shadow(false)
661 .no_clip(),
662 move || {
663 let progress = shared_progress.get().clamp(0.0, 1.0);
664 let active_index = shared_last_active.get().min(count - 1);
665 let center_y = node_height * 0.5;
666 let rest_center = active_index as f32 * pitch + spec.diameter * 0.5;
667 let ridden = drag_x
673 .get()
674 .map(|x| x.clamp(rest_center - pitch * 0.35, rest_center + pitch * 0.35))
675 .unwrap_or(rest_center);
676 let center_x = pad + rest_center + (ridden - rest_center) * progress;
677 let diameter = spec.diameter * (1.0 + (spec.pressed_scale - 1.0) * progress);
678 let touch = drag_x.get().map(|x| {
682 (
683 pad + x.clamp(0.0, node_width - 2.0 * pad),
684 center_y,
685 progress,
686 )
687 });
688 GlassDynamics {
689 activity: Some(progress),
690 touch,
691 morph: Some(GlassMorph {
692 node_size: (node_width, node_height),
693 primary: (center_x, center_y, diameter, diameter, -1.0),
694 shapes: icon_group_neighbor_shapes(
695 count,
696 active_index,
697 spec,
698 pad,
699 center_y,
700 ),
701 glue: spec.glue_radius * progress,
702 ..Default::default()
703 }),
704 ..Default::default()
705 }
706 },
707 );
708 Box(shared, BoxSpec::default(), || {});
709
710 for (index, item) in render_items.iter().enumerate() {
712 let x = index as f32 * pitch;
713 let surface_progress = press_progress;
714 let item_is_active = index == active_index;
715 let outer = Modifier::empty()
716 .size(Size::new(spec.diameter, spec.diameter))
717 .offset(x, 0.0);
718 let rest_center = index as f32 * pitch + spec.diameter * 0.5;
719 let scale_layer = Modifier::empty().graphics_layer(move || {
720 let progress = if item_is_active {
721 surface_progress.get().clamp(0.0, 1.0)
722 } else {
723 0.0
724 };
725 let scale = 1.0 + (spec.pressed_scale - 1.0) * progress;
726 let ridden = if item_is_active {
727 drag_x
728 .get()
729 .map(|x| x.clamp(rest_center - pitch * 0.35, rest_center + pitch * 0.35))
730 .unwrap_or(rest_center)
731 } else {
732 rest_center
733 };
734 GraphicsLayer {
735 translation_x: (ridden - rest_center) * progress,
736 scale_x: scale,
737 scale_y: scale,
738 ..Default::default()
739 }
740 });
741 let mut surface = Modifier::empty().size(Size::new(spec.diameter, spec.diameter));
742 if let Some(material) = item
743 .spec
744 .resolve_material(&colors, item.spec.icon_color(&colors))
745 {
746 let surface_progress = press_progress;
747 surface =
748 surface.glass_effect_with(material.shape(LiquidShape::Circle), move || {
749 let progress = surface_progress.get().clamp(0.0, 1.0);
750 GlassDynamics {
756 highlight_boost: if item_is_active { 0.60 * progress } else { 0.0 },
757 saturation_boost: if item_is_active { 0.85 * progress } else { 0.0 },
758 tint_alpha_multiplier: item_is_active.then_some(1.0 + 0.85 * progress),
759 ..Default::default()
760 }
761 });
762 }
763 Box(outer, BoxSpec::default(), move || {
764 let surface = surface.clone();
765 Box(scale_layer.clone(), BoxSpec::default(), move || {
766 Box(surface.clone(), BoxSpec::default(), || {});
767 });
768 });
769 }
770
771 for (index, item) in render_items.iter().enumerate() {
774 let x = index as f32 * pitch;
775 let item_is_active = index == active_index;
776 let foreground_progress = press_progress;
777 let foreground_spec = item.spec.clone();
778 let icon_path = item.icon_path;
779 let description = item.content_description.clone();
780 let foreground = Modifier::empty()
781 .size(Size::new(spec.diameter, spec.diameter))
782 .offset(x, 0.0)
783 .graphics_layer(move || GraphicsLayer {
784 alpha: if item_is_active {
785 1.0 - foreground_progress.get().clamp(0.0, 1.0)
786 } else {
787 1.0
788 },
789 ..Default::default()
790 })
791 .semantics(move |config| {
792 config.is_button = true;
793 config.is_clickable = true;
794 config.content_description = Some(description.clone());
795 });
796 Box(
797 foreground,
798 BoxSpec::default().content_alignment(Alignment::CENTER),
799 move || {
800 GlassIconForeground(foreground_spec.clone(), spec.diameter, icon_path);
801 },
802 );
803 }
804
805 for (ghost, ghost_index) in ghosts.borrow().iter() {
809 let x = *ghost_index as f32 * pitch;
810 let fade = ghost_alpha;
811 let ghost_layer = Modifier::empty()
812 .size(Size::new(spec.diameter, spec.diameter))
813 .offset(x, 0.0)
814 .graphics_layer(move || GraphicsLayer {
815 alpha: fade.get().clamp(0.0, 1.0),
816 ..Default::default()
817 });
818 let ghost_spec = ghost.spec.clone();
819 let ghost_icon = ghost.icon_path;
820 let surface = ghost
821 .spec
822 .resolve_material(&colors, ghost.spec.icon_color(&colors))
823 .map(|material| {
824 Modifier::empty()
825 .size(Size::new(spec.diameter, spec.diameter))
826 .glass_effect(material.shape(LiquidShape::Circle))
827 });
828 Box(
829 ghost_layer,
830 BoxSpec::default().content_alignment(Alignment::CENTER),
831 move || {
832 if let Some(surface) = surface.clone() {
833 Box(surface, BoxSpec::default(), || {});
834 }
835 GlassIconForeground(ghost_spec.clone(), spec.diameter, ghost_icon);
836 },
837 );
838 }
839 });
840}
841
842#[cfg(test)]
843mod tests {
844 use super::*;
845
846 #[test]
847 fn glass_buttons_expose_button_semantics_to_every_platform_bridge() {
848 let semantics =
849 cranpose_ui::collect_semantics_from_modifier(&with_button_semantics(Modifier::empty()))
850 .expect("glass button semantics");
851 assert!(semantics.is_button);
852 assert!(semantics.is_clickable);
853 }
854
855 #[test]
856 fn icon_backplate_colors_only_the_compact_foreground_core() {
857 let blue = Color::from_rgb_u8(0, 122, 255);
858 let spec = GlassButtonSpec::glass()
859 .with_icon_backplate(blue)
860 .with_content_color(Color::WHITE);
861 assert_eq!(spec.style, GlassButtonStyle::Glass);
862 assert_eq!(spec.icon_backplate, Some(blue));
863 assert_eq!(spec.content_color, Some(Color::WHITE));
864 assert!(spec.glass.is_none());
865 assert!((0.49..=0.51).contains(&ICON_BACKPLATE_DIAMETER_RATIO));
866 assert!((0.27..=0.29).contains(&ICON_BACKPLATE_GLYPH_RATIO));
867
868 let colors = crate::theme::LiquidColors::light(blue);
869 let material = GlassButtonSpec::glass()
870 .resolve_material(&colors, colors.label)
871 .expect("glass button material");
872 assert_eq!(material.tint, None);
873 assert_eq!(material.resolve(&colors).tint, colors.glass_tint);
874 }
875
876 #[test]
877 fn neutral_button_tint_comes_from_the_theme_not_foreground_polarity() {
878 let accent = Color::from_rgb_u8(0, 122, 255);
879 for colors in [
880 crate::theme::LiquidColors::light(accent),
881 crate::theme::LiquidColors::dark(accent),
882 ] {
883 let material = GlassButtonSpec::glass()
884 .resolve_material(&colors, colors.label)
885 .expect("glass button material");
886 assert_eq!(material.tint, None);
887 assert_eq!(material.resolve(&colors).tint, colors.glass_tint);
888 }
889 }
890
891 #[test]
892 fn icon_button_group_builders_and_hit_regions_preserve_member_gaps() {
893 let spec = GlassIconButtonGroupSpec::new(44.0)
894 .with_spacing(8.0)
895 .with_pressed_scale(1.2)
896 .with_glue_radius(12.0);
897 assert_eq!(icon_group_width(2, spec), 96.0);
898 assert_eq!(icon_group_item_at(22.0, 22.0, 2, spec), Some(0));
899 assert_eq!(icon_group_item_at(48.0, 22.0, 2, spec), None);
900 assert_eq!(icon_group_item_at(74.0, 22.0, 2, spec), Some(1));
901 assert_eq!(icon_group_item_at(22.0, 50.0, 2, spec), None);
902
903 let shapes = icon_group_neighbor_shapes(4, 2, spec, 16.0, 38.0);
904 assert_eq!(shapes.len(), 2);
905 assert!((shapes[0].0 - (16.0 + 52.0 + 22.0 + 44.0 * 0.38)).abs() < 1e-5);
906 assert!((shapes[1].0 - (16.0 + 156.0 + 22.0 - 44.0 * 0.38)).abs() < 1e-5);
907 assert_eq!(shapes[0].2, 44.0 * 0.36);
908
909 let item = GlassIconButtonGroupItem::new("M0 0", "Confirm", || {})
910 .with_spec(GlassButtonSpec::prominent());
911 assert_eq!(item.content_description, "Confirm");
912 assert_eq!(item.spec.style, GlassButtonStyle::Prominent);
913 }
914}