Skip to main content

denise_render/
icon.rs

1//! Shapes a widget can draw when the font has no glyph for them.
2//!
3//! # Why this exists
4//!
5//! A `⌫` on a Backspace key is a picture of an idea, and whether it can be
6//! drawn at all currently depends on which font happens to be installed. The
7//! answers differ more than one would guess: DejaVu has `⌫`, `⇥`, `⏎` and the
8//! cursor triangles; a Mac's Arial has none of them and no triangle either; and
9//! the face that ships with this crate has twenty-three non-ASCII glyphs of
10//! which not one is either. A key that says "back" is legible everywhere and
11//! looks like a compromise; a key that says `⌫` looks right and is a box on the
12//! machine least able to spare one.
13//!
14//! An icon is drawn rather than looked up, so it is the same on every machine.
15//!
16//! # Filled polygons, and nothing else
17//!
18//! There is no path builder here and still is not one. An [`Icon`] is a short
19//! list of closed polygons on a [`GRID`]-square box, scaled into whatever
20//! rectangle it is asked for — which is enough for every shape a key or a
21//! toolbar wants, and stops well short of a vector format this crate would have
22//! to support forever.
23//!
24//! Strokes are absent for a reason rather than an oversight:
25//! [`Canvas::draw_line`](crate::Canvas::draw_line) has no thickness and
26//! deliberately does not, so a one-pixel outline on a 48-pixel key would be
27//! invisible. A shape that reads as an outline is drawn as a filled polygon
28//! with the middle knocked back out in [`Ink::Back`] — which is also how the
29//! `×` inside `⌫` is made.
30//!
31//! # Coordinates
32//!
33//! Integers on a `0..=`[`GRID`] box, y downwards, scaled with integer
34//! arithmetic. No floating point anywhere: this crate has neither `std` nor
35//! `libm`, and the whole rasteriser is built on that.
36
37use denise::{Color, Rect};
38
39use crate::canvas::Canvas;
40use crate::rounded::{COORD_LIMIT, ONE};
41
42/// The side of the square an icon's coordinates are given on.
43///
44/// A hundred because it reads as a percentage and divides by enough to place
45/// things on halves, quarters and fifths without fractions.
46pub const GRID: i32 = 100;
47
48/// The most polygons one icon may have.
49///
50/// Six is a filled shape, a hole and room to spare. An icon needing more than
51/// this is a drawing, and a drawing belongs in a `denise-image` decoder.
52pub const MAX_SHAPES: usize = 6;
53
54/// Which of the two colours a shape is drawn in.
55#[derive(Clone, Copy, Debug, PartialEq, Eq)]
56pub enum Ink {
57    /// The content colour — the same one the label would be drawn in.
58    Fore,
59    /// The colour behind the icon, for knocking a hole out of a filled shape.
60    ///
61    /// The only way to draw an outline here, since the filler has no stroke and
62    /// no even-odd rule. It is why `⌫` can have an `×` in it.
63    Back,
64}
65
66/// One closed polygon of an icon.
67#[derive(Clone, Copy, Debug)]
68pub struct Shape {
69    /// Vertices on the `0..=`[`GRID`] box, y downwards, in order round the
70    /// outline. Three at least; [`MAX_VERTICES`](crate::MAX_ICON_VERTICES) at most.
71    pub points: &'static [(i16, i16)],
72    /// Which colour it is drawn in.
73    pub ink: Ink,
74}
75
76impl Shape {
77    /// A shape in the content colour.
78    pub const fn fore(points: &'static [(i16, i16)]) -> Self {
79        Self {
80            points,
81            ink: Ink::Fore,
82        }
83    }
84
85    /// A shape knocked back out in the background colour.
86    pub const fn back(points: &'static [(i16, i16)]) -> Self {
87        Self {
88            points,
89            ink: Ink::Back,
90        }
91    }
92}
93
94/// A small drawing, in shapes rather than glyphs.
95///
96/// Order matters: shapes are drawn front to back in the order given, so a hole
97/// comes after the shape it is punched in.
98#[derive(Clone, Copy, Debug)]
99pub struct Icon {
100    /// The polygons, in drawing order.
101    pub shapes: &'static [Shape],
102}
103
104impl Icon {
105    /// An icon from its shapes.
106    pub const fn new(shapes: &'static [Shape]) -> Self {
107        Self { shapes }
108    }
109}
110
111impl Canvas<'_> {
112    /// Draws an icon into `rect`, scaled from its grid.
113    ///
114    /// `fore` is the content colour and `back` is whatever the icon is sitting
115    /// on — a shape marked [`Ink::Back`] is drawn in it, which is how an outline
116    /// or a cut-out is made.
117    ///
118    /// The icon is scaled to `rect` and **not** kept square: give it a square
119    /// rectangle if you want it square. Anything beyond
120    /// [`MAX_SHAPES`] is ignored rather than drawn wrong.
121    pub fn draw_icon(&mut self, icon: &Icon, rect: Rect, fore: Color, back: Color) {
122        if rect.is_empty() || GRID <= 0 {
123            return;
124        }
125        for shape in icon.shapes.iter().take(MAX_SHAPES) {
126            let mut points = [(0i32, 0i32); crate::MAX_ICON_VERTICES];
127            let n = shape.points.len().min(crate::MAX_ICON_VERTICES);
128            if n < 3 {
129                continue;
130            }
131            for (slot, &(gx, gy)) in points.iter_mut().zip(shape.points).take(n) {
132                *slot = (
133                    fx_along(rect.x, rect.width, gx),
134                    fx_along(rect.y, rect.height, gy),
135                );
136            }
137            let paint = match shape.ink {
138                Ink::Fore => fore,
139                Ink::Back => back,
140            };
141            self.fill_polygon_fx(&points[..n], paint.into());
142        }
143    }
144}
145
146/// One grid coordinate to a fixed-point position along an axis.
147///
148/// In fixed point rather than whole pixels so the filler can anti-alias the
149/// edge: a triangle snapped to pixel corners at 48 px has visibly ragged
150/// diagonals, and the filler is already doing the subpixel arithmetic.
151#[inline]
152fn fx_along(origin: i32, extent: i32, grid: i16) -> i32 {
153    let offset = (i64::from(grid) * i64::from(extent) * i64::from(ONE)) / i64::from(GRID);
154    let base = i64::from(origin.clamp(-COORD_LIMIT, COORD_LIMIT)) * i64::from(ONE);
155    (base + offset).clamp(i64::from(i32::MIN), i64::from(i32::MAX)) as i32
156}
157
158#[cfg(test)]
159mod tests {
160    use super::*;
161    use crate::testing::TestCanvas;
162
163    const FORE: Color = Color::rgb(255, 255, 255);
164    const BACK: Color = Color::rgb(0, 0, 0);
165
166    /// The whole box, so a fill covers everything and a hole is unmistakable.
167    static SQUARE: Icon = Icon::new(&[Shape::fore(&[(0, 0), (100, 0), (100, 100), (0, 100)])]);
168
169    /// A square with the middle taken back out.
170    static RING: Icon = Icon::new(&[
171        Shape::fore(&[(0, 0), (100, 0), (100, 100), (0, 100)]),
172        Shape::back(&[(30, 30), (70, 30), (70, 70), (30, 70)]),
173    ]);
174
175    #[test]
176    fn an_icon_fills_the_rectangle_it_is_given() {
177        let mut c = TestCanvas::new(40, 40);
178        c.canvas()
179            .draw_icon(&SQUARE, Rect::new(8, 8, 24, 24), FORE, BACK);
180
181        assert_eq!(c.at(20, 20), FORE.to_argb8888(), "the middle is not filled");
182        assert_eq!(c.at(2, 2), 0, "it painted outside its rectangle");
183        assert_eq!(c.at(37, 37), 0, "it painted outside its rectangle");
184    }
185
186    /// The reason [`Ink::Back`] exists: the filler has no stroke and no
187    /// even-odd rule, so an outline is a fill with the middle knocked out.
188    #[test]
189    fn a_back_shape_knocks_a_hole_in_the_one_before_it() {
190        let mut c = TestCanvas::new(40, 40);
191        c.canvas()
192            .draw_icon(&RING, Rect::new(0, 0, 40, 40), FORE, BACK);
193
194        assert_eq!(c.at(4, 20), FORE.to_argb8888(), "the ring is missing");
195        assert_eq!(c.at(20, 20), BACK.to_argb8888(), "the hole was not punched");
196    }
197
198    /// Order is drawing order: a hole before its shape is painted over.
199    #[test]
200    fn a_hole_before_its_shape_is_covered_by_it() {
201        static WRONG: Icon = Icon::new(&[
202            Shape::back(&[(30, 30), (70, 30), (70, 70), (30, 70)]),
203            Shape::fore(&[(0, 0), (100, 0), (100, 100), (0, 100)]),
204        ]);
205        let mut c = TestCanvas::new(40, 40);
206        c.canvas()
207            .draw_icon(&WRONG, Rect::new(0, 0, 40, 40), FORE, BACK);
208        assert_eq!(
209            c.at(20, 20),
210            FORE.to_argb8888(),
211            "shapes are drawn in order, so this hole should have been covered"
212        );
213    }
214
215    /// The same icon at twice the size is the same icon, not a bigger sample of
216    /// it. This is what a mask could not do and the reason these are polygons.
217    #[test]
218    fn an_icon_scales_rather_than_magnifies() {
219        let mut small = TestCanvas::new(32, 32);
220        small
221            .canvas()
222            .draw_icon(&RING, Rect::new(0, 0, 16, 16), FORE, BACK);
223        let mut large = TestCanvas::new(32, 32);
224        large
225            .canvas()
226            .draw_icon(&RING, Rect::new(0, 0, 32, 32), FORE, BACK);
227
228        // Proportionally the same places: the ring at a tenth in, the hole in
229        // the middle.
230        for (c, side) in [(&small, 16), (&large, 32)] {
231            let edge = side / 10;
232            assert_eq!(
233                c.at(edge, side / 2),
234                FORE.to_argb8888(),
235                "the ring is missing at {side}px"
236            );
237            assert_eq!(
238                c.at(side / 2, side / 2),
239                BACK.to_argb8888(),
240                "the hole is missing at {side}px"
241            );
242        }
243    }
244
245    /// An empty rectangle draws nothing rather than dividing by zero.
246    #[test]
247    fn an_empty_rectangle_is_not_drawn() {
248        let mut c = TestCanvas::new(8, 8);
249        c.canvas()
250            .draw_icon(&SQUARE, Rect::new(2, 2, 0, 6), FORE, BACK);
251        c.canvas()
252            .draw_icon(&SQUARE, Rect::new(2, 2, 6, 0), FORE, BACK);
253        assert!(c.pixels().iter().all(|&p| p == 0), "something was drawn");
254    }
255
256    /// A shape with fewer than three points is skipped, not drawn wrong.
257    #[test]
258    fn a_degenerate_shape_is_skipped() {
259        static LINE: Icon = Icon::new(&[
260            Shape::fore(&[(0, 0), (100, 100)]),
261            Shape::fore(&[(0, 40), (100, 40), (100, 60), (0, 60)]),
262        ]);
263        let mut c = TestCanvas::new(20, 20);
264        c.canvas()
265            .draw_icon(&LINE, Rect::new(0, 0, 20, 20), FORE, BACK);
266        // The band still drew, so the skip did not abandon the rest.
267        assert_eq!(c.at(10, 10), FORE.to_argb8888(), "the valid shape was lost");
268    }
269}