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(icon_path, diameter * ICON_BACKPLATE_GLYPH_RATIO, icon_color);
450            },
451        );
452    } else {
453        crate::icons::Icon(icon_path, diameter * 0.5, icon_color);
454    }
455}
456
457/// A circular glass icon button (44dp target).
458#[composable]
459#[allow(non_snake_case)]
460pub fn GlassIconButton(
461    modifier: Modifier,
462    spec: GlassButtonSpec,
463    diameter: f32,
464    on_click: impl Fn() + 'static,
465    icon_path: &'static str,
466) {
467    GlassIconButtonWithForegroundAlpha(modifier, spec, diameter, 1.0, on_click, icon_path);
468}
469
470#[composable]
471#[allow(non_snake_case)]
472pub(crate) fn GlassIconButtonWithForegroundAlpha(
473    modifier: Modifier,
474    spec: GlassButtonSpec,
475    diameter: f32,
476    foreground_alpha: f32,
477    on_click: impl Fn() + 'static,
478    icon_path: &'static str,
479) {
480    let colors = liquid_colors();
481    let interaction = rememberMutableInteractionSource();
482    let (pressed_modifier, pressed, content_alpha) =
483        liquid_press_scale(Modifier::empty(), interaction, 1.20);
484
485    let material = spec
486        .resolve_material(&colors, spec.icon_color(&colors))
487        .map(|glass| glass.shape(LiquidShape::Circle));
488    let mut base = Modifier::empty();
489    if let Some(glass) = material {
490        let pressed_for_glass = pressed;
491        let half = diameter * 0.5;
492        base = base.glass_effect_with(glass, move || {
493            let press = if pressed_for_glass.get() { 1.0 } else { 0.0 };
494            GlassDynamics::default().touched_up(press, None, (half, half))
495        });
496    }
497
498    let on_click = Rc::new(RefCell::new(on_click));
499    let base = base
500        .press_interaction_source(interaction)
501        .clickable(move |_point| {
502            default_haptics().perform(HapticFeedback::ImpactLight);
503            (on_click.borrow_mut())();
504        })
505        .size(Size::new(diameter, diameter));
506
507    let content_layer = Modifier::empty().graphics_layer(move || GraphicsLayer {
508        alpha: content_alpha.get().clamp(0.0, 1.0) * foreground_alpha.clamp(0.0, 1.0),
509        ..Default::default()
510    });
511    let button = base.then(modifier);
512    Box(pressed_modifier, BoxSpec::default(), move || {
513        let foreground_spec = spec.clone();
514        let content_layer = content_layer.clone();
515        Box(
516            button.clone(),
517            BoxSpec::default().content_alignment(Alignment::CENTER),
518            move || {
519                let foreground_spec = foreground_spec.clone();
520                Box(
521                    content_layer.clone(),
522                    BoxSpec::default().content_alignment(Alignment::CENTER),
523                    move || GlassIconForeground(foreground_spec.clone(), diameter, icon_path),
524                );
525            },
526        );
527    });
528}
529
530/// A row of circular actions whose pressed glass can join adjacent members.
531/// Each member keeps its own base material and foreground; one transparent,
532/// persistent interaction field supplies the shared refraction and neck.
533#[composable]
534#[allow(non_snake_case)]
535pub fn GlassIconButtonGroup(
536    modifier: Modifier,
537    spec: GlassIconButtonGroupSpec,
538    content: impl FnOnce(&GlassIconButtonGroupScope),
539) {
540    let items = collect_items(content);
541    let count = items.len();
542    if count == 0 {
543        return;
544    }
545
546    let colors = liquid_colors();
547    let live_items: Rc<RefCell<Vec<GlassIconButtonGroupItem>>> =
548        remember(|| Rc::new(RefCell::new(Vec::new()))).with(Rc::clone);
549    let ghosts: Rc<RefCell<Vec<(GlassIconButtonGroupItem, usize, f32)>>> =
550        remember(|| Rc::new(RefCell::new(Vec::new()))).with(Rc::clone);
551    let ghost_fade = remember(|| {
552        let runtime = cranpose_core::with_current_composer(|composer| composer.runtime_handle());
553        Rc::new(RefCell::new(cranpose_animation::Animatable::new(
554            0.0f32, runtime,
555        )))
556    })
557    .with(Rc::clone);
558    let active = remember(|| mutableStateOf(None::<usize>)).with(|state| *state);
559    let drag_x = remember(|| mutableStateOf(None::<f32>)).with(|state| *state);
560    let last_active = remember(|| Rc::new(Cell::new(0usize))).with(Rc::clone);
561    if let Some(index) = active.get() {
562        last_active.set(index.min(count - 1));
563    }
564    let press_progress = cranpose_animation::animateFloatAsState(
565        if active.get().is_some() { 1.0 } else { 0.0 },
566        LiquidMotion::snappy(),
567        "glass-icon-group-press",
568    );
569    let press_orphaned = remember(|| Rc::new(Cell::new(false))).with(Rc::clone);
570
571    {
572        let member_stays = |member: &GlassIconButtonGroupItem| {
573            items.iter().any(|item| {
574                item.icon_path == member.icon_path
575                    && item.content_description == member.content_description
576            })
577        };
578        let previous = live_items.borrow();
579        if !previous.is_empty() {
580            let leavers: Vec<(GlassIconButtonGroupItem, usize, f32)> = previous
581                .iter()
582                .enumerate()
583                .filter(|(_, member)| !member_stays(member))
584                .map(|(index, member)| {
585                    let departure = if index == last_active.get() {
586                        press_orphaned.set(true);
587                        press_progress.get().clamp(0.0, 1.0)
588                    } else {
589                        0.0
590                    };
591                    (member.clone(), index, departure)
592                })
593                .collect();
594            if !leavers.is_empty() {
595                *ghosts.borrow_mut() = leavers;
596                let mut fade = ghost_fade.borrow_mut();
597                fade.snapTo(1.0);
598                fade.animateTo(
599                    0.0,
600                    AnimationType::Tween(AnimationSpec::tween(250, Easing::EaseIn)),
601                );
602            }
603        }
604    }
605    let ghost_alpha = ghost_fade.borrow().state();
606    if !ghosts.borrow().is_empty() && ghost_alpha.get() <= 0.01 {
607        ghosts.borrow_mut().clear();
608    }
609    *live_items.borrow_mut() = items;
610    let render_items = live_items.borrow().clone();
611
612    let ghost_slots = ghosts
613        .borrow()
614        .iter()
615        .map(|(_, index, _)| index + 1)
616        .max()
617        .unwrap_or(0);
618    let layout_count = count.max(ghost_slots);
619    let merge = if layout_count > count {
620        (1.0 - ghost_alpha.get().clamp(0.0, 1.0)).clamp(0.0, 1.0)
621    } else {
622        0.0
623    };
624    let full_width = icon_group_width(layout_count, spec);
625    let settled_width = icon_group_width(count, spec);
626    let width = settled_width + (full_width - settled_width) * (1.0 - merge);
627    let ghost_trailing_shift = width - full_width;
628    let gesture_items = Rc::clone(&live_items);
629    let gesture_press_orphaned = Rc::clone(&press_orphaned);
630    let gesture = Modifier::empty()
631        .size(Size::new(width, spec.diameter))
632        .pointer_input(count, move |scope: PointerInputScope| {
633            let gesture_items = Rc::clone(&gesture_items);
634            let gesture_press_orphaned = Rc::clone(&gesture_press_orphaned);
635            async move {
636                scope
637                    .await_pointer_event_scope(|await_scope| async move {
638                        let mut down_index = None::<usize>;
639                        loop {
640                            let event = await_scope.await_pointer_event().await;
641                            match event.kind {
642                                PointerEventKind::Down if down_index.is_none() => {
643                                    down_index = icon_group_item_at(
644                                        event.position.x,
645                                        event.position.y,
646                                        gesture_items.borrow().len(),
647                                        spec,
648                                    );
649                                    if let Some(index) = down_index {
650                                        active.set(Some(index));
651                                        drag_x.set(Some(event.position.x));
652                                        gesture_press_orphaned.set(false);
653                                        default_haptics().perform(HapticFeedback::Selection);
654                                        event.consume();
655                                    }
656                                }
657                                PointerEventKind::Move if down_index.is_some() => {
658                                    drag_x.set(Some(event.position.x));
659                                    event.consume();
660                                }
661                                PointerEventKind::Up => {
662                                    let pressed_index = down_index.take();
663                                    active.set(None);
664                                    drag_x.set(None);
665                                    if let Some(index) = pressed_index {
666                                        let count = gesture_items.borrow().len();
667                                        let released_inside = (-TAP_EXIT_SLOP
668                                            ..=spec.diameter + TAP_EXIT_SLOP)
669                                            .contains(&event.position.y)
670                                            && (-TAP_EXIT_SLOP
671                                                ..=icon_group_width(count, spec) + TAP_EXIT_SLOP)
672                                                .contains(&event.position.x);
673                                        if released_inside {
674                                            let on_click = gesture_items
675                                                .borrow()
676                                                .get(index)
677                                                .map(|item| Rc::clone(&item.on_click));
678                                            if let Some(on_click) = on_click {
679                                                default_haptics()
680                                                    .perform(HapticFeedback::ImpactLight);
681                                                (on_click.borrow_mut())();
682                                            }
683                                        }
684                                        event.consume();
685                                    }
686                                }
687                                PointerEventKind::Cancel if down_index.take().is_some() => {
688                                    active.set(None);
689                                    drag_x.set(None);
690                                    event.consume();
691                                }
692                                _ => {}
693                            }
694                        }
695                    })
696                    .await;
697            }
698        })
699        .then(modifier);
700
701    Box(gesture, BoxSpec::default(), move || {
702        let pitch = spec.diameter + spec.spacing;
703        let active_index = last_active.get().min(count - 1);
704
705        let pad = spec.glue_radius + spec.diameter * (spec.pressed_scale - 1.0) * 0.5 + 4.0;
706        let node_width = width + pad * 2.0;
707        let node_height = spec.diameter * spec.pressed_scale + pad * 2.0;
708        let shared_progress = press_progress;
709        let shared_orphaned = Rc::clone(&press_orphaned);
710        let shared_last_active = Rc::clone(&last_active);
711        let union_tint = render_items
712            .get(active_index)
713            .and_then(|item| {
714                item.spec
715                    .resolve_material(&colors, item.spec.icon_color(&colors))
716            })
717            .and_then(|material| material.tint)
718            .map(|tint| tint.with_alpha(0.85))
719            .unwrap_or(Color::WHITE.with_alpha(0.035));
720        let shared = Modifier::empty()
721            .required_size(Size::new(node_width, node_height))
722            .offset(-pad, (spec.diameter - node_height) * 0.5)
723            .glass_effect_with(
724                Glass::regular()
725                    .blur_radius(0.0)
726                    .tint(union_tint)
727                    .lift(0.0)
728                    .highlight(0.48)
729                    .shadow(false)
730                    .no_clip(),
731                move || {
732                    let progress = if shared_orphaned.get() {
733                        0.0
734                    } else {
735                        shared_progress.get().clamp(0.0, 1.0)
736                    };
737                    let active_index = shared_last_active.get().min(count - 1);
738                    let center_y = node_height * 0.5;
739                    let rest_center = active_index as f32 * pitch + spec.diameter * 0.5;
740                    let ridden = drag_x
741                        .get()
742                        .map(|x| x.clamp(rest_center - pitch * 0.35, rest_center + pitch * 0.35))
743                        .unwrap_or(rest_center);
744                    let center_x = pad + rest_center + (ridden - rest_center) * progress;
745                    let diameter = spec.diameter * (1.0 + (spec.pressed_scale - 1.0) * progress);
746                    let touch = drag_x.get().map(|x| {
747                        (
748                            pad + x.clamp(0.0, node_width - 2.0 * pad),
749                            center_y,
750                            progress,
751                        )
752                    });
753                    GlassDynamics {
754                        activity: Some(progress),
755                        touch,
756                        morph: Some(GlassMorph {
757                            node_size: (node_width, node_height),
758                            primary: (center_x, center_y, diameter, diameter, -1.0),
759                            shapes: icon_group_neighbor_shapes(
760                                count,
761                                active_index,
762                                spec,
763                                pad,
764                                center_y,
765                            ),
766                            glue: spec.glue_radius * progress,
767                            ..Default::default()
768                        }),
769                        ..Default::default()
770                    }
771                },
772            );
773        Box(shared, BoxSpec::default(), || {});
774
775        for (index, item) in render_items.iter().enumerate() {
776            let x = index as f32 * pitch;
777            let surface_progress = press_progress;
778            let item_is_active = index == active_index;
779            let scale_orphaned = Rc::clone(&press_orphaned);
780            let outer = Modifier::empty()
781                .size(Size::new(spec.diameter, spec.diameter))
782                .offset(x, 0.0);
783            let rest_center = index as f32 * pitch + spec.diameter * 0.5;
784            let scale_layer = Modifier::empty().graphics_layer(move || {
785                let progress = if item_is_active && !scale_orphaned.get() {
786                    surface_progress.get().clamp(0.0, 1.0)
787                } else {
788                    0.0
789                };
790                let scale = 1.0 + (spec.pressed_scale - 1.0) * progress;
791                let ridden = if item_is_active {
792                    drag_x
793                        .get()
794                        .map(|x| x.clamp(rest_center - pitch * 0.35, rest_center + pitch * 0.35))
795                        .unwrap_or(rest_center)
796                } else {
797                    rest_center
798                };
799                GraphicsLayer {
800                    translation_x: (ridden - rest_center) * progress,
801                    scale_x: scale,
802                    scale_y: scale,
803                    ..Default::default()
804                }
805            });
806            let mut surface = Modifier::empty().size(Size::new(spec.diameter, spec.diameter));
807            if let Some(material) = item
808                .spec
809                .resolve_material(&colors, item.spec.icon_color(&colors))
810            {
811                let surface_progress = press_progress;
812                let dynamics_orphaned = Rc::clone(&press_orphaned);
813                surface =
814                    surface.glass_effect_with(material.shape(LiquidShape::Circle), move || {
815                        let progress = if dynamics_orphaned.get() {
816                            0.0
817                        } else {
818                            surface_progress.get().clamp(0.0, 1.0)
819                        };
820                        GlassDynamics {
821                            highlight_boost: if item_is_active { 0.60 * progress } else { 0.0 },
822                            saturation_boost: if item_is_active { 1.6 * progress } else { 0.0 },
823                            tint_alpha_multiplier: item_is_active.then_some(1.0 + 0.85 * progress),
824                            ..Default::default()
825                        }
826                    });
827            }
828            Box(outer, BoxSpec::default(), move || {
829                let surface = surface.clone();
830                Box(scale_layer.clone(), BoxSpec::default(), move || {
831                    Box(surface.clone(), BoxSpec::default(), || {});
832                });
833            });
834        }
835
836        for (index, item) in render_items.iter().enumerate() {
837            let x = index as f32 * pitch;
838            let item_is_active = index == active_index;
839            let foreground_progress = press_progress;
840            let foreground_orphaned = Rc::clone(&press_orphaned);
841            let foreground_spec = item.spec.clone();
842            let icon_path = item.icon_path;
843            let description = item.content_description.clone();
844            let merge_alpha = merge_glyph_alpha(merge);
845            let foreground = Modifier::empty()
846                .size(Size::new(spec.diameter, spec.diameter))
847                .offset(x, 0.0)
848                .graphics_layer(move || GraphicsLayer {
849                    alpha: merge_alpha
850                        * if item_is_active && !foreground_orphaned.get() {
851                            1.0 - foreground_progress.get().clamp(0.0, 1.0)
852                        } else {
853                            1.0
854                        },
855                    ..Default::default()
856                })
857                .semantics(move |config| {
858                    config.role = Some(SemanticsWidgetRole::Button);
859                    config.is_clickable = true;
860                    config.content_description = Some(description.clone());
861                });
862            Box(
863                foreground,
864                BoxSpec::default().content_alignment(Alignment::CENTER),
865                move || {
866                    GlassIconForeground(foreground_spec.clone(), spec.diameter, icon_path);
867                },
868            );
869        }
870
871        for (ghost, ghost_index, departure) in ghosts.borrow().iter() {
872            let x = *ghost_index as f32 * pitch + ghost_trailing_shift;
873            let fade = ghost_alpha;
874            let departure = *departure;
875            let ghost_layer = Modifier::empty()
876                .size(Size::new(spec.diameter, spec.diameter))
877                .offset(x, 0.0)
878                .graphics_layer(move || {
879                    let alpha = fade.get().clamp(0.0, 1.0);
880                    let scale =
881                        1.0 + (spec.pressed_scale - 1.0) * departure * (0.55 + 0.45 * alpha);
882                    GraphicsLayer {
883                        alpha,
884                        scale_x: scale,
885                        scale_y: scale,
886                        render_effect: (departure > 0.05 && alpha < 0.98).then(|| {
887                            cranpose_ui_graphics::RenderEffect::blur(
888                                (1.0 - alpha) * 9.0 * departure,
889                            )
890                        }),
891                        ..Default::default()
892                    }
893                });
894            let ghost_spec = ghost.spec.clone();
895            let ghost_icon = ghost.icon_path;
896            let surface = ghost
897                .spec
898                .resolve_material(&colors, ghost.spec.icon_color(&colors))
899                .map(|material| {
900                    Modifier::empty()
901                        .size(Size::new(spec.diameter, spec.diameter))
902                        .glass_effect_with(material.shape(LiquidShape::Circle), move || {
903                            GlassDynamics {
904                                highlight_boost: 0.60 * departure,
905                                saturation_boost: 1.6 * departure,
906                                tint_alpha_multiplier: Some(1.0 + 0.85 * departure),
907                                ..Default::default()
908                            }
909                        })
910                });
911            let glyph_alpha = 1.0 - departure;
912            Box(
913                ghost_layer,
914                BoxSpec::default().content_alignment(Alignment::CENTER),
915                move || {
916                    if let Some(surface) = surface.clone() {
917                        Box(surface, BoxSpec::default(), || {});
918                    }
919                    let glyph_layer = Modifier::empty().graphics_layer(move || GraphicsLayer {
920                        alpha: glyph_alpha,
921                        ..Default::default()
922                    });
923                    let glyph_spec = ghost_spec.clone();
924                    Box(
925                        glyph_layer,
926                        BoxSpec::default().content_alignment(Alignment::CENTER),
927                        move || {
928                            GlassIconForeground(glyph_spec.clone(), spec.diameter, ghost_icon);
929                        },
930                    );
931                },
932            );
933        }
934    });
935}
936
937#[cfg(test)]
938mod tests {
939    use super::*;
940
941    #[test]
942    fn glass_buttons_expose_button_semantics_to_every_platform_bridge() {
943        let semantics =
944            cranpose_ui::collect_semantics_from_modifier(&with_button_semantics(Modifier::empty()))
945                .expect("glass button semantics");
946        assert_eq!(semantics.role, Some(SemanticsWidgetRole::Button));
947        assert!(semantics.is_clickable);
948    }
949
950    #[test]
951    fn icon_backplate_colors_only_the_compact_foreground_core() {
952        let blue = Color::from_rgb_u8(0, 122, 255);
953        let spec = GlassButtonSpec::glass()
954            .with_icon_backplate(blue)
955            .with_content_color(Color::WHITE);
956        assert_eq!(spec.style, GlassButtonStyle::Glass);
957        assert_eq!(spec.icon_backplate, Some(blue));
958        assert_eq!(spec.content_color, Some(Color::WHITE));
959        assert!(spec.glass.is_none());
960        assert!((0.49..=0.51).contains(&ICON_BACKPLATE_DIAMETER_RATIO));
961        assert!((0.27..=0.29).contains(&ICON_BACKPLATE_GLYPH_RATIO));
962
963        let colors = crate::theme::LiquidColors::light(blue);
964        let material = GlassButtonSpec::glass()
965            .resolve_material(&colors, colors.label)
966            .expect("glass button material");
967        assert_eq!(material.tint, None);
968        assert_eq!(material.resolve(&colors).tint, colors.glass_tint);
969    }
970
971    #[test]
972    fn neutral_button_tint_comes_from_the_theme_not_foreground_polarity() {
973        let accent = Color::from_rgb_u8(0, 122, 255);
974        for colors in [
975            crate::theme::LiquidColors::light(accent),
976            crate::theme::LiquidColors::dark(accent),
977        ] {
978            let material = GlassButtonSpec::glass()
979                .resolve_material(&colors, colors.label)
980                .expect("glass button material");
981            assert_eq!(material.tint, None);
982            assert_eq!(material.resolve(&colors).tint, colors.glass_tint);
983        }
984    }
985
986    #[test]
987    fn icon_button_group_builders_and_hit_regions_preserve_member_gaps() {
988        let spec = GlassIconButtonGroupSpec::new(44.0)
989            .with_spacing(8.0)
990            .with_pressed_scale(1.2)
991            .with_glue_radius(12.0);
992        assert_eq!(icon_group_width(2, spec), 96.0);
993        assert_eq!(icon_group_item_at(22.0, 22.0, 2, spec), Some(0));
994        assert_eq!(icon_group_item_at(48.0, 22.0, 2, spec), None);
995        assert_eq!(icon_group_item_at(74.0, 22.0, 2, spec), Some(1));
996        assert_eq!(icon_group_item_at(22.0, 50.0, 2, spec), None);
997
998        let shapes = icon_group_neighbor_shapes(4, 2, spec, 16.0, 38.0);
999        assert_eq!(shapes.len(), 2);
1000        assert!((shapes[0].0 - (16.0 + 52.0 + 22.0 + 44.0 * 0.38)).abs() < 1e-5);
1001        assert!((shapes[1].0 - (16.0 + 156.0 + 22.0 - 44.0 * 0.38)).abs() < 1e-5);
1002        assert_eq!(shapes[0].2, 44.0 * 0.36);
1003
1004        let item = GlassIconButtonGroupItem::new("M0 0", "Confirm", || {})
1005            .with_spec(GlassButtonSpec::prominent());
1006        assert_eq!(item.content_description, "Confirm");
1007        assert_eq!(item.spec.style, GlassButtonStyle::Prominent);
1008    }
1009
1010    #[test]
1011    fn a_scope_declares_actions_in_order_with_their_callbacks() {
1012        let fired = Rc::new(Cell::new(0usize));
1013        let more = Rc::clone(&fired);
1014        let confirm = Rc::clone(&fired);
1015        let items = collect_items(|scope| {
1016            scope.action("M0 0", "More", move || more.set(1));
1017            scope.push(
1018                GlassIconButtonGroupItem::new("M1 1", "Confirm", move || confirm.set(2))
1019                    .with_spec(GlassButtonSpec::prominent()),
1020            );
1021        });
1022
1023        assert_eq!(items.len(), 2);
1024        assert_eq!(items[0].content_description, "More");
1025        assert_eq!(items[0].spec.style, GlassButtonStyle::Glass);
1026        assert_eq!(items[1].spec.style, GlassButtonStyle::Prominent);
1027
1028        (items[0].on_click.borrow_mut())();
1029        assert_eq!(fired.get(), 1);
1030        (items[1].on_click.borrow_mut())();
1031        assert_eq!(fired.get(), 2);
1032    }
1033
1034    #[test]
1035    fn a_group_with_no_actions_declares_none() {
1036        assert!(collect_items(|_| {}).is_empty());
1037    }
1038}