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(
450 icon_path,
451 None,
452 diameter * ICON_BACKPLATE_GLYPH_RATIO,
453 icon_color,
454 );
455 },
456 );
457 } else {
458 crate::icons::Icon(icon_path, None, diameter * 0.5, icon_color);
459 }
460}
461
462#[composable]
464#[allow(non_snake_case)]
465pub fn GlassIconButton(
466 modifier: Modifier,
467 spec: GlassButtonSpec,
468 diameter: f32,
469 on_click: impl Fn() + 'static,
470 icon_path: &'static str,
471) {
472 GlassIconButtonWithForegroundAlpha(modifier, spec, diameter, 1.0, on_click, icon_path);
473}
474
475#[composable]
476#[allow(non_snake_case)]
477pub(crate) fn GlassIconButtonWithForegroundAlpha(
478 modifier: Modifier,
479 spec: GlassButtonSpec,
480 diameter: f32,
481 foreground_alpha: f32,
482 on_click: impl Fn() + 'static,
483 icon_path: &'static str,
484) {
485 let colors = liquid_colors();
486 let interaction = rememberMutableInteractionSource();
487 let (pressed_modifier, pressed, content_alpha) =
488 liquid_press_scale(Modifier::empty(), interaction, 1.20);
489
490 let material = spec
491 .resolve_material(&colors, spec.icon_color(&colors))
492 .map(|glass| glass.shape(LiquidShape::Circle));
493 let mut base = Modifier::empty();
494 if let Some(glass) = material {
495 let pressed_for_glass = pressed;
496 let half = diameter * 0.5;
497 base = base.glass_effect_with(glass, move || {
498 let press = if pressed_for_glass.get() { 1.0 } else { 0.0 };
499 GlassDynamics::default().touched_up(press, None, (half, half))
500 });
501 }
502
503 let on_click = Rc::new(RefCell::new(on_click));
504 let base = base
505 .press_interaction_source(interaction)
506 .clickable(move |_point| {
507 default_haptics().perform(HapticFeedback::ImpactLight);
508 (on_click.borrow_mut())();
509 })
510 .size(Size::new(diameter, diameter));
511
512 let content_layer = Modifier::empty().graphics_layer(move || GraphicsLayer {
513 alpha: content_alpha.get().clamp(0.0, 1.0) * foreground_alpha.clamp(0.0, 1.0),
514 ..Default::default()
515 });
516 let button = base.then(modifier);
517 Box(pressed_modifier, BoxSpec::default(), move || {
518 let foreground_spec = spec.clone();
519 let content_layer = content_layer.clone();
520 Box(
521 button.clone(),
522 BoxSpec::default().content_alignment(Alignment::CENTER),
523 move || {
524 let foreground_spec = foreground_spec.clone();
525 Box(
526 content_layer.clone(),
527 BoxSpec::default().content_alignment(Alignment::CENTER),
528 move || GlassIconForeground(foreground_spec.clone(), diameter, icon_path),
529 );
530 },
531 );
532 });
533}
534
535#[composable]
539#[allow(non_snake_case)]
540pub fn GlassIconButtonGroup(
541 modifier: Modifier,
542 spec: GlassIconButtonGroupSpec,
543 content: impl FnOnce(&GlassIconButtonGroupScope),
544) {
545 let items = collect_items(content);
546 let count = items.len();
547 if count == 0 {
548 return;
549 }
550
551 let colors = liquid_colors();
552 let live_items: Rc<RefCell<Vec<GlassIconButtonGroupItem>>> =
553 remember(|| Rc::new(RefCell::new(Vec::new()))).with(Rc::clone);
554 let ghosts: Rc<RefCell<Vec<(GlassIconButtonGroupItem, usize, f32)>>> =
555 remember(|| Rc::new(RefCell::new(Vec::new()))).with(Rc::clone);
556 let ghost_fade = remember(|| {
557 let runtime = cranpose_core::with_current_composer(|composer| composer.runtime_handle());
558 Rc::new(RefCell::new(cranpose_animation::Animatable::new(
559 0.0f32, runtime,
560 )))
561 })
562 .with(Rc::clone);
563 let active = remember(|| mutableStateOf(None::<usize>)).with(|state| *state);
564 let drag_x = remember(|| mutableStateOf(None::<f32>)).with(|state| *state);
565 let last_active = remember(|| Rc::new(Cell::new(0usize))).with(Rc::clone);
566 if let Some(index) = active.get() {
567 last_active.set(index.min(count - 1));
568 }
569 let press_progress = cranpose_animation::animateFloatAsState(
570 if active.get().is_some() { 1.0 } else { 0.0 },
571 LiquidMotion::snappy(),
572 "glass-icon-group-press",
573 );
574 let press_orphaned = remember(|| Rc::new(Cell::new(false))).with(Rc::clone);
575
576 {
577 let member_stays = |member: &GlassIconButtonGroupItem| {
578 items.iter().any(|item| {
579 item.icon_path == member.icon_path
580 && item.content_description == member.content_description
581 })
582 };
583 let previous = live_items.borrow();
584 if !previous.is_empty() {
585 let leavers: Vec<(GlassIconButtonGroupItem, usize, f32)> = previous
586 .iter()
587 .enumerate()
588 .filter(|(_, member)| !member_stays(member))
589 .map(|(index, member)| {
590 let departure = if index == last_active.get() {
591 press_orphaned.set(true);
592 press_progress.get().clamp(0.0, 1.0)
593 } else {
594 0.0
595 };
596 (member.clone(), index, departure)
597 })
598 .collect();
599 if !leavers.is_empty() {
600 *ghosts.borrow_mut() = leavers;
601 let mut fade = ghost_fade.borrow_mut();
602 fade.snapTo(1.0);
603 fade.animateTo(
604 0.0,
605 AnimationType::Tween(AnimationSpec::tween(250, Easing::EaseIn)),
606 );
607 }
608 }
609 }
610 let ghost_alpha = ghost_fade.borrow().state();
611 if !ghosts.borrow().is_empty() && ghost_alpha.get() <= 0.01 {
612 ghosts.borrow_mut().clear();
613 }
614 *live_items.borrow_mut() = items;
615 let render_items = live_items.borrow().clone();
616
617 let ghost_slots = ghosts
618 .borrow()
619 .iter()
620 .map(|(_, index, _)| index + 1)
621 .max()
622 .unwrap_or(0);
623 let layout_count = count.max(ghost_slots);
624 let merge = if layout_count > count {
625 (1.0 - ghost_alpha.get().clamp(0.0, 1.0)).clamp(0.0, 1.0)
626 } else {
627 0.0
628 };
629 let full_width = icon_group_width(layout_count, spec);
630 let settled_width = icon_group_width(count, spec);
631 let width = settled_width + (full_width - settled_width) * (1.0 - merge);
632 let ghost_trailing_shift = width - full_width;
633 let gesture_items = Rc::clone(&live_items);
634 let gesture_press_orphaned = Rc::clone(&press_orphaned);
635 let gesture = Modifier::empty()
636 .size(Size::new(width, spec.diameter))
637 .pointer_input(count, move |scope: PointerInputScope| {
638 let gesture_items = Rc::clone(&gesture_items);
639 let gesture_press_orphaned = Rc::clone(&gesture_press_orphaned);
640 async move {
641 scope
642 .await_pointer_event_scope(|await_scope| async move {
643 let mut down_index = None::<usize>;
644 loop {
645 let event = await_scope.await_pointer_event().await;
646 match event.kind {
647 PointerEventKind::Down if down_index.is_none() => {
648 down_index = icon_group_item_at(
649 event.position.x,
650 event.position.y,
651 gesture_items.borrow().len(),
652 spec,
653 );
654 if let Some(index) = down_index {
655 active.set(Some(index));
656 drag_x.set(Some(event.position.x));
657 gesture_press_orphaned.set(false);
658 default_haptics().perform(HapticFeedback::Selection);
659 event.consume();
660 }
661 }
662 PointerEventKind::Move if down_index.is_some() => {
663 drag_x.set(Some(event.position.x));
664 event.consume();
665 }
666 PointerEventKind::Up => {
667 let pressed_index = down_index.take();
668 active.set(None);
669 drag_x.set(None);
670 if let Some(index) = pressed_index {
671 let count = gesture_items.borrow().len();
672 let released_inside = (-TAP_EXIT_SLOP
673 ..=spec.diameter + TAP_EXIT_SLOP)
674 .contains(&event.position.y)
675 && (-TAP_EXIT_SLOP
676 ..=icon_group_width(count, spec) + TAP_EXIT_SLOP)
677 .contains(&event.position.x);
678 if released_inside {
679 let on_click = gesture_items
680 .borrow()
681 .get(index)
682 .map(|item| Rc::clone(&item.on_click));
683 if let Some(on_click) = on_click {
684 default_haptics()
685 .perform(HapticFeedback::ImpactLight);
686 (on_click.borrow_mut())();
687 }
688 }
689 event.consume();
690 }
691 }
692 PointerEventKind::Cancel if down_index.take().is_some() => {
693 active.set(None);
694 drag_x.set(None);
695 event.consume();
696 }
697 _ => {}
698 }
699 }
700 })
701 .await;
702 }
703 })
704 .then(modifier);
705
706 Box(gesture, BoxSpec::default(), move || {
707 let pitch = spec.diameter + spec.spacing;
708 let active_index = last_active.get().min(count - 1);
709
710 let pad = spec.glue_radius + spec.diameter * (spec.pressed_scale - 1.0) * 0.5 + 4.0;
711 let node_width = width + pad * 2.0;
712 let node_height = spec.diameter * spec.pressed_scale + pad * 2.0;
713 let shared_progress = press_progress;
714 let shared_orphaned = Rc::clone(&press_orphaned);
715 let shared_last_active = Rc::clone(&last_active);
716 let union_tint = render_items
717 .get(active_index)
718 .and_then(|item| {
719 item.spec
720 .resolve_material(&colors, item.spec.icon_color(&colors))
721 })
722 .and_then(|material| material.tint)
723 .map(|tint| tint.with_alpha(0.85))
724 .unwrap_or(Color::WHITE.with_alpha(0.035));
725 let shared = Modifier::empty()
726 .required_size(Size::new(node_width, node_height))
727 .offset(-pad, (spec.diameter - node_height) * 0.5)
728 .glass_effect_with(
729 Glass::regular()
730 .blur_radius(0.0)
731 .tint(union_tint)
732 .lift(0.0)
733 .highlight(0.48)
734 .shadow(false)
735 .no_clip(),
736 move || {
737 let progress = if shared_orphaned.get() {
738 0.0
739 } else {
740 shared_progress.get().clamp(0.0, 1.0)
741 };
742 let active_index = shared_last_active.get().min(count - 1);
743 let center_y = node_height * 0.5;
744 let rest_center = active_index as f32 * pitch + spec.diameter * 0.5;
745 let ridden = drag_x
746 .get()
747 .map(|x| x.clamp(rest_center - pitch * 0.35, rest_center + pitch * 0.35))
748 .unwrap_or(rest_center);
749 let center_x = pad + rest_center + (ridden - rest_center) * progress;
750 let diameter = spec.diameter * (1.0 + (spec.pressed_scale - 1.0) * progress);
751 let touch = drag_x.get().map(|x| {
752 (
753 pad + x.clamp(0.0, node_width - 2.0 * pad),
754 center_y,
755 progress,
756 )
757 });
758 GlassDynamics {
759 activity: Some(progress),
760 touch,
761 morph: Some(GlassMorph {
762 node_size: (node_width, node_height),
763 primary: (center_x, center_y, diameter, diameter, -1.0),
764 shapes: icon_group_neighbor_shapes(
765 count,
766 active_index,
767 spec,
768 pad,
769 center_y,
770 ),
771 glue: spec.glue_radius * progress,
772 ..Default::default()
773 }),
774 ..Default::default()
775 }
776 },
777 );
778 Box(shared, BoxSpec::default(), || {});
779
780 for (index, item) in render_items.iter().enumerate() {
781 let x = index as f32 * pitch;
782 let surface_progress = press_progress;
783 let item_is_active = index == active_index;
784 let scale_orphaned = Rc::clone(&press_orphaned);
785 let outer = Modifier::empty()
786 .size(Size::new(spec.diameter, spec.diameter))
787 .offset(x, 0.0);
788 let rest_center = index as f32 * pitch + spec.diameter * 0.5;
789 let scale_layer = Modifier::empty().graphics_layer(move || {
790 let progress = if item_is_active && !scale_orphaned.get() {
791 surface_progress.get().clamp(0.0, 1.0)
792 } else {
793 0.0
794 };
795 let scale = 1.0 + (spec.pressed_scale - 1.0) * progress;
796 let ridden = if item_is_active {
797 drag_x
798 .get()
799 .map(|x| x.clamp(rest_center - pitch * 0.35, rest_center + pitch * 0.35))
800 .unwrap_or(rest_center)
801 } else {
802 rest_center
803 };
804 GraphicsLayer {
805 translation_x: (ridden - rest_center) * progress,
806 scale_x: scale,
807 scale_y: scale,
808 ..Default::default()
809 }
810 });
811 let mut surface = Modifier::empty().size(Size::new(spec.diameter, spec.diameter));
812 if let Some(material) = item
813 .spec
814 .resolve_material(&colors, item.spec.icon_color(&colors))
815 {
816 let surface_progress = press_progress;
817 let dynamics_orphaned = Rc::clone(&press_orphaned);
818 surface =
819 surface.glass_effect_with(material.shape(LiquidShape::Circle), move || {
820 let progress = if dynamics_orphaned.get() {
821 0.0
822 } else {
823 surface_progress.get().clamp(0.0, 1.0)
824 };
825 GlassDynamics {
826 highlight_boost: if item_is_active { 0.60 * progress } else { 0.0 },
827 saturation_boost: if item_is_active { 1.6 * progress } else { 0.0 },
828 tint_alpha_multiplier: item_is_active.then_some(1.0 + 0.85 * progress),
829 ..Default::default()
830 }
831 });
832 }
833 Box(outer, BoxSpec::default(), move || {
834 let surface = surface.clone();
835 Box(scale_layer.clone(), BoxSpec::default(), move || {
836 Box(surface.clone(), BoxSpec::default(), || {});
837 });
838 });
839 }
840
841 for (index, item) in render_items.iter().enumerate() {
842 let x = index as f32 * pitch;
843 let item_is_active = index == active_index;
844 let foreground_progress = press_progress;
845 let foreground_orphaned = Rc::clone(&press_orphaned);
846 let foreground_spec = item.spec.clone();
847 let icon_path = item.icon_path;
848 let description = item.content_description.clone();
849 let merge_alpha = merge_glyph_alpha(merge);
850 let foreground = Modifier::empty()
851 .size(Size::new(spec.diameter, spec.diameter))
852 .offset(x, 0.0)
853 .graphics_layer(move || GraphicsLayer {
854 alpha: merge_alpha
855 * if item_is_active && !foreground_orphaned.get() {
856 1.0 - foreground_progress.get().clamp(0.0, 1.0)
857 } else {
858 1.0
859 },
860 ..Default::default()
861 })
862 .semantics(move |config| {
863 config.role = Some(SemanticsWidgetRole::Button);
864 config.is_clickable = true;
865 config.content_description = Some(description.clone());
866 })
867 .focusable();
868 Box(
869 foreground,
870 BoxSpec::default().content_alignment(Alignment::CENTER),
871 move || {
872 GlassIconForeground(foreground_spec.clone(), spec.diameter, icon_path);
873 },
874 );
875 }
876
877 for (ghost, ghost_index, departure) in ghosts.borrow().iter() {
878 let x = *ghost_index as f32 * pitch + ghost_trailing_shift;
879 let fade = ghost_alpha;
880 let departure = *departure;
881 let ghost_layer = Modifier::empty()
882 .size(Size::new(spec.diameter, spec.diameter))
883 .offset(x, 0.0)
884 .graphics_layer(move || {
885 let alpha = fade.get().clamp(0.0, 1.0);
886 let scale =
887 1.0 + (spec.pressed_scale - 1.0) * departure * (0.55 + 0.45 * alpha);
888 GraphicsLayer {
889 alpha,
890 scale_x: scale,
891 scale_y: scale,
892 render_effect: (departure > 0.05 && alpha < 0.98).then(|| {
893 cranpose_ui_graphics::RenderEffect::blur(
894 (1.0 - alpha) * 9.0 * departure,
895 )
896 }),
897 ..Default::default()
898 }
899 });
900 let ghost_spec = ghost.spec.clone();
901 let ghost_icon = ghost.icon_path;
902 let surface = ghost
903 .spec
904 .resolve_material(&colors, ghost.spec.icon_color(&colors))
905 .map(|material| {
906 Modifier::empty()
907 .size(Size::new(spec.diameter, spec.diameter))
908 .glass_effect_with(material.shape(LiquidShape::Circle), move || {
909 GlassDynamics {
910 highlight_boost: 0.60 * departure,
911 saturation_boost: 1.6 * departure,
912 tint_alpha_multiplier: Some(1.0 + 0.85 * departure),
913 ..Default::default()
914 }
915 })
916 });
917 let glyph_alpha = 1.0 - departure;
918 Box(
919 ghost_layer,
920 BoxSpec::default().content_alignment(Alignment::CENTER),
921 move || {
922 if let Some(surface) = surface.clone() {
923 Box(surface, BoxSpec::default(), || {});
924 }
925 let glyph_layer = Modifier::empty().graphics_layer(move || GraphicsLayer {
926 alpha: glyph_alpha,
927 ..Default::default()
928 });
929 let glyph_spec = ghost_spec.clone();
930 Box(
931 glyph_layer,
932 BoxSpec::default().content_alignment(Alignment::CENTER),
933 move || {
934 GlassIconForeground(glyph_spec.clone(), spec.diameter, ghost_icon);
935 },
936 );
937 },
938 );
939 }
940 });
941}
942
943#[cfg(test)]
944mod tests {
945 use super::*;
946
947 #[test]
948 fn glass_buttons_expose_button_semantics_to_every_platform_bridge() {
949 let semantics =
950 cranpose_ui::collect_semantics_from_modifier(&with_button_semantics(Modifier::empty()))
951 .expect("glass button semantics");
952 assert_eq!(semantics.role, Some(SemanticsWidgetRole::Button));
953 assert!(semantics.is_clickable);
954 }
955
956 #[test]
957 fn icon_backplate_colors_only_the_compact_foreground_core() {
958 let blue = Color::from_rgb_u8(0, 122, 255);
959 let spec = GlassButtonSpec::glass()
960 .with_icon_backplate(blue)
961 .with_content_color(Color::WHITE);
962 assert_eq!(spec.style, GlassButtonStyle::Glass);
963 assert_eq!(spec.icon_backplate, Some(blue));
964 assert_eq!(spec.content_color, Some(Color::WHITE));
965 assert!(spec.glass.is_none());
966 assert!((0.49..=0.51).contains(&ICON_BACKPLATE_DIAMETER_RATIO));
967 assert!((0.27..=0.29).contains(&ICON_BACKPLATE_GLYPH_RATIO));
968
969 let colors = crate::theme::LiquidColors::light(blue);
970 let material = GlassButtonSpec::glass()
971 .resolve_material(&colors, colors.label)
972 .expect("glass button material");
973 assert_eq!(material.tint, None);
974 assert_eq!(material.resolve(&colors).tint, colors.glass_tint);
975 }
976
977 #[test]
978 fn neutral_button_tint_comes_from_the_theme_not_foreground_polarity() {
979 let accent = Color::from_rgb_u8(0, 122, 255);
980 for colors in [
981 crate::theme::LiquidColors::light(accent),
982 crate::theme::LiquidColors::dark(accent),
983 ] {
984 let material = GlassButtonSpec::glass()
985 .resolve_material(&colors, colors.label)
986 .expect("glass button material");
987 assert_eq!(material.tint, None);
988 assert_eq!(material.resolve(&colors).tint, colors.glass_tint);
989 }
990 }
991
992 #[test]
993 fn icon_button_group_builders_and_hit_regions_preserve_member_gaps() {
994 let spec = GlassIconButtonGroupSpec::new(44.0)
995 .with_spacing(8.0)
996 .with_pressed_scale(1.2)
997 .with_glue_radius(12.0);
998 assert_eq!(icon_group_width(2, spec), 96.0);
999 assert_eq!(icon_group_item_at(22.0, 22.0, 2, spec), Some(0));
1000 assert_eq!(icon_group_item_at(48.0, 22.0, 2, spec), None);
1001 assert_eq!(icon_group_item_at(74.0, 22.0, 2, spec), Some(1));
1002 assert_eq!(icon_group_item_at(22.0, 50.0, 2, spec), None);
1003
1004 let shapes = icon_group_neighbor_shapes(4, 2, spec, 16.0, 38.0);
1005 assert_eq!(shapes.len(), 2);
1006 assert!((shapes[0].0 - (16.0 + 52.0 + 22.0 + 44.0 * 0.38)).abs() < 1e-5);
1007 assert!((shapes[1].0 - (16.0 + 156.0 + 22.0 - 44.0 * 0.38)).abs() < 1e-5);
1008 assert_eq!(shapes[0].2, 44.0 * 0.36);
1009
1010 let item = GlassIconButtonGroupItem::new("M0 0", "Confirm", || {})
1011 .with_spec(GlassButtonSpec::prominent());
1012 assert_eq!(item.content_description, "Confirm");
1013 assert_eq!(item.spec.style, GlassButtonStyle::Prominent);
1014 }
1015
1016 #[test]
1017 fn a_scope_declares_actions_in_order_with_their_callbacks() {
1018 let fired = Rc::new(Cell::new(0usize));
1019 let more = Rc::clone(&fired);
1020 let confirm = Rc::clone(&fired);
1021 let items = collect_items(|scope| {
1022 scope.action("M0 0", "More", move || more.set(1));
1023 scope.push(
1024 GlassIconButtonGroupItem::new("M1 1", "Confirm", move || confirm.set(2))
1025 .with_spec(GlassButtonSpec::prominent()),
1026 );
1027 });
1028
1029 assert_eq!(items.len(), 2);
1030 assert_eq!(items[0].content_description, "More");
1031 assert_eq!(items[0].spec.style, GlassButtonStyle::Glass);
1032 assert_eq!(items[1].spec.style, GlassButtonStyle::Prominent);
1033
1034 (items[0].on_click.borrow_mut())();
1035 assert_eq!(fired.get(), 1);
1036 (items[1].on_click.borrow_mut())();
1037 assert_eq!(fired.get(), 2);
1038 }
1039
1040 #[test]
1041 fn a_group_with_no_actions_declares_none() {
1042 assert!(collect_items(|_| {}).is_empty());
1043 }
1044}