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                let press = if pressed_for_glass.get() { 1.0 } else { 0.0 };
337                let dynamics = GlassDynamics::default();
338                if size.width > 0.0 && size.height > 0.0 {
339                    dynamics.touched_up(press, None, (size.width * 0.5, size.height * 0.5))
340                } else {
341                    dynamics
342                }
343            });
344    }
345
346    let on_click = Rc::new(RefCell::new(on_click));
347    let base = with_button_semantics(
348        base.press_interaction_source(interaction)
349            .clickable(move |_point| {
350                default_haptics().perform(HapticFeedback::ImpactLight);
351                (on_click.borrow_mut())();
352            })
353            .padding_symmetric(16.0, 10.0),
354    );
355
356    // Touched glass turns more transparent: the label ghosts while pressed.
357    let content_layer = Modifier::empty().graphics_layer(move || GraphicsLayer {
358        alpha: content_alpha.get().clamp(0.0, 1.0),
359        ..Default::default()
360    });
361    let content = Rc::new(RefCell::new(content));
362    let button = base.then(modifier);
363    Box(pressed_modifier, BoxSpec::default(), move || {
364        let content = Rc::clone(&content);
365        let content_layer = content_layer.clone();
366        Box(
367            button.clone(),
368            BoxSpec::default().content_alignment(Alignment::CENTER),
369            move || {
370                let content = Rc::clone(&content);
371                Box(
372                    content_layer.clone(),
373                    BoxSpec::default().content_alignment(Alignment::CENTER),
374                    move || (content.borrow_mut())(),
375                );
376            },
377        );
378    });
379}
380
381/// Convenience text label styled for the enclosing button.
382#[composable]
383#[allow(non_snake_case)]
384pub fn GlassButtonLabel(text: impl Into<String>, spec: GlassButtonSpec) {
385    let typography = liquid_typography();
386    let color = spec.content_color(&liquid_colors());
387    let style = TextStyle {
388        span_style: cranpose_ui::text::SpanStyle {
389            color: Some(color),
390            ..typography.headline.span_style.clone()
391        },
392        ..typography.headline.clone()
393    };
394    Text(text.into(), Modifier::empty(), style);
395}
396
397#[composable]
398#[allow(non_snake_case)]
399pub(crate) fn GlassIconForeground(spec: GlassButtonSpec, diameter: f32, icon_path: &'static str) {
400    let colors = liquid_colors();
401    let icon_color = spec.icon_color(&colors);
402    if let Some(backplate) = spec.icon_backplate {
403        let backplate_diameter = diameter * ICON_BACKPLATE_DIAMETER_RATIO;
404        Box(
405            Modifier::empty()
406                .size(Size::new(backplate_diameter, backplate_diameter))
407                .draw_behind(move |scope| {
408                    scope.draw_circle(
409                        cranpose_ui_graphics::Brush::solid(backplate),
410                        cranpose_ui_graphics::Point::new(
411                            backplate_diameter * 0.5,
412                            backplate_diameter * 0.5,
413                        ),
414                        backplate_diameter * 0.5,
415                    );
416                }),
417            BoxSpec::default().content_alignment(Alignment::CENTER),
418            move || {
419                crate::icons::Icon(icon_path, diameter * ICON_BACKPLATE_GLYPH_RATIO, icon_color);
420            },
421        );
422    } else {
423        crate::icons::Icon(icon_path, diameter * 0.5, icon_color);
424    }
425}
426
427/// A circular glass icon button (44dp target).
428#[composable]
429#[allow(non_snake_case)]
430pub fn GlassIconButton(
431    modifier: Modifier,
432    spec: GlassButtonSpec,
433    diameter: f32,
434    on_click: impl Fn() + 'static,
435    icon_path: &'static str,
436) {
437    GlassIconButtonWithForegroundAlpha(modifier, spec, diameter, 1.0, on_click, icon_path);
438}
439
440#[composable]
441#[allow(non_snake_case)]
442pub(crate) fn GlassIconButtonWithForegroundAlpha(
443    modifier: Modifier,
444    spec: GlassButtonSpec,
445    diameter: f32,
446    foreground_alpha: f32,
447    on_click: impl Fn() + 'static,
448    icon_path: &'static str,
449) {
450    let colors = liquid_colors();
451    let interaction = rememberMutableInteractionSource();
452    let (pressed_modifier, pressed, content_alpha) =
453        liquid_press_scale(Modifier::empty(), interaction.clone(), 1.20);
454
455    let material = spec
456        .resolve_material(&colors, spec.icon_color(&colors))
457        .map(|glass| glass.shape(LiquidShape::Circle));
458    let mut base = Modifier::empty();
459    if let Some(glass) = material {
460        let pressed_for_glass = pressed;
461        let half = diameter * 0.5;
462        base = base.glass_effect_with(glass, move || {
463            let press = if pressed_for_glass.get() { 1.0 } else { 0.0 };
464            GlassDynamics::default().touched_up(press, None, (half, half))
465        });
466    }
467
468    let on_click = Rc::new(RefCell::new(on_click));
469    let base = base
470        .press_interaction_source(interaction)
471        .clickable(move |_point| {
472            default_haptics().perform(HapticFeedback::ImpactLight);
473            (on_click.borrow_mut())();
474        })
475        .size(Size::new(diameter, diameter));
476
477    // The reference press: the icon ghosts while the glass lifts.
478    let content_layer = Modifier::empty().graphics_layer(move || GraphicsLayer {
479        alpha: content_alpha.get().clamp(0.0, 1.0) * foreground_alpha.clamp(0.0, 1.0),
480        ..Default::default()
481    });
482    let button = base.then(modifier);
483    Box(pressed_modifier, BoxSpec::default(), move || {
484        let foreground_spec = spec.clone();
485        let content_layer = content_layer.clone();
486        Box(
487            button.clone(),
488            BoxSpec::default().content_alignment(Alignment::CENTER),
489            move || {
490                let foreground_spec = foreground_spec.clone();
491                Box(
492                    content_layer.clone(),
493                    BoxSpec::default().content_alignment(Alignment::CENTER),
494                    move || GlassIconForeground(foreground_spec.clone(), diameter, icon_path),
495                );
496            },
497        );
498    });
499}
500
501/// A row of circular actions whose pressed glass can join adjacent members.
502/// Each member keeps its own base material and foreground; one transparent,
503/// persistent interaction field supplies the shared refraction and neck.
504#[composable]
505#[allow(non_snake_case)]
506pub fn GlassIconButtonGroup(
507    modifier: Modifier,
508    spec: GlassIconButtonGroupSpec,
509    items: Vec<GlassIconButtonGroupItem>,
510) {
511    let count = items.len();
512    if count == 0 {
513        return;
514    }
515
516    let colors = liquid_colors();
517    let live_items: Rc<RefCell<Vec<GlassIconButtonGroupItem>>> =
518        remember(|| Rc::new(RefCell::new(Vec::new()))).with(Rc::clone);
519    // A member that leaves the group dissolves in place instead of popping:
520    // the reference confirm disc turns translucent over the surviving "…"
521    // and is gone ~250 ms later (touched-up-state frames f_0350..f_0370).
522    let ghosts: Rc<RefCell<Vec<(GlassIconButtonGroupItem, usize, f32)>>> =
523        remember(|| Rc::new(RefCell::new(Vec::new()))).with(Rc::clone);
524    let ghost_fade = remember(|| {
525        let runtime = cranpose_core::with_current_composer(|composer| composer.runtime_handle());
526        Rc::new(RefCell::new(cranpose_animation::Animatable::new(
527            0.0f32, runtime,
528        )))
529    })
530    .with(Rc::clone);
531    let active = remember(|| mutableStateOf(None::<usize>)).with(|state| *state);
532    // The held disc rides the finger toward a neighbor (the reference press
533    // drifts and necks into the adjacent circle); None between gestures.
534    let drag_x = remember(|| mutableStateOf(None::<f32>)).with(|state| *state);
535    let last_active = remember(|| Rc::new(Cell::new(0usize))).with(Rc::clone);
536    if let Some(index) = active.get() {
537        last_active.set(index.min(count - 1));
538    }
539    let press_progress = cranpose_animation::animateFloatAsState(
540        if active.get().is_some() { 1.0 } else { 0.0 },
541        LiquidMotion::snappy(),
542        "glass-icon-group-press",
543    );
544    // Set when the pressed member departs mid-settle: its ghost carries the
545    // swollen visual out, and the live members render unpressed.
546    let press_orphaned = remember(|| Rc::new(Cell::new(false))).with(Rc::clone);
547
548    {
549        // Membership identity is SEMANTIC (icon + description): callers
550        // rebuild the item vec with fresh callback Rcs every recomposition,
551        // and the pointer-based PartialEq made every member read as a
552        // "leaver" each frame — the real ghost got discarded one frame
553        // after its birth.
554        let member_stays = |member: &GlassIconButtonGroupItem| {
555            items.iter().any(|item| {
556                item.icon_path == member.icon_path
557                    && item.content_description == member.content_description
558            })
559        };
560        let previous = live_items.borrow();
561        if !previous.is_empty() {
562            // A departing member dissolves from its CURRENT pose: the
563            // reference confirm disc blurs out straight from the swollen
564            // ball (touched-up f_0355..f_0370) and never re-forms the rest
565            // button, so each ghost carries the press progress it left with.
566            let leavers: Vec<(GlassIconButtonGroupItem, usize, f32)> = previous
567                .iter()
568                .enumerate()
569                .filter(|(_, member)| !member_stays(member))
570                .map(|(index, member)| {
571                    let departure = if index == last_active.get() {
572                        // The press dies with its member: the live survivor
573                        // must not inherit the swell the ghost carries out.
574                        press_orphaned.set(true);
575                        press_progress.get().clamp(0.0, 1.0)
576                    } else {
577                        0.0
578                    };
579                    (member.clone(), index, departure)
580                })
581                .collect();
582            if !leavers.is_empty() {
583                *ghosts.borrow_mut() = leavers;
584                let mut fade = ghost_fade.borrow_mut();
585                fade.snapTo(1.0);
586                fade.animateTo(
587                    0.0,
588                    AnimationType::Tween(AnimationSpec::tween(250, Easing::EaseIn)),
589                );
590            }
591        }
592    }
593    let ghost_alpha = ghost_fade.borrow().state();
594    if !ghosts.borrow().is_empty() && ghost_alpha.get() <= 0.01 {
595        ghosts.borrow_mut().clear();
596    }
597    *live_items.borrow_mut() = items;
598    let render_items = live_items.borrow().clone();
599
600    // A dissolving ghost keeps its layout slot: without it the narrower
601    // group recenters instantly and the ghost renders outside the node (the
602    // dissolve vanished in one frame). The reference "…" stays put while
603    // the ball fades (touched-up f_0360..f_0375).
604    let ghost_slots = ghosts
605        .borrow()
606        .iter()
607        .map(|(_, index, _)| index + 1)
608        .max()
609        .unwrap_or(0);
610    let layout_count = count.max(ghost_slots);
611    // The collapse is a MERGE (reference f_0350..f_0390): as the departed
612    // ball dissolves, the group's span contracts from the old slot count to
613    // the new one — in the reference's trailing-anchored toolbar the
614    // survivor glides INTO the fading ball and the two fuse into one
615    // circle where the ball stood. Ghosts anchor to the TRAILING edge so
616    // the ball holds its screen position while the span contracts under a
617    // trailing-aligned host.
618    let merge = if layout_count > count {
619        (1.0 - ghost_alpha.get().clamp(0.0, 1.0)).clamp(0.0, 1.0)
620    } else {
621        0.0
622    };
623    let full_width = icon_group_width(layout_count, spec);
624    let settled_width = icon_group_width(count, spec);
625    let width = settled_width + (full_width - settled_width) * (1.0 - merge);
626    let ghost_trailing_shift = width - full_width;
627    let gesture_items = Rc::clone(&live_items);
628    let gesture_press_orphaned = Rc::clone(&press_orphaned);
629    let gesture = Modifier::empty()
630        .size(Size::new(width, spec.diameter))
631        .pointer_input(count, move |scope: PointerInputScope| {
632            let gesture_items = Rc::clone(&gesture_items);
633            let gesture_press_orphaned = Rc::clone(&gesture_press_orphaned);
634            async move {
635                scope
636                    .await_pointer_event_scope(|await_scope| async move {
637                        let mut down_index = None::<usize>;
638                        loop {
639                            let event = await_scope.await_pointer_event().await;
640                            match event.kind {
641                                PointerEventKind::Down if down_index.is_none() => {
642                                    down_index = icon_group_item_at(
643                                        event.position.x,
644                                        event.position.y,
645                                        gesture_items.borrow().len(),
646                                        spec,
647                                    );
648                                    if let Some(index) = down_index {
649                                        active.set(Some(index));
650                                        drag_x.set(Some(event.position.x));
651                                        gesture_press_orphaned.set(false);
652                                        default_haptics().perform(HapticFeedback::Selection);
653                                        event.consume();
654                                    }
655                                }
656                                PointerEventKind::Move if down_index.is_some() => {
657                                    drag_x.set(Some(event.position.x));
658                                    event.consume();
659                                }
660                                PointerEventKind::Up => {
661                                    let pressed_index = down_index.take();
662                                    active.set(None);
663                                    drag_x.set(None);
664                                    if let Some(index) = pressed_index {
665                                        // The gesture belongs to the PRESSED
666                                        // member: any release inside the
667                                        // control commits it (the reference
668                                        // hold necks toward the neighbor and
669                                        // still confirms on release — the
670                                        // ride clamp means the ball never
671                                        // leaves its member anyway). Only
672                                        // exiting the control cancels.
673                                        let count = gesture_items.borrow().len();
674                                        let released_inside = (-TAP_EXIT_SLOP
675                                            ..=spec.diameter + TAP_EXIT_SLOP)
676                                            .contains(&event.position.y)
677                                            && (-TAP_EXIT_SLOP
678                                                ..=icon_group_width(count, spec) + TAP_EXIT_SLOP)
679                                                .contains(&event.position.x);
680                                        if released_inside {
681                                            let on_click = gesture_items
682                                                .borrow()
683                                                .get(index)
684                                                .map(|item| Rc::clone(&item.on_click));
685                                            if let Some(on_click) = on_click {
686                                                default_haptics()
687                                                    .perform(HapticFeedback::ImpactLight);
688                                                (on_click.borrow_mut())();
689                                            }
690                                        }
691                                        event.consume();
692                                    }
693                                }
694                                PointerEventKind::Cancel if down_index.take().is_some() => {
695                                    active.set(None);
696                                    drag_x.set(None);
697                                    event.consume();
698                                }
699                                _ => {}
700                            }
701                        }
702                    })
703                    .await;
704            }
705        })
706        .then(modifier);
707
708    Box(gesture, BoxSpec::default(), move || {
709        let pitch = spec.diameter + spec.spacing;
710        let active_index = last_active.get().min(count - 1);
711
712        // The union field owns the connection and shared refraction. It sits
713        // behind the item faces, and while pressed it carries the ACTIVE
714        // item's tint so the neck toward a neighbor flows in that material
715        // (the reference bridge is the confirm action's cyan, not plain
716        // glass); the tint alpha rides press activity, so the resting field
717        // stays a whisper.
718        let pad = spec.glue_radius + spec.diameter * (spec.pressed_scale - 1.0) * 0.5 + 4.0;
719        let node_width = width + pad * 2.0;
720        let node_height = spec.diameter * spec.pressed_scale + pad * 2.0;
721        let shared_progress = press_progress;
722        let shared_orphaned = Rc::clone(&press_orphaned);
723        let shared_last_active = Rc::clone(&last_active);
724        let union_tint = render_items
725            .get(active_index)
726            .and_then(|item| {
727                item.spec
728                    .resolve_material(&colors, item.spec.icon_color(&colors))
729            })
730            .and_then(|material| material.tint)
731            // The faces cover their own discs, so this alpha only ever
732            // shows in the BRIDGE between them — the reference neck is a
733            // solid run of the ball's cyan (f_0280), not a faint haze.
734            .map(|tint| tint.with_alpha(0.85))
735            .unwrap_or(Color::WHITE.with_alpha(0.035));
736        let shared = Modifier::empty()
737            .required_size(Size::new(node_width, node_height))
738            .offset(-pad, (spec.diameter - node_height) * 0.5)
739            .glass_effect_with(
740                // Regular variant: the tint covers the whole union field
741                // uniformly, so the thin neck carries the material — the
742                // lens variant's interior ramp fades tint exactly there.
743                Glass::regular()
744                    .blur_radius(0.0)
745                    .tint(union_tint)
746                    .lift(0.0)
747                    .highlight(0.48)
748                    .shadow(false)
749                    .no_clip(),
750                move || {
751                    let progress = if shared_orphaned.get() {
752                        0.0
753                    } else {
754                        shared_progress.get().clamp(0.0, 1.0)
755                    };
756                    let active_index = shared_last_active.get().min(count - 1);
757                    let center_y = node_height * 0.5;
758                    let rest_center = active_index as f32 * pitch + spec.diameter * 0.5;
759                    // Ride the finger toward a neighbor, one pitch at most:
760                    // approaching the next circle necks the union field.
761                    // Half a pitch keeps the neighbor distinct: the disc
762                    // leans and the glue stretches a thin neck instead of
763                    // the shapes overlapping outright.
764                    let ridden = drag_x
765                        .get()
766                        .map(|x| x.clamp(rest_center - pitch * 0.35, rest_center + pitch * 0.35))
767                        .unwrap_or(rest_center);
768                    let center_x = pad + rest_center + (ridden - rest_center) * progress;
769                    let diameter = spec.diameter * (1.0 + (spec.pressed_scale - 1.0) * progress);
770                    // The reference press concentrates saturation + a soft
771                    // gradient light under the FINGER (touched-up-state
772                    // recording), riding press activity.
773                    let touch = drag_x.get().map(|x| {
774                        (
775                            pad + x.clamp(0.0, node_width - 2.0 * pad),
776                            center_y,
777                            progress,
778                        )
779                    });
780                    GlassDynamics {
781                        activity: Some(progress),
782                        touch,
783                        morph: Some(GlassMorph {
784                            node_size: (node_width, node_height),
785                            primary: (center_x, center_y, diameter, diameter, -1.0),
786                            shapes: icon_group_neighbor_shapes(
787                                count,
788                                active_index,
789                                spec,
790                                pad,
791                                center_y,
792                            ),
793                            glue: spec.glue_radius * progress,
794                            ..Default::default()
795                        }),
796                        ..Default::default()
797                    }
798                },
799            );
800        Box(shared, BoxSpec::default(), || {});
801
802        // Independent base surfaces preserve each member's style and tint.
803        for (index, item) in render_items.iter().enumerate() {
804            let x = index as f32 * pitch;
805            let surface_progress = press_progress;
806            let item_is_active = index == active_index;
807            let scale_orphaned = Rc::clone(&press_orphaned);
808            let outer = Modifier::empty()
809                .size(Size::new(spec.diameter, spec.diameter))
810                .offset(x, 0.0);
811            let rest_center = index as f32 * pitch + spec.diameter * 0.5;
812            let scale_layer = Modifier::empty().graphics_layer(move || {
813                let progress = if item_is_active && !scale_orphaned.get() {
814                    surface_progress.get().clamp(0.0, 1.0)
815                } else {
816                    0.0
817                };
818                let scale = 1.0 + (spec.pressed_scale - 1.0) * progress;
819                let ridden = if item_is_active {
820                    drag_x
821                        .get()
822                        .map(|x| x.clamp(rest_center - pitch * 0.35, rest_center + pitch * 0.35))
823                        .unwrap_or(rest_center)
824                } else {
825                    rest_center
826                };
827                GraphicsLayer {
828                    translation_x: (ridden - rest_center) * progress,
829                    scale_x: scale,
830                    scale_y: scale,
831                    ..Default::default()
832                }
833            });
834            let mut surface = Modifier::empty().size(Size::new(spec.diameter, spec.diameter));
835            if let Some(material) = item
836                .spec
837                .resolve_material(&colors, item.spec.icon_color(&colors))
838            {
839                let surface_progress = press_progress;
840                let dynamics_orphaned = Rc::clone(&press_orphaned);
841                surface =
842                    surface.glass_effect_with(material.shape(LiquidShape::Circle), move || {
843                        let progress = if dynamics_orphaned.get() {
844                            0.0
845                        } else {
846                            surface_progress.get().clamp(0.0, 1.0)
847                        };
848                        // The reference touch-up is an HDR-like lift of the
849                        // SAME material — highlight and saturation surge, no
850                        // recolor (tint stays put). The held ball densifies
851                        // toward the flat saturated accent (touched-up-state
852                        // f_0285: near-pure electric cyan, glyph dissolved).
853                        GlassDynamics {
854                            highlight_boost: if item_is_active { 0.60 * progress } else { 0.0 },
855                            saturation_boost: if item_is_active { 1.6 * progress } else { 0.0 },
856                            tint_alpha_multiplier: item_is_active.then_some(1.0 + 0.85 * progress),
857                            ..Default::default()
858                        }
859                    });
860            }
861            Box(outer, BoxSpec::default(), move || {
862                let surface = surface.clone();
863                Box(scale_layer.clone(), BoxSpec::default(), move || {
864                    Box(surface.clone(), BoxSpec::default(), || {});
865                });
866            });
867        }
868
869        // The active foreground is absorbed into the rising material. Other
870        // members stay crisp and retain their independent ownership.
871        for (index, item) in render_items.iter().enumerate() {
872            let x = index as f32 * pitch;
873            let item_is_active = index == active_index;
874            let foreground_progress = press_progress;
875            let foreground_orphaned = Rc::clone(&press_orphaned);
876            let foreground_spec = item.spec.clone();
877            let icon_path = item.icon_path;
878            let description = item.content_description.clone();
879            // During a merge the surviving glyph rides out early and
880            // rematerializes once fused (reference: the "…" dots are gone
881            // by f_0356 and sharpen back f_0372+).
882            let merge_alpha = merge_glyph_alpha(merge);
883            let foreground = Modifier::empty()
884                .size(Size::new(spec.diameter, spec.diameter))
885                .offset(x, 0.0)
886                .graphics_layer(move || GraphicsLayer {
887                    alpha: merge_alpha
888                        * if item_is_active && !foreground_orphaned.get() {
889                            1.0 - foreground_progress.get().clamp(0.0, 1.0)
890                        } else {
891                            1.0
892                        },
893                    ..Default::default()
894                })
895                .semantics(move |config| {
896                    config.is_button = true;
897                    config.is_clickable = true;
898                    config.content_description = Some(description.clone());
899                });
900            Box(
901                foreground,
902                BoxSpec::default().content_alignment(Alignment::CENTER),
903                move || {
904                    GlassIconForeground(foreground_spec.clone(), spec.diameter, icon_path);
905                },
906            );
907        }
908
909        // Departed members: the same surface + glyph at their old slot,
910        // riding one shared fade to transparent. No gestures, no union
911        // membership — a purely optical afterimage.
912        for (ghost, ghost_index, departure) in ghosts.borrow().iter() {
913            // Trailing-anchored: the ball keeps its screen slot while the
914            // group's span contracts past it (see the merge above).
915            let x = *ghost_index as f32 * pitch + ghost_trailing_shift;
916            let fade = ghost_alpha;
917            let departure = *departure;
918            let ghost_layer = Modifier::empty()
919                .size(Size::new(spec.diameter, spec.diameter))
920                .offset(x, 0.0)
921                .graphics_layer(move || {
922                    let alpha = fade.get().clamp(0.0, 1.0);
923                    // The reference dissolve shrinks the swollen disc a
924                    // little as it blurs out (f_0360: smaller than held,
925                    // soft); a rest-state departure keeps scale 1 and no
926                    // blur — the plain crisp fade.
927                    let scale =
928                        1.0 + (spec.pressed_scale - 1.0) * departure * (0.55 + 0.45 * alpha);
929                    GraphicsLayer {
930                        alpha,
931                        scale_x: scale,
932                        scale_y: scale,
933                        render_effect: (departure > 0.05 && alpha < 0.98).then(|| {
934                            cranpose_ui_graphics::RenderEffect::blur(
935                                (1.0 - alpha) * 9.0 * departure,
936                            )
937                        }),
938                        ..Default::default()
939                    }
940                });
941            let ghost_spec = ghost.spec.clone();
942            let ghost_icon = ghost.icon_path;
943            let surface = ghost
944                .spec
945                .resolve_material(&colors, ghost.spec.icon_color(&colors))
946                .map(|material| {
947                    Modifier::empty()
948                        .size(Size::new(spec.diameter, spec.diameter))
949                        .glass_effect_with(material.shape(LiquidShape::Circle), move || {
950                            // The departed press keeps its HDR lift: the
951                            // smudge stays the saturated accent it held
952                            // (touched-up f_0355+), not the rest material.
953                            GlassDynamics {
954                                highlight_boost: 0.60 * departure,
955                                saturation_boost: 1.6 * departure,
956                                tint_alpha_multiplier: Some(1.0 + 0.85 * departure),
957                                ..Default::default()
958                            }
959                        })
960                });
961            let glyph_alpha = 1.0 - departure;
962            Box(
963                ghost_layer,
964                BoxSpec::default().content_alignment(Alignment::CENTER),
965                move || {
966                    if let Some(surface) = surface.clone() {
967                        Box(surface, BoxSpec::default(), || {});
968                    }
969                    // The glyph had already dissolved into the held ball —
970                    // a swollen departure must not resurrect it.
971                    let glyph_layer = Modifier::empty().graphics_layer(move || GraphicsLayer {
972                        alpha: glyph_alpha,
973                        ..Default::default()
974                    });
975                    let glyph_spec = ghost_spec.clone();
976                    Box(
977                        glyph_layer,
978                        BoxSpec::default().content_alignment(Alignment::CENTER),
979                        move || {
980                            GlassIconForeground(glyph_spec.clone(), spec.diameter, ghost_icon);
981                        },
982                    );
983                },
984            );
985        }
986    });
987}
988
989#[cfg(test)]
990mod tests {
991    use super::*;
992
993    #[test]
994    fn glass_buttons_expose_button_semantics_to_every_platform_bridge() {
995        let semantics =
996            cranpose_ui::collect_semantics_from_modifier(&with_button_semantics(Modifier::empty()))
997                .expect("glass button semantics");
998        assert!(semantics.is_button);
999        assert!(semantics.is_clickable);
1000    }
1001
1002    #[test]
1003    fn icon_backplate_colors_only_the_compact_foreground_core() {
1004        let blue = Color::from_rgb_u8(0, 122, 255);
1005        let spec = GlassButtonSpec::glass()
1006            .with_icon_backplate(blue)
1007            .with_content_color(Color::WHITE);
1008        assert_eq!(spec.style, GlassButtonStyle::Glass);
1009        assert_eq!(spec.icon_backplate, Some(blue));
1010        assert_eq!(spec.content_color, Some(Color::WHITE));
1011        assert!(spec.glass.is_none());
1012        assert!((0.49..=0.51).contains(&ICON_BACKPLATE_DIAMETER_RATIO));
1013        assert!((0.27..=0.29).contains(&ICON_BACKPLATE_GLYPH_RATIO));
1014
1015        let colors = crate::theme::LiquidColors::light(blue);
1016        let material = GlassButtonSpec::glass()
1017            .resolve_material(&colors, colors.label)
1018            .expect("glass button material");
1019        assert_eq!(material.tint, None);
1020        assert_eq!(material.resolve(&colors).tint, colors.glass_tint);
1021    }
1022
1023    #[test]
1024    fn neutral_button_tint_comes_from_the_theme_not_foreground_polarity() {
1025        let accent = Color::from_rgb_u8(0, 122, 255);
1026        for colors in [
1027            crate::theme::LiquidColors::light(accent),
1028            crate::theme::LiquidColors::dark(accent),
1029        ] {
1030            let material = GlassButtonSpec::glass()
1031                .resolve_material(&colors, colors.label)
1032                .expect("glass button material");
1033            assert_eq!(material.tint, None);
1034            assert_eq!(material.resolve(&colors).tint, colors.glass_tint);
1035        }
1036    }
1037
1038    #[test]
1039    fn icon_button_group_builders_and_hit_regions_preserve_member_gaps() {
1040        let spec = GlassIconButtonGroupSpec::new(44.0)
1041            .with_spacing(8.0)
1042            .with_pressed_scale(1.2)
1043            .with_glue_radius(12.0);
1044        assert_eq!(icon_group_width(2, spec), 96.0);
1045        assert_eq!(icon_group_item_at(22.0, 22.0, 2, spec), Some(0));
1046        assert_eq!(icon_group_item_at(48.0, 22.0, 2, spec), None);
1047        assert_eq!(icon_group_item_at(74.0, 22.0, 2, spec), Some(1));
1048        assert_eq!(icon_group_item_at(22.0, 50.0, 2, spec), None);
1049
1050        let shapes = icon_group_neighbor_shapes(4, 2, spec, 16.0, 38.0);
1051        assert_eq!(shapes.len(), 2);
1052        assert!((shapes[0].0 - (16.0 + 52.0 + 22.0 + 44.0 * 0.38)).abs() < 1e-5);
1053        assert!((shapes[1].0 - (16.0 + 156.0 + 22.0 - 44.0 * 0.38)).abs() < 1e-5);
1054        assert_eq!(shapes[0].2, 44.0 * 0.36);
1055
1056        let item = GlassIconButtonGroupItem::new("M0 0", "Confirm", || {})
1057            .with_spec(GlassButtonSpec::prominent());
1058        assert_eq!(item.content_description, "Confirm");
1059        assert_eq!(item.spec.style, GlassButtonStyle::Prominent);
1060    }
1061}