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