Skip to main content

max / shop

Bundle Quasi Mono, cut at build time, and delete the 13 MB Iosevka shop's half of the typography standard (wiki `typography-standard`, GO shop 0c6247db). The house face now exists for both slots, so the terminal draws the same marks as everything around it rather than whichever face the OS ordered first. THE FACE IS NOT COMMITTED. `crates/shop-font` runs `quasi-type` from a build script and the face lands in OUT_DIR: a pinned base plus the house glyph set in, Quasi Mono out. A font in the repo is a second source of truth that nothing rebuilds, and the pipeline is what keeps the glyph set and the shipped face from drifting. The base is cached in the target directory, so the first build in a fresh checkout needs network and the rest do not. One crate rather than an include_bytes! per caller: the binary and two test suites all want the same bytes, and cutting it three times would download the base three times and leave three places to state the weight. 13 MB committed against 70 KB cut; the release binary goes 22 MB to 9.2 MB. What the swap costs is braille and the legacy-computing sextants, which now come from fallback. Nothing in the tree emits either - there is no Canvas and no Sparkline anywhere - and the day one does they are generated recipes in the house set rather than a fallback face on its own baseline. THE WEIGHT IS NAMED. The face is variable, wght 200-800, and a cut keeps its base's default instance, which for Atkinson Hyperlegible Mono is ExtraLight. A terminal that loaded the file and took what it opened at would draw every prompt in ExtraLight, so the shaper and the scaler both set `wght` and shop-font states the number beside the face's own default. THE CELL NOW ROUNDS UP. `CellMetrics` rounded the advance to nearest, which never rounded down in practice because IosevkaTerm advances 1/2 em and lands whole at every even size. Quasi Mono advances 79/125 em and 79 is prime, so it is whole at 125px and nowhere usable: at 15px it measures 9.48 and `round` would have taken half a pixel out of every column. A cell narrower than the advance overlaps every glyph with its neighbour and corrupts the per-row render cache, where a cell wider leaves a hairline the furniture snap closes anyway. The tests that named the old face's numbers now state the relationship instead - the cell is the face's own measurement rounded - since the bundled face has changed once and may again. `shop --license` prints the OFL text. It travelled as a file in the repo before, which does not reach anyone holding only the binary.
Author: Max Johnson <me@maxj.phd> · 2026-08-17 15:12 UTC
Signed with PGP, not checked
Commit: 584d11946047e9363569d34bb9fdb0d77feda293
Parent: 7601734
15 files changed, +580 insertions, -168 deletions
M Cargo.lock +257 -23
@@ -17,6 +17,21 @@
17 17 "memchr",
18 18 ]
19 19
20 + [[package]]
21 + name = "alloc-no-stdlib"
22 + version = "2.0.4"
23 + source = "registry+https://github.com/rust-lang/crates.io-index"
24 + checksum = "cc7bb162ec39d46ab1ca8c77bf72e890535becd1751bb45f64c597edb4c8c6b3"
25 +
26 + [[package]]
27 + name = "alloc-stdlib"
28 + version = "0.2.4"
29 + source = "registry+https://github.com/rust-lang/crates.io-index"
30 + checksum = "0e76a019e91224d279006ff972f1e984179a6e9feb050adba6ce8274aef23195"
31 + dependencies = [
32 + "alloc-no-stdlib",
33 + ]
34 +
20 35 [[package]]
21 36 name = "allocator-api2"
22 37 version = "0.2.21"
@@ -88,6 +103,15 @@
88 103 source = "registry+https://github.com/rust-lang/crates.io-index"
89 104 checksum = "330a5ed07fa54e4702c9d6c4174f74427fc0ef6e214bbd677ae50a5099946470"
90 105
106 + [[package]]
107 + name = "arbitrary"
108 + version = "1.4.2"
109 + source = "registry+https://github.com/rust-lang/crates.io-index"
110 + checksum = "c3d036a3c4ab069c7b410a2ce876bd74808d2d0888a82667669f8e783a898bf1"
111 + dependencies = [
112 + "derive_arbitrary",
113 + ]
114 +
91 115 [[package]]
92 116 name = "arrayvec"
93 117 version = "0.7.8"
@@ -136,6 +160,36 @@
136 160 source = "registry+https://github.com/rust-lang/crates.io-index"
137 161 checksum = "b588b76d00fde79687d7646a9b5bdf3cc0f655e0bbd080335a95d7e96f3587da"
138 162
163 + [[package]]
164 + name = "block-buffer"
165 + version = "0.10.4"
166 + source = "registry+https://github.com/rust-lang/crates.io-index"
167 + checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71"
168 + dependencies = [
169 + "generic-array",
170 + ]
171 +
172 + [[package]]
173 + name = "brotli"
174 + version = "8.0.4"
175 + source = "registry+https://github.com/rust-lang/crates.io-index"
176 + checksum = "5cc91aac060a7a1e25823bdccbfb6af1875b88f17c6daac97894eed8207166b3"
177 + dependencies = [
178 + "alloc-no-stdlib",
179 + "alloc-stdlib",
180 + "brotli-decompressor",
181 + ]
182 +
183 + [[package]]
184 + name = "brotli-decompressor"
185 + version = "5.0.3"
186 + source = "registry+https://github.com/rust-lang/crates.io-index"
187 + checksum = "3a32acac15fe1967bc3986b2a6347dffc965602354ea6f450ad07e8bfd253583"
188 + dependencies = [
189 + "alloc-no-stdlib",
190 + "alloc-stdlib",
191 + ]
192 +
139 193 [[package]]
140 194 name = "bumpalo"
141 195 version = "3.20.3"
@@ -279,6 +333,15 @@
279 333 "crossbeam-utils",
280 334 ]
281 335
336 + [[package]]
337 + name = "cpufeatures"
338 + version = "0.2.17"
339 + source = "registry+https://github.com/rust-lang/crates.io-index"
340 + checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280"
341 + dependencies = [
342 + "libc",
343 + ]
344 +
282 345 [[package]]
283 346 name = "crc32fast"
284 347 version = "1.5.0"
@@ -300,12 +363,43 @@
300 363 source = "registry+https://github.com/rust-lang/crates.io-index"
301 364 checksum = "460fbee9c2c2f33933d720630a6a0bac33ba7053db5344fac858d4b8952d77d5"
302 365
366 + [[package]]
367 + name = "crypto-common"
368 + version = "0.1.7"
369 + source = "registry+https://github.com/rust-lang/crates.io-index"
370 + checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a"
371 + dependencies = [
372 + "generic-array",
373 + "typenum",
374 + ]
375 +
303 376 [[package]]
304 377 name = "cursor-icon"
305 378 version = "1.2.0"
306 379 source = "registry+https://github.com/rust-lang/crates.io-index"
307 380 checksum = "f27ae1dd37df86211c42e150270f82743308803d90a6f6e6651cd730d5e1732f"
308 381
382 + [[package]]
383 + name = "derive_arbitrary"
384 + version = "1.4.2"
385 + source = "registry+https://github.com/rust-lang/crates.io-index"
386 + checksum = "1e567bd82dcff979e4b03460c307b3cdc9e96fde3d73bed1496d2bc75d9dd62a"
387 + dependencies = [
388 + "proc-macro2",
389 + "quote",
390 + "syn 2.0.119",
391 + ]
392 +
393 + [[package]]
394 + name = "digest"
395 + version = "0.10.7"
396 + source = "registry+https://github.com/rust-lang/crates.io-index"
397 + checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292"
398 + dependencies = [
399 + "block-buffer",
400 + "crypto-common",
401 + ]
402 +
309 403 [[package]]
310 404 name = "dispatch2"
311 405 version = "0.3.1"
@@ -394,6 +488,7 @@
394 488 dependencies = [
395 489 "crc32fast",
396 490 "miniz_oxide",
491 + "zlib-rs",
397 492 ]
398 493
399 494 [[package]]
@@ -404,9 +499,9 @@
404 499
405 500 [[package]]
406 501 name = "font-types"
407 - version = "0.12.2"
502 + version = "0.12.3"
408 503 source = "registry+https://github.com/rust-lang/crates.io-index"
409 - checksum = "0a7299a780854a6d391be2ae1c8521c9368471b559dbfd6a8dbd9f407eaff100"
504 + checksum = "75382bc7392ef10aad10935f92fc3db36d2d4dad0e5d96d8d65e04f89a07ec39"
410 505 dependencies = [
411 506 "bytemuck",
412 507 ]
@@ -444,6 +539,16 @@
444 539 "slab",
445 540 ]
446 541
542 + [[package]]
543 + name = "generic-array"
544 + version = "0.14.7"
545 + source = "registry+https://github.com/rust-lang/crates.io-index"
546 + checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a"
547 + dependencies = [
548 + "typenum",
549 + "version_check",
550 + ]
551 +
447 552 [[package]]
448 553 name = "getrandom"
449 554 version = "0.4.3"
@@ -671,6 +776,18 @@
671 776 "tracing",
672 777 ]
673 778
779 + [[package]]
780 + name = "kurbo"
781 + version = "0.13.1"
782 + source = "registry+https://github.com/rust-lang/crates.io-index"
783 + checksum = "4b60dfc32f652b926df6192e55525b16d186c69d47876c3ead4da5cc9f8450e2"
784 + dependencies = [
785 + "arrayvec",
786 + "euclid",
787 + "polycool",
788 + "smallvec",
789 + ]
790 +
674 791 [[package]]
675 792 name = "lazy_static"
676 793 version = "1.5.0"
@@ -1009,6 +1126,15 @@
1009 1126 source = "registry+https://github.com/rust-lang/crates.io-index"
1010 1127 checksum = "bc6355899e1c9462875b6757c79f3caa011a1fdae12bbb1a2e72dd1f234f8336"
1011 1128
1129 + [[package]]
1130 + name = "polycool"
1131 + version = "0.4.0"
1132 + source = "registry+https://github.com/rust-lang/crates.io-index"
1133 + checksum = "50596ddc09eb5ad5f75cacd40209568e66df71baf86e1499a0e99c4cff12a5a6"
1134 + dependencies = [
1135 + "arrayvec",
1136 + ]
1137 +
1012 1138 [[package]]
1013 1139 name = "portable-atomic"
1014 1140 version = "1.14.0"
@@ -1051,6 +1177,22 @@
1051 1177 source = "registry+https://github.com/rust-lang/crates.io-index"
1052 1178 checksum = "d55d956fa96f5ec02be2e13af0e20391a5aa83d6a074e3ad368959d0fab299ea"
1053 1179
1180 + [[package]]
1181 + name = "quasi-type"
1182 + version = "0.1.0"
1183 + dependencies = [
1184 + "brotli",
1185 + "font-types",
1186 + "kurbo",
1187 + "read-fonts 0.43.0",
1188 + "serde",
1189 + "sha2",
1190 + "skrifa 0.46.0",
1191 + "toml",
1192 + "write-fonts",
1193 + "zip",
1194 + ]
1195 +
1054 1196 [[package]]
1055 1197 name = "quick-xml"
1056 1198 version = "0.41.0"
@@ -1104,6 +1246,17 @@
1104 1246 "once_cell",
1105 1247 ]
1106 1248
1249 + [[package]]
1250 + name = "read-fonts"
1251 + version = "0.43.0"
1252 + source = "registry+https://github.com/rust-lang/crates.io-index"
1253 + checksum = "a6b5cae85882cfb0167038e7726a426f47ebd808e67af78c2d6bcbb02acbf3ce"
1254 + dependencies = [
1255 + "bytemuck",
1256 + "font-types",
1257 + "once_cell",
1258 + ]
1259 +
1107 1260 [[package]]
1108 1261 name = "redox_syscall"
1109 1262 version = "0.5.18"
@@ -1225,6 +1378,17 @@
1225 1378 "serde_core",
1226 1379 ]
1227 1380
1381 + [[package]]
1382 + name = "sha2"
1383 + version = "0.10.9"
1384 + source = "registry+https://github.com/rust-lang/crates.io-index"
1385 + checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283"
1386 + dependencies = [
1387 + "cfg-if",
1388 + "cpufeatures",
1389 + "digest",
1390 + ]
1391 +
1228 1392 [[package]]
1229 1393 name = "sharded-slab"
1230 1394 version = "0.1.7"
@@ -1251,6 +1415,7 @@
1251 1415 "kittygfx",
1252 1416 "makeover",
1253 1417 "pollster",
1418 + "shop-font",
1254 1419 "shop-grid",
1255 1420 "shop-pty",
1256 1421 "shop-render",
@@ -1276,6 +1441,13 @@
1276 1441 "serde_json",
1277 1442 ]
1278 1443
1444 + [[package]]
1445 + name = "shop-font"
1446 + version = "0.1.0"
1447 + dependencies = [
1448 + "quasi-type",
1449 + ]
1450 +
1279 1451 [[package]]
1280 1452 name = "shop-grid"
1281 1453 version = "0.1.0"
@@ -1301,6 +1473,7 @@
1301 1473 "bytemuck",
1302 1474 "fontconfig",
1303 1475 "guillotiere",
1476 + "shop-font",
1304 1477 "swash",
1305 1478 "tracing",
1306 1479 "wgpu",
@@ -1339,7 +1512,17 @@
1339 1512 checksum = "819ab7d62b1d3e72d9d9dea5650bac30424f9111364bb94928dbf5ecad1baa68"
1340 1513 dependencies = [
1341 1514 "bytemuck",
1342 - "read-fonts",
1515 + "read-fonts 0.41.0",
1516 + ]
1517 +
1518 + [[package]]
1519 + name = "skrifa"
1520 + version = "0.46.0"
1521 + source = "registry+https://github.com/rust-lang/crates.io-index"
1522 + checksum = "de0bbcc507e438bfe765692ae5228059959781db7430fe36e1cfd569e7c88c89"
1523 + dependencies = [
1524 + "bytemuck",
1525 + "read-fonts 0.43.0",
1343 1526 ]
1344 1527
1345 1528 [[package]]
@@ -1418,7 +1601,7 @@
1418 1601 source = "registry+https://github.com/rust-lang/crates.io-index"
1419 1602 checksum = "6c2499c2d826531388872b2268718aed907a39bd785ab0dcfe57fab26283f92e"
1420 1603 dependencies = [
1421 - "skrifa",
1604 + "skrifa 0.44.0",
1422 1605 "yazi",
1423 1606 "zeno",
1424 1607 ]
@@ -1588,6 +1771,12 @@
1588 1771 "tracing-log",
1589 1772 ]
1590 1773
1774 + [[package]]
1775 + name = "typenum"
1776 + version = "1.20.1"
1777 + source = "registry+https://github.com/rust-lang/crates.io-index"
1778 + checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20"
1779 +
1591 1780 [[package]]
1592 1781 name = "unicode-ident"
1593 1782 version = "1.0.24"
@@ -2067,6 +2256,19 @@
2067 2256 source = "registry+https://github.com/rust-lang/crates.io-index"
2068 2257 checksum = "23b97319f7b8343df12cc98938e5c3eb436064524c8d2b4e30a1d3a36eecdf81"
2069 2258
2259 + [[package]]
2260 + name = "write-fonts"
2261 + version = "0.52.0"
2262 + source = "registry+https://github.com/rust-lang/crates.io-index"
2263 + checksum = "843cebb7bd36359861dbce43d54ac8b33dfdf7b80a95913adc1ef7c7bb881e62"
2264 + dependencies = [
2265 + "font-types",
2266 + "indexmap",
2267 + "kurbo",
2268 + "log",
2269 + "read-fonts 0.43.0",
2270 + ]
2271 +
2070 2272 [[package]]
2071 2273 name = "xcursor"
2072 2274 version = "0.3.10"
@@ -2142,31 +2344,43 @@
2142 2344 "syn 2.0.119",
2143 2345 ]
2144 2346
2347 + [[package]]
2348 + name = "zip"
2349 + version = "6.0.0"
2350 + source = "registry+https://github.com/rust-lang/crates.io-index"
2351 + checksum = "eb2a05c7c36fde6c09b08576c9f7fb4cda705990f73b58fe011abf7dfb24168b"
2352 + dependencies = [
2353 + "arbitrary",
2354 + "crc32fast",
2355 + "flate2",
2356 + "indexmap",
2357 + "memchr",
2358 + "zopfli",
2359 + ]
2360 +
2361 + [[package]]
2362 + name = "zlib-rs"
2363 + version = "0.6.7"
2364 + source = "registry+https://github.com/rust-lang/crates.io-index"
2365 + checksum = "34b31d188d9d685a4f9c7b46d6e36631b07058d2cfe190267adce54dc230bf12"
2366 +
2145 2367 [[package]]
2146 2368 name = "zmij"
2147 2369 version = "1.0.23"
2148 2370 source = "registry+https://github.com/rust-lang/crates.io-index"
2149 2371 checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b"
2150 2372
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"
2373 + [[package]]
2374 + name = "zopfli"
2375 + version = "0.8.3"
2376 + source = "registry+https://github.com/rust-lang/crates.io-index"
2377 + checksum = "f05cd8797d63865425ff89b5c4a48804f35ba0ce8d125800027ad6017d2b5249"
2378 + dependencies = [
2379 + "bumpalo",
2380 + "crc32fast",
2381 + "log",
2382 + "simd-adler32",
2383 + ]
2170 2384
2171 2385 [[patch.unused]]
2172 2386 name = "synckit-client"
@@ -2207,3 +2421,23 @@
2207 2421 [[patch.unused]]
2208 2422 name = "quasi-webview"
2209 2423 version = "0.18.0"
2424 +
2425 + [[patch.unused]]
2426 + name = "docengine"
2427 + version = "0.7.0"
2428 +
2429 + [[patch.unused]]
2430 + name = "kberg"
2431 + version = "0.1.0"
2432 +
2433 + [[patch.unused]]
2434 + name = "ops-status"
2435 + version = "0.1.0"
2436 +
2437 + [[patch.unused]]
2438 + name = "painhours"
2439 + version = "0.1.0"
2440 +
2441 + [[patch.unused]]
2442 + name = "tagtree"
2443 + version = "0.4.0"
M Cargo.toml +1
@@ -2,6 +2,7 @@
2 2 resolver = "2"
3 3 members = [
4 4 "crates/shop",
5 + "crates/shop-font",
5 6 "crates/kitty-graphics",
6 7 "crates/shop-wayland",
7 8 "crates/shop-render",
M README.md +20 -4
@@ -194,10 +194,26 @@
194 194
195 195 ## Fonts
196 196
197 - Shop bundles one font, a nerd-patched Iosevka, so a fresh install has its icons
198 - without a font install first. That covers Latin, Greek, Cyrillic, combining
199 - marks, box drawing and braille, and it has no CJK: a nerd-font patch adds icons
200 - in the private use area and cannot add a script.
197 + Shop bundles one font, Quasi Mono, so a fresh install has its box drawing and
198 + its marks without a font install first. That covers Latin, Greek, Cyrillic,
199 + combining marks, box drawing and block elements, and it has no CJK.
200 +
201 + The face is not committed. It is cut at build time by
202 + [quasi-type](https://makenot.work/git/max/quasi-type), the house font pipeline:
203 + a pinned base plus the house glyph set in, a `Quasi <Slot>` face out. `shop-font`
204 + runs it from a build script and the base is cached in the target directory, so
205 + the first build in a fresh checkout needs network and the rest do not. A font in
206 + the repo would be a second source of truth that nothing rebuilds.
207 +
208 + It replaced a nerd-patched IosevkaTerm, 13 MB committed in `assets/` against 70
209 + KB cut here. What that costs is braille and the legacy-computing sextants, which
210 + now come from fallback; nothing shop draws for itself emits either. What it buys
211 + is that the marks are the same drawings the rest of the house uses, rather than
212 + whichever face the OS happened to order first.
213 +
214 + The face is variable, `wght` 200 to 800 in one file, and **its own default is the
215 + light end**. Shop names the weight it draws at (`shop_font::WEIGHT`) rather than
216 + taking the file's, which is what a naive loader and `fc-match` both do.
201 217
202 218 For anything the bundled font does not have, shop asks the system. On a miss it
203 219 puts the character to fontconfig, loads whatever comes back, and keeps it for
@@ -20,3 +20,7 @@
20 20 anyhow.workspace = true
21 21 fontconfig.workspace = true
22 22 tracing.workspace = true
23 +
24 + [dev-dependencies]
25 + # The bundled face, cut once by the workspace rather than three times.
26 + shop-font = { path = "../shop-font" }
@@ -19,6 +19,7 @@
19 19 [dependencies]
20 20 shop-wayland = { path = "../shop-wayland" }
21 21 shop-render = { path = "../shop-render" }
22 + shop-font = { path = "../shop-font" }
22 23 shop-pty = { path = "../shop-pty" }
23 24 shop-grid = { path = "../shop-grid" }
24 25 shop-xkb = { path = "../shop-xkb" }
@@ -1,9 +1,9 @@
1 1 //! Asking the system which font has a character the bundled one does not.
2 2 //!
3 - //! Shop bundles one font so that a fresh install has its icons without a font
4 - //! install first, and that font is Iosevka: Latin, Greek, Cyrillic, box drawing
5 - //! and braille in full, and no Han, kana or hangul at all. A nerd-font patch
6 - //! adds icons in the private use area and cannot add a script.
3 + //! Shop bundles one font so that a fresh install has its box drawing and its
4 + //! house marks without a font install first, and that font is Quasi Mono:
5 + //! Latin, Greek, Cyrillic, box drawing and block elements in full, and no Han,
6 + //! kana or hangul at all.
7 7 //!
8 8 //! Rather than pick a second font ourselves and ship it, ask the machine. On
9 9 //! Linux fontconfig is the thing that already knows, and already carries the
@@ -12,6 +12,17 @@
12 12 //! Wayland-only, so fontconfig is present wherever shop runs, and it is
13 13 //! consulted only when the bundled font misses, which for ordinary output is
14 14 //! never.
15 + //!
16 + //! **What this path is for is scripts, and it is a poor answer for symbols.**
17 + //! `Shaper::baseline` takes the line off the primary face alone, deliberately,
18 + //! so a fallback face with its own metrics sits its glyphs on a different line.
19 + //! For a run of Han that is right; for a box-drawing character it would draw
20 + //! borders off the line, which is why the whole cell tier is in the face rather
21 + //! than left to fallback. Two blocks are outside it and now arrive this way:
22 + //! braille (U+2800) and the legacy-computing sextants, which IosevkaTerm
23 + //! carried and Quasi Mono does not. Nothing in the tree emits either - there is
24 + //! no `Canvas` and no `Sparkline` anywhere - and the day one does, they are
25 + //! generated recipes in the house set rather than a fallback.
15 26
16 27 use fontconfig::{CharSet, Fontconfig, Pattern};
17 28
@@ -81,7 +81,20 @@
81 81 // Rounded, because a cell has to be a whole number of pixels: a
82 82 // fractional advance puts every column at a fractional x, and the
83 83 // glyph is resampled rather than drawn.
84 - advance: exact_advance.round().max(1.0),
84 + //
85 + // **Up, not to nearest**, for the same reason the height is: a cell
86 + // narrower than the advance makes every glyph overlap its right-hand
87 + // neighbour, which corrupts the per-row render cache, where a cell
88 + // wider than the advance only leaves a hairline of background that
89 + // the furniture snap closes anyway.
90 + //
91 + // This stopped being hypothetical when shop's bundled face changed.
92 + // IosevkaTerm advanced 1/2 em and landed whole at every even size,
93 + // so `round` never rounded down in practice. Quasi Mono advances
94 + // 79/125 em and 79 is prime, so it is whole at 125px and nowhere
95 + // usable — at 15px it measures 9.48 and `round` would have taken
96 + // half a pixel out of every column.
97 + advance: exact_advance.ceil().max(1.0),
85 98 // Rounded *up*, not to nearest. A cell shorter than the line clips
86 99 // the descenders of every row, and a glyph bleeding into the row
87 100 // below breaks the per-row render cache; the cost of rounding up is
@@ -144,35 +157,54 @@
144 157 mod tests {
145 158 use super::*;
146 159
147 - const FONT: &[u8] = include_bytes!("../../../assets/IosevkaTermNerdFontMono-Regular.ttf");
160 + const FONT: &[u8] = shop_font::FACE;
148 161
149 162 // The measurement the hardcoded constants got wrong, pinned so a font swap
150 163 // cannot quietly reintroduce it.
164 + //
165 + // Stated as the face's own tables rather than as two numbers, because the
166 + // numbers moved when the bundled face did: IosevkaTerm is 1/2 em and 14px
167 + // gave exactly 7.0, Quasi Mono is 79/125 and gives 8.848. A test that
168 + // asserted 7.0 was asserting which font shop bundles, which is what the
169 + // rest of this file is for.
151 170 #[test]
152 171 fn the_bundled_face_measures_what_its_tables_say() {
172 + let font = FontRef::from_index(FONT, 0).unwrap();
173 + let m = font.metrics(&[]).scale(14.0);
153 174 let cell = CellMetrics::measure(FONT, 14.0).unwrap();
154 - // 500/1000 em at 14px, and hhea 965/-285 with no leading.
155 175 assert!(
156 - (cell.exact_advance - 7.0).abs() < 0.01,
157 - "advance {}",
176 + (cell.exact_advance
177 + - font
178 + .glyph_metrics(&[])
179 + .scale(14.0)
180 + .advance_width(font.charmap().map('M')))
181 + .abs()
182 + < 0.01,
183 + "advance {} is not the face's own",
158 184 cell.exact_advance
159 185 );
160 186 assert!(
161 - (cell.exact_height - 17.5).abs() < 0.01,
162 - "height {}",
187 + (cell.exact_height - (m.ascent + m.descent + m.leading)).abs() < 0.01,
188 + "height {} is not ascent + descent + leading",
163 189 cell.exact_height
164 190 );
165 - assert!((cell.advance - 7.0).abs() < f32::EPSILON);
166 - assert!((cell.height - 18.0).abs() < f32::EPSILON);
191 + assert!((cell.advance - cell.exact_advance.ceil()).abs() < f32::EPSILON);
192 + assert!((cell.height - cell.exact_height.ceil()).abs() < f32::EPSILON);
167 193 }
168 194
169 195 // The old constants, as a record of what was wrong rather than as a target.
196 + //
197 + // They were 8.0 x 17.0 and derived from no face at all, which is the defect:
198 + // against IosevkaTerm at 14px (7.0 x 17.5) every column carried a pixel of
199 + // dead space and every row lost half of one. That they happen to sit closer
200 + // to the face bundled today is luck and changes nothing — a number nothing
201 + // measures is wrong the moment the font moves, which it has since.
170 202 #[test]
171 - fn the_old_constants_were_a_pixel_out_per_column() {
203 + fn the_old_constants_are_not_this_faces_cell_either() {
172 204 let cell = CellMetrics::measure(FONT, 14.0).unwrap();
173 205 assert!(
174 - (8.0 - cell.exact_advance) >= 0.99,
175 - "the hardcoded 8.0 left {} of dead column",
206 + (8.0 - cell.exact_advance).abs() > 0.5,
207 + "the hardcoded 8.0 is {} off this face's advance",
176 208 8.0 - cell.exact_advance
177 209 );
178 210 assert!(
@@ -164,9 +164,13 @@
164 164 // it has to be the cell the caller actually lays out with rather than
165 165 // one this constructor measures for itself.
166 166 cell: crate::CellMetrics,
167 + // Where on the face's weight axis to draw. The bundled face is variable
168 + // and its own default is the light end of the axis, so this is named
169 + // rather than taken from the file.
170 + weight: f32,
167 171 ) -> anyhow::Result<Self> {
168 172 let atlas = GlyphAtlas::new(device, queue, ATLAS_SIZE);
169 - let shaper = Shaper::new(font_data, font_px, cell)?;
173 + let shaper = Shaper::new(font_data, font_px, cell, weight)?;
170 174
171 175 let uniforms_buf = device.create_buffer_init(&wgpu::util::BufferInitDescriptor {
172 176 label: Some("shop.text.uniforms"),
@@ -410,7 +414,9 @@
410 414 }
411 415 CellText::Cluster(s) => {
412 416 // The pen DOES advance across the cluster, and the marks
413 - // still land on the base. Measured on the bundled Iosevka:
417 + // still land on the base. Measured on the face bundled at
418 + // the time, IosevkaTerm, and it is a property of how fonts
419 + // are built rather than of that face:
414 420 // a combining glyph carries a full cell of advance and its
415 421 // ink hangs off the LEFT of its origin (acute -5px for a
416 422 // 3px mark, diaeresis -7px for a 6px one). The font is
@@ -425,7 +431,7 @@
425 431 //
426 432 // The two other shapes this can take are handled by the
427 433 // same line: where the shaper composes the pair into one
428 - // precomposed glyph (Iosevka does for e + acute) there is
434 + // precomposed glyph (both faces do for e + acute) there is
429 435 // nothing after the base to place, and where a font
430 436 // positions marks by GPOS the mark's advance is zero and
431 437 // its offsets do the work.
@@ -64,6 +64,16 @@
64 64 scale_ctx: ScaleContext,
65 65 shape_ctx: ShapeContext,
66 66 px: f32,
67 + /// Where on the primary face's weight axis to draw.
68 + ///
69 + /// Named rather than inherited, because a variable face's default instance
70 + /// is whatever its designer set and is not always the weight anyone wants:
71 + /// Quasi Mono is cut from a base whose default is `wght` 200, so a terminal
72 + /// that took the file's own default would draw every prompt in ExtraLight.
73 + ///
74 + /// Applied to every face, not only the primary one. A static fallback has
75 + /// no axes and swash drops the setting, so this needs no branch.
76 + weight: f32,
67 77 /// The cell this shaper is drawing into, in physical pixels.
68 78 cell: CellMetrics,
69 79 /// Scale that maps the face's own cell onto the pixel one, applied to
@@ -102,7 +112,12 @@
102 112 }
103 113
104 114 impl Shaper {
105 - pub(crate) fn new(font_data: Vec<u8>, px: f32, cell: CellMetrics) -> anyhow::Result<Self> {
115 + pub(crate) fn new(
116 + font_data: Vec<u8>,
117 + px: f32,
118 + cell: CellMetrics,
119 + weight: f32,
120 + ) -> anyhow::Result<Self> {
106 121 let font = FontRef::from_index(&font_data, 0)
107 122 .ok_or_else(|| anyhow::anyhow!("swash: not a font"))?;
108 123 let font_offset = font.offset;
@@ -127,6 +142,7 @@
127 142 scale_ctx: ScaleContext::new(),
128 143 shape_ctx: ShapeContext::new(),
129 144 px,
145 + weight,
130 146 cell,
131 147 snap,
132 148 furniture: HashSet::new(),
@@ -145,7 +161,7 @@
145 161 /// Char → the font that has it and its glyph id there.
146 162 ///
147 163 /// Falls back to `(PRIMARY, 0)` when nothing has it, which draws whatever
148 - /// the bundled font puts at `.notdef` — a hollow box in Iosevka's case. The
164 + /// the bundled font puts at `.notdef` — a hollow box in most faces. The
149 165 /// cell still holds its columns either way, so a missing glyph costs the
150 166 /// look of the line and never its layout.
151 167 pub(crate) fn glyph_id_for(&mut self, c: char) -> (FontId, GlyphId) {
@@ -208,7 +224,12 @@
208 224 offset: f.offset,
209 225 key: swash::CacheKey::new(),
210 226 };
211 - let mut shaper = self.shape_ctx.builder(face).size(self.px).build();
227 + let mut shaper = self
228 + .shape_ctx
229 + .builder(face)
230 + .size(self.px)
231 + .variations(&[("wght", self.weight)][..])
232 + .build();
212 233 shaper.add_str(text);
213 234 let mut out = Vec::new();
214 235 shaper.shape_with(|cluster| {
@@ -235,6 +256,7 @@
235 256 .scale_ctx
236 257 .builder(face)
237 258 .size(self.px)
259 + .variations(&[("wght", self.weight)][..])
238 260 .hint(true)
239 261 .build();
240 262 let mut render = Render::new(&[Source::Outline]);
@@ -292,11 +314,11 @@
292 314 mod tests {
293 315 use super::*;
294 316
295 - const FONT: &[u8] = include_bytes!("../../../assets/IosevkaTermNerdFontMono-Regular.ttf");
317 + const FONT: &[u8] = shop_font::FACE;
296 318
297 319 /// A shaper over the bundled face, drawing into the cell that face implies.
298 320 fn shaper(px: f32, cell: CellMetrics) -> Shaper {
299 - Shaper::new(FONT.to_vec(), px, cell).expect("the bundled face loads")
321 + Shaper::new(FONT.to_vec(), px, cell, shop_font::WEIGHT).expect("the bundled face loads")
300 322 }
301 323
302 324 /// The rasterized extent of `c`, as (left, right, top, bottom) in pixels
@@ -385,21 +407,24 @@
385 407 assert!(y0 > 1.0 && y1 < cell.height - 1.0, "`x` spans {y0}..{y1}");
386 408 }
387 409
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.
410 + // The case the snap was built for: a size where the advance is not a whole
411 + // number of pixels, so the cell rounds up and the difference would be
412 + // background down every column — the same shape of defect as the hardcoded
413 + // 8.0, arrived at honestly.
414 + //
415 + // With the face shop bundles today this is not a special case but the only
416 + // case: Quasi Mono's advance is 79/125 em and 79 is prime, so it lands
417 + // whole at 125px and nowhere usable. IosevkaTerm was 1/2 and did it at
418 + // every even size, which is why this test used to have to reach for 15px.
394 419 #[test]
395 420 fn a_size_where_the_advance_is_fractional_still_tiles() {
396 421 let cell = CellMetrics::measure(FONT, 15.0).unwrap();
397 422 assert!(
398 - (cell.exact_advance - 7.5).abs() < 0.01,
399 - "advance {}",
423 + (cell.exact_advance - cell.exact_advance.floor()).abs() > 0.01,
424 + "advance {} is whole, so this test is not testing the snap",
400 425 cell.exact_advance
401 426 );
402 - assert!((cell.advance - 8.0).abs() < f32::EPSILON);
427 + assert!((cell.advance - cell.exact_advance.ceil()).abs() < f32::EPSILON);
403 428
404 429 let mut shaper = shaper(15.0, cell);
405 430 let (x0, x1, y0, y1) = extent(&mut shaper, '\u{2588}');
@@ -70,10 +70,18 @@
70 70 };
71 71
72 72 const INITIAL: (u32, u32) = (960, 540);
73 - /// IosevkaTerm Nerd Font Mono, terminal-optimized variant of Iosevka with
74 - /// nerd-font glyphs baked in. Bundled so starship, yazi, helix and friends
75 - /// get their icons without a system font install. ~13 MB in the binary.
76 - const FONT_BYTES: &[u8] = include_bytes!("../../../assets/IosevkaTermNerdFontMono-Regular.ttf");
73 + /// Quasi Mono, the house monospace face, cut at build time by `shop-font`.
74 + ///
75 + /// Bundled so a fresh install has its box drawing, block elements and the house
76 + /// marks without a font install first. It replaced a nerd-patched IosevkaTerm
77 + /// committed in `assets/`: 13 MB of binary for a Private Use Area nothing in
78 + /// the house emits, against 70 KB here. What the swap costs is braille and the
79 + /// legacy-computing sextants, which now come from fallback; nothing in the tree
80 + /// draws either.
81 + ///
82 + /// **Variable, and its own default is the light end of the axis.** The weight
83 + /// is named at `shop_font::WEIGHT` rather than taken from the file.
84 + const FONT_BYTES: &[u8] = shop_font::FACE;
77 85 const FONT_PX: f32 = 14.0;
78 86 const PAD_X: f32 = 10.0;
79 87 const PAD_Y: f32 = 6.0;
@@ -177,6 +185,7 @@
177 185 --record PATH Tee the terminal output to PATH as raw bytes.
178 186 -h, --help Print this help.
179 187 -V, --version Print the version.
188 + --license Print the bundled font's licence.
180 189
181 190 Options take their value either way: --theme dark or --theme=dark.
182 191
@@ -220,7 +229,7 @@
220 229 .ok_or_else(|| CliExit::Reject(format!("{flag} needs a value"))),
221 230 };
222 231 match name {
223 - "-h" | "--help" | "-V" | "--version" if inline.is_some() => {
232 + "-h" | "--help" | "-V" | "--version" | "--license" if inline.is_some() => {
224 233 return Err(CliExit::Reject(format!("{name} takes no value")));
225 234 }
226 235 "-h" | "--help" => return Err(CliExit::Answer(HELP.into())),
@@ -230,6 +239,18 @@
230 239 env!("CARGO_PKG_VERSION")
231 240 )));
232 241 }
242 + // OFL 1.1 requires the licence to travel with a modified build, and
243 + // the bundled face is one: a patched Atkinson Hyperlegible Mono. It
244 + // used to travel as a file in the repo, which does not reach anyone
245 + // holding only the binary. Now it is in the binary and reachable.
246 + "--license" => {
247 + return Err(CliExit::Answer(format!(
248 + "shop bundles {} {}, cut from the house glyph set.\n\n{}",
249 + shop_font::FAMILY,
250 + shop_font::DEFAULT_STYLE,
251 + shop_font::LICENSE
252 + )));
253 + }
233 254 "--exec" => cli.exec_cmd = Some(value("--exec")?),
234 255 "--theme" => cli.theme = Some(value("--theme")?),
235 256 "--record" => cli.record_path = Some(value("--record")?),
@@ -411,7 +432,15 @@
411 432 };
412 433 // Scale 1 at startup; a `wl_surface.enter` on a HiDPI output rebuilds the
413 434 // renderer with the scaled cell.
414 - let text = TextRenderer::new(&device, &queue, format, font_data, FONT_PX, cell.scaled(1))?;
435 + let text = TextRenderer::new(
436 + &device,
437 + &queue,
438 + format,
439 + font_data,
440 + FONT_PX,
441 + cell.scaled(1),
442 + shop_font::WEIGHT,
443 + )?;
415 444 let images = ImageRenderer::new(&device, format);
416 445
417 446 let mut grid = Grid::new(cols_initial, rows_initial);
@@ -1256,6 +1285,7 @@
1256 1285 self.font_data.clone(),
1257 1286 FONT_PX * new_scale as f32,
1258 1287 self.cell.scaled(new_scale),
1288 + shop_font::WEIGHT,
1259 1289 ) {
1260 1290 Ok(t) => self.text = t,
1261 1291 Err(e) => {
@@ -2391,17 +2421,27 @@
2391 2421 }
2392 2422
2393 2423 // The regression guard the two constants never had. They were 8.0 x 17.0
2394 - // against a face that is 7.0 x 17.5 at this size, so every column carried a
2395 - // pixel of dead space and every row lost half of one.
2424 + // and derived from no face at all, so every column carried a pixel of dead
2425 + // space and every row lost half of one.
2426 + //
2427 + // Asserted as a relationship rather than as two numbers: the numbers are
2428 + // the bundled face's, and the bundled face has already changed once.
2396 2429 #[test]
2397 2430 fn the_cell_is_the_bundled_faces_own_and_not_a_number_someone_typed() {
2398 2431 let cell = test_cell();
2399 - assert!((cell.advance - 7.0).abs() < f32::EPSILON, "{cell:?}");
2400 - assert!((cell.height - 18.0).abs() < f32::EPSILON, "{cell:?}");
2401 2432 assert!(
2402 - (cell.advance - cell.exact_advance).abs() < f32::EPSILON,
2403 - "this face's advance is a whole pixel at 14px, so nothing is rounded away"
2433 + (cell.advance - cell.exact_advance.ceil()).abs() < f32::EPSILON,
2434 + "{cell:?}"
2404 2435 );
2436 + assert!(
2437 + (cell.height - cell.exact_height.ceil()).abs() < f32::EPSILON,
2438 + "{cell:?}"
2439 + );
2440 + // Under a pixel of rounding in both axes, which is what the furniture
2441 + // snap absorbs. More than that would mean the cell and the face have
2442 + // come apart rather than been rounded.
2443 + let (dx, dy) = cell.rounding_error();
2444 + assert!(dx < 1.0 && dy < 1.0, "{cell:?} rounds by {dx} x {dy}");
2405 2445 }
2406 2446
2407 2447 // Cells tile: what the grid steps by is what a glyph fills. Asserted
Binary file
@@ -1,96 +1,0 @@
1 - Covers: IosevkaTermNerdFontMono-Regular.ttf
2 - Nerd Font patched build; the patcher is MIT and the font keeps the licence below.
3 -
4 - Copyright 2015-2025, Renzhi Li (aka. Belleve Invis, belleve@typeof.net).
5 -
6 - This Font Software is licensed under the SIL Open Font License, Version 1.1.
7 - This license is copied below, and is also available with a FAQ at:
8 - http://scripts.sil.org/OFL
9 -
10 -
11 - -----------------------------------------------------------
12 - SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
13 - -----------------------------------------------------------
14 -
15 - PREAMBLE
16 - The goals of the Open Font License (OFL) are to stimulate worldwide
17 - development of collaborative font projects, to support the font creation
18 - efforts of academic and linguistic communities, and to provide a free and
19 - open framework in which fonts may be shared and improved in partnership
20 - with others.
21 -
22 - The OFL allows the licensed fonts to be used, studied, modified and
23 - redistributed freely as long as they are not sold by themselves. The
24 - fonts, including any derivative works, can be bundled, embedded,
25 - redistributed and/or sold with any software provided that any reserved
26 - names are not used by derivative works. The fonts and derivatives,
27 - however, cannot be released under any other type of license. The
28 - requirement for fonts to remain under this license does not apply
29 - to any document created using the fonts or their derivatives.
30 -
31 - DEFINITIONS
32 - "Font Software" refers to the set of files released by the Copyright
33 - Holder(s) under this license and clearly marked as such. This may
34 - include source files, build scripts and documentation.
35 -
36 - "Reserved Font Name" refers to any names specified as such after the
37 - copyright statement(s).
38 -
39 - "Original Version" refers to the collection of Font Software components as
40 - distributed by the Copyright Holder(s).
41 -
42 - "Modified Version" refers to any derivative made by adding to, deleting,
43 - or substituting -- in part or in whole -- any of the components of the
44 - Original Version, by changing formats or by porting the Font Software to a
45 - new environment.
46 -
47 - "Author" refers to any designer, engineer, programmer, technical
48 - writer or other person who contributed to the Font Software.
49 -
50 - PERMISSION & CONDITIONS
51 - Permission is hereby granted, free of charge, to any person obtaining
52 - a copy of the Font Software, to use, study, copy, merge, embed, modify,
53 - redistribute, and sell modified and unmodified copies of the Font
54 - Software, subject to the following conditions:
55 -
56 - 1) Neither the Font Software nor any of its individual components,
57 - in Original or Modified Versions, may be sold by itself.
58 -
59 - 2) Original or Modified Versions of the Font Software may be bundled,
60 - redistributed and/or sold with any software, provided that each copy
61 - contains the above copyright notice and this license. These can be
62 - included either as stand-alone text files, human-readable headers or
63 - in the appropriate machine-readable metadata fields within text or
64 - binary files as long as those fields can be easily viewed by the user.
65 -
66 - 3) No Modified Version of the Font Software may use the Reserved Font
67 - Name(s) unless explicit written permission is granted by the corresponding
68 - Copyright Holder. This restriction only applies to the primary font name as
69 - presented to the users.
70 -
71 - 4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
72 - Software shall not be used to promote, endorse or advertise any
73 - Modified Version, except to acknowledge the contribution(s) of the
74 - Copyright Holder(s) and the Author(s) or with their explicit written
75 - permission.
76 -
77 - 5) The Font Software, modified or unmodified, in part or in whole,
78 - must be distributed entirely under this license, and must not be
79 - distributed under any other license. The requirement for fonts to
80 - remain under this license does not apply to any document created
81 - using the Font Software.
82 -
83 - TERMINATION
84 - This license becomes null and void if any of the above conditions are
85 - not met.
86 -
87 - DISCLAIMER
88 - THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
89 - EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
90 - MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
91 - OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
92 - COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
93 - INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
94 - DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
95 - FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
96 - OTHER DEALINGS IN THE FONT SOFTWARE.
@@ -1,0 +1,21 @@
1 + [package]
2 + name = "shop-font"
3 + version = "0.1.0"
4 + description = "The bundled face, cut from the house font pipeline at build time"
5 + publish = false
6 + edition.workspace = true
7 + rust-version.workspace = true
8 + authors.workspace = true
9 + repository.workspace = true
10 + license.workspace = true
11 +
12 + [lints]
13 + workspace = true
14 +
15 + [dependencies]
16 +
17 + [build-dependencies]
18 + # The house font pipeline. A git dependency rather than a vendored binary: the
19 + # face rebuilds from a checkout, which is the rule that keeps the glyph set and
20 + # the face from becoming two sources of truth.
21 + quasi-type = { git = "https://makenot.work/git/max/quasi-type.git", version = "0.1" }