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::{neutral_surface_tint, Glass, GlassDynamics, LiquidModifierExt, LiquidShape};
5use crate::motion::liquid_press_scale;
6use crate::theme::{liquid_colors, liquid_typography};
7use cranpose_macros::composable;
8use cranpose_services::{default_haptics, HapticFeedback};
9use cranpose_ui::rememberMutableInteractionSource;
10use cranpose_ui::text::TextStyle;
11use cranpose_ui::widgets::{Box, BoxSpec, Text};
12use cranpose_ui::{Modifier, Size};
13use cranpose_ui_graphics::{Color, GraphicsLayer};
14use cranpose_ui_layout::Alignment;
15use std::cell::RefCell;
16use std::rc::Rc;
17
18const ICON_BACKPLATE_DIAMETER_RATIO: f32 = 0.50;
19const ICON_BACKPLATE_GLYPH_RATIO: f32 = 0.28;
20
21/// Visual style of a [`GlassButton`].
22#[derive(Clone, Copy, Debug, PartialEq, Eq, Default)]
23pub enum GlassButtonStyle {
24    /// Translucent glass with the label in the accent color.
25    #[default]
26    Glass,
27    /// Accent-tinted glass with white content — the primary action.
28    Prominent,
29    /// No material: just the label with press feedback (toolbar/text button).
30    Plain,
31    /// Destructive variant of `Plain`.
32    Destructive,
33}
34
35/// Configuration for [`GlassButton`] / [`GlassIconButton`].
36#[derive(Clone, Debug, Default, PartialEq)]
37pub struct GlassButtonSpec {
38    pub style: GlassButtonStyle,
39    /// Overrides the material (advanced).
40    pub glass: Option<Glass>,
41    /// Overrides the label/icon color (defaults per style).
42    pub content_color: Option<Color>,
43    /// Optional inner color disc for icon buttons. The outer material remains
44    /// clear glass; the disc colors only the icon's compact foreground core.
45    pub icon_backplate: Option<Color>,
46}
47
48impl GlassButtonSpec {
49    pub fn glass() -> Self {
50        Self::default()
51    }
52
53    pub fn prominent() -> Self {
54        Self {
55            style: GlassButtonStyle::Prominent,
56            ..Self::default()
57        }
58    }
59
60    pub fn plain() -> Self {
61        Self {
62            style: GlassButtonStyle::Plain,
63            ..Self::default()
64        }
65    }
66
67    pub fn destructive() -> Self {
68        Self {
69            style: GlassButtonStyle::Destructive,
70            ..Self::default()
71        }
72    }
73
74    pub fn with_glass(mut self, glass: Glass) -> Self {
75        self.glass = Some(glass);
76        self
77    }
78
79    pub fn with_content_color(mut self, color: Color) -> Self {
80        self.content_color = Some(color);
81        self
82    }
83
84    pub fn with_icon_backplate(mut self, color: Color) -> Self {
85        self.icon_backplate = Some(color);
86        self
87    }
88
89    /// The label color for this style under `colors`.
90    pub fn content_color(&self, colors: &crate::theme::LiquidColors) -> Color {
91        if let Some(color) = self.content_color {
92            return color;
93        }
94        match self.style {
95            GlassButtonStyle::Glass => colors.accent,
96            GlassButtonStyle::Prominent => colors.on_accent,
97            GlassButtonStyle::Plain => colors.accent,
98            GlassButtonStyle::Destructive => colors.destructive,
99        }
100    }
101
102    /// The glyph color for icon buttons: the reference nav/search circles
103    /// draw BLACK glyphs on plain glass (only prominent tints stay white).
104    pub fn icon_color(&self, colors: &crate::theme::LiquidColors) -> Color {
105        if let Some(color) = self.content_color {
106            return color;
107        }
108        match self.style {
109            GlassButtonStyle::Glass | GlassButtonStyle::Plain => colors.label,
110            GlassButtonStyle::Prominent => colors.on_accent,
111            GlassButtonStyle::Destructive => colors.destructive,
112        }
113    }
114
115    fn resolve_material(
116        &self,
117        colors: &crate::theme::LiquidColors,
118        foreground: Color,
119    ) -> Option<Glass> {
120        if let Some(glass) = &self.glass {
121            return Some(if glass.foreground.is_some() {
122                glass.clone()
123            } else {
124                glass
125                    .clone()
126                    .adaptive_frost(foreground, glass.adaptive_frost)
127            });
128        }
129        match self.style {
130            GlassButtonStyle::Glass => Some(
131                Glass::regular()
132                    .tint(neutral_surface_tint(foreground, 0.08, 0.10))
133                    .adaptive_frost(foreground, 0.65),
134            ),
135            GlassButtonStyle::Prominent => Some(
136                Glass::regular()
137                    .tint(colors.accent.with_alpha(0.75))
138                    .adaptive_frost(foreground, 0.65),
139            ),
140            GlassButtonStyle::Plain | GlassButtonStyle::Destructive => None,
141        }
142    }
143}
144
145/// A glass button. `content` composes the label (see [`GlassButton`] with
146/// [`Text`], or an [`crate::icons::Icon`] + text row).
147#[composable]
148#[allow(non_snake_case)]
149pub fn GlassButton(
150    modifier: Modifier,
151    spec: GlassButtonSpec,
152    on_click: impl Fn() + 'static,
153    content: impl FnMut() + 'static,
154) {
155    let colors = liquid_colors();
156    let interaction = rememberMutableInteractionSource();
157    let (pressed_modifier, pressed, content_alpha) =
158        liquid_press_scale(Modifier::empty(), interaction.clone(), 1.08);
159
160    let material = spec.resolve_material(&colors, spec.content_color(&colors));
161    let mut base = pressed_modifier;
162    if let Some(glass) = material {
163        let pressed_for_glass = pressed;
164        base = base.glass_effect_with(glass, move || GlassDynamics {
165            tilt: (0.0, 0.0),
166            highlight_boost: if pressed_for_glass.get() { 0.85 } else { 0.0 },
167            ..Default::default()
168        });
169    }
170
171    let on_click = Rc::new(RefCell::new(on_click));
172    let base = base
173        .press_interaction_source(interaction)
174        .clickable(move |_point| {
175            default_haptics().perform(HapticFeedback::ImpactLight);
176            (on_click.borrow_mut())();
177        })
178        .padding_symmetric(16.0, 10.0);
179
180    // Touched glass turns more transparent: the label ghosts while pressed.
181    let content_layer = Modifier::empty().graphics_layer(move || GraphicsLayer {
182        alpha: content_alpha.get().clamp(0.0, 1.0),
183        ..Default::default()
184    });
185    let content = Rc::new(RefCell::new(content));
186    Box(
187        base.then(modifier),
188        BoxSpec::default().content_alignment(Alignment::CENTER),
189        move || {
190            let content = Rc::clone(&content);
191            Box(
192                content_layer.clone(),
193                BoxSpec::default().content_alignment(Alignment::CENTER),
194                move || (content.borrow_mut())(),
195            );
196        },
197    );
198}
199
200/// Convenience text label styled for the enclosing button.
201#[composable]
202#[allow(non_snake_case)]
203pub fn GlassButtonLabel(text: impl Into<String>, spec: GlassButtonSpec) {
204    let typography = liquid_typography();
205    let color = spec.content_color(&liquid_colors());
206    let style = TextStyle {
207        span_style: cranpose_ui::text::SpanStyle {
208            color: Some(color),
209            ..typography.headline.span_style.clone()
210        },
211        ..typography.headline.clone()
212    };
213    Text(text.into(), Modifier::empty(), style);
214}
215
216#[composable]
217#[allow(non_snake_case)]
218pub(crate) fn GlassIconForeground(spec: GlassButtonSpec, diameter: f32, icon_path: &'static str) {
219    let colors = liquid_colors();
220    let icon_color = spec.icon_color(&colors);
221    if let Some(backplate) = spec.icon_backplate {
222        let backplate_diameter = diameter * ICON_BACKPLATE_DIAMETER_RATIO;
223        Box(
224            Modifier::empty()
225                .size(Size::new(backplate_diameter, backplate_diameter))
226                .draw_behind(move |scope| {
227                    scope.draw_circle(
228                        cranpose_ui_graphics::Brush::solid(backplate),
229                        cranpose_ui_graphics::Point::new(
230                            backplate_diameter * 0.5,
231                            backplate_diameter * 0.5,
232                        ),
233                        backplate_diameter * 0.5,
234                    );
235                }),
236            BoxSpec::default().content_alignment(Alignment::CENTER),
237            move || {
238                crate::icons::Icon(icon_path, diameter * ICON_BACKPLATE_GLYPH_RATIO, icon_color);
239            },
240        );
241    } else {
242        crate::icons::Icon(icon_path, diameter * 0.5, icon_color);
243    }
244}
245
246/// A circular glass icon button (44dp target).
247#[composable]
248#[allow(non_snake_case)]
249pub fn GlassIconButton(
250    modifier: Modifier,
251    spec: GlassButtonSpec,
252    diameter: f32,
253    on_click: impl Fn() + 'static,
254    icon_path: &'static str,
255) {
256    GlassIconButtonWithForegroundAlpha(modifier, spec, diameter, 1.0, on_click, icon_path);
257}
258
259#[composable]
260#[allow(non_snake_case)]
261pub(crate) fn GlassIconButtonWithForegroundAlpha(
262    modifier: Modifier,
263    spec: GlassButtonSpec,
264    diameter: f32,
265    foreground_alpha: f32,
266    on_click: impl Fn() + 'static,
267    icon_path: &'static str,
268) {
269    let colors = liquid_colors();
270    let interaction = rememberMutableInteractionSource();
271    let (pressed_modifier, pressed, content_alpha) =
272        liquid_press_scale(Modifier::empty(), interaction.clone(), 1.12);
273
274    let material = spec
275        .resolve_material(&colors, spec.icon_color(&colors))
276        .map(|glass| glass.shape(LiquidShape::Circle));
277    let mut base = pressed_modifier;
278    if let Some(glass) = material {
279        let pressed_for_glass = pressed;
280        base = base.glass_effect_with(glass, move || GlassDynamics {
281            tilt: (0.0, 0.0),
282            highlight_boost: if pressed_for_glass.get() { 0.85 } else { 0.0 },
283            ..Default::default()
284        });
285    }
286
287    let on_click = Rc::new(RefCell::new(on_click));
288    let base = base
289        .press_interaction_source(interaction)
290        .clickable(move |_point| {
291            default_haptics().perform(HapticFeedback::ImpactLight);
292            (on_click.borrow_mut())();
293        })
294        .size(Size::new(diameter, diameter));
295
296    // The reference press: the icon ghosts while the glass lifts.
297    let content_layer = Modifier::empty().graphics_layer(move || GraphicsLayer {
298        alpha: content_alpha.get().clamp(0.0, 1.0) * foreground_alpha.clamp(0.0, 1.0),
299        ..Default::default()
300    });
301    Box(
302        base.then(modifier),
303        BoxSpec::default().content_alignment(Alignment::CENTER),
304        move || {
305            let foreground_spec = spec.clone();
306            Box(
307                content_layer.clone(),
308                BoxSpec::default().content_alignment(Alignment::CENTER),
309                move || GlassIconForeground(foreground_spec.clone(), diameter, icon_path),
310            );
311        },
312    );
313}
314
315#[cfg(test)]
316mod tests {
317    use super::*;
318
319    #[test]
320    fn icon_backplate_colors_only_the_compact_foreground_core() {
321        let blue = Color::from_rgb_u8(0, 122, 255);
322        let spec = GlassButtonSpec::glass()
323            .with_icon_backplate(blue)
324            .with_content_color(Color::WHITE);
325        assert_eq!(spec.style, GlassButtonStyle::Glass);
326        assert_eq!(spec.icon_backplate, Some(blue));
327        assert_eq!(spec.content_color, Some(Color::WHITE));
328        assert!(spec.glass.is_none());
329        assert!((0.49..=0.51).contains(&ICON_BACKPLATE_DIAMETER_RATIO));
330        assert!((0.27..=0.29).contains(&ICON_BACKPLATE_GLYPH_RATIO));
331
332        let colors = crate::theme::LiquidColors::light(blue);
333        let material = GlassButtonSpec::glass()
334            .resolve_material(&colors, colors.label)
335            .expect("glass button material");
336        let tint = material.tint.expect("interactive neutral tint");
337        assert!(tint.r() < 0.05);
338        assert!((0.075..=0.085).contains(&tint.a()));
339    }
340}