Skip to main content

cranpose_liquid/widgets/
button.rs

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/// Visual style of a [`GlassButton`].
38#[derive(Clone, Copy, Debug, PartialEq, Eq, Default)]
39pub enum GlassButtonStyle {
40    /// Translucent glass with the label in the accent color.
41    #[default]
42    Glass,
43    /// Accent-tinted glass with white content — the primary action.
44    Prominent,
45    /// No material: just the label with press feedback (toolbar/text button).
46    Plain,
47    /// Destructive variant of `Plain`.
48    Destructive,
49}
50
51/// Configuration for [`GlassButton`] / [`GlassIconButton`].
52#[derive(Clone, Debug, Default, PartialEq)]
53pub struct GlassButtonSpec {
54    pub style: GlassButtonStyle,
55    /// Overrides the material (advanced).
56    pub glass: Option<Glass>,
57    /// Overrides the label/icon color (defaults per style).
58    pub content_color: Option<Color>,
59    /// Optional inner color disc for icon buttons. The outer material remains
60    /// clear glass; the disc colors only the icon's compact foreground core.
61    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    /// The label color for this style under `colors`.
106    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    /// The glyph color for icon buttons: the reference nav/search circles
119    /// draw BLACK glyphs on plain glass (only prominent tints stay white).
120    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/// One action in a [`GlassIconButtonGroup`].
158#[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
195/// The scope a group's actions are declared in.
196///
197/// Each call adds one circular action, in the order it is made, which is also
198/// the order they sit in left to right.
199pub struct GlassIconButtonGroupScope {
200    items: ScopeContent<GlassIconButtonGroupItem>,
201}
202
203impl GlassIconButtonGroupScope {
204    /// An action drawing `icon_path`, announced as `content_description`.
205    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    /// An action built out, for the material a particular action needs — see
219    /// [`GlassIconButtonGroupItem`].
220    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/// Geometry and interaction material for [`GlassIconButtonGroup`].
232#[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/// A glass button. `content` composes the label (see [`GlassButton`] with
337/// [`Text`], or an [`crate::icons::Icon`] + text row).
338#[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/// Convenience text label styled for the enclosing button.
411#[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/// A circular glass icon button (44dp target).
460#[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/// A row of circular actions whose pressed glass can join adjacent members.
531/// Each member keeps its own base material and foreground; one transparent,
532/// persistent interaction field supplies the shared refraction and neck.
533#[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;