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]
339pub fn GlassButton(
340 modifier: Modifier,
341 spec: GlassButtonSpec,
342 on_click: impl Fn() + 'static,
343 content: impl FnMut() + 'static,
344) {
345 let colors = liquid_colors();
346 let interaction = rememberMutableInteractionSource();
347 let (pressed_modifier, pressed, content_alpha) =
348 liquid_press_scale(Modifier::empty(), interaction, 1.18);
349
350 let material = spec.resolve_material(&colors, spec.content_color(&colors));
351 let mut base = Modifier::empty();
352 if let Some(glass) = material {
353 let pressed_for_glass = pressed;
354 let glow_size = cranpose_core::remember(|| {
355 std::rc::Rc::new(std::cell::Cell::new(cranpose_ui_graphics::Size {
356 width: 0.0,
357 height: 0.0,
358 }))
359 })
360 .with(std::rc::Rc::clone);
361 let glow_size_for_glass = std::rc::Rc::clone(&glow_size);
362 base = base
363 .report_size(std::rc::Rc::clone(&glow_size))
364 .glass_effect_with(glass, move || {
365 let size = glow_size_for_glass.get();
366 let press = if pressed_for_glass.get() { 1.0 } else { 0.0 };
367 let dynamics = GlassDynamics::default();
368 if size.width > 0.0 && size.height > 0.0 {
369 dynamics.touched_up(press, None, (size.width * 0.5, size.height * 0.5))
370 } else {
371 dynamics
372 }
373 });
374 }
375
376 let on_click = Rc::new(RefCell::new(on_click));
377 let base = with_button_semantics(
378 base.press_interaction_source(interaction)
379 .clickable(move |_point| {
380 default_haptics().perform(HapticFeedback::ImpactLight);
381 (on_click.borrow_mut())();
382 })
383 .padding_symmetric(16.0, 10.0),
384 );
385
386 let content_layer = Modifier::empty().graphics_layer(move || GraphicsLayer {
387 alpha: content_alpha.get().clamp(0.0, 1.0),
388 ..Default::default()
389 });
390 let content = Rc::new(RefCell::new(content));
391 let button = base.then(modifier);
392 Box(pressed_modifier, BoxSpec::default(), move || {
393 let content = Rc::clone(&content);
394 let content_layer = content_layer.clone();
395 Box(
396 button.clone(),
397 BoxSpec::default().content_alignment(Alignment::CENTER),
398 move || {
399 let content = Rc::clone(&content);
400 Box(
401 content_layer.clone(),
402 BoxSpec::default().content_alignment(Alignment::CENTER),
403 move || (content.borrow_mut())(),
404 );
405 },
406 );
407 });
408}
409
410#[composable]
412pub fn GlassButtonLabel(text: impl Into<String>, spec: GlassButtonSpec) {
413 let typography = liquid_typography();
414 let color = spec.content_color(&liquid_colors());
415 let style = TextStyle {
416 span_style: cranpose_ui::text::SpanStyle {
417 color: Some(color),
418 ..typography.headline.span_style.clone()
419 },
420 ..typography.headline
421 };
422 Text(text.into(), Modifier::empty(), style);
423}
424
425#[composable]
426pub(crate) fn GlassIconForeground(spec: GlassButtonSpec, diameter: f32, icon_path: &'static str) {
427 let colors = liquid_colors();
428 let icon_color = spec.icon_color(&colors);
429 if let Some(backplate) = spec.icon_backplate {
430 let backplate_diameter = diameter * ICON_BACKPLATE_DIAMETER_RATIO;
431 Box(
432 Modifier::empty()
433 .size(Size::new(backplate_diameter, backplate_diameter))
434 .draw_behind(move |scope| {
435 scope.draw_circle(
436 cranpose_ui_graphics::Brush::solid(backplate),
437 cranpose_ui_graphics::Point::new(
438 backplate_diameter * 0.5,
439 backplate_diameter * 0.5,
440 ),
441 backplate_diameter * 0.5,
442 );
443 }),
444 BoxSpec::default().content_alignment(Alignment::CENTER),
445 move || {
446 crate::icons::Icon(
447 icon_path,
448 None,
449 diameter * ICON_BACKPLATE_GLYPH_RATIO,
450 icon_color,
451 );
452 },
453 );
454 } else {
455 crate::icons::Icon(icon_path, None, diameter * 0.5, icon_color);
456 }
457}
458
459#[composable]
461pub fn GlassIconButton(
462 modifier: Modifier,
463 spec: GlassButtonSpec,
464 diameter: f32,
465 on_click: impl Fn() + 'static,
466 icon_path: &'static str,
467) {
468 GlassIconButtonWithForegroundAlpha(modifier, spec, diameter, 1.0, on_click, icon_path);
469}
470
471#[composable]
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]
534pub fn GlassIconButtonGroup(
535 modifier: Modifier,
536 spec: GlassIconButtonGroupSpec,
537 content: impl FnOnce(&GlassIconButtonGroupScope),
538) {
539 let items = collect_items(content);
540 let count = items.len();
541 if count == 0 {
542 return;
543 }
544
545 let colors = liquid_colors();
546 let live_items: Rc<RefCell<Vec<GlassIconButtonGroupItem>>> =
547 remember(|| Rc::new(RefCell::new(Vec::new()))).with(Rc::clone);
548 let ghosts: Rc<RefCell<Vec<(GlassIconButtonGroupItem, usize, f32)>>> =
549 remember(|| Rc::new(RefCell::new(Vec::new()))).with(Rc::clone);
550 let ghost_fade = remember(|| {
551 let runtime = cranpose_core::with_current_composer(|composer| composer.runtime_handle());
552 Rc::new(RefCell::new(cranpose_animation::Animatable::new(
553 0.0f32, runtime,
554 )))
555 })
556 .with(Rc::clone);
557 let active = remember(|| mutableStateOf(None::<usize>)).with(|state| *state);
558 let drag_x = remember(|| mutableStateOf(None::<f32>)).with(|state| *state);
559 let last_active = remember(|| Rc::new(Cell::new(0usize))).with(Rc::clone);
560 if let Some(index) = active.get() {
561 last_active.set(index.min(count - 1));
562 }
563 let press_progress = cranpose_animation::animateFloatAsState(
564 if active.get().is_some() { 1.0 } else { 0.0 },
565 LiquidMotion::snappy(),
566 "glass-icon-group-press",
567 );
568 let press_orphaned = remember(|| Rc::new(Cell::new(false))).with(Rc::clone);
569
570 {
571 let member_stays = |member: &GlassIconButtonGroupItem| {
572 items.iter().any(|item| {
573 item.icon_path == member.icon_path
574 && item.content_description == member.content_description
575 })
576 };
577 let previous = live_items.borrow();
578 if !previous.is_empty() {
579 let leavers: Vec<(GlassIconButtonGroupItem, usize, f32)> = previous
580 .iter()
581 .enumerate()
582 .filter(|(_, member)| !member_stays(member))
583 .map(|(index, member)| {
584 let departure = if index == last_active.get() {
585 press_orphaned.set(true);
586 press_progress.get().clamp(0.0, 1.0)
587 } else {
588 0.0
589 };
590 (member.clone(), index, departure)
591 })
592 .collect();
593 if !leavers.is_empty() {
594 *ghosts.borrow_mut() = leavers;
595 let mut fade = ghost_fade.borrow_mut();
596 fade.snapTo(1.0);
597 fade.animateTo(
598 0.0,
599 AnimationType::Tween(AnimationSpec::tween(250, Easing::EaseIn)),
600 );
601 }
602 }
603 }
604 let ghost_alpha = ghost_fade.borrow().state();
605 if !ghosts.borrow().is_empty() && ghost_alpha.get() <= 0.01 {
606 ghosts.borrow_mut().clear();
607 }
608 *live_items.borrow_mut() = items;
609 let render_items = live_items.borrow().clone();
610
611 let ghost_slots = ghosts
612 .borrow()
613 .iter()
614 .map(|(_, index, _)| index + 1)
615 .max()
616 .unwrap_or(0);
617 let layout_count = count.max(ghost_slots);
618 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();
666 let released_inside = (-TAP_EXIT_SLOP
667 ..=spec.diameter + TAP_EXIT_SLOP)
668 .contains(&event.position.y)
669 && (-TAP_EXIT_SLOP
670 ..=icon_group_width(count, spec) + TAP_EXIT_SLOP)
671 .contains(&event.position.x);
672 if released_inside {
673 let on_click = gesture_items
674 .borrow()
675 .get(index)
676 .map(|item| Rc::clone(&item.on_click));
677 if let Some(on_click) = on_click {
678 default_haptics()
679 .perform(HapticFeedback::ImpactLight);
680 (on_click.borrow_mut())();
681 }
682 }
683 event.consume();
684 }
685 }
686 PointerEventKind::Cancel if down_index.take().is_some() => {
687 active.set(None);
688 drag_x.set(None);
689 event.consume();
690 }
691 _ => {}
692 }
693 }
694 })
695 .await;
696 }
697 })
698 .then(modifier);
699
700 Box(gesture, BoxSpec::default(), move || {
701 let pitch = spec.diameter + spec.spacing;
702 let active_index = last_active.get().min(count - 1);
703
704 let pad = spec.glue_radius + spec.diameter * (spec.pressed_scale - 1.0) * 0.5 + 4.0;
705 let node_width = width + pad * 2.0;
706 let node_height = spec.diameter * spec.pressed_scale + pad * 2.0;
707 let shared_progress = press_progress;
708 let shared_orphaned = Rc::clone(&press_orphaned);
709 let shared_last_active = Rc::clone(&last_active);
710 let union_tint = render_items
711 .get(active_index)
712 .and_then(|item| {
713 item.spec
714 .resolve_material(&colors, item.spec.icon_color(&colors))
715 })
716 .and_then(|material| material.tint)
717 .map_or(Color(1.0, 1.0, 1.0, 0.035), |tint| tint.with_alpha(0.85));
718 let shared = Modifier::empty()
719 .required_size(Size::new(node_width, node_height))
720 .offset(-pad, (spec.diameter - node_height) * 0.5)
721 .glass_effect_with(
722 Glass::regular()
723 .blur_radius(0.0)
724 .tint(union_tint)
725 .lift(0.0)
726 .highlight(0.48)
727 .shadow(false)
728 .no_clip(),
729 move || {
730 let progress = if shared_orphaned.get() {
731 0.0
732 } else {
733 shared_progress.get().clamp(0.0, 1.0)
734 };
735 let active_index = shared_last_active.get().min(count - 1);
736 let center_y = node_height * 0.5;
737 let rest_center = active_index as f32 * pitch + spec.diameter * 0.5;
738 let ridden = drag_x
739 .get()
740 .map(|x| x.clamp(rest_center - pitch * 0.35, rest_center + pitch * 0.35))
741 .unwrap_or(rest_center);
742 let center_x = pad + rest_center + (ridden - rest_center) * progress;
743 let diameter = spec.diameter * (1.0 + (spec.pressed_scale - 1.0) * progress);
744 let touch = drag_x.get().map(|x| {
745 (
746 pad + x.clamp(0.0, node_width - 2.0 * pad),
747 center_y,
748 progress,
749 )
750 });
751 GlassDynamics {
752 activity: Some(progress),
753 touch,
754 morph: Some(GlassMorph {
755 node_size: (node_width, node_height),
756 primary: (center_x, center_y, diameter, diameter, -1.0),
757 shapes: icon_group_neighbor_shapes(
758 count,
759 active_index,
760 spec,
761 pad,
762 center_y,
763 ),
764 glue: spec.glue_radius * progress,
765 ..Default::default()
766 }),
767 ..Default::default()
768 }
769 },
770 );
771 Box(shared, BoxSpec::default(), || {});
772
773 for (index, item) in render_items.iter().enumerate() {
774 let x = index as f32 * pitch;
775 let surface_progress = press_progress;
776 let item_is_active = index == active_index;
777 let scale_orphaned = Rc::clone(&press_orphaned);
778 let outer = Modifier::empty()
779 .size(Size::new(spec.diameter, spec.diameter))
780 .offset(x, 0.0);
781 let rest_center = index as f32 * pitch + spec.diameter * 0.5;
782 let scale_layer = Modifier::empty().graphics_layer(move || {
783 let progress = if item_is_active && !scale_orphaned.get() {
784 surface_progress.get().clamp(0.0, 1.0)
785 } else {
786 0.0
787 };
788 let scale = 1.0 + (spec.pressed_scale - 1.0) * progress;
789 let ridden = if item_is_active {
790 drag_x
791 .get()
792 .map(|x| x.clamp(rest_center - pitch * 0.35, rest_center + pitch * 0.35))
793 .unwrap_or(rest_center)
794 } else {
795 rest_center
796 };
797 GraphicsLayer {
798 translation_x: (ridden - rest_center) * progress,
799 scale_x: scale,
800 scale_y: scale,
801 ..Default::default()
802 }
803 });
804 let mut surface = Modifier::empty().size(Size::new(spec.diameter, spec.diameter));
805 if let Some(material) = item
806 .spec
807 .resolve_material(&colors, item.spec.icon_color(&colors))
808 {
809 let surface_progress = press_progress;
810 let dynamics_orphaned = Rc::clone(&press_orphaned);
811 surface =
812 surface.glass_effect_with(material.shape(LiquidShape::Circle), move || {
813 let progress = if dynamics_orphaned.get() {
814 0.0
815 } else {
816 surface_progress.get().clamp(0.0, 1.0)
817 };
818 GlassDynamics {
819 highlight_boost: if item_is_active { 0.60 * progress } else { 0.0 },
820 saturation_boost: if item_is_active { 1.6 * progress } else { 0.0 },
821 tint_alpha_multiplier: item_is_active.then_some(1.0 + 0.85 * progress),
822 ..Default::default()
823 }
824 });
825 }
826 Box(outer, BoxSpec::default(), move || {
827 let surface = surface.clone();
828 Box(scale_layer.clone(), BoxSpec::default(), move || {
829 Box(surface.clone(), BoxSpec::default(), || {});
830 });
831 });
832 }
833
834 for (index, item) in render_items.iter().enumerate() {
835 let x = index as f32 * pitch;
836 let item_is_active = index == active_index;
837 let foreground_progress = press_progress;
838 let foreground_orphaned = Rc::clone(&press_orphaned);
839 let foreground_spec = item.spec.clone();
840 let icon_path = item.icon_path;
841 let description = item.content_description.clone();
842 let merge_alpha = merge_glyph_alpha(merge);
843 let foreground = Modifier::empty()
844 .size(Size::new(spec.diameter, spec.diameter))
845 .offset(x, 0.0)
846 .graphics_layer(move || GraphicsLayer {
847 alpha: merge_alpha
848 * if item_is_active && !foreground_orphaned.get() {
849 1.0 - foreground_progress.get().clamp(0.0, 1.0)
850 } else {
851 1.0
852 },
853 ..Default::default()
854 })
855 .semantics(move |config| {
856 config.role = Some(SemanticsWidgetRole::Button);
857 config.is_clickable = true;
858 config.content_description = Some(description.clone());
859 })
860 .focusable();
861 Box(
862 foreground,
863 BoxSpec::default().content_alignment(Alignment::CENTER),
864 move || {
865 GlassIconForeground(foreground_spec.clone(), spec.diameter, icon_path);
866 },
867 );
868 }
869
870 for (ghost, ghost_index, departure) in ghosts.borrow().iter() {
871 let x = *ghost_index as f32 * pitch + ghost_trailing_shift;
872 let fade = ghost_alpha;
873 let departure = *departure;
874 let ghost_layer = Modifier::empty()
875 .size(Size::new(spec.diameter, spec.diameter))
876 .offset(x, 0.0)
877 .graphics_layer(move || {
878 let alpha = fade.get().clamp(0.0, 1.0);
879 let scale =
880 1.0 + (spec.pressed_scale - 1.0) * departure * (0.55 + 0.45 * alpha);
881 GraphicsLayer {
882 alpha,
883 scale_x: scale,
884 scale_y: scale,
885 render_effect: (departure > 0.05 && alpha < 0.98).then(|| {
886 cranpose_ui_graphics::RenderEffect::blur(
887 (1.0 - alpha) * 9.0 * departure,
888 )
889 }),
890 ..Default::default()
891 }
892 });
893 let ghost_spec = ghost.spec.clone();
894 let ghost_icon = ghost.icon_path;
895 let surface = ghost
896 .spec
897 .resolve_material(&colors, ghost.spec.icon_color(&colors))
898 .map(|material| {
899 Modifier::empty()
900 .size(Size::new(spec.diameter, spec.diameter))
901 .glass_effect_with(material.shape(LiquidShape::Circle), move || {
902 GlassDynamics {
903 highlight_boost: 0.60 * departure,
904 saturation_boost: 1.6 * departure,
905 tint_alpha_multiplier: Some(1.0 + 0.85 * departure),
906 ..Default::default()
907 }
908 })
909 });
910 let glyph_alpha = 1.0 - departure;
911 Box(
912 ghost_layer,
913 BoxSpec::default().content_alignment(Alignment::CENTER),
914 move || {
915 if let Some(surface) = surface.clone() {
916 Box(surface, BoxSpec::default(), || {});
917 }
918 let glyph_layer = Modifier::empty().graphics_layer(move || GraphicsLayer {
919 alpha: glyph_alpha,
920 ..Default::default()
921 });
922 let glyph_spec = ghost_spec.clone();
923 Box(
924 glyph_layer,
925 BoxSpec::default().content_alignment(Alignment::CENTER),
926 move || {
927 GlassIconForeground(glyph_spec.clone(), spec.diameter, ghost_icon);
928 },
929 );
930 },
931 );
932 }
933 });
934}
935
936#[cfg(test)]
937#[path = "tests/button_tests.rs"]
938mod tests;