Skip to main content

max / shop

Own the cell furniture: snap it to the cell rather than hope it fits Measuring the cell off the face fixed the pixel that was wrong; it could not fix the fraction that is always wrong. An advance is a fraction of an em, so it lands on a whole pixel only at sizes that clear its denominator: IosevkaTerm 1/2 em every even size IBM Plex Mono 3/5 em multiples of 5 Atkinson Mono 79/125 em 125px, and nowhere usable 79 is prime. So "pick a font size where the advance divides" is not advice that can be taken for the face this terminal is about to bundle, and the residual is not a thing to aim at zero -- it is a thing the renderer has to absorb. Even today's lucky 14px only divides on the advance: the line box is 17.5 in an 18px cell, so every row boundary carries half a pixel of background. So glyphs that are drawn to meet their neighbours are now scaled to the cell at rasterization: box drawing, block elements, braille (a Canvas paints one picture across many cells), the legacy-computing blocks, and the four powerline separators, which are the case a user actually notices. The factor is within a pixel of 1 in both axes, because the cell is the face's own metrics rounded -- the distortion is too small to see and it is exactly enough to close the seam. Two things fall out of doing it properly. The baseline is the cell's, not the face's raw ascent. A glyph scaled about the pen origin reaches the top and bottom of the cell only if the baseline sits proportionally inside it, so the rounding slack is shared the way the face shares it instead of being dropped under the descender. Text shifts by under half a pixel and is otherwise untouched -- a letter stretched to the cell would be a different typeface, and there is a test that says so. The snap target is the grid's own step, passed in, not something the renderer measures for itself. The renderer is rebuilt at font_px * scale and a fractional advance does not measure there to exactly advance * scale; snapping to the caller's cell absorbs that gap too, instead of leaving the two to disagree by a fraction of a pixel on HiDPI. Tested against real rasterizer output rather than arithmetic: a full block inks the whole cell, the rules reach both edges, a letter does not, and 15px -- where this face advances 7.5 into an 8px cell -- tiles as well as 14px does.
Co-Authored-By
Claude Opus 5 (1M context) <noreply@anthropic.com>
Author: Max Johnson <me@maxj.phd> · 2026-08-17 03:27 UTC
Signed with PGP, not checked
Commit: 760173429066d9fe8ed6f16ccff977463f0d6cc0
Parent: a65646d
5 files changed, +302 insertions, -33 deletions
M Cargo.lock +8 -8
@@ -2168,6 +2168,14 @@
2168 2168 name = "tagtree"
2169 2169 version = "0.4.0"
2170 2170
2171 + [[patch.unused]]
2172 + name = "synckit-client"
2173 + version = "0.8.0"
2174 +
2175 + [[patch.unused]]
2176 + name = "synckit-config"
2177 + version = "0.2.0"
2178 +
2171 2179 [[patch.unused]]
2172 2180 name = "quasi-axum"
2173 2181 version = "0.18.0"
@@ -2199,11 +2207,3 @@
2199 2207 [[patch.unused]]
2200 2208 name = "quasi-webview"
2201 2209 version = "0.18.0"
2202 -
2203 - [[patch.unused]]
2204 - name = "synckit-client"
2205 - version = "0.8.0"
2206 -
2207 - [[patch.unused]]
2208 - name = "synckit-config"
2209 - version = "0.2.0"
@@ -31,8 +31,18 @@
31 31 pub advance: f32,
32 32 /// Cell height: the face's line height, rounded up to a whole pixel.
33 33 pub height: f32,
34 - /// Baseline from the top of the cell.
34 + /// The face's own ascent at this size, unrounded.
35 35 pub ascent: f32,
36 + /// Where the baseline sits, measured down from the top of the cell.
37 + ///
38 + /// Not the same as [`ascent`](Self::ascent). The cell is the line height
39 + /// rounded up, so it has a little more room than the face asked for, and
40 + /// that slack is shared between the two sides in the proportion the face
41 + /// itself uses rather than all being dropped below the descender. It is
42 + /// also what makes the cell-furniture snap exact: a glyph scaled about the
43 + /// baseline by `height / exact_height` reaches the top and the bottom of
44 + /// the cell precisely when the baseline sits here.
45 + pub baseline: f32,
36 46 /// The advance before rounding, kept so the residual is measurable rather
37 47 /// than folklore. See [`rounding_error`](Self::rounding_error).
38 48 pub exact_advance: f32,
@@ -79,17 +89,49 @@
79 89 // the two.
80 90 height: exact_height.ceil().max(1.0),
81 91 ascent: metrics.ascent,
92 + baseline: if exact_height > 0.0 {
93 + metrics.ascent * exact_height.ceil().max(1.0) / exact_height
94 + } else {
95 + metrics.ascent
96 + },
82 97 exact_advance,
83 98 exact_height,
84 99 })
85 100 }
86 101
102 + /// The same cell in physical pixels at an integer output scale.
103 + ///
104 + /// Every field scales, so the snap factors derived from it stay the ones
105 + /// the grid is actually stepping by. That matters more than it looks: the
106 + /// renderer is rebuilt at `font_px * scale`, and a face whose advance is
107 + /// fractional does not measure there to exactly `advance * scale`. Snapping
108 + /// to this rather than to the renderer's own measurement is what makes the
109 + /// two agree instead of drifting by a fraction of a pixel.
110 + #[must_use]
111 + pub fn scaled(self, scale: u32) -> Self {
112 + let s = scale.max(1) as f32;
113 + Self {
114 + advance: self.advance * s,
115 + height: self.height * s,
116 + ascent: self.ascent * s,
117 + baseline: self.baseline * s,
118 + exact_advance: self.exact_advance * s,
119 + exact_height: self.exact_height * s,
120 + }
121 + }
122 +
87 123 /// How far the whole-pixel cell sits from the face's own metrics.
88 124 ///
89 - /// Zero is not guaranteed and is worth aiming for: it is exactly the
90 - /// residual that makes box drawing not quite tile, so a face cut for this
91 - /// terminal should land on whole pixels at the sizes it is used at. The
92 - /// bundled face is 0.0 wide and 0.5 tall at 14px.
125 + /// Zero is not achievable in general and is not aimed for. An advance is
126 + /// a fraction of an em, so it lands on a whole pixel only at sizes that
127 + /// clear its denominator: IosevkaTerm is 1/2 em and does it at every even
128 + /// size, IBM Plex Mono is 3/5 and needs a multiple of 5, and Atkinson
129 + /// Hyperlegible Mono is 79/125 and does it at 125px and nowhere usable.
130 + /// Chasing it would mean choosing the font size to suit the face.
131 + ///
132 + /// So the renderer snaps cell furniture to the cell instead, and this
133 + /// number is what the snap absorbs. Kept because it says how hard the snap
134 + /// is working, which is worth a glance when a face is swapped.
93 135 pub fn rounding_error(&self) -> (f32, f32) {
94 136 (
95 137 self.advance - self.exact_advance,
@@ -160,9 +160,13 @@
160 160 format: wgpu::TextureFormat,
161 161 font_data: Vec<u8>,
162 162 font_px: f32,
163 + // The grid's step, in physical pixels. Furniture is snapped to it, so
164 + // it has to be the cell the caller actually lays out with rather than
165 + // one this constructor measures for itself.
166 + cell: crate::CellMetrics,
163 167 ) -> anyhow::Result<Self> {
164 168 let atlas = GlyphAtlas::new(device, queue, ATLAS_SIZE);
165 - let shaper = Shaper::new(font_data, font_px)?;
169 + let shaper = Shaper::new(font_data, font_px, cell)?;
166 170
167 171 let uniforms_buf = device.create_buffer_init(&wgpu::util::BufferInitDescriptor {
168 172 label: Some("shop.text.uniforms"),
@@ -469,7 +473,7 @@
469 473 cell_w: f32,
470 474 cell_h: f32,
471 475 ) {
472 - let ascent = self.shaper.ascent();
476 + let baseline = self.shaper.baseline();
473 477 let glyph_count: usize = self.row_cache.iter().map(Vec::len).sum();
474 478 let mut instances: Vec<Instance> = Vec::with_capacity(fills.len() + glyph_count);
475 479 let solid_uv = self.atlas.solid_uv;
@@ -491,7 +495,7 @@
491 495 instances.push(Instance {
492 496 pos: [
493 497 cell_x + cell.left as f32 + cell.dx,
494 - row_y + ascent - cell.top as f32 - cell.dy,
498 + row_y + baseline - cell.top as f32 - cell.dy,
495 499 ],
496 500 size: [cell.slot.px[0] as f32, cell.slot.px[1] as f32],
497 501 uv_min: cell.slot.uv_min,
@@ -550,7 +554,7 @@
550 554 fills: &[BgFill],
551 555 cells: &[CellDraw],
552 556 ) {
553 - let ascent = self.shaper.ascent();
557 + let baseline = self.shaper.baseline();
554 558 let solid_uv = self.atlas.solid_uv;
555 559 let mut instances: Vec<Instance> = Vec::with_capacity(fills.len() + cells.len());
556 560
@@ -588,7 +592,7 @@
588 592 && c.slot.px[1] > 0
589 593 {
590 594 instances.push(Instance {
591 - pos: [cell.x + c.left as f32, cell.y + ascent - c.top as f32],
595 + pos: [cell.x + c.left as f32, cell.y + baseline - c.top as f32],
592 596 size: [c.slot.px[0] as f32, c.slot.px[1] as f32],
593 597 uv_min: c.slot.uv_min,
594 598 uv_max: c.slot.uv_max,
@@ -647,7 +651,7 @@
647 651 encoder: &mut wgpu::CommandEncoder,
648 652 cells: &[CellDraw],
649 653 ) {
650 - let ascent = self.shaper.ascent();
654 + let baseline = self.shaper.baseline();
651 655 let mut instances: Vec<Instance> = Vec::with_capacity(cells.len());
652 656
653 657 for cell in cells {
@@ -673,7 +677,7 @@
673 677 && c.slot.px[1] > 0
674 678 {
675 679 let gx = cell.x + c.left as f32;
676 - let gy = cell.y + ascent - c.top as f32;
680 + let gy = cell.y + baseline - c.top as f32;
677 681 instances.push(Instance {
678 682 pos: [gx, gy],
679 683 size: [c.slot.px[0] as f32, c.slot.px[1] as f32],
@@ -732,7 +736,7 @@
732 736
733 737 /// Shape `text`, rasterize any missing glyphs, and draw the resulting
734 738 /// quads. `x`/`y` is the pen origin in pixels (top-left convention, with
735 - /// the baseline placed at `y + ascent`).
739 + /// the baseline placed at `y + baseline`).
736 740 // wgpu handles plus pen origin and colour. See `draw_cached`.
737 741 #[allow(clippy::too_many_arguments)]
738 742 pub fn draw(
@@ -746,8 +750,8 @@
746 750 y: f32,
747 751 color: [f32; 4],
748 752 ) {
749 - let ascent = self.shaper.ascent();
750 - let baseline_y = y + ascent;
753 + let baseline = self.shaper.baseline();
754 + let baseline_y = y + baseline;
751 755 // Chrome text, not grid text: drawn from the bundled font, which is
752 756 // the one whose metrics the surrounding layout is built on.
753 757 let glyphs = self.shaper.shape(PRIMARY, text);
@@ -8,14 +8,39 @@
8 8
9 9 use std::collections::HashMap;
10 10
11 + use std::collections::HashSet;
12 +
11 13 use swash::{
12 14 FontRef, GlyphId,
13 15 scale::{Render, ScaleContext, Source, image::Content},
14 16 shape::ShapeContext,
15 - zeno::Format,
17 + zeno::{Format, Transform},
16 18 };
17 19
18 20 use crate::fallback::Fallback;
21 + use crate::metrics::CellMetrics;
22 +
23 + /// Characters drawn to meet their neighbours rather than to sit inside their
24 + /// own cell.
25 + ///
26 + /// Box drawing and block elements are the obvious ones: a border seams if the
27 + /// bar does not reach both edges. Braille is here because a `Canvas` or a
28 + /// sparkline paints one picture across many cells. The legacy-computing
29 + /// sextants are blocks by another name. And the four powerline separators are
30 + /// the classic visible case — a prompt with a hairline of background showing
31 + /// through every chevron.
32 + ///
33 + /// Over-including is cheap and under-including is not: a glyph that did not
34 + /// need snapping is scaled by well under a pixel, which nobody can see, while
35 + /// a glyph that needed it and did not get it leaves a seam on every row.
36 + pub(crate) fn is_cell_furniture(c: char) -> bool {
37 + matches!(c,
38 + '\u{2500}'..='\u{259F}' // box drawing, block elements
39 + | '\u{2800}'..='\u{28FF}' // braille
40 + | '\u{E0B0}'..='\u{E0B3}' // powerline separators
41 + | '\u{1FB00}'..='\u{1FBFF}' // legacy computing
42 + )
43 + }
19 44
20 45 /// Which font in the set. 0 is always the bundled one.
21 46 pub(crate) type FontId = u16;
@@ -39,6 +64,22 @@
39 64 scale_ctx: ScaleContext,
40 65 shape_ctx: ShapeContext,
41 66 px: f32,
67 + /// The cell this shaper is drawing into, in physical pixels.
68 + cell: CellMetrics,
69 + /// Scale that maps the face's own cell onto the pixel one, applied to
70 + /// [`is_cell_furniture`] glyphs at rasterization.
71 + ///
72 + /// Both factors are within a pixel of 1, because the pixel cell is the
73 + /// face's own measurements rounded. That is the whole trick: the distortion
74 + /// is too small to see and it is exactly enough to close the seam.
75 + snap: (f32, f32),
76 + /// Glyph ids known to be furniture, learned as characters are resolved.
77 + ///
78 + /// Kept by id rather than re-derived from a character, because
79 + /// rasterization is reached with a glyph id and a font and nothing else —
80 + /// which is also what the atlas cache is keyed on, so a glyph is snapped or
81 + /// not for its whole life and never both.
82 + furniture: HashSet<(FontId, GlyphId)>,
42 83 }
43 84
44 85 pub(crate) struct ShapedGlyph {
@@ -61,10 +102,20 @@
61 102 }
62 103
63 104 impl Shaper {
64 - pub(crate) fn new(font_data: Vec<u8>, px: f32) -> anyhow::Result<Self> {
105 + pub(crate) fn new(font_data: Vec<u8>, px: f32, cell: CellMetrics) -> anyhow::Result<Self> {
65 106 let font = FontRef::from_index(&font_data, 0)
66 107 .ok_or_else(|| anyhow::anyhow!("swash: not a font"))?;
67 108 let font_offset = font.offset;
109 + // Measured here rather than taken from `cell`, and the difference is
110 + // the point: `cell` is the grid's step in physical pixels, which is a
111 + // logical cell times an integer scale, while this is what the face
112 + // draws at the size this shaper rasterizes at. Snapping between the two
113 + // absorbs the gap instead of leaving it on screen.
114 + let own = CellMetrics::measure(&font_data, px)?;
115 + let snap = (
116 + safe_ratio(cell.advance, own.exact_advance),
117 + safe_ratio(cell.height, own.exact_height),
118 + );
68 119 Ok(Self {
69 120 faces: vec![Face {
70 121 data: font_data,
@@ -76,6 +127,9 @@
76 127 scale_ctx: ScaleContext::new(),
77 128 shape_ctx: ShapeContext::new(),
78 129 px,
130 + cell,
131 + snap,
132 + furniture: HashSet::new(),
79 133 })
80 134 }
81 135
@@ -97,12 +151,22 @@
97 151 pub(crate) fn glyph_id_for(&mut self, c: char) -> (FontId, GlyphId) {
98 152 let id = self.face(PRIMARY).charmap().map(c);
99 153 if id != 0 {
154 + if is_cell_furniture(c) {
155 + self.furniture.insert((PRIMARY, id));
156 + }
100 157 return (PRIMARY, id);
101 158 }
102 159 match self.resolve(c) {
103 160 Some(font) => {
104 161 let id = self.face(font).charmap().map(c);
105 - if id == 0 { (PRIMARY, 0) } else { (font, id) }
162 + if id == 0 {
163 + (PRIMARY, 0)
164 + } else {
165 + if is_cell_furniture(c) {
166 + self.furniture.insert((font, id));
167 + }
168 + (font, id)
169 + }
106 170 }
107 171 None => (PRIMARY, 0),
108 172 }
@@ -173,9 +237,17 @@
173 237 .size(self.px)
174 238 .hint(true)
175 239 .build();
176 - let image = Render::new(&[Source::Outline])
177 - .format(Format::Alpha)
178 - .render(&mut scaler, id)?;
240 + let mut render = Render::new(&[Source::Outline]);
241 + render.format(Format::Alpha);
242 + // Furniture is drawn to the cell, not to its own advance. Scaled about
243 + // the pen origin, which sits on the baseline at the cell's left edge,
244 + // so the horizontal factor takes the bar out to both edges and the
245 + // vertical one takes it to the top and bottom — given a baseline at
246 + // `CellMetrics::baseline`, which is what `ascent` below reports.
247 + if self.furniture.contains(&(font, id)) {
248 + render.transform(Some(Transform::scale(self.snap.0, self.snap.1)));
249 + }
250 + let image = render.render(&mut scaler, id)?;
179 251 if image.content != Content::Mask {
180 252 return None;
181 253 }
@@ -188,12 +260,160 @@
188 260 })
189 261 }
190 262
191 - /// The baseline, taken from the bundled font only.
263 + /// The baseline, as a distance down from the top of the cell.
264 + ///
265 + /// Named for what it is rather than for the metric it used to be. It
266 + /// returned the face's raw ascent until the cell furniture was snapped,
267 + /// and the two are no longer the same number.
192 268 ///
193 269 /// One baseline for the whole grid, whatever font a given cell came from:
194 270 /// a fallback face with its own ascent would sit its glyphs on a different
195 271 /// line and make a row of mixed scripts wander.
196 - pub(crate) fn ascent(&self) -> f32 {
197 - self.face(PRIMARY).metrics(&[]).scale(self.px).ascent
272 + ///
273 + /// The cell's baseline rather than the face's raw ascent. The cell is the
274 + /// line height rounded up, and putting the baseline proportionally inside
275 + /// it shares that slack the way the face shares it rather than dropping all
276 + /// of it under the descender. It is also the position the furniture snap is
277 + /// derived against, so a full block reaches both edges of the cell exactly.
278 + pub(crate) fn baseline(&self) -> f32 {
279 + self.cell.baseline
280 + }
281 + }
282 +
283 + /// `a / b`, or 1.0 when `b` is not a usable divisor.
284 + ///
285 + /// A face that measured to nothing would otherwise turn every glyph into a
286 + /// division by zero; drawing it unsnapped is the harmless answer.
287 + fn safe_ratio(a: f32, b: f32) -> f32 {
288 + if b > f32::EPSILON { a / b } else { 1.0 }
289 + }
290 +
291 + #[cfg(test)]
292 + mod tests {
293 + use super::*;
294 +
295 + const FONT: &[u8] = include_bytes!("../../../assets/IosevkaTermNerdFontMono-Regular.ttf");
296 +
297 + /// A shaper over the bundled face, drawing into the cell that face implies.
298 + fn shaper(px: f32, cell: CellMetrics) -> Shaper {
299 + Shaper::new(FONT.to_vec(), px, cell).expect("the bundled face loads")
300 + }
301 +
302 + /// The rasterized extent of `c`, as (left, right, top, bottom) in pixels
303 + /// relative to the cell's top-left corner.
304 + fn extent(shaper: &mut Shaper, c: char) -> (f32, f32, f32, f32) {
305 + let (font, id) = shaper.glyph_id_for(c);
306 + let baseline = shaper.baseline();
307 + let r = shaper.rasterize(font, id).expect("the glyph rasterizes");
308 + let left = r.placement_left as f32;
309 + let top = baseline - r.placement_top as f32;
310 + (left, left + r.width as f32, top, top + r.height as f32)
311 + }
312 +
313 + // The whole point. A full block has to ink the entire cell, or every row
314 + // and every column of a filled region shows a hairline of background.
315 + #[test]
316 + fn a_full_block_inks_the_whole_cell() {
317 + let cell = CellMetrics::measure(FONT, 14.0).unwrap();
318 + let mut shaper = shaper(14.0, cell);
319 + let (x0, x1, y0, y1) = extent(&mut shaper, '█');
320 + assert!(x0 <= 0.5, "left edge at {x0}");
321 + assert!(
322 + x1 >= cell.advance - 0.5,
323 + "right edge at {x1} of {}",
324 + cell.advance
325 + );
326 + assert!(y0 <= 0.5, "top edge at {y0}");
327 + assert!(
328 + y1 >= cell.height - 0.5,
329 + "bottom edge at {y1} of {}",
330 + cell.height
331 + );
332 + }
333 +
334 + // The snap is doing real work and almost none of it, which is what makes
335 + // it safe. The block ends up taller than the face's own line box, by the
336 + // half pixel the cell rounding added, and the factor that did it is well
337 + // inside a pixel — so nothing on screen is visibly distorted.
338 + #[test]
339 + fn the_snap_closes_the_rounding_and_nothing_more() {
340 + let cell = CellMetrics::measure(FONT, 14.0).unwrap();
341 + let mut shaper = shaper(14.0, cell);
342 + let (sx, sy) = shaper.snap;
343 + assert!(
344 + (sx - 1.0).abs() < 0.05 && (sy - 1.0).abs() < 0.05,
345 + "the snap is stretching by ({sx}, {sy}), which would be visible"
346 + );
347 + let (_, _, y0, y1) = extent(&mut shaper, '\u{2588}');
348 + let inked = y1 - y0;
349 + assert!(
350 + inked > cell.exact_height,
351 + "the block inks {inked}, which is no more than the face's own {} \
352 + line box — so the cell's extra {} is still background",
353 + cell.exact_height,
354 + cell.height - cell.exact_height
355 + );
356 + }
357 +
358 + // A rule has to reach both edges, or a horizontal border is a dashed line.
359 + #[test]
360 + fn a_horizontal_rule_reaches_both_edges_of_the_cell() {
361 + let cell = CellMetrics::measure(FONT, 14.0).unwrap();
362 + let mut shaper = shaper(14.0, cell);
363 + let (x0, x1, _, _) = extent(&mut shaper, '─');
364 + assert!(x0 <= 0.5 && x1 >= cell.advance - 0.5, "spans {x0}..{x1}");
365 + }
366 +
367 + // And a vertical one has to reach the top and the bottom, which is the
368 + // half-pixel the cell rounding created.
369 + #[test]
370 + fn a_vertical_rule_reaches_the_top_and_bottom_of_the_cell() {
371 + let cell = CellMetrics::measure(FONT, 14.0).unwrap();
372 + let mut shaper = shaper(14.0, cell);
373 + let (_, _, y0, y1) = extent(&mut shaper, '│');
374 + assert!(y0 <= 0.5, "top at {y0}");
375 + assert!(y1 >= cell.height - 0.5, "bottom at {y1} of {}", cell.height);
376 + }
377 +
378 + // Text is not furniture and must not be stretched: a letter scaled to the
379 + // cell would be a different typeface.
380 + #[test]
381 + fn a_letter_is_left_alone() {
382 + let cell = CellMetrics::measure(FONT, 14.0).unwrap();
383 + let mut shaper = shaper(14.0, cell);
384 + let (_, _, y0, y1) = extent(&mut shaper, 'x');
385 + assert!(y0 > 1.0 && y1 < cell.height - 1.0, "`x` spans {y0}..{y1}");
386 + }
387 +
388 + // The case the snap was built for. At 15px the bundled face advances 7.5,
389 + // so the cell rounds to 8 and a full pixel of every column would be
390 + // background — the same shape of defect as the hardcoded 8.0, arrived at
391 + // honestly. An advance is a fraction of an em and lands whole only at sizes
392 + // that clear its denominator, so this is the general case and 14px is the
393 + // lucky one.
394 + #[test]
395 + fn a_size_where_the_advance_is_fractional_still_tiles() {
396 + let cell = CellMetrics::measure(FONT, 15.0).unwrap();
397 + assert!(
398 + (cell.exact_advance - 7.5).abs() < 0.01,
399 + "advance {}",
400 + cell.exact_advance
401 + );
402 + assert!((cell.advance - 8.0).abs() < f32::EPSILON);
403 +
404 + let mut shaper = shaper(15.0, cell);
405 + let (x0, x1, y0, y1) = extent(&mut shaper, '\u{2588}');
406 + assert!(x0 <= 0.5 && x1 >= cell.advance - 0.5, "spans {x0}..{x1}");
407 + assert!(y0 <= 0.5 && y1 >= cell.height - 0.5, "spans {y0}..{y1}");
408 + }
409 +
410 + #[test]
411 + fn the_ranges_that_snap_are_the_ones_that_meet_their_neighbours() {
412 + for c in ['─', '┼', '╬', '█', '▄', '░', '⠿', '\u{E0B0}', '\u{1FB00}'] {
413 + assert!(is_cell_furniture(c), "{c} should snap");
414 + }
415 + for c in ['a', 'M', ' ', '★', '→', '✘'] {
416 + assert!(!is_cell_furniture(c), "{c} should not snap");
417 + }
198 418 }
199 419 }
@@ -409,7 +409,9 @@
409 409 desired_maximum_frame_latency: 2,
410 410 present_mode,
411 411 };
412 - let text = TextRenderer::new(&device, &queue, format, font_data, FONT_PX)?;
412 + // Scale 1 at startup; a `wl_surface.enter` on a HiDPI output rebuilds the
413 + // renderer with the scaled cell.
414 + let text = TextRenderer::new(&device, &queue, format, font_data, FONT_PX, cell.scaled(1))?;
413 415 let images = ImageRenderer::new(&device, format);
414 416
415 417 let mut grid = Grid::new(cols_initial, rows_initial);
@@ -1253,6 +1255,7 @@
1253 1255 self.surface_format,
1254 1256 self.font_data.clone(),
1255 1257 FONT_PX * new_scale as f32,
1258 + self.cell.scaled(new_scale),
1256 1259 ) {
1257 1260 Ok(t) => self.text = t,
1258 1261 Err(e) => {