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