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::{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/// Visual style of a [`GlassButton`].
31#[derive(Clone, Copy, Debug, PartialEq, Eq, Default)]
32pub enum GlassButtonStyle {
33    /// Translucent glass with the label in the accent color.
34    #[default]
35    Glass,
36    /// Accent-tinted glass with white content — the primary action.
37    Prominent,
38    /// No material: just the label with press feedback (toolbar/text button).
39    Plain,
40    /// Destructive variant of `Plain`.
41    Destructive,
42}
43
44/// Configuration for [`GlassButton`] / [`GlassIconButton`].
45#[derive(Clone, Debug, Default, PartialEq)]
46pub struct GlassButtonSpec {
47    pub style: GlassButtonStyle,
48    /// Text button sizing; icon buttons use their explicit diameter.
49    pub size: GlassButtonSize,
50    /// Overrides the material (advanced).
51    pub glass: Option<Glass>,
52    /// Overrides the label/icon color (defaults per style).
53    pub content_color: Option<Color>,
54    /// Optional inner color disc for icon buttons. The outer material remains
55    /// clear glass; the disc colors only the icon's compact foreground core.
56    pub icon_backplate: Option<Color>,
57}
58
59/// Native text button sizes, including their label style and content insets.
60#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
61pub enum GlassButtonSize {
62    /// Compact action with a 15 point label.
63    Small,
64    /// Standard action with a 17 point label.
65    #[default]
66    Regular,
67    /// Large action with a 17 point label.
68    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    /// Sets the label style and padding used by a text button.
133    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    /// The label color for this style under `colors`.
144    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    /// The glyph color for icon buttons: the reference nav/search circles
157    /// draw BLACK glyphs on plain glass (only prominent tints stay white).
158    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/// One action in a [`GlassIconButtonGroup`].
199#[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
236/// The scope a group's actions are declared in.
237///
238/// Each call adds one circular action, in the order it is made, which is also
239/// the order they sit in left to right.
240pub struct GlassIconButtonGroupScope {
241    items: ScopeContent<GlassIconButtonGroupItem>,
242}
243
244impl GlassIconButtonGroupScope {
245    /// An action drawing `icon_path`, announced as `content_description`.
246    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    /// An action built out, for the material a particular action needs — see
260    /// [`GlassIconButtonGroupItem`].
261    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/// Geometry and interaction material for [`GlassIconButtonGroup`].
273#[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/// A glass button. `content` composes the label (see [`GlassButton`] with
378/// [`Text`], or an [`crate::icons::Icon`] + text row).
379#[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/// Convenience text label styled for the enclosing button.
455#[composable]
456pub fn GlassButtonLabel(text: cranpose_ui::SharedText, 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, 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/// A circular glass icon button (44dp target).
498#[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/// A row of circular actions whose pressed glass can join adjacent members.
548/// Each member keeps its own base material and foreground; one transparent,
549/// persistent interaction field supplies the shared refraction and neck.
550#[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;