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