Skip to main content

cranpose_liquid/widgets/
button.rs

1//! Glass buttons: capsule glass with a spring press (scale + specular boost)
2//! and haptic feedback.
3
4use crate::material::{Glass, GlassDynamics, GlassMorph, LiquidModifierExt, LiquidShape};
5use crate::motion::{liquid_press_scale, LiquidMotion};
6use crate::theme::{liquid_colors, liquid_typography};
7use cranpose_animation::{AnimationSpec, AnimationType, Easing};
8use cranpose_core::{mutableStateOf, remember};
9use cranpose_macros::composable;
10use cranpose_services::{default_haptics, HapticFeedback};
11use cranpose_ui::rememberMutableInteractionSource;
12use cranpose_ui::text::TextStyle;
13use cranpose_ui::widgets::{Box, BoxSpec, Text};
14use cranpose_ui::{Modifier, PointerEventKind, PointerInputScope, Size};
15use cranpose_ui_graphics::{Color, GraphicsLayer};
16use cranpose_ui_layout::Alignment;
17use std::cell::{Cell, RefCell};
18use std::rc::Rc;
19
20const ICON_BACKPLATE_DIAMETER_RATIO: f32 = 0.50;
21const ICON_BACKPLATE_GLYPH_RATIO: f32 = 0.28;
22
23/// Visual style of a [`GlassButton`].
24#[derive(Clone, Copy, Debug, PartialEq, Eq, Default)]
25pub enum GlassButtonStyle {
26    /// Translucent glass with the label in the accent color.
27    #[default]
28    Glass,
29    /// Accent-tinted glass with white content — the primary action.
30    Prominent,
31    /// No material: just the label with press feedback (toolbar/text button).
32    Plain,
33    /// Destructive variant of `Plain`.
34    Destructive,
35}
36
37/// Configuration for [`GlassButton`] / [`GlassIconButton`].
38#[derive(Clone, Debug, Default, PartialEq)]
39pub struct GlassButtonSpec {
40    pub style: GlassButtonStyle,
41    /// Overrides the material (advanced).
42    pub glass: Option<Glass>,
43    /// Overrides the label/icon color (defaults per style).
44    pub content_color: Option<Color>,
45    /// Optional inner color disc for icon buttons. The outer material remains
46    /// clear glass; the disc colors only the icon's compact foreground core.
47    pub icon_backplate: Option<Color>,
48}
49
50impl GlassButtonSpec {
51    pub fn glass() -> Self {
52        Self::default()
53    }
54
55    pub fn prominent() -> Self {
56        Self {
57            style: GlassButtonStyle::Prominent,
58            ..Self::default()
59        }
60    }
61
62    pub fn plain() -> Self {
63        Self {
64            style: GlassButtonStyle::Plain,
65            ..Self::default()
66        }
67    }
68
69    pub fn destructive() -> Self {
70        Self {
71            style: GlassButtonStyle::Destructive,
72            ..Self::default()
73        }
74    }
75
76    pub fn with_glass(mut self, glass: Glass) -> Self {
77        self.glass = Some(glass);
78        self
79    }
80
81    pub fn with_content_color(mut self, color: Color) -> Self {
82        self.content_color = Some(color);
83        self
84    }
85
86    pub fn with_icon_backplate(mut self, color: Color) -> Self {
87        self.icon_backplate = Some(color);
88        self
89    }
90
91    /// The label color for this style under `colors`.
92    pub fn content_color(&self, colors: &crate::theme::LiquidColors) -> Color {
93        if let Some(color) = self.content_color {
94            return color;
95        }
96        match self.style {
97            GlassButtonStyle::Glass => colors.accent,
98            GlassButtonStyle::Prominent => colors.on_accent,
99            GlassButtonStyle::Plain => colors.accent,
100            GlassButtonStyle::Destructive => colors.destructive,
101        }
102    }
103
104    /// The glyph color for icon buttons: the reference nav/search circles
105    /// draw BLACK glyphs on plain glass (only prominent tints stay white).
106    pub fn icon_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 | GlassButtonStyle::Plain => colors.label,
112            GlassButtonStyle::Prominent => colors.on_accent,
113            GlassButtonStyle::Destructive => colors.destructive,
114        }
115    }
116
117    fn resolve_material(
118        &self,
119        colors: &crate::theme::LiquidColors,
120        foreground: Color,
121    ) -> Option<Glass> {
122        if let Some(glass) = &self.glass {
123            return Some(if glass.foreground.is_some() {
124                glass.clone()
125            } else {
126                glass
127                    .clone()
128                    .adaptive_frost(foreground, glass.adaptive_frost)
129            });
130        }
131        match self.style {
132            GlassButtonStyle::Glass => Some(Glass::regular().adaptive_frost(foreground, 0.65)),
133            GlassButtonStyle::Prominent => Some(
134                Glass::regular()
135                    .tint(colors.accent.with_alpha(0.75))
136                    .adaptive_frost(foreground, 0.65),
137            ),
138            GlassButtonStyle::Plain | GlassButtonStyle::Destructive => None,
139        }
140    }
141}
142
143/// One action in a [`GlassIconButtonGroup`].
144#[derive(Clone)]
145pub struct GlassIconButtonGroupItem {
146    spec: GlassButtonSpec,
147    icon_path: &'static str,
148    content_description: String,
149    on_click: Rc<RefCell<dyn FnMut()>>,
150}
151
152impl PartialEq for GlassIconButtonGroupItem {
153    fn eq(&self, other: &Self) -> bool {
154        self.spec == other.spec
155            && self.icon_path == other.icon_path
156            && self.content_description == other.content_description
157            && Rc::ptr_eq(&self.on_click, &other.on_click)
158    }
159}
160
161impl GlassIconButtonGroupItem {
162    pub fn new(
163        icon_path: &'static str,
164        content_description: impl Into<String>,
165        on_click: impl FnMut() + 'static,
166    ) -> Self {
167        Self {
168            spec: GlassButtonSpec::glass(),
169            icon_path,
170            content_description: content_description.into(),
171            on_click: Rc::new(RefCell::new(on_click)),
172        }
173    }
174
175    pub fn with_spec(mut self, spec: GlassButtonSpec) -> Self {
176        self.spec = spec;
177        self
178    }
179}
180
181/// Geometry and interaction material for [`GlassIconButtonGroup`].
182#[derive(Clone, Copy, Debug, PartialEq)]
183pub struct GlassIconButtonGroupSpec {
184    diameter: f32,
185    spacing: f32,
186    pressed_scale: f32,
187    glue_radius: f32,
188}
189
190impl GlassIconButtonGroupSpec {
191    pub fn new(diameter: f32) -> Self {
192        Self {
193            diameter: diameter.max(1.0),
194            ..Self::default()
195        }
196    }
197
198    pub fn with_spacing(mut self, spacing: f32) -> Self {
199        self.spacing = spacing.max(0.0);
200        self
201    }
202
203    pub fn with_pressed_scale(mut self, pressed_scale: f32) -> Self {
204        self.pressed_scale = pressed_scale.max(1.0);
205        self
206    }
207
208    pub fn with_glue_radius(mut self, glue_radius: f32) -> Self {
209        self.glue_radius = glue_radius.max(0.0);
210        self
211    }
212}
213
214impl Default for GlassIconButtonGroupSpec {
215    fn default() -> Self {
216        Self {
217            diameter: 44.0,
218            spacing: 8.0,
219            pressed_scale: 1.45,
220            glue_radius: 12.0,
221        }
222    }
223}
224
225fn icon_group_width(count: usize, spec: GlassIconButtonGroupSpec) -> f32 {
226    if count == 0 {
227        0.0
228    } else {
229        spec.diameter * count as f32 + spec.spacing * count.saturating_sub(1) as f32
230    }
231}
232
233fn icon_group_item_at(
234    x: f32,
235    y: f32,
236    count: usize,
237    spec: GlassIconButtonGroupSpec,
238) -> Option<usize> {
239    if !(0.0..=spec.diameter).contains(&y) {
240        return None;
241    }
242    let pitch = spec.diameter + spec.spacing;
243    let index = (x / pitch).floor() as isize;
244    if index < 0 || index as usize >= count {
245        return None;
246    }
247    let local_x = x - index as f32 * pitch;
248    (local_x <= spec.diameter).then_some(index as usize)
249}
250
251fn icon_group_neighbor_shapes(
252    count: usize,
253    active: usize,
254    spec: GlassIconButtonGroupSpec,
255    pad: f32,
256    center_y: f32,
257) -> Vec<(f32, f32, f32, f32, f32)> {
258    let pitch = spec.diameter + spec.spacing;
259    let contact_diameter = spec.diameter * 0.36;
260    let contact_offset = spec.diameter * 0.38;
261    (0..count)
262        .filter(|index| index.abs_diff(active) == 1)
263        .map(|index| {
264            let toward_active = if index < active { 1.0 } else { -1.0 };
265            (
266                pad + index as f32 * pitch + spec.diameter * 0.5 + toward_active * contact_offset,
267                center_y,
268                contact_diameter,
269                contact_diameter,
270                -1.0,
271            )
272        })
273        .collect()
274}
275
276/// A glass button. `content` composes the label (see [`GlassButton`] with
277/// [`Text`], or an [`crate::icons::Icon`] + text row).
278#[composable]
279#[allow(non_snake_case)]
280pub fn GlassButton(
281    modifier: Modifier,
282    spec: GlassButtonSpec,
283    on_click: impl Fn() + 'static,
284    content: impl FnMut() + 'static,
285) {
286    let colors = liquid_colors();
287    let interaction = rememberMutableInteractionSource();
288    let (pressed_modifier, pressed, content_alpha) =
289        liquid_press_scale(Modifier::empty(), interaction.clone(), 1.18);
290
291    let material = spec.resolve_material(&colors, spec.content_color(&colors));
292    let mut base = Modifier::empty();
293    if let Some(glass) = material {
294        let pressed_for_glass = pressed;
295        // Held glass lights from WITHIN: the reference's pressed disc
296        // carries a luminous core (touched-up-state sheet), not a flat
297        // fill — the centered touch glow supplies the HDR heart while
298        // highlight_boost lifts the specular shell.
299        let glow_size = cranpose_core::remember(|| {
300            std::rc::Rc::new(std::cell::Cell::new(cranpose_ui_graphics::Size {
301                width: 0.0,
302                height: 0.0,
303            }))
304        })
305        .with(std::rc::Rc::clone);
306        let glow_size_for_glass = std::rc::Rc::clone(&glow_size);
307        base = base
308            .report_size(std::rc::Rc::clone(&glow_size))
309            .glass_effect_with(glass, move || {
310                let size = glow_size_for_glass.get();
311                GlassDynamics {
312                    highlight_boost: if pressed_for_glass.get() { 0.85 } else { 0.0 },
313                    touch: (pressed_for_glass.get() && size.width > 0.0 && size.height > 0.0)
314                        .then_some((size.width * 0.5, size.height * 0.5, 1.0)),
315                    ..Default::default()
316                }
317            });
318    }
319
320    let on_click = Rc::new(RefCell::new(on_click));
321    let base = base
322        .press_interaction_source(interaction)
323        .clickable(move |_point| {
324            default_haptics().perform(HapticFeedback::ImpactLight);
325            (on_click.borrow_mut())();
326        })
327        .padding_symmetric(16.0, 10.0);
328
329    // Touched glass turns more transparent: the label ghosts while pressed.
330    let content_layer = Modifier::empty().graphics_layer(move || GraphicsLayer {
331        alpha: content_alpha.get().clamp(0.0, 1.0),
332        ..Default::default()
333    });
334    let content = Rc::new(RefCell::new(content));
335    let button = base.then(modifier);
336    Box(pressed_modifier, BoxSpec::default(), move || {
337        let content = Rc::clone(&content);
338        let content_layer = content_layer.clone();
339        Box(
340            button.clone(),
341            BoxSpec::default().content_alignment(Alignment::CENTER),
342            move || {
343                let content = Rc::clone(&content);
344                Box(
345                    content_layer.clone(),
346                    BoxSpec::default().content_alignment(Alignment::CENTER),
347                    move || (content.borrow_mut())(),
348                );
349            },
350        );
351    });
352}
353
354/// Convenience text label styled for the enclosing button.
355#[composable]
356#[allow(non_snake_case)]
357pub fn GlassButtonLabel(text: impl Into<String>, spec: GlassButtonSpec) {
358    let typography = liquid_typography();
359    let color = spec.content_color(&liquid_colors());
360    let style = TextStyle {
361        span_style: cranpose_ui::text::SpanStyle {
362            color: Some(color),
363            ..typography.headline.span_style.clone()
364        },
365        ..typography.headline.clone()
366    };
367    Text(text.into(), Modifier::empty(), style);
368}
369
370#[composable]
371#[allow(non_snake_case)]
372pub(crate) fn GlassIconForeground(spec: GlassButtonSpec, diameter: f32, icon_path: &'static str) {
373    let colors = liquid_colors();
374    let icon_color = spec.icon_color(&colors);
375    if let Some(backplate) = spec.icon_backplate {
376        let backplate_diameter = diameter * ICON_BACKPLATE_DIAMETER_RATIO;
377        Box(
378            Modifier::empty()
379                .size(Size::new(backplate_diameter, backplate_diameter))
380                .draw_behind(move |scope| {
381                    scope.draw_circle(
382                        cranpose_ui_graphics::Brush::solid(backplate),
383                        cranpose_ui_graphics::Point::new(
384                            backplate_diameter * 0.5,
385                            backplate_diameter * 0.5,
386                        ),
387                        backplate_diameter * 0.5,
388                    );
389                }),
390            BoxSpec::default().content_alignment(Alignment::CENTER),
391            move || {
392                crate::icons::Icon(icon_path, diameter * ICON_BACKPLATE_GLYPH_RATIO, icon_color);
393            },
394        );
395    } else {
396        crate::icons::Icon(icon_path, diameter * 0.5, icon_color);
397    }
398}
399
400/// A circular glass icon button (44dp target).
401#[composable]
402#[allow(non_snake_case)]
403pub fn GlassIconButton(
404    modifier: Modifier,
405    spec: GlassButtonSpec,
406    diameter: f32,
407    on_click: impl Fn() + 'static,
408    icon_path: &'static str,
409) {
410    GlassIconButtonWithForegroundAlpha(modifier, spec, diameter, 1.0, on_click, icon_path);
411}
412
413#[composable]
414#[allow(non_snake_case)]
415pub(crate) fn GlassIconButtonWithForegroundAlpha(
416    modifier: Modifier,
417    spec: GlassButtonSpec,
418    diameter: f32,
419    foreground_alpha: f32,
420    on_click: impl Fn() + 'static,
421    icon_path: &'static str,
422) {
423    let colors = liquid_colors();
424    let interaction = rememberMutableInteractionSource();
425    let (pressed_modifier, pressed, content_alpha) =
426        liquid_press_scale(Modifier::empty(), interaction.clone(), 1.20);
427
428    let material = spec
429        .resolve_material(&colors, spec.icon_color(&colors))
430        .map(|glass| glass.shape(LiquidShape::Circle));
431    let mut base = Modifier::empty();
432    if let Some(glass) = material {
433        let pressed_for_glass = pressed;
434        base = base.glass_effect_with(glass, move || GlassDynamics {
435            highlight_boost: if pressed_for_glass.get() { 0.85 } else { 0.0 },
436            ..Default::default()
437        });
438    }
439
440    let on_click = Rc::new(RefCell::new(on_click));
441    let base = base
442        .press_interaction_source(interaction)
443        .clickable(move |_point| {
444            default_haptics().perform(HapticFeedback::ImpactLight);
445            (on_click.borrow_mut())();
446        })
447        .size(Size::new(diameter, diameter));
448
449    // The reference press: the icon ghosts while the glass lifts.
450    let content_layer = Modifier::empty().graphics_layer(move || GraphicsLayer {
451        alpha: content_alpha.get().clamp(0.0, 1.0) * foreground_alpha.clamp(0.0, 1.0),
452        ..Default::default()
453    });
454    let button = base.then(modifier);
455    Box(pressed_modifier, BoxSpec::default(), move || {
456        let foreground_spec = spec.clone();
457        let content_layer = content_layer.clone();
458        Box(
459            button.clone(),
460            BoxSpec::default().content_alignment(Alignment::CENTER),
461            move || {
462                let foreground_spec = foreground_spec.clone();
463                Box(
464                    content_layer.clone(),
465                    BoxSpec::default().content_alignment(Alignment::CENTER),
466                    move || GlassIconForeground(foreground_spec.clone(), diameter, icon_path),
467                );
468            },
469        );
470    });
471}
472
473/// A row of circular actions whose pressed glass can join adjacent members.
474/// Each member keeps its own base material and foreground; one transparent,
475/// persistent interaction field supplies the shared refraction and neck.
476#[composable]
477#[allow(non_snake_case)]
478pub fn GlassIconButtonGroup(
479    modifier: Modifier,
480    spec: GlassIconButtonGroupSpec,
481    items: Vec<GlassIconButtonGroupItem>,
482) {
483    let count = items.len();
484    if count == 0 {
485        return;
486    }
487
488    let colors = liquid_colors();
489    let live_items: Rc<RefCell<Vec<GlassIconButtonGroupItem>>> =
490        remember(|| Rc::new(RefCell::new(Vec::new()))).with(Rc::clone);
491    // A member that leaves the group dissolves in place instead of popping:
492    // the reference confirm disc turns translucent over the surviving "…"
493    // and is gone ~250 ms later (touched-up-state frames f_0350..f_0370).
494    let ghosts: Rc<RefCell<Vec<(GlassIconButtonGroupItem, usize)>>> =
495        remember(|| Rc::new(RefCell::new(Vec::new()))).with(Rc::clone);
496    let ghost_fade = remember(|| {
497        let runtime = cranpose_core::with_current_composer(|composer| composer.runtime_handle());
498        Rc::new(RefCell::new(cranpose_animation::Animatable::new(
499            0.0f32, runtime,
500        )))
501    })
502    .with(Rc::clone);
503    {
504        let previous = live_items.borrow();
505        if !previous.is_empty() && *previous != items {
506            let leavers: Vec<(GlassIconButtonGroupItem, usize)> = previous
507                .iter()
508                .enumerate()
509                .filter(|(_, member)| !items.contains(member))
510                .map(|(index, member)| (member.clone(), index))
511                .collect();
512            if !leavers.is_empty() {
513                *ghosts.borrow_mut() = leavers;
514                let mut fade = ghost_fade.borrow_mut();
515                fade.snapTo(1.0);
516                fade.animateTo(
517                    0.0,
518                    AnimationType::Tween(AnimationSpec::tween(250, Easing::EaseIn)),
519                );
520            }
521        }
522    }
523    let ghost_alpha = ghost_fade.borrow().state();
524    if !ghosts.borrow().is_empty() && ghost_alpha.get() <= 0.01 {
525        ghosts.borrow_mut().clear();
526    }
527    *live_items.borrow_mut() = items;
528    let render_items = live_items.borrow().clone();
529
530    let active = remember(|| mutableStateOf(None::<usize>)).with(|state| *state);
531    // The held disc rides the finger toward a neighbor (the reference press
532    // drifts and necks into the adjacent circle); None between gestures.
533    let drag_x = remember(|| mutableStateOf(None::<f32>)).with(|state| *state);
534    let last_active = remember(|| Rc::new(Cell::new(0usize))).with(Rc::clone);
535    if let Some(index) = active.get() {
536        last_active.set(index.min(count - 1));
537    }
538    let press_progress = cranpose_animation::animateFloatAsState(
539        if active.get().is_some() { 1.0 } else { 0.0 },
540        LiquidMotion::snappy(),
541        "glass-icon-group-press",
542    );
543
544    let width = icon_group_width(count, spec);
545    let gesture_items = Rc::clone(&live_items);
546    let gesture = Modifier::empty()
547        .size(Size::new(width, spec.diameter))
548        .pointer_input(count, move |scope: PointerInputScope| {
549            let gesture_items = Rc::clone(&gesture_items);
550            async move {
551                scope
552                    .await_pointer_event_scope(|await_scope| async move {
553                        let mut down_index = None::<usize>;
554                        loop {
555                            let event = await_scope.await_pointer_event().await;
556                            match event.kind {
557                                PointerEventKind::Down if down_index.is_none() => {
558                                    down_index = icon_group_item_at(
559                                        event.position.x,
560                                        event.position.y,
561                                        gesture_items.borrow().len(),
562                                        spec,
563                                    );
564                                    if let Some(index) = down_index {
565                                        active.set(Some(index));
566                                        drag_x.set(Some(event.position.x));
567                                        default_haptics().perform(HapticFeedback::Selection);
568                                        event.consume();
569                                    }
570                                }
571                                PointerEventKind::Move if down_index.is_some() => {
572                                    drag_x.set(Some(event.position.x));
573                                    event.consume();
574                                }
575                                PointerEventKind::Up => {
576                                    let pressed_index = down_index.take();
577                                    active.set(None);
578                                    drag_x.set(None);
579                                    if let Some(index) = pressed_index {
580                                        let released_index = icon_group_item_at(
581                                            event.position.x,
582                                            event.position.y,
583                                            gesture_items.borrow().len(),
584                                            spec,
585                                        );
586                                        if released_index == Some(index) {
587                                            let on_click = gesture_items
588                                                .borrow()
589                                                .get(index)
590                                                .map(|item| Rc::clone(&item.on_click));
591                                            if let Some(on_click) = on_click {
592                                                default_haptics()
593                                                    .perform(HapticFeedback::ImpactLight);
594                                                (on_click.borrow_mut())();
595                                            }
596                                        }
597                                        event.consume();
598                                    }
599                                }
600                                PointerEventKind::Cancel if down_index.take().is_some() => {
601                                    active.set(None);
602                                    drag_x.set(None);
603                                    event.consume();
604                                }
605                                _ => {}
606                            }
607                        }
608                    })
609                    .await;
610            }
611        })
612        .then(modifier);
613
614    Box(gesture, BoxSpec::default(), move || {
615        let pitch = spec.diameter + spec.spacing;
616        let active_index = last_active.get().min(count - 1);
617
618        // The union field owns the connection and shared refraction. It sits
619        // behind the item faces, and while pressed it carries the ACTIVE
620        // item's tint so the neck toward a neighbor flows in that material
621        // (the reference bridge is the confirm action's cyan, not plain
622        // glass); the tint alpha rides press activity, so the resting field
623        // stays a whisper.
624        let pad = spec.glue_radius + spec.diameter * (spec.pressed_scale - 1.0) * 0.5 + 4.0;
625        let node_width = width + pad * 2.0;
626        let node_height = spec.diameter * spec.pressed_scale + pad * 2.0;
627        let shared_progress = press_progress;
628        let shared_last_active = Rc::clone(&last_active);
629        let union_tint = render_items
630            .get(active_index)
631            .and_then(|item| {
632                item.spec
633                    .resolve_material(&colors, item.spec.icon_color(&colors))
634            })
635            .and_then(|material| material.tint)
636            .map(|tint| tint.with_alpha(0.45))
637            .unwrap_or(Color::WHITE.with_alpha(0.035));
638        let shared = Modifier::empty()
639            .required_size(Size::new(node_width, node_height))
640            .offset(-pad, (spec.diameter - node_height) * 0.5)
641            .glass_effect_with(
642                // Regular variant: the tint covers the whole union field
643                // uniformly, so the thin neck carries the material — the
644                // lens variant's interior ramp fades tint exactly there.
645                Glass::regular()
646                    .blur_radius(0.0)
647                    .tint(union_tint)
648                    .lift(0.0)
649                    .highlight(0.48)
650                    .shadow(false)
651                    .no_clip(),
652                move || {
653                    let progress = shared_progress.get().clamp(0.0, 1.0);
654                    let active_index = shared_last_active.get().min(count - 1);
655                    let center_y = node_height * 0.5;
656                    let rest_center = active_index as f32 * pitch + spec.diameter * 0.5;
657                    // Ride the finger toward a neighbor, one pitch at most:
658                    // approaching the next circle necks the union field.
659                    // Half a pitch keeps the neighbor distinct: the disc
660                    // leans and the glue stretches a thin neck instead of
661                    // the shapes overlapping outright.
662                    let ridden = drag_x
663                        .get()
664                        .map(|x| x.clamp(rest_center - pitch * 0.35, rest_center + pitch * 0.35))
665                        .unwrap_or(rest_center);
666                    let center_x = pad + rest_center + (ridden - rest_center) * progress;
667                    let diameter = spec.diameter * (1.0 + (spec.pressed_scale - 1.0) * progress);
668                    // The reference press concentrates saturation + a soft
669                    // gradient light under the FINGER (touched-up-state
670                    // recording), riding press activity.
671                    let touch = drag_x.get().map(|x| {
672                        (
673                            pad + x.clamp(0.0, node_width - 2.0 * pad),
674                            center_y,
675                            progress,
676                        )
677                    });
678                    GlassDynamics {
679                        activity: Some(progress),
680                        touch,
681                        morph: Some(GlassMorph {
682                            node_size: (node_width, node_height),
683                            primary: (center_x, center_y, diameter, diameter, -1.0),
684                            shapes: icon_group_neighbor_shapes(
685                                count,
686                                active_index,
687                                spec,
688                                pad,
689                                center_y,
690                            ),
691                            glue: spec.glue_radius * progress,
692                            ..Default::default()
693                        }),
694                        ..Default::default()
695                    }
696                },
697            );
698        Box(shared, BoxSpec::default(), || {});
699
700        // Independent base surfaces preserve each member's style and tint.
701        for (index, item) in render_items.iter().enumerate() {
702            let x = index as f32 * pitch;
703            let surface_progress = press_progress;
704            let item_is_active = index == active_index;
705            let outer = Modifier::empty()
706                .size(Size::new(spec.diameter, spec.diameter))
707                .offset(x, 0.0);
708            let rest_center = index as f32 * pitch + spec.diameter * 0.5;
709            let scale_layer = Modifier::empty().graphics_layer(move || {
710                let progress = if item_is_active {
711                    surface_progress.get().clamp(0.0, 1.0)
712                } else {
713                    0.0
714                };
715                let scale = 1.0 + (spec.pressed_scale - 1.0) * progress;
716                let ridden = if item_is_active {
717                    drag_x
718                        .get()
719                        .map(|x| x.clamp(rest_center - pitch * 0.35, rest_center + pitch * 0.35))
720                        .unwrap_or(rest_center)
721                } else {
722                    rest_center
723                };
724                GraphicsLayer {
725                    translation_x: (ridden - rest_center) * progress,
726                    scale_x: scale,
727                    scale_y: scale,
728                    ..Default::default()
729                }
730            });
731            let mut surface = Modifier::empty().size(Size::new(spec.diameter, spec.diameter));
732            if let Some(material) = item
733                .spec
734                .resolve_material(&colors, item.spec.icon_color(&colors))
735            {
736                let surface_progress = press_progress;
737                surface =
738                    surface.glass_effect_with(material.shape(LiquidShape::Circle), move || {
739                        let progress = surface_progress.get().clamp(0.0, 1.0);
740                        // The reference touch-up is an HDR-like lift of the
741                        // SAME material — highlight and saturation surge, no
742                        // recolor (tint stays put).
743                        GlassDynamics {
744                            highlight_boost: if item_is_active { 0.60 * progress } else { 0.0 },
745                            saturation_boost: if item_is_active { 0.85 * progress } else { 0.0 },
746                            ..Default::default()
747                        }
748                    });
749            }
750            Box(outer, BoxSpec::default(), move || {
751                let surface = surface.clone();
752                Box(scale_layer.clone(), BoxSpec::default(), move || {
753                    Box(surface.clone(), BoxSpec::default(), || {});
754                });
755            });
756        }
757
758        // The active foreground is absorbed into the rising material. Other
759        // members stay crisp and retain their independent ownership.
760        for (index, item) in render_items.iter().enumerate() {
761            let x = index as f32 * pitch;
762            let item_is_active = index == active_index;
763            let foreground_progress = press_progress;
764            let foreground_spec = item.spec.clone();
765            let icon_path = item.icon_path;
766            let description = item.content_description.clone();
767            let foreground = Modifier::empty()
768                .size(Size::new(spec.diameter, spec.diameter))
769                .offset(x, 0.0)
770                .graphics_layer(move || GraphicsLayer {
771                    alpha: if item_is_active {
772                        1.0 - foreground_progress.get().clamp(0.0, 1.0)
773                    } else {
774                        1.0
775                    },
776                    ..Default::default()
777                })
778                .semantics(move |config| {
779                    config.is_button = true;
780                    config.is_clickable = true;
781                    config.content_description = Some(description.clone());
782                });
783            Box(
784                foreground,
785                BoxSpec::default().content_alignment(Alignment::CENTER),
786                move || {
787                    GlassIconForeground(foreground_spec.clone(), spec.diameter, icon_path);
788                },
789            );
790        }
791
792        // Departed members: the same surface + glyph at their old slot,
793        // riding one shared fade to transparent. No gestures, no union
794        // membership — a purely optical afterimage.
795        for (ghost, ghost_index) in ghosts.borrow().iter() {
796            let x = *ghost_index as f32 * pitch;
797            let fade = ghost_alpha;
798            let ghost_layer = Modifier::empty()
799                .size(Size::new(spec.diameter, spec.diameter))
800                .offset(x, 0.0)
801                .graphics_layer(move || GraphicsLayer {
802                    alpha: fade.get().clamp(0.0, 1.0),
803                    ..Default::default()
804                });
805            let ghost_spec = ghost.spec.clone();
806            let ghost_icon = ghost.icon_path;
807            let surface = ghost
808                .spec
809                .resolve_material(&colors, ghost.spec.icon_color(&colors))
810                .map(|material| {
811                    Modifier::empty()
812                        .size(Size::new(spec.diameter, spec.diameter))
813                        .glass_effect(material.shape(LiquidShape::Circle))
814                });
815            Box(
816                ghost_layer,
817                BoxSpec::default().content_alignment(Alignment::CENTER),
818                move || {
819                    if let Some(surface) = surface.clone() {
820                        Box(surface, BoxSpec::default(), || {});
821                    }
822                    GlassIconForeground(ghost_spec.clone(), spec.diameter, ghost_icon);
823                },
824            );
825        }
826    });
827}
828
829#[cfg(test)]
830mod tests {
831    use super::*;
832
833    #[test]
834    fn icon_backplate_colors_only_the_compact_foreground_core() {
835        let blue = Color::from_rgb_u8(0, 122, 255);
836        let spec = GlassButtonSpec::glass()
837            .with_icon_backplate(blue)
838            .with_content_color(Color::WHITE);
839        assert_eq!(spec.style, GlassButtonStyle::Glass);
840        assert_eq!(spec.icon_backplate, Some(blue));
841        assert_eq!(spec.content_color, Some(Color::WHITE));
842        assert!(spec.glass.is_none());
843        assert!((0.49..=0.51).contains(&ICON_BACKPLATE_DIAMETER_RATIO));
844        assert!((0.27..=0.29).contains(&ICON_BACKPLATE_GLYPH_RATIO));
845
846        let colors = crate::theme::LiquidColors::light(blue);
847        let material = GlassButtonSpec::glass()
848            .resolve_material(&colors, colors.label)
849            .expect("glass button material");
850        assert_eq!(material.tint, None);
851        assert_eq!(material.resolve(&colors).tint, colors.glass_tint);
852    }
853
854    #[test]
855    fn neutral_button_tint_comes_from_the_theme_not_foreground_polarity() {
856        let accent = Color::from_rgb_u8(0, 122, 255);
857        for colors in [
858            crate::theme::LiquidColors::light(accent),
859            crate::theme::LiquidColors::dark(accent),
860        ] {
861            let material = GlassButtonSpec::glass()
862                .resolve_material(&colors, colors.label)
863                .expect("glass button material");
864            assert_eq!(material.tint, None);
865            assert_eq!(material.resolve(&colors).tint, colors.glass_tint);
866        }
867    }
868
869    #[test]
870    fn icon_button_group_builders_and_hit_regions_preserve_member_gaps() {
871        let spec = GlassIconButtonGroupSpec::new(44.0)
872            .with_spacing(8.0)
873            .with_pressed_scale(1.2)
874            .with_glue_radius(12.0);
875        assert_eq!(icon_group_width(2, spec), 96.0);
876        assert_eq!(icon_group_item_at(22.0, 22.0, 2, spec), Some(0));
877        assert_eq!(icon_group_item_at(48.0, 22.0, 2, spec), None);
878        assert_eq!(icon_group_item_at(74.0, 22.0, 2, spec), Some(1));
879        assert_eq!(icon_group_item_at(22.0, 50.0, 2, spec), None);
880
881        let shapes = icon_group_neighbor_shapes(4, 2, spec, 16.0, 38.0);
882        assert_eq!(shapes.len(), 2);
883        assert!((shapes[0].0 - (16.0 + 52.0 + 22.0 + 44.0 * 0.38)).abs() < 1e-5);
884        assert!((shapes[1].0 - (16.0 + 156.0 + 22.0 - 44.0 * 0.38)).abs() < 1e-5);
885        assert_eq!(shapes[0].2, 44.0 * 0.36);
886
887        let item = GlassIconButtonGroupItem::new("M0 0", "Confirm", || {})
888            .with_spec(GlassButtonSpec::prominent());
889        assert_eq!(item.content_description, "Confirm");
890        assert_eq!(item.spec.style, GlassButtonStyle::Prominent);
891    }
892}