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