Skip to main content

cranpose_liquid/widgets/
button.rs

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