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,
113 };
114 Box(modifier, BoxSpec::default(), || {})
115}
116
117#[derive(Clone, Copy, Debug, Default, PartialEq)]
119pub struct IconButtonColors {
120 pub background: Option<Color>,
122 pub pressed_background: Option<Color>,
124 pub disabled_background: Option<Color>,
126}
127
128impl IconButtonColors {
129 pub const fn with_background(mut self, color: Color) -> Self {
131 self.background = Some(color);
132 self
133 }
134
135 pub const fn with_pressed_background(mut self, color: Color) -> Self {
137 self.pressed_background = Some(color);
138 self
139 }
140
141 pub const fn with_disabled_background(mut self, color: Color) -> Self {
143 self.disabled_background = Some(color);
144 self
145 }
146
147 fn surface(&self, enabled: bool, pressed: bool) -> Option<Color> {
149 if !enabled {
150 return self.disabled_background.or(self.background);
151 }
152 if pressed {
153 return self.pressed_background.or(self.background);
154 }
155 self.background
156 }
157}
158
159#[derive(Clone, Copy, Debug, PartialEq)]
161pub struct IconButtonSpec {
162 pub enabled: bool,
165 pub touch_target: f32,
168 pub colors: IconButtonColors,
170}
171
172impl Default for IconButtonSpec {
173 fn default() -> Self {
174 Self {
175 enabled: true,
176 touch_target: MINIMUM_TOUCH_TARGET,
177 colors: IconButtonColors::default(),
178 }
179 }
180}
181
182impl IconButtonSpec {
183 pub const fn with_enabled(mut self, enabled: bool) -> Self {
185 self.enabled = enabled;
186 self
187 }
188
189 pub fn with_touch_target(mut self, size: f32) -> Self {
192 self.touch_target = size.max(MINIMUM_TOUCH_TARGET);
193 self
194 }
195
196 pub const fn with_colors(mut self, colors: IconButtonColors) -> Self {
198 self.colors = colors;
199 self
200 }
201
202 pub fn resolved_touch_target(&self) -> f32 {
204 self.touch_target.max(MINIMUM_TOUCH_TARGET)
205 }
206}
207
208#[composable]
210pub fn IconButton<F>(
211 modifier: Modifier,
212 content_description: impl Into<String>,
213 on_click: impl Fn() + 'static,
214 content: F,
215) -> NodeId
216where
217 F: FnMut() + 'static,
218{
219 IconButtonWith(
220 modifier,
221 content_description,
222 IconButtonSpec::default(),
223 None,
224 on_click,
225 content,
226 )
227}
228
229#[composable]
236pub fn IconButtonWith<F>(
237 modifier: Modifier,
238 content_description: impl Into<String>,
239 spec: IconButtonSpec,
240 interaction_source: Option<MutableInteractionSource>,
241 on_click: impl Fn() + 'static,
242 content: F,
243) -> NodeId
244where
245 F: FnMut() + 'static,
246{
247 let description = content_description.into();
248 let enabled = spec.enabled;
249 let target = spec.resolved_touch_target();
250 let source = interaction_source.unwrap_or_else(rememberMutableInteractionSource);
251 let pressed = source.collectIsPressedAsState().get();
252
253 let mut modifier = modifier
254 .size(Size::new(target, target))
255 .press_interaction_source(source)
256 .semantics(move |config| {
257 config.content_description = Some(description.clone());
258 config.role = Some(SemanticsWidgetRole::Button);
259 config.enabled = enabled;
260 config.is_clickable = enabled;
261 });
262 if let Some(surface) = spec.colors.surface(enabled, pressed) {
263 modifier = modifier.background(surface);
264 }
265 if enabled {
266 modifier = modifier.clickable(move |_| on_click());
267 }
268
269 Box(
270 modifier,
271 BoxSpec::default().content_alignment(Alignment::CENTER),
272 content,
273 )
274}
275
276#[cfg(test)]
277mod tests {
278 use super::*;
279
280 #[test]
281 fn icon_path_is_parseable() {
282 assert!(VectorPath::parse("M0 0h24v24H0z").is_ok());
283 }
284
285 #[test]
286 fn an_icon_button_is_never_smaller_than_the_minimum_touch_target() {
287 let spec = IconButtonSpec::default().with_touch_target(20.0);
288 assert_eq!(spec.resolved_touch_target(), MINIMUM_TOUCH_TARGET);
289 let roomy = IconButtonSpec::default().with_touch_target(64.0);
290 assert_eq!(roomy.resolved_touch_target(), 64.0);
291 }
292
293 #[test]
294 fn colours_follow_the_state_and_fall_back_to_the_resting_surface() {
295 let rest = Color(0.1, 0.1, 0.1, 1.0);
296 let held = Color(0.2, 0.2, 0.2, 1.0);
297 let off = Color(0.3, 0.3, 0.3, 1.0);
298
299 let full = IconButtonColors::default()
300 .with_background(rest)
301 .with_pressed_background(held)
302 .with_disabled_background(off);
303 assert_eq!(full.surface(true, false), Some(rest));
304 assert_eq!(full.surface(true, true), Some(held));
305 assert_eq!(full.surface(false, false), Some(off));
306
307 let plain = IconButtonColors::default().with_background(rest);
310 assert_eq!(plain.surface(true, true), Some(rest));
311 assert_eq!(plain.surface(false, true), Some(rest));
312
313 assert_eq!(IconButtonColors::default().surface(true, true), None);
314 }
315
316 #[test]
317 fn an_icon_states_its_size_and_tint() {
318 assert_eq!(IconSpec::default().size, DEFAULT_ICON_SIZE);
319 assert_eq!(IconSpec::default().tint, None);
320 let spec = IconSpec::sized(16.0).with_tint(Color(1.0, 0.0, 0.0, 1.0));
321 assert_eq!(spec.size, 16.0);
322 assert_eq!(spec.tint, Some(Color(1.0, 0.0, 0.0, 1.0)));
323 assert_eq!(spec.with_size(32.0).size, 32.0);
324 }
325
326 #[test]
327 fn a_disabled_icon_button_still_publishes_itself() {
328 let spec = IconButtonSpec::default().with_enabled(false);
329 assert!(!spec.enabled);
330 assert_eq!(spec.resolved_touch_target(), MINIMUM_TOUCH_TARGET);
331 }
332}