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