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;
22const TAP_EXIT_SLOP: f32 = 12.0;
25
26fn with_button_semantics(modifier: Modifier) -> Modifier {
27 modifier.semantics(|config| {
28 config.is_button = true;
29 config.is_clickable = true;
30 })
31}
32
33#[derive(Clone, Copy, Debug, PartialEq, Eq, Default)]
35pub enum GlassButtonStyle {
36 #[default]
38 Glass,
39 Prominent,
41 Plain,
43 Destructive,
45}
46
47#[derive(Clone, Debug, Default, PartialEq)]
49pub struct GlassButtonSpec {
50 pub style: GlassButtonStyle,
51 pub glass: Option<Glass>,
53 pub content_color: Option<Color>,
55 pub icon_backplate: Option<Color>,
58}
59
60impl GlassButtonSpec {
61 pub fn glass() -> Self {
62 Self::default()
63 }
64
65 pub fn prominent() -> Self {
66 Self {
67 style: GlassButtonStyle::Prominent,
68 ..Self::default()
69 }
70 }
71
72 pub fn plain() -> Self {
73 Self {
74 style: GlassButtonStyle::Plain,
75 ..Self::default()
76 }
77 }
78
79 pub fn destructive() -> Self {
80 Self {
81 style: GlassButtonStyle::Destructive,
82 ..Self::default()
83 }
84 }
85
86 pub fn with_glass(mut self, glass: Glass) -> Self {
87 self.glass = Some(glass);
88 self
89 }
90
91 pub fn with_content_color(mut self, color: Color) -> Self {
92 self.content_color = Some(color);
93 self
94 }
95
96 pub fn with_icon_backplate(mut self, color: Color) -> Self {
97 self.icon_backplate = Some(color);
98 self
99 }
100
101 pub fn content_color(&self, colors: &crate::theme::LiquidColors) -> Color {
103 if let Some(color) = self.content_color {
104 return color;
105 }
106 match self.style {
107 GlassButtonStyle::Glass => colors.accent,
108 GlassButtonStyle::Prominent => colors.on_accent,
109 GlassButtonStyle::Plain => colors.accent,
110 GlassButtonStyle::Destructive => colors.destructive,
111 }
112 }
113
114 pub fn icon_color(&self, colors: &crate::theme::LiquidColors) -> Color {
117 if let Some(color) = self.content_color {
118 return color;
119 }
120 match self.style {
121 GlassButtonStyle::Glass | GlassButtonStyle::Plain => colors.label,
122 GlassButtonStyle::Prominent => colors.on_accent,
123 GlassButtonStyle::Destructive => colors.destructive,
124 }
125 }
126
127 fn resolve_material(
128 &self,
129 colors: &crate::theme::LiquidColors,
130 foreground: Color,
131 ) -> Option<Glass> {
132 if let Some(glass) = &self.glass {
133 return Some(if glass.foreground.is_some() {
134 glass.clone()
135 } else {
136 glass
137 .clone()
138 .adaptive_frost(foreground, glass.adaptive_frost)
139 });
140 }
141 match self.style {
142 GlassButtonStyle::Glass => Some(Glass::regular().adaptive_frost(foreground, 0.65)),
143 GlassButtonStyle::Prominent => Some(
144 Glass::regular()
145 .tint(colors.accent.with_alpha(0.75))
146 .adaptive_frost(foreground, 0.65),
147 ),
148 GlassButtonStyle::Plain | GlassButtonStyle::Destructive => None,
149 }
150 }
151}
152
153#[derive(Clone)]
155pub struct GlassIconButtonGroupItem {
156 spec: GlassButtonSpec,
157 icon_path: &'static str,
158 content_description: String,
159 on_click: Rc<RefCell<dyn FnMut()>>,
160}
161
162impl PartialEq for GlassIconButtonGroupItem {
163 fn eq(&self, other: &Self) -> bool {
164 self.spec == other.spec
165 && self.icon_path == other.icon_path
166 && self.content_description == other.content_description
167 && Rc::ptr_eq(&self.on_click, &other.on_click)
168 }
169}
170
171impl GlassIconButtonGroupItem {
172 pub fn new(
173 icon_path: &'static str,
174 content_description: impl Into<String>,
175 on_click: impl FnMut() + 'static,
176 ) -> Self {
177 Self {
178 spec: GlassButtonSpec::glass(),
179 icon_path,
180 content_description: content_description.into(),
181 on_click: Rc::new(RefCell::new(on_click)),
182 }
183 }
184
185 pub fn with_spec(mut self, spec: GlassButtonSpec) -> Self {
186 self.spec = spec;
187 self
188 }
189}
190
191#[derive(Clone, Copy, Debug, PartialEq)]
193pub struct GlassIconButtonGroupSpec {
194 diameter: f32,
195 spacing: f32,
196 pressed_scale: f32,
197 glue_radius: f32,
198}
199
200impl GlassIconButtonGroupSpec {
201 pub fn new(diameter: f32) -> Self {
202 Self {
203 diameter: diameter.max(1.0),
204 ..Self::default()
205 }
206 }
207
208 pub fn with_spacing(mut self, spacing: f32) -> Self {
209 self.spacing = spacing.max(0.0);
210 self
211 }
212
213 pub fn with_pressed_scale(mut self, pressed_scale: f32) -> Self {
214 self.pressed_scale = pressed_scale.max(1.0);
215 self
216 }
217
218 pub fn with_glue_radius(mut self, glue_radius: f32) -> Self {
219 self.glue_radius = glue_radius.max(0.0);
220 self
221 }
222}
223
224impl Default for GlassIconButtonGroupSpec {
225 fn default() -> Self {
226 Self {
227 diameter: 44.0,
228 spacing: 8.0,
229 pressed_scale: 1.55,
232 glue_radius: 12.0,
233 }
234 }
235}
236
237fn merge_glyph_alpha(merge: f32) -> f32 {
241 let m = merge.clamp(0.0, 1.0);
242 if m <= 0.0 {
243 return 1.0;
244 }
245 let fade_out = 1.0 - ((m - 0.02) / 0.10).clamp(0.0, 1.0);
246 let fade_in = ((m - 0.78) / 0.18).clamp(0.0, 1.0);
247 fade_out.max(fade_in)
248}
249
250fn icon_group_width(count: usize, spec: GlassIconButtonGroupSpec) -> f32 {
251 if count == 0 {
252 0.0
253 } else {
254 spec.diameter * count as f32 + spec.spacing * count.saturating_sub(1) as f32
255 }
256}
257
258fn icon_group_item_at(
259 x: f32,
260 y: f32,
261 count: usize,
262 spec: GlassIconButtonGroupSpec,
263) -> Option<usize> {
264 if !(0.0..=spec.diameter).contains(&y) {
265 return None;
266 }
267 let pitch = spec.diameter + spec.spacing;
268 let index = (x / pitch).floor() as isize;
269 if index < 0 || index as usize >= count {
270 return None;
271 }
272 let local_x = x - index as f32 * pitch;
273 (local_x <= spec.diameter).then_some(index as usize)
274}
275
276fn icon_group_neighbor_shapes(
277 count: usize,
278 active: usize,
279 spec: GlassIconButtonGroupSpec,
280 pad: f32,
281 center_y: f32,
282) -> Vec<(f32, f32, f32, f32, f32)> {
283 let pitch = spec.diameter + spec.spacing;
284 let contact_diameter = spec.diameter * 0.36;
285 let contact_offset = spec.diameter * 0.38;
286 (0..count)
287 .filter(|index| index.abs_diff(active) == 1)
288 .map(|index| {
289 let toward_active = if index < active { 1.0 } else { -1.0 };
290 (
291 pad + index as f32 * pitch + spec.diameter * 0.5 + toward_active * contact_offset,
292 center_y,
293 contact_diameter,
294 contact_diameter,
295 -1.0,
296 )
297 })
298 .collect()
299}
300
301#[composable]
304#[allow(non_snake_case)]
305pub fn GlassButton(
306 modifier: Modifier,
307 spec: GlassButtonSpec,
308 on_click: impl Fn() + 'static,
309 content: impl FnMut() + 'static,
310) {
311 let colors = liquid_colors();
312 let interaction = rememberMutableInteractionSource();
313 let (pressed_modifier, pressed, content_alpha) =
314 liquid_press_scale(Modifier::empty(), interaction.clone(), 1.18);
315
316 let material = spec.resolve_material(&colors, spec.content_color(&colors));
317 let mut base = Modifier::empty();
318 if let Some(glass) = material {
319 let pressed_for_glass = pressed;
320 let glow_size = cranpose_core::remember(|| {
325 std::rc::Rc::new(std::cell::Cell::new(cranpose_ui_graphics::Size {
326 width: 0.0,
327 height: 0.0,
328 }))
329 })
330 .with(std::rc::Rc::clone);
331 let glow_size_for_glass = std::rc::Rc::clone(&glow_size);
332 base = base
333 .report_size(std::rc::Rc::clone(&glow_size))
334 .glass_effect_with(glass, move || {
335 let size = glow_size_for_glass.get();
336 let press = if pressed_for_glass.get() { 1.0 } else { 0.0 };
337 let dynamics = GlassDynamics::default();
338 if size.width > 0.0 && size.height > 0.0 {
339 dynamics.touched_up(press, None, (size.width * 0.5, size.height * 0.5))
340 } else {
341 dynamics
342 }
343 });
344 }
345
346 let on_click = Rc::new(RefCell::new(on_click));
347 let base = with_button_semantics(
348 base.press_interaction_source(interaction)
349 .clickable(move |_point| {
350 default_haptics().perform(HapticFeedback::ImpactLight);
351 (on_click.borrow_mut())();
352 })
353 .padding_symmetric(16.0, 10.0),
354 );
355
356 let content_layer = Modifier::empty().graphics_layer(move || GraphicsLayer {
358 alpha: content_alpha.get().clamp(0.0, 1.0),
359 ..Default::default()
360 });
361 let content = Rc::new(RefCell::new(content));
362 let button = base.then(modifier);
363 Box(pressed_modifier, BoxSpec::default(), move || {
364 let content = Rc::clone(&content);
365 let content_layer = content_layer.clone();
366 Box(
367 button.clone(),
368 BoxSpec::default().content_alignment(Alignment::CENTER),
369 move || {
370 let content = Rc::clone(&content);
371 Box(
372 content_layer.clone(),
373 BoxSpec::default().content_alignment(Alignment::CENTER),
374 move || (content.borrow_mut())(),
375 );
376 },
377 );
378 });
379}
380
381#[composable]
383#[allow(non_snake_case)]
384pub fn GlassButtonLabel(text: impl Into<String>, spec: GlassButtonSpec) {
385 let typography = liquid_typography();
386 let color = spec.content_color(&liquid_colors());
387 let style = TextStyle {
388 span_style: cranpose_ui::text::SpanStyle {
389 color: Some(color),
390 ..typography.headline.span_style.clone()
391 },
392 ..typography.headline.clone()
393 };
394 Text(text.into(), Modifier::empty(), style);
395}
396
397#[composable]
398#[allow(non_snake_case)]
399pub(crate) fn GlassIconForeground(spec: GlassButtonSpec, diameter: f32, icon_path: &'static str) {
400 let colors = liquid_colors();
401 let icon_color = spec.icon_color(&colors);
402 if let Some(backplate) = spec.icon_backplate {
403 let backplate_diameter = diameter * ICON_BACKPLATE_DIAMETER_RATIO;
404 Box(
405 Modifier::empty()
406 .size(Size::new(backplate_diameter, backplate_diameter))
407 .draw_behind(move |scope| {
408 scope.draw_circle(
409 cranpose_ui_graphics::Brush::solid(backplate),
410 cranpose_ui_graphics::Point::new(
411 backplate_diameter * 0.5,
412 backplate_diameter * 0.5,
413 ),
414 backplate_diameter * 0.5,
415 );
416 }),
417 BoxSpec::default().content_alignment(Alignment::CENTER),
418 move || {
419 crate::icons::Icon(icon_path, diameter * ICON_BACKPLATE_GLYPH_RATIO, icon_color);
420 },
421 );
422 } else {
423 crate::icons::Icon(icon_path, diameter * 0.5, icon_color);
424 }
425}
426
427#[composable]
429#[allow(non_snake_case)]
430pub fn GlassIconButton(
431 modifier: Modifier,
432 spec: GlassButtonSpec,
433 diameter: f32,
434 on_click: impl Fn() + 'static,
435 icon_path: &'static str,
436) {
437 GlassIconButtonWithForegroundAlpha(modifier, spec, diameter, 1.0, on_click, icon_path);
438}
439
440#[composable]
441#[allow(non_snake_case)]
442pub(crate) fn GlassIconButtonWithForegroundAlpha(
443 modifier: Modifier,
444 spec: GlassButtonSpec,
445 diameter: f32,
446 foreground_alpha: f32,
447 on_click: impl Fn() + 'static,
448 icon_path: &'static str,
449) {
450 let colors = liquid_colors();
451 let interaction = rememberMutableInteractionSource();
452 let (pressed_modifier, pressed, content_alpha) =
453 liquid_press_scale(Modifier::empty(), interaction.clone(), 1.20);
454
455 let material = spec
456 .resolve_material(&colors, spec.icon_color(&colors))
457 .map(|glass| glass.shape(LiquidShape::Circle));
458 let mut base = Modifier::empty();
459 if let Some(glass) = material {
460 let pressed_for_glass = pressed;
461 let half = diameter * 0.5;
462 base = base.glass_effect_with(glass, move || {
463 let press = if pressed_for_glass.get() { 1.0 } else { 0.0 };
464 GlassDynamics::default().touched_up(press, None, (half, half))
465 });
466 }
467
468 let on_click = Rc::new(RefCell::new(on_click));
469 let base = base
470 .press_interaction_source(interaction)
471 .clickable(move |_point| {
472 default_haptics().perform(HapticFeedback::ImpactLight);
473 (on_click.borrow_mut())();
474 })
475 .size(Size::new(diameter, diameter));
476
477 let content_layer = Modifier::empty().graphics_layer(move || GraphicsLayer {
479 alpha: content_alpha.get().clamp(0.0, 1.0) * foreground_alpha.clamp(0.0, 1.0),
480 ..Default::default()
481 });
482 let button = base.then(modifier);
483 Box(pressed_modifier, BoxSpec::default(), move || {
484 let foreground_spec = spec.clone();
485 let content_layer = content_layer.clone();
486 Box(
487 button.clone(),
488 BoxSpec::default().content_alignment(Alignment::CENTER),
489 move || {
490 let foreground_spec = foreground_spec.clone();
491 Box(
492 content_layer.clone(),
493 BoxSpec::default().content_alignment(Alignment::CENTER),
494 move || GlassIconForeground(foreground_spec.clone(), diameter, icon_path),
495 );
496 },
497 );
498 });
499}
500
501#[composable]
505#[allow(non_snake_case)]
506pub fn GlassIconButtonGroup(
507 modifier: Modifier,
508 spec: GlassIconButtonGroupSpec,
509 items: Vec<GlassIconButtonGroupItem>,
510) {
511 let count = items.len();
512 if count == 0 {
513 return;
514 }
515
516 let colors = liquid_colors();
517 let live_items: Rc<RefCell<Vec<GlassIconButtonGroupItem>>> =
518 remember(|| Rc::new(RefCell::new(Vec::new()))).with(Rc::clone);
519 let ghosts: Rc<RefCell<Vec<(GlassIconButtonGroupItem, usize, f32)>>> =
523 remember(|| Rc::new(RefCell::new(Vec::new()))).with(Rc::clone);
524 let ghost_fade = remember(|| {
525 let runtime = cranpose_core::with_current_composer(|composer| composer.runtime_handle());
526 Rc::new(RefCell::new(cranpose_animation::Animatable::new(
527 0.0f32, runtime,
528 )))
529 })
530 .with(Rc::clone);
531 let active = remember(|| mutableStateOf(None::<usize>)).with(|state| *state);
532 let drag_x = remember(|| mutableStateOf(None::<f32>)).with(|state| *state);
535 let last_active = remember(|| Rc::new(Cell::new(0usize))).with(Rc::clone);
536 if let Some(index) = active.get() {
537 last_active.set(index.min(count - 1));
538 }
539 let press_progress = cranpose_animation::animateFloatAsState(
540 if active.get().is_some() { 1.0 } else { 0.0 },
541 LiquidMotion::snappy(),
542 "glass-icon-group-press",
543 );
544 let press_orphaned = remember(|| Rc::new(Cell::new(false))).with(Rc::clone);
547
548 {
549 let member_stays = |member: &GlassIconButtonGroupItem| {
555 items.iter().any(|item| {
556 item.icon_path == member.icon_path
557 && item.content_description == member.content_description
558 })
559 };
560 let previous = live_items.borrow();
561 if !previous.is_empty() {
562 let leavers: Vec<(GlassIconButtonGroupItem, usize, f32)> = previous
567 .iter()
568 .enumerate()
569 .filter(|(_, member)| !member_stays(member))
570 .map(|(index, member)| {
571 let departure = if index == last_active.get() {
572 press_orphaned.set(true);
575 press_progress.get().clamp(0.0, 1.0)
576 } else {
577 0.0
578 };
579 (member.clone(), index, departure)
580 })
581 .collect();
582 if !leavers.is_empty() {
583 *ghosts.borrow_mut() = leavers;
584 let mut fade = ghost_fade.borrow_mut();
585 fade.snapTo(1.0);
586 fade.animateTo(
587 0.0,
588 AnimationType::Tween(AnimationSpec::tween(250, Easing::EaseIn)),
589 );
590 }
591 }
592 }
593 let ghost_alpha = ghost_fade.borrow().state();
594 if !ghosts.borrow().is_empty() && ghost_alpha.get() <= 0.01 {
595 ghosts.borrow_mut().clear();
596 }
597 *live_items.borrow_mut() = items;
598 let render_items = live_items.borrow().clone();
599
600 let ghost_slots = ghosts
605 .borrow()
606 .iter()
607 .map(|(_, index, _)| index + 1)
608 .max()
609 .unwrap_or(0);
610 let layout_count = count.max(ghost_slots);
611 let merge = if layout_count > count {
619 (1.0 - ghost_alpha.get().clamp(0.0, 1.0)).clamp(0.0, 1.0)
620 } else {
621 0.0
622 };
623 let full_width = icon_group_width(layout_count, spec);
624 let settled_width = icon_group_width(count, spec);
625 let width = settled_width + (full_width - settled_width) * (1.0 - merge);
626 let ghost_trailing_shift = width - full_width;
627 let gesture_items = Rc::clone(&live_items);
628 let gesture_press_orphaned = Rc::clone(&press_orphaned);
629 let gesture = Modifier::empty()
630 .size(Size::new(width, spec.diameter))
631 .pointer_input(count, move |scope: PointerInputScope| {
632 let gesture_items = Rc::clone(&gesture_items);
633 let gesture_press_orphaned = Rc::clone(&gesture_press_orphaned);
634 async move {
635 scope
636 .await_pointer_event_scope(|await_scope| async move {
637 let mut down_index = None::<usize>;
638 loop {
639 let event = await_scope.await_pointer_event().await;
640 match event.kind {
641 PointerEventKind::Down if down_index.is_none() => {
642 down_index = icon_group_item_at(
643 event.position.x,
644 event.position.y,
645 gesture_items.borrow().len(),
646 spec,
647 );
648 if let Some(index) = down_index {
649 active.set(Some(index));
650 drag_x.set(Some(event.position.x));
651 gesture_press_orphaned.set(false);
652 default_haptics().perform(HapticFeedback::Selection);
653 event.consume();
654 }
655 }
656 PointerEventKind::Move if down_index.is_some() => {
657 drag_x.set(Some(event.position.x));
658 event.consume();
659 }
660 PointerEventKind::Up => {
661 let pressed_index = down_index.take();
662 active.set(None);
663 drag_x.set(None);
664 if let Some(index) = pressed_index {
665 let count = gesture_items.borrow().len();
674 let released_inside = (-TAP_EXIT_SLOP
675 ..=spec.diameter + TAP_EXIT_SLOP)
676 .contains(&event.position.y)
677 && (-TAP_EXIT_SLOP
678 ..=icon_group_width(count, spec) + TAP_EXIT_SLOP)
679 .contains(&event.position.x);
680 if released_inside {
681 let on_click = gesture_items
682 .borrow()
683 .get(index)
684 .map(|item| Rc::clone(&item.on_click));
685 if let Some(on_click) = on_click {
686 default_haptics()
687 .perform(HapticFeedback::ImpactLight);
688 (on_click.borrow_mut())();
689 }
690 }
691 event.consume();
692 }
693 }
694 PointerEventKind::Cancel if down_index.take().is_some() => {
695 active.set(None);
696 drag_x.set(None);
697 event.consume();
698 }
699 _ => {}
700 }
701 }
702 })
703 .await;
704 }
705 })
706 .then(modifier);
707
708 Box(gesture, BoxSpec::default(), move || {
709 let pitch = spec.diameter + spec.spacing;
710 let active_index = last_active.get().min(count - 1);
711
712 let pad = spec.glue_radius + spec.diameter * (spec.pressed_scale - 1.0) * 0.5 + 4.0;
719 let node_width = width + pad * 2.0;
720 let node_height = spec.diameter * spec.pressed_scale + pad * 2.0;
721 let shared_progress = press_progress;
722 let shared_orphaned = Rc::clone(&press_orphaned);
723 let shared_last_active = Rc::clone(&last_active);
724 let union_tint = render_items
725 .get(active_index)
726 .and_then(|item| {
727 item.spec
728 .resolve_material(&colors, item.spec.icon_color(&colors))
729 })
730 .and_then(|material| material.tint)
731 .map(|tint| tint.with_alpha(0.85))
735 .unwrap_or(Color::WHITE.with_alpha(0.035));
736 let shared = Modifier::empty()
737 .required_size(Size::new(node_width, node_height))
738 .offset(-pad, (spec.diameter - node_height) * 0.5)
739 .glass_effect_with(
740 Glass::regular()
744 .blur_radius(0.0)
745 .tint(union_tint)
746 .lift(0.0)
747 .highlight(0.48)
748 .shadow(false)
749 .no_clip(),
750 move || {
751 let progress = if shared_orphaned.get() {
752 0.0
753 } else {
754 shared_progress.get().clamp(0.0, 1.0)
755 };
756 let active_index = shared_last_active.get().min(count - 1);
757 let center_y = node_height * 0.5;
758 let rest_center = active_index as f32 * pitch + spec.diameter * 0.5;
759 let ridden = drag_x
765 .get()
766 .map(|x| x.clamp(rest_center - pitch * 0.35, rest_center + pitch * 0.35))
767 .unwrap_or(rest_center);
768 let center_x = pad + rest_center + (ridden - rest_center) * progress;
769 let diameter = spec.diameter * (1.0 + (spec.pressed_scale - 1.0) * progress);
770 let touch = drag_x.get().map(|x| {
774 (
775 pad + x.clamp(0.0, node_width - 2.0 * pad),
776 center_y,
777 progress,
778 )
779 });
780 GlassDynamics {
781 activity: Some(progress),
782 touch,
783 morph: Some(GlassMorph {
784 node_size: (node_width, node_height),
785 primary: (center_x, center_y, diameter, diameter, -1.0),
786 shapes: icon_group_neighbor_shapes(
787 count,
788 active_index,
789 spec,
790 pad,
791 center_y,
792 ),
793 glue: spec.glue_radius * progress,
794 ..Default::default()
795 }),
796 ..Default::default()
797 }
798 },
799 );
800 Box(shared, BoxSpec::default(), || {});
801
802 for (index, item) in render_items.iter().enumerate() {
804 let x = index as f32 * pitch;
805 let surface_progress = press_progress;
806 let item_is_active = index == active_index;
807 let scale_orphaned = Rc::clone(&press_orphaned);
808 let outer = Modifier::empty()
809 .size(Size::new(spec.diameter, spec.diameter))
810 .offset(x, 0.0);
811 let rest_center = index as f32 * pitch + spec.diameter * 0.5;
812 let scale_layer = Modifier::empty().graphics_layer(move || {
813 let progress = if item_is_active && !scale_orphaned.get() {
814 surface_progress.get().clamp(0.0, 1.0)
815 } else {
816 0.0
817 };
818 let scale = 1.0 + (spec.pressed_scale - 1.0) * progress;
819 let ridden = if item_is_active {
820 drag_x
821 .get()
822 .map(|x| x.clamp(rest_center - pitch * 0.35, rest_center + pitch * 0.35))
823 .unwrap_or(rest_center)
824 } else {
825 rest_center
826 };
827 GraphicsLayer {
828 translation_x: (ridden - rest_center) * progress,
829 scale_x: scale,
830 scale_y: scale,
831 ..Default::default()
832 }
833 });
834 let mut surface = Modifier::empty().size(Size::new(spec.diameter, spec.diameter));
835 if let Some(material) = item
836 .spec
837 .resolve_material(&colors, item.spec.icon_color(&colors))
838 {
839 let surface_progress = press_progress;
840 let dynamics_orphaned = Rc::clone(&press_orphaned);
841 surface =
842 surface.glass_effect_with(material.shape(LiquidShape::Circle), move || {
843 let progress = if dynamics_orphaned.get() {
844 0.0
845 } else {
846 surface_progress.get().clamp(0.0, 1.0)
847 };
848 GlassDynamics {
854 highlight_boost: if item_is_active { 0.60 * progress } else { 0.0 },
855 saturation_boost: if item_is_active { 1.6 * progress } else { 0.0 },
856 tint_alpha_multiplier: item_is_active.then_some(1.0 + 0.85 * progress),
857 ..Default::default()
858 }
859 });
860 }
861 Box(outer, BoxSpec::default(), move || {
862 let surface = surface.clone();
863 Box(scale_layer.clone(), BoxSpec::default(), move || {
864 Box(surface.clone(), BoxSpec::default(), || {});
865 });
866 });
867 }
868
869 for (index, item) in render_items.iter().enumerate() {
872 let x = index as f32 * pitch;
873 let item_is_active = index == active_index;
874 let foreground_progress = press_progress;
875 let foreground_orphaned = Rc::clone(&press_orphaned);
876 let foreground_spec = item.spec.clone();
877 let icon_path = item.icon_path;
878 let description = item.content_description.clone();
879 let merge_alpha = merge_glyph_alpha(merge);
883 let foreground = Modifier::empty()
884 .size(Size::new(spec.diameter, spec.diameter))
885 .offset(x, 0.0)
886 .graphics_layer(move || GraphicsLayer {
887 alpha: merge_alpha
888 * if item_is_active && !foreground_orphaned.get() {
889 1.0 - foreground_progress.get().clamp(0.0, 1.0)
890 } else {
891 1.0
892 },
893 ..Default::default()
894 })
895 .semantics(move |config| {
896 config.is_button = true;
897 config.is_clickable = true;
898 config.content_description = Some(description.clone());
899 });
900 Box(
901 foreground,
902 BoxSpec::default().content_alignment(Alignment::CENTER),
903 move || {
904 GlassIconForeground(foreground_spec.clone(), spec.diameter, icon_path);
905 },
906 );
907 }
908
909 for (ghost, ghost_index, departure) in ghosts.borrow().iter() {
913 let x = *ghost_index as f32 * pitch + ghost_trailing_shift;
916 let fade = ghost_alpha;
917 let departure = *departure;
918 let ghost_layer = Modifier::empty()
919 .size(Size::new(spec.diameter, spec.diameter))
920 .offset(x, 0.0)
921 .graphics_layer(move || {
922 let alpha = fade.get().clamp(0.0, 1.0);
923 let scale =
928 1.0 + (spec.pressed_scale - 1.0) * departure * (0.55 + 0.45 * alpha);
929 GraphicsLayer {
930 alpha,
931 scale_x: scale,
932 scale_y: scale,
933 render_effect: (departure > 0.05 && alpha < 0.98).then(|| {
934 cranpose_ui_graphics::RenderEffect::blur(
935 (1.0 - alpha) * 9.0 * departure,
936 )
937 }),
938 ..Default::default()
939 }
940 });
941 let ghost_spec = ghost.spec.clone();
942 let ghost_icon = ghost.icon_path;
943 let surface = ghost
944 .spec
945 .resolve_material(&colors, ghost.spec.icon_color(&colors))
946 .map(|material| {
947 Modifier::empty()
948 .size(Size::new(spec.diameter, spec.diameter))
949 .glass_effect_with(material.shape(LiquidShape::Circle), move || {
950 GlassDynamics {
954 highlight_boost: 0.60 * departure,
955 saturation_boost: 1.6 * departure,
956 tint_alpha_multiplier: Some(1.0 + 0.85 * departure),
957 ..Default::default()
958 }
959 })
960 });
961 let glyph_alpha = 1.0 - departure;
962 Box(
963 ghost_layer,
964 BoxSpec::default().content_alignment(Alignment::CENTER),
965 move || {
966 if let Some(surface) = surface.clone() {
967 Box(surface, BoxSpec::default(), || {});
968 }
969 let glyph_layer = Modifier::empty().graphics_layer(move || GraphicsLayer {
972 alpha: glyph_alpha,
973 ..Default::default()
974 });
975 let glyph_spec = ghost_spec.clone();
976 Box(
977 glyph_layer,
978 BoxSpec::default().content_alignment(Alignment::CENTER),
979 move || {
980 GlassIconForeground(glyph_spec.clone(), spec.diameter, ghost_icon);
981 },
982 );
983 },
984 );
985 }
986 });
987}
988
989#[cfg(test)]
990mod tests {
991 use super::*;
992
993 #[test]
994 fn glass_buttons_expose_button_semantics_to_every_platform_bridge() {
995 let semantics =
996 cranpose_ui::collect_semantics_from_modifier(&with_button_semantics(Modifier::empty()))
997 .expect("glass button semantics");
998 assert!(semantics.is_button);
999 assert!(semantics.is_clickable);
1000 }
1001
1002 #[test]
1003 fn icon_backplate_colors_only_the_compact_foreground_core() {
1004 let blue = Color::from_rgb_u8(0, 122, 255);
1005 let spec = GlassButtonSpec::glass()
1006 .with_icon_backplate(blue)
1007 .with_content_color(Color::WHITE);
1008 assert_eq!(spec.style, GlassButtonStyle::Glass);
1009 assert_eq!(spec.icon_backplate, Some(blue));
1010 assert_eq!(spec.content_color, Some(Color::WHITE));
1011 assert!(spec.glass.is_none());
1012 assert!((0.49..=0.51).contains(&ICON_BACKPLATE_DIAMETER_RATIO));
1013 assert!((0.27..=0.29).contains(&ICON_BACKPLATE_GLYPH_RATIO));
1014
1015 let colors = crate::theme::LiquidColors::light(blue);
1016 let material = GlassButtonSpec::glass()
1017 .resolve_material(&colors, colors.label)
1018 .expect("glass button material");
1019 assert_eq!(material.tint, None);
1020 assert_eq!(material.resolve(&colors).tint, colors.glass_tint);
1021 }
1022
1023 #[test]
1024 fn neutral_button_tint_comes_from_the_theme_not_foreground_polarity() {
1025 let accent = Color::from_rgb_u8(0, 122, 255);
1026 for colors in [
1027 crate::theme::LiquidColors::light(accent),
1028 crate::theme::LiquidColors::dark(accent),
1029 ] {
1030 let material = GlassButtonSpec::glass()
1031 .resolve_material(&colors, colors.label)
1032 .expect("glass button material");
1033 assert_eq!(material.tint, None);
1034 assert_eq!(material.resolve(&colors).tint, colors.glass_tint);
1035 }
1036 }
1037
1038 #[test]
1039 fn icon_button_group_builders_and_hit_regions_preserve_member_gaps() {
1040 let spec = GlassIconButtonGroupSpec::new(44.0)
1041 .with_spacing(8.0)
1042 .with_pressed_scale(1.2)
1043 .with_glue_radius(12.0);
1044 assert_eq!(icon_group_width(2, spec), 96.0);
1045 assert_eq!(icon_group_item_at(22.0, 22.0, 2, spec), Some(0));
1046 assert_eq!(icon_group_item_at(48.0, 22.0, 2, spec), None);
1047 assert_eq!(icon_group_item_at(74.0, 22.0, 2, spec), Some(1));
1048 assert_eq!(icon_group_item_at(22.0, 50.0, 2, spec), None);
1049
1050 let shapes = icon_group_neighbor_shapes(4, 2, spec, 16.0, 38.0);
1051 assert_eq!(shapes.len(), 2);
1052 assert!((shapes[0].0 - (16.0 + 52.0 + 22.0 + 44.0 * 0.38)).abs() < 1e-5);
1053 assert!((shapes[1].0 - (16.0 + 156.0 + 22.0 - 44.0 * 0.38)).abs() < 1e-5);
1054 assert_eq!(shapes[0].2, 44.0 * 0.36);
1055
1056 let item = GlassIconButtonGroupItem::new("M0 0", "Confirm", || {})
1057 .with_spec(GlassButtonSpec::prominent());
1058 assert_eq!(item.content_description, "Confirm");
1059 assert_eq!(item.spec.style, GlassButtonStyle::Prominent);
1060 }
1061}