Skip to main content

max / shop

10.9 KB · 245 lines History Blame Raw
1 //! The terminal cell, measured off the face rather than guessed.
2 //!
3 //! The cell comes from the face. The face is the only thing that knows how wide
4 //! its own glyphs are, so a cell derived from it cannot drift when the font is
5 //! replaced, and a hardcoded one always does.
6 //!
7 //! Cell furniture spans the full advance: `─` and `█` both cover the whole cell
8 //! width, because that is what they are for. A cell one pixel wider than the
9 //! advance therefore puts a hole every column in a run of `───` and draws `███`
10 //! as vertical stripes. Box drawing that does not tile is the single most
11 //! visible thing a terminal can get wrong.
12
13 use swash::FontRef;
14
15 /// A terminal cell, in logical pixels at one font size.
16 #[derive(Debug, Clone, Copy, PartialEq)]
17 pub struct CellMetrics {
18 /// Cell width: the face's own advance, rounded to a whole pixel.
19 pub advance: f32,
20 /// Cell height: the face's line height, rounded up to a whole pixel.
21 pub height: f32,
22 /// The face's own ascent at this size, unrounded.
23 pub ascent: f32,
24 /// Where the baseline sits, measured down from the top of the cell.
25 ///
26 /// Not the same as [`ascent`](Self::ascent). The cell is the line height
27 /// rounded up, so it has a little more room than the face asked for, and
28 /// that slack is shared between the two sides in the proportion the face
29 /// itself uses rather than all being dropped below the descender. It is
30 /// also what makes the cell-furniture snap exact: a glyph scaled about the
31 /// baseline by `height / exact_height` reaches the top and the bottom of
32 /// the cell precisely when the baseline sits here.
33 pub baseline: f32,
34 /// The advance before rounding, kept so the residual is measurable rather
35 /// than folklore. See [`rounding_error`](Self::rounding_error).
36 pub exact_advance: f32,
37 /// The line height before rounding.
38 pub exact_height: f32,
39 }
40
41 impl CellMetrics {
42 /// Measure a face at a size.
43 ///
44 /// Takes bytes rather than a live renderer on purpose: `shop` needs the
45 /// cell before it has a wgpu device, because the PTY is sized in cells and
46 /// is spawned first.
47 pub fn measure(font_data: &[u8], px: f32) -> anyhow::Result<Self> {
48 let font = FontRef::from_index(font_data, 0)
49 .ok_or_else(|| anyhow::anyhow!("swash: not a font"))?;
50 let metrics = font.metrics(&[]).scale(px);
51
52 // One representative glyph rather than `average_width` or `max_width`.
53 // The grid has no concept of a per-glyph width anywhere outside a
54 // cluster, so what is wanted is the advance every cell will actually
55 // use, and in a monospace face `M` carries it. `average_width` is a
56 // weighted figure that means nothing here, and `max_width` is whatever
57 // the widest glyph in the file happens to be — in a Nerd Font, a
58 // double-width icon.
59 let exact_advance = font
60 .glyph_metrics(&[])
61 .scale(px)
62 .advance_width(font.charmap().map('M'));
63
64 // Ascent, descent and leading, which is the line box the face asks for.
65 // `descent` is positive in swash, so this is a sum.
66 let exact_height = metrics.ascent + metrics.descent + metrics.leading;
67
68 Ok(Self {
69 // Rounded, because a cell has to be a whole number of pixels: a
70 // fractional advance puts every column at a fractional x, and the
71 // glyph is resampled rather than drawn.
72 //
73 // **Up, not to nearest**, for the same reason the height is: a cell
74 // narrower than the advance makes every glyph overlap its right-hand
75 // neighbour, which corrupts the per-row render cache, where a cell
76 // wider than the advance only leaves a hairline of background that
77 // the furniture snap closes anyway.
78 //
79 // This stopped being hypothetical when shop's bundled face changed.
80 // IosevkaTerm advanced 1/2 em and landed whole at every even size,
81 // so `round` never rounded down in practice. Quasi Mono advances
82 // 79/125 em and 79 is prime, so it is whole at 125px and nowhere
83 // usable — at 15px it measures 9.48 and `round` would have taken
84 // half a pixel out of every column.
85 advance: exact_advance.ceil().max(1.0),
86 // Rounded *up*, not to nearest. A cell shorter than the line clips
87 // the descenders of every row, and a glyph bleeding into the row
88 // below breaks the per-row render cache; the cost of rounding up is
89 // a sub-pixel of background between rows, which is the cheaper of
90 // the two.
91 height: exact_height.ceil().max(1.0),
92 ascent: metrics.ascent,
93 baseline: if exact_height > 0.0 {
94 metrics.ascent * exact_height.ceil().max(1.0) / exact_height
95 } else {
96 metrics.ascent
97 },
98 exact_advance,
99 exact_height,
100 })
101 }
102
103 /// The same cell in physical pixels at an integer output scale.
104 ///
105 /// Every field scales, so the snap factors derived from it stay the ones
106 /// the grid is actually stepping by. That matters more than it looks: the
107 /// renderer is rebuilt at `font_px * scale`, and a face whose advance is
108 /// fractional does not measure there to exactly `advance * scale`. Snapping
109 /// to this rather than to the renderer's own measurement is what makes the
110 /// two agree instead of drifting by a fraction of a pixel.
111 #[must_use]
112 pub fn scaled(self, scale: u32) -> Self {
113 let s = scale.max(1) as f32;
114 Self {
115 advance: self.advance * s,
116 height: self.height * s,
117 ascent: self.ascent * s,
118 baseline: self.baseline * s,
119 exact_advance: self.exact_advance * s,
120 exact_height: self.exact_height * s,
121 }
122 }
123
124 /// How far the whole-pixel cell sits from the face's own metrics.
125 ///
126 /// Zero is not achievable in general and is not aimed for. An advance is
127 /// a fraction of an em, so it lands on a whole pixel only at sizes that
128 /// clear its denominator: IosevkaTerm is 1/2 em and does it at every even
129 /// size, IBM Plex Mono is 3/5 and needs a multiple of 5, and Atkinson
130 /// Hyperlegible Mono is 79/125 and does it at 125px and nowhere usable.
131 /// Chasing it would mean choosing the font size to suit the face.
132 ///
133 /// So the renderer snaps cell furniture to the cell instead, and this
134 /// number is what the snap absorbs. Kept because it says how hard the snap
135 /// is working, which is worth a glance when a face is swapped.
136 pub fn rounding_error(&self) -> (f32, f32) {
137 (
138 self.advance - self.exact_advance,
139 self.height - self.exact_height,
140 )
141 }
142 }
143
144 #[cfg(test)]
145 mod tests {
146 use super::*;
147
148 const FONT: &[u8] = shop_font::FACE;
149
150 // The measurement the hardcoded constants got wrong, pinned so a font swap
151 // cannot quietly reintroduce it.
152 //
153 // Stated as the face's own tables rather than as two numbers, because the
154 // numbers moved when the bundled face did: IosevkaTerm is 1/2 em and 14px
155 // gave exactly 7.0, Quasi Mono is 79/125 and gives 8.848. A test that
156 // asserted 7.0 was asserting which font shop bundles, which is what the
157 // rest of this file is for.
158 #[test]
159 fn the_bundled_face_measures_what_its_tables_say() {
160 let font = FontRef::from_index(FONT, 0).unwrap();
161 let m = font.metrics(&[]).scale(14.0);
162 let cell = CellMetrics::measure(FONT, 14.0).unwrap();
163 assert!(
164 (cell.exact_advance
165 - font
166 .glyph_metrics(&[])
167 .scale(14.0)
168 .advance_width(font.charmap().map('M')))
169 .abs()
170 < 0.01,
171 "advance {} is not the face's own",
172 cell.exact_advance
173 );
174 assert!(
175 (cell.exact_height - (m.ascent + m.descent + m.leading)).abs() < 0.01,
176 "height {} is not ascent + descent + leading",
177 cell.exact_height
178 );
179 assert!((cell.advance - cell.exact_advance.ceil()).abs() < f32::EPSILON);
180 assert!((cell.height - cell.exact_height.ceil()).abs() < f32::EPSILON);
181 }
182
183 // The old constants, as a record of what was wrong rather than as a target.
184 //
185 // They were 8.0 x 17.0 and derived from no face at all, which is the defect:
186 // against IosevkaTerm at 14px (7.0 x 17.5) every column carried a pixel of
187 // dead space and every row lost half of one. That they happen to sit closer
188 // to the face bundled today is luck and changes nothing — a number nothing
189 // measures is wrong the moment the font moves, which it has since.
190 #[test]
191 fn the_old_constants_are_not_this_faces_cell_either() {
192 let cell = CellMetrics::measure(FONT, 14.0).unwrap();
193 assert!(
194 (8.0 - cell.exact_advance).abs() > 0.5,
195 "the hardcoded 8.0 is {} off this face's advance",
196 8.0 - cell.exact_advance
197 );
198 assert!(
199 cell.exact_height > 17.0,
200 "the hardcoded 17.0 clipped {} of every row",
201 cell.exact_height - 17.0
202 );
203 }
204
205 // The property the cell exists to have. Cell furniture is drawn to fill
206 // its cell exactly, so a cell wider than the advance shows background
207 // between every column: `───` gets a hole every cell and `███` draws as
208 // stripes. Measured against the face's own box-drawing glyphs rather than
209 // argued, because this is the failure the hardcoded 8.0 actually produced.
210 #[test]
211 fn box_drawing_fills_the_cell_it_is_given() {
212 let font = FontRef::from_index(FONT, 0).unwrap();
213 let cell = CellMetrics::measure(FONT, 14.0).unwrap();
214 let glyphs = font.glyph_metrics(&[]).scale(14.0);
215 let charmap = font.charmap();
216 for c in ['', '', '', '', ''] {
217 let advance = glyphs.advance_width(charmap.map(c));
218 assert!(
219 (advance - cell.exact_advance).abs() < 0.01,
220 "`{c}` advances {advance} in a {} cell",
221 cell.exact_advance
222 );
223 }
224 }
225
226 #[test]
227 fn the_cell_scales_with_the_size_it_is_measured_at() {
228 let one = CellMetrics::measure(FONT, 14.0).unwrap();
229 let two = CellMetrics::measure(FONT, 28.0).unwrap();
230 assert!((two.exact_advance - one.exact_advance * 2.0).abs() < 0.01);
231 assert!((two.exact_height - one.exact_height * 2.0).abs() < 0.01);
232 }
233
234 #[test]
235 fn a_cell_is_never_zero_even_at_an_absurd_size() {
236 let cell = CellMetrics::measure(FONT, 0.1).unwrap();
237 assert!(cell.advance >= 1.0 && cell.height >= 1.0);
238 }
239
240 #[test]
241 fn bytes_that_are_not_a_font_are_refused() {
242 assert!(CellMetrics::measure(b"not a font", 14.0).is_err());
243 }
244 }
245