1#![allow(non_snake_case)]
4
5use crate::composable;
6use crate::interaction::{rememberMutableInteractionSource, MutableInteractionSource};
7use crate::widgets::{Box, BoxSpec};
8use crate::{Modifier, SemanticsWidgetRole, Size};
9use cranpose_core::rememberKeyed;
10use cranpose_core::NodeId;
11use cranpose_ui_graphics::{Brush, Color, VectorPath};
12use cranpose_ui_layout::Alignment;
13
14const ICON_VIEW_BOX: f32 = 24.0;
16
17pub const DEFAULT_ICON_SIZE: f32 = 24.0;
19
20pub const MINIMUM_TOUCH_TARGET: f32 = 48.0;
26
27#[derive(Clone, Copy, Debug, PartialEq)]
29pub struct IconSpec {
30 pub size: f32,
32 pub tint: Option<Color>,
35}
36
37impl Default for IconSpec {
38 fn default() -> Self {
39 Self {
40 size: DEFAULT_ICON_SIZE,
41 tint: None,
42 }
43 }
44}
45
46impl IconSpec {
47 pub const fn sized(size: f32) -> Self {
49 Self { size, tint: None }
50 }
51
52 pub const fn with_size(mut self, size: f32) -> Self {
54 self.size = size;
55 self
56 }
57
58 pub const fn with_tint(mut self, tint: Color) -> Self {
60 self.tint = Some(tint);
61 self
62 }
63}
64
65#[composable]
71pub fn Icon(path: &'static str, size: f32, color: Color) -> NodeId {
72 IconWith(
73 Modifier::empty(),
74 path,
75 IconSpec::sized(size).with_tint(color),
76 None,
77 )
78}
79
80#[composable]
82pub fn IconWith(
83 modifier: Modifier,
84 path: &'static str,
85 spec: IconSpec,
86 content_description: Option<String>,
87) -> NodeId {
88 let parsed = rememberKeyed(path, |value| VectorPath::parse(value).ok());
89 let size = spec.size;
90 let tint = spec.tint;
91 let modifier = modifier
92 .size(Size::new(size, size))
93 .draw_behind(move |scope| {
94 if let Some(path) = &parsed {
95 let scaled = path.scaled(size / ICON_VIEW_BOX);
96 match tint {
97 Some(tint) => scope.draw_vector_path(&scaled, Brush::solid(tint)),
98 None => {
99 scope.draw_vector_path(&scaled, Brush::solid(Color(0.0, 0.0, 0.0, 1.0)))
100 }
101 }
102 }
103 });
104 let modifier = match content_description {
105 Some(description) => modifier.semantics(move |config| {
106 config.content_description = Some(description.clone());
107 config.role = Some(SemanticsWidgetRole::Image);
108 }),
109 None => modifier,
112 };
113 Box(modifier, BoxSpec::default(), || {})
114}
115
116#[derive(Clone, Copy, Debug, Default, PartialEq)]
118pub struct IconButtonColors {
119 pub background: Option<Color>,
121 pub pressed_background: Option<Color>,
123 pub disabled_background: Option<Color>,
125}
126
127impl IconButtonColors {
128 pub const fn with_background(mut self, color: Color) -> Self {
130 self.background = Some(color);
131 self
132 }
133
134 pub const fn with_pressed_background(mut self, color: Color) -> Self {
136 self.pressed_background = Some(color);
137 self
138 }
139
140 pub const fn with_disabled_background(mut self, color: Color) -> Self {
142 self.disabled_background = Some(color);
143 self
144 }
145
146 fn surface(&self, enabled: bool, pressed: bool) -> Option<Color> {
148 if !enabled {
149 return self.disabled_background.or(self.background);
150 }
151 if pressed {
152 return self.pressed_background.or(self.background);
153 }
154 self.background
155 }
156}
157
158#[derive(Clone, Copy, Debug, PartialEq)]
160pub struct IconButtonSpec {
161 pub enabled: bool,
164 pub touch_target: f32,
167 pub colors: IconButtonColors,
169}
170
171impl Default for IconButtonSpec {
172 fn default() -> Self {
173 Self {
174 enabled: true,
175 touch_target: MINIMUM_TOUCH_TARGET,
176 colors: IconButtonColors::default(),
177 }
178 }
179}
180
181impl IconButtonSpec {
182 pub const fn with_enabled(mut self, enabled: bool) -> Self {
184 self.enabled = enabled;
185 self
186 }
187
188 pub fn with_touch_target(mut self, size: f32) -> Self {
191 self.touch_target = size.max(MINIMUM_TOUCH_TARGET);
192 self
193 }
194
195 pub const fn with_colors(mut self, colors: IconButtonColors) -> Self {
197 self.colors = colors;
198 self
199 }
200
201 pub fn resolved_touch_target(&self) -> f32 {
203 self.touch_target.max(MINIMUM_TOUCH_TARGET)
204 }
205}
206
207#[composable]
209pub fn IconButton<F>(
210 modifier: Modifier,
211 content_description: impl Into<String>,
212 on_click: impl Fn() + 'static,
213 content: F,
214) -> NodeId
215where
216 F: FnMut() + 'static,
217{
218 IconButtonWith(
219 modifier,
220 content_description,
221 IconButtonSpec::default(),
222 None,
223 on_click,
224 content,
225 )
226}
227
228#[composable]
235pub fn IconButtonWith<F>(
236 modifier: Modifier,
237 content_description: impl Into<String>,
238 spec: IconButtonSpec,
239 interaction_source: Option<MutableInteractionSource>,
240 on_click: impl Fn() + 'static,
241 content: F,
242) -> NodeId
243where
244 F: FnMut() + 'static,
245{
246 let description = content_description.into();
247 let enabled = spec.enabled;
248 let target = spec.resolved_touch_target();
249 let source = interaction_source.unwrap_or_else(rememberMutableInteractionSource);
250 let pressed = source.collectIsPressedAsState().get();
251
252 let mut modifier = modifier
253 .size(Size::new(target, target))
254 .press_interaction_source(source)
255 .semantics(move |config| {
256 config.content_description = Some(description.clone());
257 config.role = Some(SemanticsWidgetRole::Button);
258 config.enabled = enabled;
259 config.is_clickable = enabled;
260 });
261 if let Some(surface) = spec.colors.surface(enabled, pressed) {
262 modifier = modifier.background(surface);
263 }
264 if enabled {
265 modifier = modifier.clickable(move |_| on_click());
266 }
267
268 Box(
269 modifier,
270 BoxSpec::default().content_alignment(Alignment::CENTER),
271 content,
272 )
273}
274
275#[cfg(test)]
276mod tests {
277 use super::*;
278
279 #[test]
280 fn icon_path_is_parseable() {
281 assert!(VectorPath::parse("M0 0h24v24H0z").is_ok());
282 }
283
284 #[test]
285 fn an_icon_button_is_never_smaller_than_the_minimum_touch_target() {
286 let spec = IconButtonSpec::default().with_touch_target(20.0);
287 assert_eq!(spec.resolved_touch_target(), MINIMUM_TOUCH_TARGET);
288 let roomy = IconButtonSpec::default().with_touch_target(64.0);
289 assert_eq!(roomy.resolved_touch_target(), 64.0);
290 }
291
292 #[test]
293 fn colours_follow_the_state_and_fall_back_to_the_resting_surface() {
294 let rest = Color(0.1, 0.1, 0.1, 1.0);
295 let held = Color(0.2, 0.2, 0.2, 1.0);
296 let off = Color(0.3, 0.3, 0.3, 1.0);
297
298 let full = IconButtonColors::default()
299 .with_background(rest)
300 .with_pressed_background(held)
301 .with_disabled_background(off);
302 assert_eq!(full.surface(true, false), Some(rest));
303 assert_eq!(full.surface(true, true), Some(held));
304 assert_eq!(full.surface(false, false), Some(off));
305
306 let plain = IconButtonColors::default().with_background(rest);
309 assert_eq!(plain.surface(true, true), Some(rest));
310 assert_eq!(plain.surface(false, true), Some(rest));
311
312 assert_eq!(IconButtonColors::default().surface(true, true), None);
313 }
314
315 #[test]
316 fn an_icon_states_its_size_and_tint() {
317 assert_eq!(IconSpec::default().size, DEFAULT_ICON_SIZE);
318 assert_eq!(IconSpec::default().tint, None);
319 let spec = IconSpec::sized(16.0).with_tint(Color(1.0, 0.0, 0.0, 1.0));
320 assert_eq!(spec.size, 16.0);
321 assert_eq!(spec.tint, Some(Color(1.0, 0.0, 0.0, 1.0)));
322 assert_eq!(spec.with_size(32.0).size, 32.0);
323 }
324
325 #[test]
326 fn a_disabled_icon_button_still_publishes_itself() {
327 let spec = IconButtonSpec::default().with_enabled(false);
328 assert!(!spec.enabled);
329 assert_eq!(spec.resolved_touch_target(), MINIMUM_TOUCH_TARGET);
330 }
331}