1use 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#[derive(Clone, Copy, Debug, PartialEq, Eq, Default)]
23pub enum GlassButtonStyle {
24 #[default]
26 Glass,
27 Prominent,
29 Plain,
31 Destructive,
33}
34
35#[derive(Clone, Debug, Default, PartialEq)]
37pub struct GlassButtonSpec {
38 pub style: GlassButtonStyle,
39 pub glass: Option<Glass>,
41 pub content_color: Option<Color>,
43 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 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 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#[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 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#[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#[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 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}