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