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(path: &'static str, size: f32, color: Color) -> NodeId {
73 IconWith(
74 Modifier::empty(),
75 path,
76 IconSpec::sized(size).with_tint(color),
77 None,
78 )
79}
80
81#[composable]
83pub fn IconWith(
84 modifier: Modifier,
85 path: &'static str,
86 spec: IconSpec,
87 content_description: Option<String>,
88) -> NodeId {
89 let parsed = rememberKeyed(path, |value| VectorPath::parse(value).ok());
90 let size = spec.size;
91 let tint = spec.tint;
92 let modifier = modifier
93 .size(Size::new(size, size))
94 .draw_behind(move |scope| {
95 if let Some(path) = &parsed {
96 let scaled = path.scaled(size / ICON_VIEW_BOX);
97 match tint {
98 Some(tint) => scope.draw_vector_path(&scaled, Brush::solid(tint)),
99 None => {
100 scope.draw_vector_path(&scaled, Brush::solid(Color(0.0, 0.0, 0.0, 1.0)))
101 }
102 }
103 }
104 });
105 let modifier = match content_description {
106 Some(description) => modifier.semantics(move |config| {
107 config.content_description = Some(description.clone());
108 config.role = Some(SemanticsWidgetRole::Image);
109 }),
110 None => modifier,
111 };
112 Box(modifier, BoxSpec::default(), || {})
113}
114
115#[derive(Clone, Copy, Debug, Default, PartialEq)]
117pub struct IconButtonColors {
118 pub background: Option<Color>,
120 pub pressed_background: Option<Color>,
122 pub disabled_background: Option<Color>,
124}
125
126impl IconButtonColors {
127 pub const fn with_background(mut self, color: Color) -> Self {
129 self.background = Some(color);
130 self
131 }
132
133 pub const fn with_pressed_background(mut self, color: Color) -> Self {
135 self.pressed_background = Some(color);
136 self
137 }
138
139 pub const fn with_disabled_background(mut self, color: Color) -> Self {
141 self.disabled_background = Some(color);
142 self
143 }
144
145 fn surface(&self, enabled: bool, pressed: bool) -> Option<Color> {
146 if !enabled {
147 return self.disabled_background.or(self.background);
148 }
149 if pressed {
150 return self.pressed_background.or(self.background);
151 }
152 self.background
153 }
154}
155
156#[derive(Clone, Copy, Debug, PartialEq)]
158pub struct IconButtonSpec {
159 pub enabled: bool,
162 pub touch_target: f32,
165 pub colors: IconButtonColors,
167}
168
169impl Default for IconButtonSpec {
170 fn default() -> Self {
171 Self {
172 enabled: true,
173 touch_target: MINIMUM_TOUCH_TARGET,
174 colors: IconButtonColors::default(),
175 }
176 }
177}
178
179impl IconButtonSpec {
180 pub const fn with_enabled(mut self, enabled: bool) -> Self {
182 self.enabled = enabled;
183 self
184 }
185
186 pub fn with_touch_target(mut self, size: f32) -> Self {
189 self.touch_target = size.max(MINIMUM_TOUCH_TARGET);
190 self
191 }
192
193 pub const fn with_colors(mut self, colors: IconButtonColors) -> Self {
195 self.colors = colors;
196 self
197 }
198
199 pub fn resolved_touch_target(&self) -> f32 {
201 self.touch_target.max(MINIMUM_TOUCH_TARGET)
202 }
203}
204
205#[composable]
207pub fn IconButton<F>(
208 modifier: Modifier,
209 content_description: impl Into<String>,
210 on_click: impl Fn() + 'static,
211 content: F,
212) -> NodeId
213where
214 F: FnMut() + 'static,
215{
216 IconButtonWith(
217 modifier,
218 content_description,
219 IconButtonSpec::default(),
220 None,
221 on_click,
222 content,
223 )
224}
225
226#[composable]
233pub fn IconButtonWith<F>(
234 modifier: Modifier,
235 content_description: impl Into<String>,
236 spec: IconButtonSpec,
237 interaction_source: Option<MutableInteractionSource>,
238 on_click: impl Fn() + 'static,
239 content: F,
240) -> NodeId
241where
242 F: FnMut() + 'static,
243{
244 let description = content_description.into();
245 let enabled = spec.enabled;
246 let target = spec.resolved_touch_target();
247 let source = interaction_source.unwrap_or_else(rememberMutableInteractionSource);
248 let pressed = source.collectIsPressedAsState().get();
249
250 let mut modifier = modifier
251 .size(Size::new(target, target))
252 .press_interaction_source(source)
253 .semantics(move |config| {
254 config.content_description = Some(description.clone());
255 config.role = Some(SemanticsWidgetRole::Button);
256 config.enabled = enabled;
257 config.is_clickable = enabled;
258 });
259 if let Some(surface) = spec.colors.surface(enabled, pressed) {
260 modifier = modifier.background(surface);
261 }
262 if enabled {
263 modifier = modifier.clickable(move |_| on_click());
264 }
265
266 Box(
267 modifier,
268 BoxSpec::default().content_alignment(Alignment::CENTER),
269 content,
270 )
271}
272
273#[cfg(test)]
274mod tests {
275 use super::*;
276
277 #[test]
278 fn icon_path_is_parseable() {
279 assert!(VectorPath::parse("M0 0h24v24H0z").is_ok());
280 }
281
282 #[test]
283 fn an_icon_button_is_never_smaller_than_the_minimum_touch_target() {
284 let spec = IconButtonSpec::default().with_touch_target(20.0);
285 assert_eq!(spec.resolved_touch_target(), MINIMUM_TOUCH_TARGET);
286 let roomy = IconButtonSpec::default().with_touch_target(64.0);
287 assert_eq!(roomy.resolved_touch_target(), 64.0);
288 }
289
290 #[test]
291 fn colours_follow_the_state_and_fall_back_to_the_resting_surface() {
292 let rest = Color(0.1, 0.1, 0.1, 1.0);
293 let held = Color(0.2, 0.2, 0.2, 1.0);
294 let off = Color(0.3, 0.3, 0.3, 1.0);
295
296 let full = IconButtonColors::default()
297 .with_background(rest)
298 .with_pressed_background(held)
299 .with_disabled_background(off);
300 assert_eq!(full.surface(true, false), Some(rest));
301 assert_eq!(full.surface(true, true), Some(held));
302 assert_eq!(full.surface(false, false), Some(off));
303
304 let plain = IconButtonColors::default().with_background(rest);
305 assert_eq!(plain.surface(true, true), Some(rest));
306 assert_eq!(plain.surface(false, true), Some(rest));
307
308 assert_eq!(IconButtonColors::default().surface(true, true), None);
309 }
310
311 #[test]
312 fn an_icon_states_its_size_and_tint() {
313 assert_eq!(IconSpec::default().size, DEFAULT_ICON_SIZE);
314 assert_eq!(IconSpec::default().tint, None);
315 let spec = IconSpec::sized(16.0).with_tint(Color(1.0, 0.0, 0.0, 1.0));
316 assert_eq!(spec.size, 16.0);
317 assert_eq!(spec.tint, Some(Color(1.0, 0.0, 0.0, 1.0)));
318 assert_eq!(spec.with_size(32.0).size, 32.0);
319 }
320
321 #[test]
322 fn a_disabled_icon_button_still_publishes_itself() {
323 let spec = IconButtonSpec::default().with_enabled(false);
324 assert!(!spec.enabled);
325 assert_eq!(spec.resolved_touch_target(), MINIMUM_TOUCH_TARGET);
326 }
327}