Skip to main content

max / shop

Measure the cell off the face instead of declaring it CELL_ADVANCE and CELL_HEIGHT were two numbers in main.rs, adjusted by hand as the default font changed (11.0 to 8.5 to 8.0 across three commits) and never derived from anything. Against the face they are supposed to describe they were wrong in both directions: face at 14px hardcoded advance 7.00 px 8.0 line height 17.50 px 17.0 That is a live defect and a visible one. `─` and `█` in the bundled IosevkaTerm both span exactly 0..7.00px, the full advance, because filling the cell is what cell furniture is for. In an 8px cell a run of `───` therefore has a hole every 8 pixels and `███` draws as vertical stripes. Box drawing that does not tile is close to the most visible thing a terminal can get wrong, and nothing in the repo tied those two numbers to the font, so nothing could catch it. `shop_render::CellMetrics::measure` reads the advance and the line box off the face. It takes bytes rather than a renderer because the PTY is sized in cells and is spawned long before there is a wgpu device to ask. Rounding is stated rather than implied: the advance to nearest, because a fractional advance puts every column on a fractional x and the glyph gets resampled; the height *up*, because a cell shorter than the line clips every row's descenders and a glyph bleeding downward breaks the per-row render cache, and half a pixel of background between rows is the cheaper of the two. What is left over is kept as `rounding_error` rather than discarded, since that residual is exactly what stops box drawing tiling perfectly -- which makes it a thing to aim at zero when cutting a face for this terminal. grid_cols, grid_rows, cell_at and identity take the cell now. Threading it rather than reaching for a global makes the dependency visible, and the hit-testing tests measure against the real bundled face rather than a fixture, because numbers a test invented would pass while the terminal put the pointer in the wrong cell. Also removes the second copy: shop-grid's `Identity::default` repeated the same (8, 17). It is (0, 0) now and says it is a placeholder, so a reply carrying it reads as unconfigured rather than as plausibly stale. shop overwrites all of it at startup and on every resize. Found while cutting the in-house face's box drawing, which is cell-exact by construction -- and would have been defeated here.
Co-Authored-By
Claude Opus 5 (1M context) <noreply@anthropic.com>
Author: Max Johnson <me@maxj.phd> · 2026-08-17 03:13 UTC
Signed with PGP, not checked
Commit: a65646d512642e9208743487ba5281c77f9c8c5e
Parent: 8d8143b
5 files changed, +335 insertions, -64 deletions
M Cargo.lock +20 -20
@@ -2148,6 +2148,26 @@
2148 2148 source = "registry+https://github.com/rust-lang/crates.io-index"
2149 2149 checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b"
2150 2150
2151 + [[patch.unused]]
2152 + name = "docengine"
2153 + version = "0.7.0"
2154 +
2155 + [[patch.unused]]
2156 + name = "kberg"
2157 + version = "0.1.0"
2158 +
2159 + [[patch.unused]]
2160 + name = "ops-status"
2161 + version = "0.1.0"
2162 +
2163 + [[patch.unused]]
2164 + name = "painhours"
2165 + version = "0.1.0"
2166 +
2167 + [[patch.unused]]
2168 + name = "tagtree"
2169 + version = "0.4.0"
2170 +
2151 2171 [[patch.unused]]
2152 2172 name = "quasi-axum"
2153 2173 version = "0.18.0"
@@ -2180,26 +2200,6 @@
2180 2200 name = "quasi-webview"
2181 2201 version = "0.18.0"
2182 2202
2183 - [[patch.unused]]
2184 - name = "kberg"
2185 - version = "0.1.0"
2186 -
2187 - [[patch.unused]]
2188 - name = "ops-status"
2189 - version = "0.1.0"
2190 -
2191 - [[patch.unused]]
2192 - name = "painhours"
2193 - version = "0.1.0"
2194 -
2195 - [[patch.unused]]
2196 - name = "tagtree"
2197 - version = "0.4.0"
2198 -
2199 - [[patch.unused]]
2200 - name = "docengine"
2201 - version = "0.7.0"
2202 -
2203 2203 [[patch.unused]]
2204 2204 name = "synckit-client"
2205 2205 version = "0.8.0"
@@ -420,11 +420,20 @@
420 420 }
421 421
422 422 impl Default for Identity {
423 + /// A placeholder, and only ever that.
424 + ///
425 + /// `shop` overwrites all of it at startup and again on every resize, so
426 + /// nothing a user sees comes from here. `cell_px` used to repeat the two
427 + /// constants `main.rs` declared, which meant a second copy of a number
428 + /// nobody derived; it is deliberately not the cell of any face now, so a
429 + /// reply carrying it is obviously unconfigured rather than plausibly
430 + /// stale. The real one is measured off the bundled face by
431 + /// `shop_render::CellMetrics`.
423 432 fn default() -> Self {
424 433 Self {
425 434 name: "shop".into(),
426 435 version: "0".into(),
427 - cell_px: (8, 17),
436 + cell_px: (0, 0),
428 437 fg: [0xe6, 0xde, 0xd3],
429 438 bg: [0x25, 0x23, 0x1f],
430 439 }
@@ -11,8 +11,10 @@
11 11 mod atlas;
12 12 mod fallback;
13 13 mod image;
14 + mod metrics;
14 15 mod pipeline;
15 16 mod shaper;
16 17
17 18 pub use image::{ImagePlacement, ImageRenderer};
19 + pub use metrics::CellMetrics;
18 20 pub use pipeline::{BgFill, CellDraw, CellText, TextRenderer};
@@ -31,7 +31,7 @@
31 31 MouseTracking, Point, Selection, SelectionMode,
32 32 };
33 33 use shop_pty::{Pty, PtySize};
34 - use shop_render::{BgFill, CellText, ImagePlacement, ImageRenderer, TextRenderer};
34 + use shop_render::{BgFill, CellMetrics, CellText, ImagePlacement, ImageRenderer, TextRenderer};
35 35 use shop_wayland::{
36 36 Capability, CompositorHandler, CompositorState, Connection, OutputHandler, OutputState,
37 37 Pending, ProvidesRegistryState, QueueHandle, RegistryState, SeatHandler, SeatState,
@@ -75,8 +75,6 @@
75 75 /// get their icons without a system font install. ~13 MB in the binary.
76 76 const FONT_BYTES: &[u8] = include_bytes!("../../../assets/IosevkaTermNerdFontMono-Regular.ttf");
77 77 const FONT_PX: f32 = 14.0;
78 - const CELL_ADVANCE: f32 = 8.0;
79 - const CELL_HEIGHT: f32 = 17.0;
80 78 const PAD_X: f32 = 10.0;
81 79 const PAD_Y: f32 = 6.0;
82 80 /// How solid the cursor is at each end of its phase.
@@ -269,6 +267,24 @@
269 267
270 268 let font_data: Vec<u8> = FONT_BYTES.to_vec();
271 269
270 + // The cell, measured off the bundled face rather than declared. Done here
271 + // because the PTY is sized in cells and is spawned long before there is a
272 + // wgpu device to ask, which is why this takes bytes and not a renderer.
273 + let cell = CellMetrics::measure(FONT_BYTES, FONT_PX)?;
274 + let (dx, dy) = cell.rounding_error();
275 + if dx.abs() > 0.01 || dy.abs() > 0.01 {
276 + // Not a failure, and worth saying once: this residual is exactly what
277 + // stops box drawing tiling perfectly, so a face cut for this terminal
278 + // wants metrics that land on whole pixels at the sizes it runs at.
279 + tracing::debug!(
280 + advance = cell.exact_advance,
281 + height = cell.exact_height,
282 + cell_w = cell.advance,
283 + cell_h = cell.height,
284 + "cell rounded off the face by ({dx:.2}, {dy:.2}) px"
285 + );
286 + }
287 +
272 288 let Cli {
273 289 exec_cmd,
274 290 exec_argv,
@@ -284,16 +300,16 @@
284 300 let (spawn_cmd, spawn_args) =
285 301 spawn_target(exec_argv, exec_cmd.clone(), std::env::var("SHELL").ok());
286 302 let spawn_args_refs: Vec<&str> = spawn_args.iter().map(String::as_str).collect();
287 - let cols_initial = grid_cols(INITIAL.0);
288 - let rows_initial = grid_rows(INITIAL.1);
303 + let cols_initial = grid_cols(INITIAL.0, cell);
304 + let rows_initial = grid_rows(INITIAL.1, cell);
289 305 let mut pty = Pty::spawn(
290 306 &spawn_cmd,
291 307 &spawn_args_refs,
292 308 PtySize {
293 309 cols: cols_initial,
294 310 rows: rows_initial,
295 - cell_width: CELL_ADVANCE.round() as u16,
296 - cell_height: CELL_HEIGHT.round() as u16,
311 + cell_width: cell.advance as u16,
312 + cell_height: cell.height as u16,
297 313 },
298 314 "xterm-256color",
299 315 )?;
@@ -437,6 +453,7 @@
437 453 parser,
438 454 kitty: kgp::Parser::new(),
439 455 palette,
456 + cell,
440 457 image_placement: None,
441 458 next_anon_image_id: 1,
442 459 font_data: FONT_BYTES.to_vec(),
@@ -477,7 +494,7 @@
477 494 app.surface_config.width,
478 495 app.surface_config.height,
479 496 );
480 - let id = identity(&app.palette, app.scale);
497 + let id = identity(&app.palette, app.scale, app.cell);
481 498 app.grid.set_identity(id);
482 499
483 500 // Wayland + PTY sources onto the loop built above.
@@ -577,18 +594,18 @@
577 594 .xdg_window
578 595 .wl_surface()
579 596 .set_buffer_scale(s as i32);
580 - let cols = grid_cols(logical_w);
581 - let rows = grid_rows(logical_h);
597 + let cols = grid_cols(logical_w, app.cell);
598 + let rows = grid_rows(logical_h, app.cell);
582 599 app.grid.resize(cols, rows);
583 600 // Scale may have moved since the last configure, and cell
584 601 // size is reported in physical pixels.
585 - let id = identity(&app.palette, s);
602 + let id = identity(&app.palette, s, app.cell);
586 603 app.grid.set_identity(id);
587 604 let _ = app.pty.resize(PtySize {
588 605 cols,
589 606 rows,
590 - cell_width: (CELL_ADVANCE * s as f32).round() as u16,
591 - cell_height: (CELL_HEIGHT * s as f32).round() as u16,
607 + cell_width: (app.cell.advance * s as f32).round() as u16,
608 + cell_height: (app.cell.height * s as f32).round() as u16,
592 609 });
593 610 app.dirty = true;
594 611 }
@@ -718,17 +735,17 @@
718 735 if control.action == 'T' {
719 736 let s = app.scale as f32;
720 737 let cursor = app.grid.cursor();
721 - let x = (PAD_X + cursor.col as f32 * CELL_ADVANCE) * s;
722 - let y = (PAD_Y + cursor.row as f32 * CELL_HEIGHT) * s;
738 + let x = (PAD_X + cursor.col as f32 * app.cell.advance) * s;
739 + let y = (PAD_Y + cursor.row as f32 * app.cell.height) * s;
723 740 // Prefer the cell-sized placement (`c=`, `r=`) if given; fall
724 741 // back to the source pixel dims scaled up. yazi always sends
725 742 // `c` and `r`.
726 743 let placement_w = control
727 744 .cell_cols
728 - .map_or(w as f32, |c| c as f32 * CELL_ADVANCE * s);
745 + .map_or(w as f32, |c| c as f32 * app.cell.advance * s);
729 746 let placement_h = control
730 747 .cell_rows
731 - .map_or(h as f32, |r| r as f32 * CELL_HEIGHT * s);
748 + .map_or(h as f32, |r| r as f32 * app.cell.height * s);
732 749 app.image_placement = Some(ImagePlacement {
733 750 image_id,
734 751 x,
@@ -764,14 +781,14 @@
764 781
765 782 /// The cell under a surface-local position.
766 783 ///
767 - /// Positions arrive in logical pixels, which is also what the padding and cell
768 - /// constants are in, so scale does not enter into this. Clamped rather than
784 + /// Positions arrive in logical pixels, which is also what the padding and the
785 + /// cell are in, so scale does not enter into this. Clamped rather than
769 786 /// optional: a pointer out in the padding is treated as the nearest cell,
770 787 /// which is what makes dragging off the edge of the window select to the end
771 788 /// of the line instead of stopping dead.
772 - fn cell_at((x, y): (f64, f64), cols: u16, rows: u16) -> Point {
773 - let col = ((x - f64::from(PAD_X)) / f64::from(CELL_ADVANCE)).floor();
774 - let row = ((y - f64::from(PAD_Y)) / f64::from(CELL_HEIGHT)).floor();
789 + fn cell_at((x, y): (f64, f64), cols: u16, rows: u16, cell: CellMetrics) -> Point {
790 + let col = ((x - f64::from(PAD_X)) / f64::from(cell.advance)).floor();
791 + let row = ((y - f64::from(PAD_Y)) / f64::from(cell.height)).floor();
775 792 let to_index = |v: f64, count: u16| v.clamp(0.0, f64::from(count.saturating_sub(1))) as u16;
776 793 Point::new(to_index(row, rows), to_index(col, cols))
777 794 }
@@ -823,14 +840,14 @@
823 840 /// theme, which is the only thing that knows them: a program asking OSC 11
824 841 /// whether it is on a light or a dark terminal gets the wrong answer from
825 842 /// anything else.
826 - fn identity(palette: &Palette, scale: u32) -> shop_grid::Identity {
843 + fn identity(palette: &Palette, scale: u32, cell: CellMetrics) -> shop_grid::Identity {
827 844 let s = scale.max(1) as f32;
828 845 shop_grid::Identity {
829 846 name: "shop".into(),
830 847 version: env!("CARGO_PKG_VERSION").into(),
831 848 cell_px: (
832 - (CELL_ADVANCE * s).round() as u16,
833 - (CELL_HEIGHT * s).round() as u16,
849 + (cell.advance * s).round() as u16,
850 + (cell.height * s).round() as u16,
834 851 ),
835 852 fg: srgb_bytes(palette.fg),
836 853 bg: srgb_bytes(palette.bg),
@@ -846,14 +863,14 @@
846 863 ]
847 864 }
848 865
849 - fn grid_cols(px_w: u32) -> u16 {
850 - let usable = (px_w as f32 - 2.0 * PAD_X).max(CELL_ADVANCE);
851 - (usable / CELL_ADVANCE) as u16
866 + fn grid_cols(px_w: u32, cell: CellMetrics) -> u16 {
867 + let usable = (px_w as f32 - 2.0 * PAD_X).max(cell.advance);
868 + (usable / cell.advance) as u16
852 869 }
853 870
854 - fn grid_rows(px_h: u32) -> u16 {
855 - let usable = (px_h as f32 - 2.0 * PAD_Y).max(CELL_HEIGHT);
856 - (usable / CELL_HEIGHT) as u16
871 + fn grid_rows(px_h: u32, cell: CellMetrics) -> u16 {
872 + let usable = (px_h as f32 - 2.0 * PAD_Y).max(cell.height);
873 + (usable / cell.height) as u16
857 874 }
858 875
859 876 struct App {
@@ -874,6 +891,16 @@
874 891 kitty: kgp::Parser,
875 892 /// Every colour shop paints, resolved from a makeover theme at startup.
876 893 palette: Palette,
894 + /// The cell, measured off the bundled face at [`FONT_PX`].
895 + ///
896 + /// Logical pixels, so every physical use multiplies by `scale`. Held
897 + /// rather than recomputed per frame, and not recomputed on a scale change:
898 + /// a logical cell is what the grid, the padding and pointer positions are
899 + /// all in, and it does not move when the output does. The renderer is
900 + /// rebuilt at `FONT_PX * scale`, so at a fractional advance the physical
901 + /// glyph and `advance * scale` can differ by under a pixel; the bundled
902 + /// face is a clean half-em and does not.
903 + cell: CellMetrics,
877 904 /// Single-image MVP: track the most recently displayed image + where.
878 905 /// Multi-image / z-order / delete-selectors are follow-ups.
879 906 image_placement: Option<ImagePlacement>,
@@ -1031,8 +1058,8 @@
1031 1058 // Damage flow: apply the grid's pending changes to the per-row cache,
1032 1059 // rebuild only dirty rows, then draw from cache.
1033 1060 let s = app.scale as f32;
1034 - let cell_w_px = CELL_ADVANCE * s;
1035 - let cell_h_px = CELL_HEIGHT * s;
1061 + let cell_w_px = app.cell.advance * s;
1062 + let cell_h_px = app.cell.height * s;
1036 1063 let pad_x_px = PAD_X * s;
1037 1064 let pad_y_px = PAD_Y * s;
1038 1065 let damage = app.grid.take_damage();
@@ -1790,7 +1817,7 @@
1790 1817
1791 1818 impl App {
1792 1819 fn cell_at(&self, pos: (f64, f64)) -> Point {
1793 - let at = cell_at(pos, self.grid.cols(), self.grid.rows());
1820 + let at = cell_at(pos, self.grid.cols(), self.grid.rows(), self.cell);
1794 1821 // A wide character is one thing under two columns; a click on its right
1795 1822 // half means the character, not the blank standing in for it.
1796 1823 Point::new(at.row, self.grid.snap_col(at.row, at.col))
@@ -2061,7 +2088,12 @@
2061 2088 if self.grid.mouse_tracking() == MouseTracking::Off || self.modifiers.shift {
2062 2089 return false;
2063 2090 }
2064 - let at = cell_at(self.pointer_at, self.grid.cols(), self.grid.rows());
2091 + let at = cell_at(
2092 + self.pointer_at,
2093 + self.grid.cols(),
2094 + self.grid.rows(),
2095 + self.cell,
2096 + );
2065 2097 // Motion is continuous and reports are per cell, so a move that has
2066 2098 // not left its cell has nothing to say. Presses and releases always
2067 2099 // do, however still the pointer was.
@@ -2346,6 +2378,48 @@
2346 2378 Point::new(row, col)
2347 2379 }
2348 2380
2381 + /// The real cell, measured off the real bundled face.
2382 + ///
2383 + /// Not a fixture. Hit-testing against numbers a test made up would pass
2384 + /// while the terminal put the pointer in the wrong cell, which is the
2385 + /// class of bug this whole change is about.
2386 + fn test_cell() -> CellMetrics {
2387 + CellMetrics::measure(FONT_BYTES, FONT_PX).expect("the bundled face measures")
2388 + }
2389 +
2390 + // The regression guard the two constants never had. They were 8.0 x 17.0
2391 + // against a face that is 7.0 x 17.5 at this size, so every column carried a
2392 + // pixel of dead space and every row lost half of one.
2393 + #[test]
2394 + fn the_cell_is_the_bundled_faces_own_and_not_a_number_someone_typed() {
2395 + let cell = test_cell();
2396 + assert!((cell.advance - 7.0).abs() < f32::EPSILON, "{cell:?}");
2397 + assert!((cell.height - 18.0).abs() < f32::EPSILON, "{cell:?}");
2398 + assert!(
2399 + (cell.advance - cell.exact_advance).abs() < f32::EPSILON,
2400 + "this face's advance is a whole pixel at 14px, so nothing is rounded away"
2401 + );
2402 + }
2403 +
2404 + // Cells tile: what the grid steps by is what a glyph fills. Asserted
2405 + // through the same two functions the resize path uses rather than by
2406 + // arithmetic, so a change to either is caught.
2407 + #[test]
2408 + fn a_window_divides_into_whole_cells_with_only_the_padding_left_over() {
2409 + let cell = test_cell();
2410 + let width = 960;
2411 + let cols = grid_cols(width, cell);
2412 + let used = f32::from(cols) * cell.advance + 2.0 * PAD_X;
2413 + assert!(
2414 + used <= width as f32,
2415 + "{cols} columns need {used}px of {width}"
2416 + );
2417 + assert!(
2418 + used > width as f32 - cell.advance,
2419 + "another column would have fitted"
2420 + );
2421 + }
2422 +
2349 2423 fn argv(items: &[&str]) -> Vec<String> {
2350 2424 items.iter().map(|s| (*s).to_string()).collect()
2351 2425 }
@@ -2638,31 +2712,35 @@
2638 2712 #[test]
2639 2713 fn the_origin_cell_starts_after_the_padding() {
2640 2714 assert_eq!(
2641 - cell_at((f64::from(PAD_X), f64::from(PAD_Y)), 80, 24),
2715 + cell_at((f64::from(PAD_X), f64::from(PAD_Y)), 80, 24, test_cell()),
2642 2716 at(0, 0)
2643 2717 );
2644 2718 }
2645 2719
2646 2720 #[test]
2647 2721 fn a_position_maps_to_the_cell_it_is_inside() {
2648 - let x = f64::from(PAD_X) + f64::from(CELL_ADVANCE) * 3.5;
2649 - let y = f64::from(PAD_Y) + f64::from(CELL_HEIGHT) * 2.5;
2650 - assert_eq!(cell_at((x, y), 80, 24), at(2, 3));
2722 + let cell = test_cell();
2723 + let x = f64::from(PAD_X) + f64::from(cell.advance) * 3.5;
2724 + let y = f64::from(PAD_Y) + f64::from(cell.height) * 2.5;
2725 + assert_eq!(cell_at((x, y), 80, 24, cell), at(2, 3));
2651 2726 }
2652 2727
2653 2728 #[test]
2654 2729 fn the_padding_clamps_to_the_nearest_cell_rather_than_falling_off() {
2655 2730 // Above and left of the grid entirely.
2656 - assert_eq!(cell_at((0.0, 0.0), 80, 24), at(0, 0));
2731 + assert_eq!(cell_at((0.0, 0.0), 80, 24, test_cell()), at(0, 0));
2657 2732 // Far below and right — a drag that left the window.
2658 - assert_eq!(cell_at((100_000.0, 100_000.0), 80, 24), at(23, 79));
2733 + assert_eq!(
2734 + cell_at((100_000.0, 100_000.0), 80, 24, test_cell()),
2735 + at(23, 79)
2736 + );
2659 2737 // Negative, which is what a drag past the top-left edge reports.
2660 - assert_eq!(cell_at((-500.0, -500.0), 80, 24), at(0, 0));
2738 + assert_eq!(cell_at((-500.0, -500.0), 80, 24, test_cell()), at(0, 0));
2661 2739 }
2662 2740
2663 2741 #[test]
2664 2742 fn a_one_by_one_grid_has_no_cell_to_clamp_past() {
2665 - assert_eq!(cell_at((10_000.0, 10_000.0), 1, 1), at(0, 0));
2743 + assert_eq!(cell_at((10_000.0, 10_000.0), 1, 1, test_cell()), at(0, 0));
2666 2744 }
2667 2745
2668 2746 #[test]
@@ -1,0 +1,182 @@
1 + //! The terminal cell, measured off the face rather than guessed.
2 + //!
3 + //! A cell was two constants in `shop`'s `main.rs` until 2026-08-16, adjusted by
4 + //! hand as the default font changed (11.0 to 8.5 to 8.0) and never derived from
5 + //! anything. Against the bundled IosevkaTerm at 14px they were wrong in both
6 + //! directions and visibly so:
7 + //!
8 + //! ```text
9 + //! face at 14px hardcoded
10 + //! advance 7.00 px 8.0 a 1px gap at every column boundary
11 + //! line 17.50 px 17.0 half a pixel clipped at every row
12 + //! ```
13 + //!
14 + //! `─` and `█` both span exactly 0..7.00px in that face — the full advance,
15 + //! because that is what cell furniture is for. In an 8px cell a run of `───`
16 + //! therefore has a hole every 8 pixels and `███` draws as vertical stripes.
17 + //! Box drawing that does not tile is the single most visible thing a terminal
18 + //! can get wrong, and it was wrong for a reason no code stated.
19 + //!
20 + //! So the cell comes from the face now. The face is the only thing that knows
21 + //! how wide its own glyphs are, and a terminal that asks it cannot drift when
22 + //! the font is replaced — which is the next thing to happen here, since the
23 + //! bundled face is being swapped for the in-house one.
24 +
25 + use swash::FontRef;
26 +
27 + /// A terminal cell, in logical pixels at one font size.
28 + #[derive(Debug, Clone, Copy, PartialEq)]
29 + pub struct CellMetrics {
30 + /// Cell width: the face's own advance, rounded to a whole pixel.
31 + pub advance: f32,
32 + /// Cell height: the face's line height, rounded up to a whole pixel.
33 + pub height: f32,
34 + /// Baseline from the top of the cell.
35 + pub ascent: f32,
36 + /// The advance before rounding, kept so the residual is measurable rather
37 + /// than folklore. See [`rounding_error`](Self::rounding_error).
38 + pub exact_advance: f32,
39 + /// The line height before rounding.
40 + pub exact_height: f32,
41 + }
42 +
43 + impl CellMetrics {
44 + /// Measure a face at a size.
45 + ///
46 + /// Takes bytes rather than a live renderer on purpose: `shop` needs the
47 + /// cell before it has a wgpu device, because the PTY is sized in cells and
48 + /// is spawned first.
49 + pub fn measure(font_data: &[u8], px: f32) -> anyhow::Result<Self> {
50 + let font = FontRef::from_index(font_data, 0)
51 + .ok_or_else(|| anyhow::anyhow!("swash: not a font"))?;
52 + let metrics = font.metrics(&[]).scale(px);
53 +
54 + // One representative glyph rather than `average_width` or `max_width`.
55 + // The grid has no concept of a per-glyph width anywhere outside a
56 + // cluster, so what is wanted is the advance every cell will actually
57 + // use, and in a monospace face `M` carries it. `average_width` is a
58 + // weighted figure that means nothing here, and `max_width` is whatever
59 + // the widest glyph in the file happens to be — in a Nerd Font, a
60 + // double-width icon.
61 + let exact_advance = font
62 + .glyph_metrics(&[])
63 + .scale(px)
64 + .advance_width(font.charmap().map('M'));
65 +
66 + // Ascent, descent and leading, which is the line box the face asks for.
67 + // `descent` is positive in swash, so this is a sum.
68 + let exact_height = metrics.ascent + metrics.descent + metrics.leading;
69 +
70 + Ok(Self {
71 + // Rounded, because a cell has to be a whole number of pixels: a
72 + // fractional advance puts every column at a fractional x, and the
73 + // glyph is resampled rather than drawn.
74 + advance: exact_advance.round().max(1.0),
75 + // Rounded *up*, not to nearest. A cell shorter than the line clips
76 + // the descenders of every row, and a glyph bleeding into the row
77 + // below breaks the per-row render cache; the cost of rounding up is
78 + // a sub-pixel of background between rows, which is the cheaper of
79 + // the two.
80 + height: exact_height.ceil().max(1.0),
81 + ascent: metrics.ascent,
82 + exact_advance,
83 + exact_height,
84 + })
85 + }
86 +
87 + /// How far the whole-pixel cell sits from the face's own metrics.
88 + ///
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.
93 + pub fn rounding_error(&self) -> (f32, f32) {
94 + (
95 + self.advance - self.exact_advance,
96 + self.height - self.exact_height,
97 + )
98 + }
99 + }
100 +
101 + #[cfg(test)]
102 + mod tests {
103 + use super::*;
104 +
105 + const FONT: &[u8] = include_bytes!("../../../assets/IosevkaTermNerdFontMono-Regular.ttf");
106 +
107 + // The measurement the hardcoded constants got wrong, pinned so a font swap
108 + // cannot quietly reintroduce it.
109 + #[test]
110 + fn the_bundled_face_measures_what_its_tables_say() {
111 + let cell = CellMetrics::measure(FONT, 14.0).unwrap();
112 + // 500/1000 em at 14px, and hhea 965/-285 with no leading.
113 + assert!(
114 + (cell.exact_advance - 7.0).abs() < 0.01,
115 + "advance {}",
116 + cell.exact_advance
117 + );
118 + assert!(
119 + (cell.exact_height - 17.5).abs() < 0.01,
120 + "height {}",
121 + cell.exact_height
122 + );
123 + assert!((cell.advance - 7.0).abs() < f32::EPSILON);
124 + assert!((cell.height - 18.0).abs() < f32::EPSILON);
125 + }
126 +
127 + // The old constants, as a record of what was wrong rather than as a target.
128 + #[test]
129 + fn the_old_constants_were_a_pixel_out_per_column() {
130 + let cell = CellMetrics::measure(FONT, 14.0).unwrap();
131 + assert!(
132 + (8.0 - cell.exact_advance) >= 0.99,
133 + "the hardcoded 8.0 left {} of dead column",
134 + 8.0 - cell.exact_advance
135 + );
136 + assert!(
137 + cell.exact_height > 17.0,
138 + "the hardcoded 17.0 clipped {} of every row",
139 + cell.exact_height - 17.0
140 + );
141 + }
142 +
143 + // The property the cell exists to have. Cell furniture is drawn to fill
144 + // its cell exactly, so a cell wider than the advance shows background
145 + // between every column: `───` gets a hole every cell and `███` draws as
146 + // stripes. Measured against the face's own box-drawing glyphs rather than
147 + // argued, because this is the failure the hardcoded 8.0 actually produced.
148 + #[test]
149 + fn box_drawing_fills_the_cell_it_is_given() {
150 + let font = FontRef::from_index(FONT, 0).unwrap();
151 + let cell = CellMetrics::measure(FONT, 14.0).unwrap();
152 + let glyphs = font.glyph_metrics(&[]).scale(14.0);
153 + let charmap = font.charmap();
154 + for c in ['─', '│', '█', '┼', '╬'] {
155 + let advance = glyphs.advance_width(charmap.map(c));
156 + assert!(
157 + (advance - cell.exact_advance).abs() < 0.01,
158 + "`{c}` advances {advance} in a {} cell",
159 + cell.exact_advance
160 + );
161 + }
162 + }
163 +
164 + #[test]
165 + fn the_cell_scales_with_the_size_it_is_measured_at() {
166 + let one = CellMetrics::measure(FONT, 14.0).unwrap();
167 + let two = CellMetrics::measure(FONT, 28.0).unwrap();
168 + assert!((two.exact_advance - one.exact_advance * 2.0).abs() < 0.01);
169 + assert!((two.exact_height - one.exact_height * 2.0).abs() < 0.01);
170 + }
171 +
172 + #[test]
173 + fn a_cell_is_never_zero_even_at_an_absurd_size() {
174 + let cell = CellMetrics::measure(FONT, 0.1).unwrap();
175 + assert!(cell.advance >= 1.0 && cell.height >= 1.0);
176 + }
177 +
178 + #[test]
179 + fn bytes_that_are_not_a_font_are_refused() {
180 + assert!(CellMetrics::measure(b"not a font", 14.0).is_err());
181 + }
182 + }