Skip to main content

oxframe_2d/
icon.rs

1//! An icon from a set: an SVG on a 24-unit grid whose color and line width
2//! are signals. `icon!("lucide:server")` in `oxframe` (feature `icons`)
3//! makes the builder; [`Icon::from_svg`] is what it expands to.
4
5use std::ops::Deref;
6
7use oxframe_core::{Color, Signal};
8
9use crate::builder::{BaseProps, NodeBuilder, SvgBuilder};
10use crate::graph::{NodeId, SceneGraph};
11use crate::handles::{Node, Path, Svg};
12use crate::theme::{Ink, IntoPaint};
13
14/// How an icon is painted: Lucide and Tabler outlines are strokes, filled
15/// variants and brand logos are fills.
16#[derive(Debug, Clone, Copy, PartialEq, Eq)]
17pub enum IconStyle {
18    /// Outlines, 2 units wide on the 24-unit grid.
19    Stroke,
20    /// Filled shapes.
21    Fill,
22}
23
24/// Builder for an [`Icon`]: `icon!("lucide:server").size(64.0).color(Accent)`.
25#[derive(Debug)]
26#[must_use = "a builder does nothing until it is passed to `add`"]
27pub struct IconBuilder {
28    svg: SvgBuilder,
29    style: IconStyle,
30    size: f64,
31    color: Option<Color>,
32    stroke_width: f64,
33}
34
35impl IconBuilder {
36    /// Width and height in pixels. Default 48.
37    pub fn size(mut self, v: f64) -> Self {
38        self.size = v;
39        self
40    }
41
42    /// Color of the strokes or fills. Default: the theme's text color.
43    pub fn color(mut self, v: impl IntoPaint) -> Self {
44        self.color = Some(v.into_paint());
45        self
46    }
47
48    /// Line width on the 24-unit grid, scaled with the size. Default 2;
49    /// a fill icon has no lines.
50    pub fn stroke_width(mut self, v: f64) -> Self {
51        self.stroke_width = v;
52        self
53    }
54}
55
56impl NodeBuilder for IconBuilder {
57    type Handle = Icon;
58
59    fn base(&mut self) -> &mut BaseProps {
60        self.svg.base()
61    }
62
63    fn build(self, g: &SceneGraph, parent: NodeId) -> Icon {
64        let color = Signal::new(self.color.unwrap_or_else(|| Ink::Text.into_paint()));
65        let stroke_width = Signal::new(self.stroke_width);
66        let style = self.style;
67        let svg = self.svg.width(self.size).height(self.size).build(g, parent);
68        for path in g.descendant_paths(svg.id()) {
69            let n = g.node(path);
70            if n.stroke.peek().to_rgba8()[3] > 0 {
71                let c = color.clone();
72                n.stroke.bind(move || c.get());
73                // Widths come scaled to the size; keep each path's own ratio.
74                let base = n.line_width.peek() / 2.0;
75                let w = stroke_width.clone();
76                n.line_width.bind(move || base * w.get());
77            }
78            if n.fill.peek().to_rgba8()[3] > 0 {
79                let c = color.clone();
80                n.fill.bind(move || c.get());
81            }
82        }
83        Icon {
84            svg,
85            style,
86            color,
87            stroke_width,
88        }
89    }
90}
91
92/// Handle to an icon. Derefs to [`Node`]; [`color`](Self::color) and
93/// [`stroke_width`](Self::stroke_width) animate every path of it.
94#[derive(Debug, Clone)]
95pub struct Icon {
96    svg: Svg,
97    style: IconStyle,
98    color: Signal<Color>,
99    stroke_width: Signal<f64>,
100}
101
102impl Icon {
103    /// A builder for an icon drawn by `svg`, a 24 × 24 SVG. Prefer `icon!`.
104    ///
105    /// # Panics
106    ///
107    /// When the icon is added, if `svg` does not parse.
108    pub fn from_svg(svg: &[u8], style: IconStyle) -> IconBuilder {
109        IconBuilder {
110            svg: Svg::new().src(svg),
111            style,
112            size: 48.0,
113            color: None,
114            stroke_width: 2.0,
115        }
116    }
117
118    /// The untyped node handle.
119    pub fn node(&self) -> Node {
120        *self.svg
121    }
122
123    /// The color of every stroke or fill.
124    pub fn color(&self) -> Signal<Color> {
125        self.color.clone()
126    }
127
128    /// The line width on the 24-unit grid.
129    pub fn stroke_width(&self) -> Signal<f64> {
130        self.stroke_width.clone()
131    }
132
133    /// Every path of the icon, in paint order.
134    pub fn paths(&self) -> Vec<Path> {
135        self.svg.paths()
136    }
137
138    /// Whether the icon is outlines or fills.
139    pub fn style(&self) -> IconStyle {
140        self.style
141    }
142}
143
144impl Deref for Icon {
145    type Target = Node;
146    fn deref(&self) -> &Node {
147        &self.svg
148    }
149}
150
151impl From<Icon> for Node {
152    fn from(h: Icon) -> Node {
153        *h.svg
154    }
155}
156
157impl From<&Icon> for Node {
158    fn from(h: &Icon) -> Node {
159        *h.svg
160    }
161}
162
163#[cfg(test)]
164mod tests {
165    use super::*;
166    use crate::View;
167    use crate::graph::SceneGraph;
168
169    const ARROW: &str = r##"<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="#000" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M5 12h14"/><path d="m12 5 7 7-7 7"/></svg>"##;
170    const DOT: &str = r##"<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="#000"><circle cx="12" cy="12" r="8"/></svg>"##;
171
172    #[test]
173    fn stroke_icon_follows_its_signals() {
174        SceneGraph::new(200, 200).enter(|| {
175            let icon = View::current().add(
176                Icon::from_svg(ARROW.as_bytes(), IconStyle::Stroke)
177                    .size(96.0)
178                    .color(Color::rgb(1.0, 0.0, 0.0)),
179            );
180            assert_eq!(icon.size(), (96.0, 96.0));
181            let paths = Svg::from_id(icon.id()).paths();
182            assert_eq!(paths.len(), 2);
183            assert_eq!(paths[0].stroke().get(), Color::rgb(1.0, 0.0, 0.0));
184            assert!((paths[0].line_width().get() - 8.0).abs() < 1e-6);
185            icon.color().set(Color::WHITE);
186            icon.stroke_width().set(1.0);
187            assert_eq!(paths[1].stroke().get(), Color::WHITE);
188            assert!((paths[1].line_width().get() - 4.0).abs() < 1e-6);
189            assert_eq!(paths[1].fill().get().to_rgba8()[3], 0);
190            assert_eq!(icon.style(), IconStyle::Stroke);
191        });
192    }
193
194    #[test]
195    fn fill_icon_colors_its_fill() {
196        SceneGraph::new(200, 200).enter(|| {
197            let icon = View::current()
198                .add(Icon::from_svg(DOT.as_bytes(), IconStyle::Fill).color(Color::WHITE));
199            let paths = Svg::from_id(icon.id()).paths();
200            assert_eq!(paths[0].fill().get(), Color::WHITE);
201            assert_eq!(icon.style(), IconStyle::Fill);
202        });
203    }
204}