Skip to main content

cranpose_liquid/widgets/
button.rs

1//! Glass buttons: capsule glass with a spring press (scale + specular boost)
2//! and haptic feedback.
3
4use crate::material::{Glass, GlassDynamics, 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
18/// Visual style of a [`GlassButton`].
19#[derive(Clone, Copy, Debug, PartialEq, Eq, Default)]
20pub enum GlassButtonStyle {
21    /// Translucent glass with the label in the accent color.
22    #[default]
23    Glass,
24    /// Accent-tinted glass with white content — the primary action.
25    Prominent,
26    /// No material: just the label with press feedback (toolbar/text button).
27    Plain,
28    /// Destructive variant of `Plain`.
29    Destructive,
30}
31
32/// Configuration for [`GlassButton`] / [`GlassIconButton`].
33#[derive(Clone, Debug, Default, PartialEq)]
34pub struct GlassButtonSpec {
35    pub style: GlassButtonStyle,
36    /// Overrides the material (advanced).
37    pub glass: Option<Glass>,
38    /// Overrides the label/icon color (defaults per style).
39    pub content_color: Option<Color>,
40}
41
42impl GlassButtonSpec {
43    pub fn glass() -> Self {
44        Self::default()
45    }
46
47    pub fn prominent() -> Self {
48        Self {
49            style: GlassButtonStyle::Prominent,
50            ..Self::default()
51        }
52    }
53
54    pub fn plain() -> Self {
55        Self {
56            style: GlassButtonStyle::Plain,
57            ..Self::default()
58        }
59    }
60
61    pub fn destructive() -> Self {
62        Self {
63            style: GlassButtonStyle::Destructive,
64            ..Self::default()
65        }
66    }
67
68    pub fn with_glass(mut self, glass: Glass) -> Self {
69        self.glass = Some(glass);
70        self
71    }
72
73    pub fn with_content_color(mut self, color: Color) -> Self {
74        self.content_color = Some(color);
75        self
76    }
77
78    /// The label color for this style under `colors`.
79    pub fn content_color(&self, colors: &crate::theme::LiquidColors) -> Color {
80        if let Some(color) = self.content_color {
81            return color;
82        }
83        match self.style {
84            GlassButtonStyle::Glass => colors.accent,
85            GlassButtonStyle::Prominent => colors.on_accent,
86            GlassButtonStyle::Plain => colors.accent,
87            GlassButtonStyle::Destructive => colors.destructive,
88        }
89    }
90
91    /// The glyph color for icon buttons: the reference nav/search circles
92    /// draw BLACK glyphs on plain glass (only prominent tints stay white).
93    pub fn icon_color(&self, colors: &crate::theme::LiquidColors) -> Color {
94        if let Some(color) = self.content_color {
95            return color;
96        }
97        match self.style {
98            GlassButtonStyle::Glass | GlassButtonStyle::Plain => colors.label,
99            GlassButtonStyle::Prominent => colors.on_accent,
100            GlassButtonStyle::Destructive => colors.destructive,
101        }
102    }
103
104    fn resolve_material(&self, colors: &crate::theme::LiquidColors) -> Option<Glass> {
105        if let Some(glass) = &self.glass {
106            return Some(glass.clone());
107        }
108        match self.style {
109            GlassButtonStyle::Glass => Some(Glass::regular()),
110            GlassButtonStyle::Prominent => {
111                Some(Glass::regular().tint(colors.accent.with_alpha(0.75)))
112            }
113            GlassButtonStyle::Plain | GlassButtonStyle::Destructive => None,
114        }
115    }
116}
117
118/// A glass button. `content` composes the label (see [`GlassButton`] with
119/// [`Text`], or an [`crate::icons::Icon`] + text row).
120#[composable]
121#[allow(non_snake_case)]
122pub fn GlassButton(
123    modifier: Modifier,
124    spec: GlassButtonSpec,
125    on_click: impl Fn() + 'static,
126    content: impl FnMut() + 'static,
127) {
128    let colors = liquid_colors();
129    let interaction = rememberMutableInteractionSource();
130    let (pressed_modifier, pressed, content_alpha) =
131        liquid_press_scale(Modifier::empty(), interaction.clone(), 1.08);
132
133    let material = spec.resolve_material(&colors);
134    let mut base = pressed_modifier;
135    if let Some(glass) = material {
136        let pressed_for_glass = pressed;
137        base = base.glass_effect_with(glass, move || GlassDynamics {
138            tilt: (0.0, 0.0),
139            highlight_boost: if pressed_for_glass.get() { 0.85 } else { 0.0 },
140            ..Default::default()
141        });
142    }
143
144    let on_click = Rc::new(RefCell::new(on_click));
145    let base = base
146        .press_interaction_source(interaction)
147        .clickable(move |_point| {
148            default_haptics().perform(HapticFeedback::ImpactLight);
149            (on_click.borrow_mut())();
150        })
151        .padding_symmetric(16.0, 10.0);
152
153    // Touched glass turns more transparent: the label ghosts while pressed.
154    let content_layer = Modifier::empty().graphics_layer(move || GraphicsLayer {
155        alpha: content_alpha.get().clamp(0.0, 1.0),
156        ..Default::default()
157    });
158    let content = Rc::new(RefCell::new(content));
159    Box(
160        base.then(modifier),
161        BoxSpec::default().content_alignment(Alignment::CENTER),
162        move || {
163            let content = Rc::clone(&content);
164            Box(
165                content_layer.clone(),
166                BoxSpec::default().content_alignment(Alignment::CENTER),
167                move || (content.borrow_mut())(),
168            );
169        },
170    );
171}
172
173/// Convenience text label styled for the enclosing button.
174#[composable]
175#[allow(non_snake_case)]
176pub fn GlassButtonLabel(text: impl Into<String>, spec: GlassButtonSpec) {
177    let typography = liquid_typography();
178    let color = spec.content_color(&liquid_colors());
179    let style = TextStyle {
180        span_style: cranpose_ui::text::SpanStyle {
181            color: Some(color),
182            ..typography.headline.span_style.clone()
183        },
184        ..typography.headline.clone()
185    };
186    Text(text.into(), Modifier::empty(), style);
187}
188
189/// A circular glass icon button (44dp target).
190#[composable]
191#[allow(non_snake_case)]
192pub fn GlassIconButton(
193    modifier: Modifier,
194    spec: GlassButtonSpec,
195    diameter: f32,
196    on_click: impl Fn() + 'static,
197    icon_path: &'static str,
198) {
199    let colors = liquid_colors();
200    let interaction = rememberMutableInteractionSource();
201    let (pressed_modifier, pressed, content_alpha) =
202        liquid_press_scale(Modifier::empty(), interaction.clone(), 1.12);
203
204    let material = spec
205        .resolve_material(&colors)
206        .map(|glass| glass.shape(LiquidShape::Circle));
207    let mut base = pressed_modifier;
208    if let Some(glass) = material {
209        let pressed_for_glass = pressed;
210        base = base.glass_effect_with(glass, move || GlassDynamics {
211            tilt: (0.0, 0.0),
212            highlight_boost: if pressed_for_glass.get() { 0.85 } else { 0.0 },
213            ..Default::default()
214        });
215    }
216
217    let icon_color = spec.icon_color(&colors);
218    let on_click = Rc::new(RefCell::new(on_click));
219    let base = base
220        .press_interaction_source(interaction)
221        .clickable(move |_point| {
222            default_haptics().perform(HapticFeedback::ImpactLight);
223            (on_click.borrow_mut())();
224        })
225        .size(Size::new(diameter, diameter));
226
227    // The reference press: the icon ghosts while the glass lifts.
228    let content_layer = Modifier::empty().graphics_layer(move || GraphicsLayer {
229        alpha: content_alpha.get().clamp(0.0, 1.0),
230        ..Default::default()
231    });
232    Box(
233        base.then(modifier),
234        BoxSpec::default().content_alignment(Alignment::CENTER),
235        move || {
236            Box(
237                content_layer.clone(),
238                BoxSpec::default().content_alignment(Alignment::CENTER),
239                move || {
240                    crate::icons::Icon(icon_path, diameter * 0.5, icon_color);
241                },
242            );
243        },
244    );
245}