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