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}