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