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