Skip to main content

cranpose_ui/widgets/
icon.rs

1//! Generic vector icons and clickable icon buttons.
2
3#![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
14/// The coordinate system every icon path is authored in.
15const ICON_VIEW_BOX: f32 = 24.0;
16
17/// The size an icon is drawn at when the caller states none.
18pub const DEFAULT_ICON_SIZE: f32 = 24.0;
19
20/// The smallest square a pointer target may be.
21///
22/// Every platform's accessibility guidance lands on the same number, and it is
23/// why an icon button is bigger than its icon: the glyph is 24dp and the target
24/// around it is 48dp, so a 24dp icon is still comfortable to hit.
25pub const MINIMUM_TOUCH_TARGET: f32 = 48.0;
26
27/// How an icon is drawn.
28#[derive(Clone, Copy, Debug, PartialEq)]
29pub struct IconSpec {
30    /// The square the icon is drawn in.
31    pub size: f32,
32    /// The tint. `None` leaves the path's own colouring, which is what a
33    /// multi-colour illustration wants.
34    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    /// An icon of `size`, untinted.
48    pub const fn sized(size: f32) -> Self {
49        Self { size, tint: None }
50    }
51
52    /// Sets the drawn size.
53    pub const fn with_size(mut self, size: f32) -> Self {
54        self.size = size;
55        self
56    }
57
58    /// Sets the tint.
59    pub const fn with_tint(mut self, tint: Color) -> Self {
60        self.tint = Some(tint);
61        self
62    }
63}
64
65/// Draws an SVG path in the standard 24dp icon coordinate system.
66///
67/// An icon is decorative by default: it carries no content description, because
68/// the control around it names the action. Use [`IconWith`] to give a
69/// standalone icon a description a screen reader reads out.
70#[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/// An icon with a modifier, a spec, and an optional content description.
81#[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        // A decorative icon publishes nothing, so a screen reader reads the
110        // control around it once instead of reading the glyph too.
111        None => modifier,
112    };
113    Box(modifier, BoxSpec::default(), || {})
114}
115
116/// The colours an icon button paints itself with.
117#[derive(Clone, Copy, Debug, Default, PartialEq)]
118pub struct IconButtonColors {
119    /// The surface behind the icon at rest.
120    pub background: Option<Color>,
121    /// The surface while the button is held.
122    pub pressed_background: Option<Color>,
123    /// The surface while the button is disabled.
124    pub disabled_background: Option<Color>,
125}
126
127impl IconButtonColors {
128    /// Sets the resting surface.
129    pub const fn with_background(mut self, color: Color) -> Self {
130        self.background = Some(color);
131        self
132    }
133
134    /// Sets the held surface.
135    pub const fn with_pressed_background(mut self, color: Color) -> Self {
136        self.pressed_background = Some(color);
137        self
138    }
139
140    /// Sets the disabled surface.
141    pub const fn with_disabled_background(mut self, color: Color) -> Self {
142        self.disabled_background = Some(color);
143        self
144    }
145
146    /// The surface for the state the button is in.
147    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/// How an icon button behaves and looks.
159#[derive(Clone, Copy, Debug, PartialEq)]
160pub struct IconButtonSpec {
161    /// Whether the button accepts input. A disabled button still publishes
162    /// itself to a screen reader, which is how the user learns it exists.
163    pub enabled: bool,
164    /// The square the pointer target occupies, never smaller than
165    /// [`MINIMUM_TOUCH_TARGET`].
166    pub touch_target: f32,
167    /// The surfaces the button paints.
168    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    /// Sets whether the button accepts input.
183    pub const fn with_enabled(mut self, enabled: bool) -> Self {
184        self.enabled = enabled;
185        self
186    }
187
188    /// Sets the pointer target size. Values below [`MINIMUM_TOUCH_TARGET`] are
189    /// raised to it: a control smaller than that is a control people miss.
190    pub fn with_touch_target(mut self, size: f32) -> Self {
191        self.touch_target = size.max(MINIMUM_TOUCH_TARGET);
192        self
193    }
194
195    /// Sets the surfaces.
196    pub const fn with_colors(mut self, colors: IconButtonColors) -> Self {
197        self.colors = colors;
198        self
199    }
200
201    /// The target size this spec actually uses.
202    pub fn resolved_touch_target(&self) -> f32 {
203        self.touch_target.max(MINIMUM_TOUCH_TARGET)
204    }
205}
206
207/// A semantic icon button with caller-provided content.
208#[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/// An icon button with an enabled state, colours, a touch target, and an
229/// interaction source the caller can observe.
230///
231/// Passing an interaction source is how a button's visual reacts to being held
232/// without the caller writing pointer handling: read
233/// [`MutableInteractionSource::collectIsPressedAsState`] in the content.
234#[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        // A button that states only its resting surface keeps it in every
307        // state rather than flashing to nothing when held.
308        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}