Skip to main content

cranpose_ui/widgets/
icon.rs

1//! Generic vector icons and clickable icon buttons.
2
3use std::rc::Rc;
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(
73    path: &'static str,
74    content_description: Option<String>,
75    size: f32,
76    color: Color,
77) -> NodeId {
78    IconWith(
79        Modifier::empty(),
80        path,
81        IconSpec::sized(size).with_tint(color),
82        content_description,
83    )
84}
85
86/// An icon with a modifier, a spec, and an optional content description.
87#[composable]
88pub fn IconWith(
89    modifier: Modifier,
90    path: &'static str,
91    spec: IconSpec,
92    content_description: Option<String>,
93) -> NodeId {
94    let size = spec.size;
95    let scaled = rememberKeyed((path, size), |&(path, size)| {
96        icon_path(path).map(|parsed| Rc::new(parsed.scaled(size / ICON_VIEW_BOX)))
97    });
98    let tint = spec.tint.unwrap_or(Color(0.0, 0.0, 0.0, 1.0));
99    let modifier = modifier
100        .size(Size::new(size, size))
101        .draw_behind(move |scope| {
102            if let Some(path) = &scaled {
103                scope.draw_vector_path(path, Brush::solid(tint));
104            }
105        });
106    let modifier = match content_description {
107        Some(description) => modifier.semantics_spec(
108            cranpose_foundation::SemanticsSpec::new()
109                .content_description(description)
110                .role(SemanticsWidgetRole::Image),
111        ),
112        None => modifier,
113    };
114    Box(modifier, BoxSpec::default(), || {})
115}
116
117// `path` parsed once on this thread, shared by every icon that draws it. A
118// program holds a few dozen icon strings: a list costs less code than a map.
119fn icon_path(path: &'static str) -> Option<Rc<VectorPath>> {
120    crate::thread_state::with(|state| {
121        let mut paths = state.icon_paths.borrow_mut();
122        if let Some((_, parsed)) = paths.iter().find(|(known, _)| *known == path) {
123            return parsed.clone();
124        }
125        let parsed = VectorPath::parse(path).ok().map(Rc::new);
126        paths.push((path, parsed.clone()));
127        parsed
128    })
129}
130
131/// The colours an icon button paints itself with.
132#[derive(Clone, Copy, Debug, Default, PartialEq)]
133pub struct IconButtonColors {
134    /// The surface behind the icon at rest.
135    pub background: Option<Color>,
136    /// The surface while the button is held.
137    pub pressed_background: Option<Color>,
138    /// The surface while the button is disabled.
139    pub disabled_background: Option<Color>,
140}
141
142impl IconButtonColors {
143    /// Sets the resting surface.
144    pub const fn with_background(mut self, color: Color) -> Self {
145        self.background = Some(color);
146        self
147    }
148
149    /// Sets the held surface.
150    pub const fn with_pressed_background(mut self, color: Color) -> Self {
151        self.pressed_background = Some(color);
152        self
153    }
154
155    /// Sets the disabled surface.
156    pub const fn with_disabled_background(mut self, color: Color) -> Self {
157        self.disabled_background = Some(color);
158        self
159    }
160
161    fn surface(&self, enabled: bool, pressed: bool) -> Option<Color> {
162        if !enabled {
163            return self.disabled_background.or(self.background);
164        }
165        if pressed {
166            return self.pressed_background.or(self.background);
167        }
168        self.background
169    }
170}
171
172/// How an icon button behaves and looks.
173#[derive(Clone, Copy, Debug, PartialEq)]
174pub struct IconButtonSpec {
175    /// Whether the button accepts input. A disabled button still publishes
176    /// itself to a screen reader, which is how the user learns it exists.
177    pub enabled: bool,
178    /// The square the pointer target occupies, never smaller than
179    /// [`MINIMUM_TOUCH_TARGET`].
180    pub touch_target: f32,
181    /// The surfaces the button paints.
182    pub colors: IconButtonColors,
183}
184
185impl Default for IconButtonSpec {
186    fn default() -> Self {
187        Self {
188            enabled: true,
189            touch_target: MINIMUM_TOUCH_TARGET,
190            colors: IconButtonColors::default(),
191        }
192    }
193}
194
195impl IconButtonSpec {
196    /// Sets whether the button accepts input.
197    pub const fn with_enabled(mut self, enabled: bool) -> Self {
198        self.enabled = enabled;
199        self
200    }
201
202    /// Sets the pointer target size. Values below [`MINIMUM_TOUCH_TARGET`] are
203    /// raised to it: a control smaller than that is a control people miss.
204    pub fn with_touch_target(mut self, size: f32) -> Self {
205        self.touch_target = size.max(MINIMUM_TOUCH_TARGET);
206        self
207    }
208
209    /// Sets the surfaces.
210    pub const fn with_colors(mut self, colors: IconButtonColors) -> Self {
211        self.colors = colors;
212        self
213    }
214
215    /// The target size this spec actually uses.
216    pub fn resolved_touch_target(&self) -> f32 {
217        self.touch_target.max(MINIMUM_TOUCH_TARGET)
218    }
219}
220
221/// A semantic icon button with caller-provided content.
222#[composable]
223pub fn IconButton<F>(
224    modifier: Modifier,
225    content_description: impl Into<String>,
226    on_click: impl Fn() + 'static,
227    content: F,
228) -> NodeId
229where
230    F: FnMut() + 'static,
231{
232    IconButtonWith(
233        modifier,
234        content_description,
235        IconButtonSpec::default(),
236        None,
237        on_click,
238        content,
239    )
240}
241
242/// An icon button with an enabled state, colours, a touch target, and an
243/// interaction source the caller can observe.
244///
245/// Passing an interaction source is how a button's visual reacts to being held
246/// without the caller writing pointer handling: read
247/// [`MutableInteractionSource::collectIsPressedAsState`] in the content.
248#[composable]
249pub fn IconButtonWith<F>(
250    modifier: Modifier,
251    content_description: impl Into<String>,
252    spec: IconButtonSpec,
253    interaction_source: Option<MutableInteractionSource>,
254    on_click: impl Fn() + 'static,
255    content: F,
256) -> NodeId
257where
258    F: FnMut() + 'static,
259{
260    let description = content_description.into();
261    let enabled = spec.enabled;
262    let target = spec.resolved_touch_target();
263    let source = interaction_source.unwrap_or_else(rememberMutableInteractionSource);
264    let pressed = source.collectIsPressedAsState().get();
265
266    let mut modifier = modifier
267        .size(Size::new(target, target))
268        .press_interaction_source(source)
269        .stable_semantics(move |config| {
270            config.content_description = Some(description.clone());
271            config.role = Some(SemanticsWidgetRole::Button);
272            config.enabled = enabled;
273            config.is_clickable = enabled;
274        });
275    if let Some(surface) = spec.colors.surface(enabled, pressed) {
276        modifier = modifier.background(surface);
277    }
278    if enabled {
279        modifier = modifier.clickable(move |_| on_click());
280    }
281
282    Box(
283        modifier,
284        BoxSpec::default().content_alignment(Alignment::CENTER),
285        content,
286    )
287}
288
289#[cfg(test)]
290#[path = "tests/icon_tests.rs"]
291mod tests;