Skip to main content

agg_gui/
vector_icon.rs

1//! Host-registered vector icons — small, theme-aware line art that
2//! widgets can name by id instead of carrying draw code or bitmaps.
3//!
4//! ## Why this exists
5//!
6//! Schema types such as [`EditorKind`](crate::widgets::EditorKind) are
7//! cheap-to-clone *data*: they travel through property snapshots, get
8//! compared, and cross crate boundaries many times per frame. An editor
9//! that wants to show icons therefore names them with strings
10//! (`"boolean.combine"`) and the *host* registers the artwork once at
11//! startup through [`register_icon`]. The renderer looks the id up at
12//! paint time and falls back to text when nothing is registered, so a
13//! missing icon degrades instead of blanking the control.
14//!
15//! ## Shape of an icon
16//!
17//! A [`VectorIcon`] is a square `view_box` plus an ordered list of
18//! filled [`IconPath`]s — painter's algorithm, first path at the back.
19//! Each path stores already-flattened closed contours, so painting is
20//! nothing but `move_to` / `line_to` / `fill` on any [`DrawCtx`]: no
21//! closures, no captured state, and the geometry is inspectable from
22//! tests. Contours are produced from ordinary SVG path data by
23//! [`path_data::parse_path`], which lets a host paste the `d` attribute
24//! of hand-authored artwork in verbatim.
25//!
26//! ## Colour roles
27//!
28//! Fills are [`IconColor`] *roles*, not colours, and resolve at paint
29//! time: [`IconColor::Ink`] becomes the caller's ink colour (the theme
30//! text colour, so outlines follow light/dark themes), while
31//! [`IconColor::Literal`] passes through untouched for colours that
32//! encode *state* rather than chrome. This mirrors MatterCAD's
33//! `GrayToColor` recolouring rule, where a desaturated grey is chrome
34//! and anything saturated is meaning.
35//!
36//! ## Coordinates
37//!
38//! Icon space is **SVG space**: origin top-left, +Y downward, `0..view_box`
39//! on both axes. agg-gui is Y-up, so [`VectorIcon::paint`] flips Y while
40//! mapping into the destination rect. Authoring stays copy-paste
41//! compatible with SVG; nothing downstream has to think about it.
42
43use std::sync::Arc;
44
45use crate::color::Color;
46use crate::draw_ctx::{DrawCtx, FillRule};
47use crate::geometry::Rect;
48
49pub mod path_data;
50mod registry;
51
52#[cfg(test)]
53mod tests;
54
55pub use path_data::{parse_path, IconPathError};
56pub use registry::{icon, icon_ids, register_icon};
57
58/// One fill colour of an icon, resolved when the icon is painted.
59#[derive(Clone, Copy, Debug, PartialEq)]
60pub enum IconColor {
61    /// UI chrome — outlines, glyphs. Painted in the caller's ink
62    /// colour, which is normally the theme's text colour, so the
63    /// linework follows the theme.
64    Ink,
65    /// A literal colour that must survive theming unchanged, because it
66    /// encodes state (MatterCAD's "removed material" grey, "kept
67    /// material" blue, "retained remover" red).
68    Literal(Color),
69}
70
71impl IconColor {
72    /// The colour to fill with, given the caller's ink colour.
73    pub fn resolve(self, ink: Color) -> Color {
74        match self {
75            IconColor::Ink => ink,
76            IconColor::Literal(c) => c,
77        }
78    }
79}
80
81/// One filled sub-shape of an icon: a set of closed contours in icon
82/// space, a colour role, and the fill rule that combines the contours
83/// (`EvenOdd` is how SVG artwork punches a hole through a ring).
84#[derive(Clone, Debug, PartialEq)]
85pub struct IconPath {
86    pub contours: Vec<Vec<[f64; 2]>>,
87    pub fill: IconColor,
88    pub fill_rule: FillRule,
89}
90
91/// A resolution-independent icon: a square `view_box` and the filled
92/// paths inside it, back to front.
93#[derive(Clone, Debug, Default, PartialEq)]
94pub struct VectorIcon {
95    /// Side length of the (square) authoring box, e.g. `64.0` for a
96    /// `viewBox="0 0 64 64"` SVG.
97    pub view_box: f64,
98    pub paths: Vec<IconPath>,
99}
100
101impl VectorIcon {
102    /// An empty icon with the given square view box.
103    pub fn new(view_box: f64) -> Self {
104        Self {
105            view_box,
106            paths: Vec::new(),
107        }
108    }
109
110    /// Append a filled path parsed from SVG path data (`M`, `L`, `H`,
111    /// `V`, `C`, `A`, `Z`, absolute or relative).
112    ///
113    /// Returns the parse error rather than dropping the path: an icon
114    /// that silently lost a contour would render as subtly wrong art
115    /// with nothing to point at.
116    pub fn with_svg_path(
117        mut self,
118        d: &str,
119        fill: IconColor,
120        fill_rule: FillRule,
121    ) -> Result<Self, IconPathError> {
122        let contours = parse_path(d)?;
123        self.paths.push(IconPath {
124            contours,
125            fill,
126            fill_rule,
127        });
128        Ok(self)
129    }
130
131    /// `with_svg_path` with the default non-zero fill rule.
132    pub fn with_svg_path_nonzero(self, d: &str, fill: IconColor) -> Result<Self, IconPathError> {
133        self.with_svg_path(d, fill, FillRule::NonZero)
134    }
135
136    /// Total number of points across every contour — a cheap "is there
137    /// any art here?" probe for tests and diagnostics.
138    pub fn point_count(&self) -> usize {
139        self.paths
140            .iter()
141            .flat_map(|p| p.contours.iter())
142            .map(|c| c.len())
143            .sum()
144    }
145
146    /// Bounding box `[min_x, min_y, max_x, max_y]` in icon space, or
147    /// `None` when the icon has no points.
148    pub fn bounds(&self) -> Option<[f64; 4]> {
149        let mut b: Option<[f64; 4]> = None;
150        for p in &self.paths {
151            for c in &p.contours {
152                for pt in c {
153                    b = Some(match b {
154                        None => [pt[0], pt[1], pt[0], pt[1]],
155                        Some(v) => [
156                            v[0].min(pt[0]),
157                            v[1].min(pt[1]),
158                            v[2].max(pt[0]),
159                            v[3].max(pt[1]),
160                        ],
161                    });
162                }
163            }
164        }
165        b
166    }
167
168    /// Paint the icon centred inside `rect`, scaled to fit (uniformly,
169    /// aspect preserved) and flipped from SVG's Y-down space into
170    /// agg-gui's Y-up space. `ink` resolves [`IconColor::Ink`] fills.
171    pub fn paint(&self, ctx: &mut dyn DrawCtx, rect: Rect, ink: Color) {
172        if self.view_box <= 0.0 || self.paths.is_empty() {
173            return;
174        }
175        let side = rect.width.min(rect.height);
176        if side <= 0.0 {
177            return;
178        }
179        let k = side / self.view_box;
180        let ox = rect.x + (rect.width - side) * 0.5;
181        // Y flip: icon y = 0 is the *top* of the box, which is the
182        // highest y in agg-gui's bottom-left origin space.
183        let top = rect.y + (rect.height + side) * 0.5;
184
185        for path in &self.paths {
186            if path.contours.iter().all(|c| c.len() < 2) {
187                continue;
188            }
189            ctx.set_fill_color(path.fill.resolve(ink));
190            ctx.set_fill_rule(path.fill_rule);
191            ctx.begin_path();
192            for contour in &path.contours {
193                if contour.len() < 2 {
194                    continue;
195                }
196                for (i, pt) in contour.iter().enumerate() {
197                    let x = ox + pt[0] * k;
198                    let y = top - pt[1] * k;
199                    if i == 0 {
200                        ctx.move_to(x, y);
201                    } else {
202                        ctx.line_to(x, y);
203                    }
204                }
205                ctx.close_path();
206            }
207            ctx.fill();
208        }
209        // Leave the shared context on the default rule; a later fill
210        // that never asked for even-odd must not inherit it.
211        ctx.set_fill_rule(FillRule::NonZero);
212    }
213}
214
215/// Convenience for hosts: build an icon from `(path data, colour role,
216/// fill rule)` triples in painter order.
217pub fn icon_from_svg_paths(
218    view_box: f64,
219    paths: &[(&str, IconColor, FillRule)],
220) -> Result<VectorIcon, IconPathError> {
221    let mut icon = VectorIcon::new(view_box);
222    for (d, fill, rule) in paths {
223        icon = icon.with_svg_path(d, *fill, *rule)?;
224    }
225    Ok(icon)
226}
227
228/// Register `icon` under `id`, replacing any icon already registered
229/// there. Sugar over [`register_icon`] for the build-then-register
230/// flow.
231pub fn register_svg_icon(
232    id: impl Into<Arc<str>>,
233    view_box: f64,
234    paths: &[(&str, IconColor, FillRule)],
235) -> Result<(), IconPathError> {
236    let icon = icon_from_svg_paths(view_box, paths)?;
237    register_icon(id, icon);
238    Ok(())
239}