Skip to main content

max / shop

Ask the system for a font when the bundled one has no glyph A line of CJK came out as boxes. The bundled font is a nerd-patched Iosevka, measured at full coverage of Latin, Greek, Cyrillic, the 112 combining marks, box drawing and braille, and zero of Han, kana and hangul — a nerd-font patch adds icons in the private use area and cannot add a script. Nothing was broken; the font was being asked for what it does not contain. Rather than pick a second font and ship it, ask the machine. fontconfig already knows, and already carries the user's own rules, so a system told to prefer some particular face gets that face here too. The query is by CHARACTER — a charset of one — which is what makes the answer "who has this glyph" rather than "give me a font I named", and it is asked with a monospace hint: without it the match for CJK comes back as Noto Sans CJK rather than Noto Sans Mono CJK, and only the second is built to sit in a grid. A glyph is now identified by (font, glyph id). Ids only mean something inside the font that issued them, so the old cache keyed on the id alone would have served one font's glyph out of another's atlas slot the moment a second font joined. Lookups are memoized per character including the misses, and per font file, so one fallback face serves every character it covers and a character nothing has is asked once. Ordinary output never reaches any of this: it runs only when the bundled font's charmap misses. The baseline still comes from the bundled font whatever a cell was drawn from, or a row of mixed scripts would wander. fontconfig's C library is Keith Packard's permissive licence and ships on every Linux desktop, which is everywhere shop runs. Verified in a window: [日本語] now renders and lines up with [abcdef].
Co-Authored-By
Claude Opus 5 (1M context) <noreply@anthropic.com>
Author: Max Johnson <me@maxj.phd> · 2026-08-09 15:50 UTC
Signed with PGP, not checked
Commit: 53551eb50bbb0e9d0231b9063be93f0c820fdeb8
Parent: 40724f0
8 files changed, +360 insertions, -107 deletions
M Cargo.lock +87 -28
@@ -10,9 +10,9 @@
10 10
11 11 [[package]]
12 12 name = "aho-corasick"
13 - version = "1.1.4"
13 + version = "1.1.5"
14 14 source = "registry+https://github.com/rust-lang/crates.io-index"
15 - checksum = "ddd31a130427c27518df266943a5308ed92d4b226cc639f5a8f1002816174301"
15 + checksum = "c982642fa9e8606056828ee9a8505737230110bb1099153c79efe865c59d12ba"
16 16 dependencies = [
17 17 "memchr",
18 18 ]
@@ -25,9 +25,9 @@
25 25
26 26 [[package]]
27 27 name = "android_system_properties"
28 - version = "0.1.5"
28 + version = "0.1.6"
29 29 source = "registry+https://github.com/rust-lang/crates.io-index"
30 - checksum = "819e7219dbd41043ac279b19830f2efc897156490d7fd6ea916720117ee66311"
30 + checksum = "ae221649c9976a6f6c56ae1facf410f3ddb33cc661c4b7b61020a912d4237fbc"
31 31 dependencies = [
32 32 "libc",
33 33 ]
@@ -156,13 +156,13 @@
156 156
157 157 [[package]]
158 158 name = "bytemuck_derive"
159 - version = "1.11.0"
159 + version = "1.12.0"
160 160 source = "registry+https://github.com/rust-lang/crates.io-index"
161 - checksum = "f65693059b6b9c588b9f62fed1cedbf0a8b805631457ea162d68f0de186f3de5"
161 + checksum = "fc0e56a716f1e132ff6bf4bdac1c944a3fcdc1cae65f70a4a2a1ac3b401d2d1f"
162 162 dependencies = [
163 163 "proc-macro2",
164 164 "quote",
165 - "syn 2.0.119",
165 + "syn 3.0.3",
166 166 ]
167 167
168 168 [[package]]
@@ -198,9 +198,9 @@
198 198
199 199 [[package]]
200 200 name = "cc"
201 - version = "1.3.0"
201 + version = "1.4.2"
202 202 source = "registry+https://github.com/rust-lang/crates.io-index"
203 - checksum = "c89588d05638b5b4594a3348a2d6c20277e43a7f5c5202b05cc56888475a47b8"
203 + checksum = "5d262e149917187838d5b42777c8253bcb64500067342904e7d429499a6f277e"
204 204 dependencies = [
205 205 "find-msvc-tools",
206 206 "shlex",
@@ -220,9 +220,9 @@
220 220
221 221 [[package]]
222 222 name = "clap"
223 - version = "4.6.4"
223 + version = "4.6.5"
224 224 source = "registry+https://github.com/rust-lang/crates.io-index"
225 - checksum = "d91e0c145792ef73a6ad36d27c75ac09f1832222a3c209689d90f534685ee5b7"
225 + checksum = "301b56658598e48f3648647ac6fc887be7e7108eddfa4e9b63fcf3ec58c0cadf"
226 226 dependencies = [
227 227 "clap_builder",
228 228 "clap_derive",
@@ -230,9 +230,9 @@
230 230
231 231 [[package]]
232 232 name = "clap_builder"
233 - version = "4.6.2"
233 + version = "4.6.5"
234 234 source = "registry+https://github.com/rust-lang/crates.io-index"
235 - checksum = "f09628afdcc538b57f3c6341e9c8e9970f18e4a481690a64974d7023bd33548b"
235 + checksum = "94a65403d1a1bd28f7dc68eb8506e8874808ee5eecb59298de588e2e1407a078"
236 236 dependencies = [
237 237 "anstream",
238 238 "anstyle",
@@ -368,6 +368,12 @@
368 368 "num-traits",
369 369 ]
370 370
371 + [[package]]
372 + name = "fastrand"
373 + version = "2.5.0"
374 + source = "registry+https://github.com/rust-lang/crates.io-index"
375 + checksum = "da7c62ceae207dd37ea5b845da6a0696c799f85e97da1ab5b7910be3c1c80223"
376 +
371 377 [[package]]
372 378 name = "fdeflate"
373 379 version = "0.3.7"
@@ -379,9 +385,9 @@
379 385
380 386 [[package]]
381 387 name = "find-msvc-tools"
382 - version = "0.1.9"
388 + version = "0.1.10"
383 389 source = "registry+https://github.com/rust-lang/crates.io-index"
384 - checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582"
390 + checksum = "26b73573e6edcd2af0cdf47bd6cb58f0b3839491263c314eaad1ccf24430e1de"
385 391
386 392 [[package]]
387 393 name = "flate2"
@@ -401,13 +407,33 @@
401 407
402 408 [[package]]
403 409 name = "font-types"
404 - version = "0.12.1"
410 + version = "0.12.2"
405 411 source = "registry+https://github.com/rust-lang/crates.io-index"
406 - checksum = "ad67eced03f5504d9cbd3a879b5958b5c54d4e5fd794361c6eb21b05fb703411"
412 + checksum = "0a7299a780854a6d391be2ae1c8521c9368471b559dbfd6a8dbd9f407eaff100"
407 413 dependencies = [
408 414 "bytemuck",
409 415 ]
410 416
417 + [[package]]
418 + name = "fontconfig"
419 + version = "0.11.0"
420 + source = "registry+https://github.com/rust-lang/crates.io-index"
421 + checksum = "0d0e1eb4148faaf675053b7299bc5b4c041c8c0c724cf77844d6be8dd0ac5b9d"
422 + dependencies = [
423 + "yeslogic-fontconfig-sys",
424 + ]
425 +
426 + [[package]]
427 + name = "getrandom"
428 + version = "0.4.3"
429 + source = "registry+https://github.com/rust-lang/crates.io-index"
430 + checksum = "300e883d756b2e4ec94e02791f39b04b522276138852cfc41d9fb7e904106099"
431 + dependencies = [
432 + "cfg-if",
433 + "libc",
434 + "r-efi",
435 + ]
436 +
411 437 [[package]]
412 438 name = "gpu-allocator"
413 439 version = "0.28.0"
@@ -950,6 +976,12 @@
950 976 "proc-macro2",
951 977 ]
952 978
979 + [[package]]
980 + name = "r-efi"
981 + version = "6.0.0"
982 + source = "registry+https://github.com/rust-lang/crates.io-index"
983 + checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf"
984 +
953 985 [[package]]
954 986 name = "raw-window-handle"
955 987 version = "0.6.2"
@@ -990,9 +1022,9 @@
990 1022
991 1023 [[package]]
992 1024 name = "regex-automata"
993 - version = "0.4.16"
1025 + version = "0.4.18"
994 1026 source = "registry+https://github.com/rust-lang/crates.io-index"
995 - checksum = "8fcfdb36bda0c880c5931cdc7a2bcdc8ba4556847b9d912bca70bc94708711ad"
1027 + checksum = "ad8553b9b26413251cbf30e620595c7a41b3887f03da04579c0e6b0d6a06b4b2"
996 1028 dependencies = [
997 1029 "aho-corasick",
998 1030 "memchr",
@@ -1127,6 +1159,7 @@
1127 1159 "shop-wayland",
1128 1160 "shop-xkb",
1129 1161 "smithay-client-toolkit",
1162 + "tempfile",
1130 1163 "toml",
1131 1164 "tracing",
1132 1165 "tracing-subscriber",
@@ -1167,8 +1200,10 @@
1167 1200 dependencies = [
1168 1201 "anyhow",
1169 1202 "bytemuck",
1203 + "fontconfig",
1170 1204 "guillotiere",
1171 1205 "swash",
1206 + "tracing",
1172 1207 "wgpu",
1173 1208 ]
1174 1209
@@ -1303,19 +1338,32 @@
1303 1338 ]
1304 1339
1305 1340 [[package]]
1306 - name = "thiserror"
1307 - version = "2.0.19"
1341 + name = "tempfile"
1342 + version = "3.27.0"
1308 1343 source = "registry+https://github.com/rust-lang/crates.io-index"
1309 - checksum = "09a43598840e33d5b0331f38c5e30d13bb11c11210a4b58f0d9b18a5a5eefcd9"
1344 + checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd"
1345 + dependencies = [
1346 + "fastrand",
1347 + "getrandom",
1348 + "once_cell",
1349 + "rustix",
1350 + "windows-sys",
1351 + ]
1352 +
1353 + [[package]]
1354 + name = "thiserror"
1355 + version = "2.0.20"
1356 + source = "registry+https://github.com/rust-lang/crates.io-index"
1357 + checksum = "ec86235f5fcc2a73650310756d2ac5b138a5780bbbdfae3eeccec992c435ba4f"
1310 1358 dependencies = [
1311 1359 "thiserror-impl",
1312 1360 ]
1313 1361
1314 1362 [[package]]
1315 1363 name = "thiserror-impl"
1316 - version = "2.0.19"
1364 + version = "2.0.20"
1317 1365 source = "registry+https://github.com/rust-lang/crates.io-index"
1318 - checksum = "43cbfe0cf76104d42a574802844187e84a305e531ed54455f11fbde0f10541cd"
1366 + checksum = "bc04cd3e1236dd4a98afca4569f2deb3f120e5422a4023be2cb683f8486292af"
1319 1367 dependencies = [
1320 1368 "proc-macro2",
1321 1369 "quote",
@@ -1876,6 +1924,17 @@
1876 1924 source = "registry+https://github.com/rust-lang/crates.io-index"
1877 1925 checksum = "e01738255b5a16e78bbb83e7fbba0a1e7dd506905cfc53f4622d89015a03fbb5"
1878 1926
1927 + [[package]]
1928 + name = "yeslogic-fontconfig-sys"
1929 + version = "6.0.1"
1930 + source = "registry+https://github.com/rust-lang/crates.io-index"
1931 + checksum = "1d8b8abf912b9a29ff112e1671c97c33636903d13a69712037190e6805af4f76"
1932 + dependencies = [
1933 + "dlib",
1934 + "once_cell",
1935 + "pkg-config",
1936 + ]
1937 +
1879 1938 [[package]]
1880 1939 name = "zeno"
1881 1940 version = "0.3.3"
@@ -1884,18 +1943,18 @@
1884 1943
1885 1944 [[package]]
1886 1945 name = "zerocopy"
1887 - version = "0.8.55"
1946 + version = "0.8.56"
1888 1947 source = "registry+https://github.com/rust-lang/crates.io-index"
1889 - checksum = "b5a105cd7b140f6eeec8acff2ea38135d3cab283ada58540f629fe51e46696eb"
1948 + checksum = "556764e583adb45a9f8d413c2a147fa7e8d821e48e12b14fd560b607998b75eb"
1890 1949 dependencies = [
1891 1950 "zerocopy-derive",
1892 1951 ]
1893 1952
1894 1953 [[package]]
1895 1954 name = "zerocopy-derive"
1896 - version = "0.8.55"
1955 + version = "0.8.56"
1897 1956 source = "registry+https://github.com/rust-lang/crates.io-index"
1898 - checksum = "0fe976fb70c78cd64cccfe3a6fc142244e8a77b70959b30faf9d0ac37ee228eb"
1957 + checksum = "f2ab42fc20575779bd240faa45f94a74256f755c0fa9e89f0ede20d91d0cdfc1"
1899 1958 dependencies = [
1900 1959 "proc-macro2",
1901 1960 "quote",
M Cargo.toml +5
@@ -46,6 +46,11 @@
46 46 # cursor moves from the same table, so the grid has to read it from the same
47 47 # data rather than assume one column per char.
48 48 unicode-width = "0.2"
49 + # Which font the SYSTEM says has a character the bundled font lacks. Consulted
50 + # only on a miss, so ordinary output never reaches it. The C library is Keith
51 + # Packard's permissive licence and ships on every Linux desktop, which is
52 + # everywhere shop runs.
53 + fontconfig = "0.11"
49 54
50 55 [workspace.package]
51 56 edition = "2024"
M README.md +19
@@ -148,6 +148,25 @@
148 148 never learns the table exists. Copying gets the whole cluster, so a path off a
149 149 Mac-formatted volume pastes back as the path it came from.
150 150
151 + ## Fonts
152 +
153 + Shop bundles one font, a nerd-patched Iosevka, so a fresh install has its icons
154 + without a font install first. That covers Latin, Greek, Cyrillic, combining
155 + marks, box drawing and braille, and it has no CJK: a nerd-font patch adds icons
156 + in the private use area and cannot add a script.
157 +
158 + For anything the bundled font does not have, shop asks the system. On a miss it
159 + puts the character to fontconfig, loads whatever comes back, and keeps it for
160 + every other character that font covers. Asking by character rather than by name
161 + is what makes it the system's answer and not ours, and it means a machine
162 + configured to prefer a particular face gets that face here too. The lookup runs
163 + only on a miss, so ordinary output never reaches it, and a miss nothing can
164 + serve still draws the bundled font's own empty box rather than nothing.
165 +
166 + The baseline comes from the bundled font whatever a cell was drawn from. A
167 + fallback face's own ascent would sit its glyphs on a different line and make a
168 + row of mixed scripts wander.
169 +
151 170 Ligatures are a different thing and shop does not do them. `>=` drawn as one
152 171 connected glyph is the font's business, not the buffer's — the two characters
153 172 still occupy two cells — and a ligature makes a cursor sitting between them land
@@ -18,3 +18,5 @@
18 18 guillotiere.workspace = true
19 19 bytemuck.workspace = true
20 20 anyhow.workspace = true
21 + fontconfig.workspace = true
22 + tracing.workspace = true
@@ -9,6 +9,7 @@
9 9 //! and cluster tracking arrive with the grid milestone.
10 10
11 11 mod atlas;
12 + mod fallback;
12 13 mod image;
13 14 mod pipeline;
14 15 mod shaper;
@@ -8,7 +8,7 @@
8 8
9 9 use crate::{
10 10 atlas::{AtlasSlot, GlyphAtlas},
11 - shaper::Shaper,
11 + shaper::{FontId, PRIMARY, Shaper},
12 12 };
13 13
14 14 /// One character with its cell-anchored position and color.
@@ -107,7 +107,10 @@
107 107 instance_cap: u64,
108 108 atlas: GlyphAtlas,
109 109 shaper: Shaper,
110 - cache: HashMap<GlyphId, Option<CachedGlyph>>,
110 + /// Keyed by (font, glyph). A glyph id is only unique inside the font that
111 + /// issued it, so the moment a fallback face joins the set, keying on the id
112 + /// alone would serve one font's glyph from another's atlas slot.
113 + cache: HashMap<(FontId, GlyphId), Option<CachedGlyph>>,
111 114 /// Per-row cache of "cheap-to-recompute" instance data. Positions get
112 115 /// computed at draw time from row_idx + cell_advance, so scroll can
113 116 /// just rotate this vec.
@@ -356,44 +359,50 @@
356 359 // One glyph id to an atlas slot, memoized. The id is what the cache is
357 360 // keyed on either way, so a mark rasterized for one base is reused for
358 361 // every other base it ever sits on.
359 - let mut push =
360 - |slot: &mut Vec<CachedCell>, shaper: &mut Shaper, id, col, dx: f32, dy: f32, color| {
361 - let cached = match cache.get(&id).copied() {
362 - Some(v) => v,
363 - None => {
364 - let entry = shaper.rasterize(id).and_then(|r| {
365 - atlas
366 - .upload(queue, r.width, r.height, &r.bitmap)
367 - .map(|s| CachedGlyph {
368 - slot: s,
369 - left: r.placement_left,
370 - top: r.placement_top,
371 - })
372 - });
373 - cache.insert(id, entry);
374 - entry
375 - }
376 - };
377 - if let Some(g) = cached
378 - && g.slot.px[0] > 0
379 - && g.slot.px[1] > 0
380 - {
381 - slot.push(CachedCell {
362 + let mut push = |slot: &mut Vec<CachedCell>,
363 + shaper: &mut Shaper,
364 + font: FontId,
365 + id,
382 366 col,
383 - slot: g.slot,
384 - left: g.left,
385 - top: g.top,
386 - dx,
387 - dy,
388 - color,
367 + dx: f32,
368 + dy: f32,
369 + color| {
370 + let cached = match cache.get(&(font, id)).copied() {
371 + Some(v) => v,
372 + None => {
373 + let entry = shaper.rasterize(font, id).and_then(|r| {
374 + atlas
375 + .upload(queue, r.width, r.height, &r.bitmap)
376 + .map(|s| CachedGlyph {
377 + slot: s,
378 + left: r.placement_left,
379 + top: r.placement_top,
380 + })
389 381 });
382 + cache.insert((font, id), entry);
383 + entry
390 384 }
391 385 };
386 + if let Some(g) = cached
387 + && g.slot.px[0] > 0
388 + && g.slot.px[1] > 0
389 + {
390 + slot.push(CachedCell {
391 + col,
392 + slot: g.slot,
393 + left: g.left,
394 + top: g.top,
395 + dx,
396 + dy,
397 + color,
398 + });
399 + }
400 + };
392 401 for (col, text, color) in cells {
393 402 match text {
394 403 CellText::Char(c) => {
395 - let id = shaper.glyph_id_for(c);
396 - push(slot, shaper, id, col, 0.0, 0.0, color);
404 + let (font, id) = shaper.glyph_id_for(c);
405 + push(slot, shaper, font, id, col, 0.0, 0.0, color);
397 406 }
398 407 CellText::Cluster(s) => {
399 408 // The pen DOES advance across the cluster, and the marks
@@ -416,11 +425,20 @@
416 425 // nothing after the base to place, and where a font
417 426 // positions marks by GPOS the mark's advance is zero and
418 427 // its offsets do the work.
428 + //
429 + // Shaped against the font that has the BASE. A cluster is
430 + // one character plus marks on it, so it belongs to one
431 + // font; shaping it against the bundled font when the base
432 + // came from a fallback would look every glyph up in the
433 + // wrong charmap and return notdef for the lot.
434 + let base = s.chars().next().unwrap_or(' ');
435 + let (font, _) = shaper.glyph_id_for(base);
419 436 let mut pen_x = 0.0;
420 - for g in shaper.shape(&s) {
437 + for g in shaper.shape(font, &s) {
421 438 push(
422 439 slot,
423 440 shaper,
441 + font,
424 442 g.id,
425 443 col,
426 444 pen_x + g.x_offset,
@@ -548,11 +566,11 @@
548 566 }
549 567
550 568 for cell in cells {
551 - let glyph_id = self.shaper.glyph_id_for(cell.c);
552 - let cached = match self.cache.get(&glyph_id).copied() {
569 + let (font, glyph_id) = self.shaper.glyph_id_for(cell.c);
570 + let cached = match self.cache.get(&(font, glyph_id)).copied() {
553 571 Some(v) => v,
554 572 None => {
555 - let entry = self.shaper.rasterize(glyph_id).and_then(|r| {
573 + let entry = self.shaper.rasterize(font, glyph_id).and_then(|r| {
556 574 self.atlas
557 575 .upload(queue, r.width, r.height, &r.bitmap)
558 576 .map(|slot| CachedGlyph {
@@ -561,7 +579,7 @@
561 579 top: r.placement_top,
562 580 })
563 581 });
564 - self.cache.insert(glyph_id, entry);
582 + self.cache.insert((font, glyph_id), entry);
565 583 entry
566 584 }
567 585 };
@@ -633,11 +651,11 @@
633 651 let mut instances: Vec<Instance> = Vec::with_capacity(cells.len());
634 652
635 653 for cell in cells {
636 - let glyph_id = self.shaper.glyph_id_for(cell.c);
637 - let cached = match self.cache.get(&glyph_id).copied() {
654 + let (font, glyph_id) = self.shaper.glyph_id_for(cell.c);
655 + let cached = match self.cache.get(&(font, glyph_id)).copied() {
638 656 Some(v) => v,
639 657 None => {
640 - let entry = self.shaper.rasterize(glyph_id).and_then(|r| {
658 + let entry = self.shaper.rasterize(font, glyph_id).and_then(|r| {
641 659 self.atlas
642 660 .upload(queue, r.width, r.height, &r.bitmap)
643 661 .map(|slot| CachedGlyph {
@@ -646,7 +664,7 @@
646 664 top: r.placement_top,
647 665 })
648 666 });
649 - self.cache.insert(glyph_id, entry);
667 + self.cache.insert((font, glyph_id), entry);
650 668 entry
651 669 }
652 670 };
@@ -730,15 +748,17 @@
730 748 ) {
731 749 let ascent = self.shaper.ascent();
732 750 let baseline_y = y + ascent;
733 - let glyphs = self.shaper.shape(text);
751 + // Chrome text, not grid text: drawn from the bundled font, which is
752 + // the one whose metrics the surrounding layout is built on.
753 + let glyphs = self.shaper.shape(PRIMARY, text);
734 754 let mut pen_x = x;
735 755 let mut instances: Vec<Instance> = Vec::with_capacity(glyphs.len());
736 756
737 757 for g in glyphs {
738 - let cached = match self.cache.get(&g.id).copied() {
758 + let cached = match self.cache.get(&(PRIMARY, g.id)).copied() {
739 759 Some(v) => v,
740 760 None => {
741 - let entry = self.shaper.rasterize(g.id).and_then(|r| {
761 + let entry = self.shaper.rasterize(PRIMARY, g.id).and_then(|r| {
742 762 self.atlas
743 763 .upload(queue, r.width, r.height, &r.bitmap)
744 764 .map(|slot| CachedGlyph {
@@ -747,7 +767,7 @@
747 767 top: r.placement_top,
748 768 })
749 769 });
750 - self.cache.insert(g.id, entry);
770 + self.cache.insert((PRIMARY, g.id), entry);
751 771 entry
752 772 }
753 773 };
@@ -1,4 +1,12 @@
1 - //! Swash wrapper: parse a font, shape a string, rasterize a glyph.
1 + //! Swash wrapper: parse fonts, shape a cluster, rasterize a glyph.
2 + //!
3 + //! Holds a SET of fonts, not one. Slot 0 is the bundled font and answers for
4 + //! almost everything; the rest arrive one at a time from [`Fallback`] when a
5 + //! character turns up that slot 0 does not have. A glyph is therefore
6 + //! identified by (font, glyph id) rather than by glyph id, since ids only mean
7 + //! something inside the font that issued them.
8 +
9 + use std::collections::HashMap;
2 10
3 11 use swash::{
4 12 FontRef, GlyphId,
@@ -7,9 +15,27 @@
7 15 zeno::Format,
8 16 };
9 17
18 + use crate::fallback::Fallback;
19 +
20 + /// Which font in the set. 0 is always the bundled one.
21 + pub(crate) type FontId = u16;
22 +
23 + pub(crate) const PRIMARY: FontId = 0;
24 +
25 + struct Face {
26 + data: Vec<u8>,
27 + offset: u32,
28 + }
29 +
10 30 pub(crate) struct Shaper {
11 - font_data: Vec<u8>,
12 - font_offset: u32,
31 + faces: Vec<Face>,
32 + /// Which face has a character, remembered including the misses: a
33 + /// character nothing has is a fontconfig query we only want to run once.
34 + resolved: HashMap<char, Option<FontId>>,
35 + /// Font file to the slot already holding it, so one fallback face serves
36 + /// every character it covers rather than being loaded per character.
37 + by_file: HashMap<(String, i32), FontId>,
38 + fallback: Fallback,
13 39 scale_ctx: ScaleContext,
14 40 shape_ctx: ShapeContext,
15 41 px: f32,
@@ -40,21 +66,85 @@
40 66 .ok_or_else(|| anyhow::anyhow!("swash: not a font"))?;
41 67 let font_offset = font.offset;
42 68 Ok(Self {
43 - font_data,
44 - font_offset,
69 + faces: vec![Face {
70 + data: font_data,
71 + offset: font_offset,
72 + }],
73 + resolved: HashMap::new(),
74 + by_file: HashMap::new(),
75 + fallback: Fallback::new(),
45 76 scale_ctx: ScaleContext::new(),
46 77 shape_ctx: ShapeContext::new(),
47 78 px,
48 79 })
49 80 }
50 81
51 - pub(crate) fn shape(&mut self, text: &str) -> Vec<ShapedGlyph> {
52 - let font = FontRef {
53 - data: &self.font_data,
54 - offset: self.font_offset,
82 + fn face(&self, id: FontId) -> FontRef<'_> {
83 + let f = &self.faces[id as usize];
84 + FontRef {
85 + data: &f.data,
86 + offset: f.offset,
87 + key: swash::CacheKey::new(),
88 + }
89 + }
90 +
91 + /// Char → the font that has it and its glyph id there.
92 + ///
93 + /// Falls back to `(PRIMARY, 0)` when nothing has it, which draws whatever
94 + /// the bundled font puts at `.notdef` — a hollow box in Iosevka's case. The
95 + /// cell still holds its columns either way, so a missing glyph costs the
96 + /// look of the line and never its layout.
97 + pub(crate) fn glyph_id_for(&mut self, c: char) -> (FontId, GlyphId) {
98 + let id = self.face(PRIMARY).charmap().map(c);
99 + if id != 0 {
100 + return (PRIMARY, id);
101 + }
102 + match self.resolve(c) {
103 + Some(font) => {
104 + let id = self.face(font).charmap().map(c);
105 + if id == 0 { (PRIMARY, 0) } else { (font, id) }
106 + }
107 + None => (PRIMARY, 0),
108 + }
109 + }
110 +
111 + /// Load (or reuse) the face the system names for `c`.
112 + fn resolve(&mut self, c: char) -> Option<FontId> {
113 + if let Some(known) = self.resolved.get(&c) {
114 + return *known;
115 + }
116 + let found = self.load(c);
117 + self.resolved.insert(c, found);
118 + found
119 + }
120 +
121 + fn load(&mut self, c: char) -> Option<FontId> {
122 + let m = self.fallback.find(c)?;
123 + let key = (m.path.clone(), m.index);
124 + if let Some(slot) = self.by_file.get(&key) {
125 + return Some(*slot);
126 + }
127 + let data = std::fs::read(&m.path).ok()?;
128 + let index = usize::try_from(m.index).unwrap_or(0);
129 + let offset = FontRef::from_index(&data, index)?.offset;
130 + let slot = FontId::try_from(self.faces.len()).ok()?;
131 + self.faces.push(Face { data, offset });
132 + self.by_file.insert(key, slot);
133 + tracing::debug!(font = %m.path, slot, "loaded a fallback font");
134 + Some(slot)
135 + }
136 +
137 + pub(crate) fn shape(&mut self, font: FontId, text: &str) -> Vec<ShapedGlyph> {
138 + // Borrowed from the field rather than through `face()`, so the shared
139 + // borrow of the font set and the exclusive one of the context stay on
140 + // different fields and the borrow checker can see it.
141 + let f = &self.faces[font as usize];
142 + let face = FontRef {
143 + data: &f.data,
144 + offset: f.offset,
55 145 key: swash::CacheKey::new(),
56 146 };
57 - let mut shaper = self.shape_ctx.builder(font).size(self.px).build();
147 + let mut shaper = self.shape_ctx.builder(face).size(self.px).build();
58 148 shaper.add_str(text);
59 149 let mut out = Vec::new();
60 150 shaper.shape_with(|cluster| {
@@ -70,15 +160,16 @@
70 160 out
71 161 }
72 162
73 - pub(crate) fn rasterize(&mut self, id: GlyphId) -> Option<Raster> {
74 - let font = FontRef {
75 - data: &self.font_data,
76 - offset: self.font_offset,
163 + pub(crate) fn rasterize(&mut self, font: FontId, id: GlyphId) -> Option<Raster> {
164 + let f = &self.faces[font as usize];
165 + let face = FontRef {
166 + data: &f.data,
167 + offset: f.offset,
77 168 key: swash::CacheKey::new(),
78 169 };
79 170 let mut scaler = self
80 171 .scale_ctx
81 - .builder(font)
172 + .builder(face)
82 173 .size(self.px)
83 174 .hint(true)
84 175 .build();
@@ -97,24 +188,12 @@
97 188 })
98 189 }
99 190
191 + /// The baseline, taken from the bundled font only.
192 + ///
193 + /// One baseline for the whole grid, whatever font a given cell came from:
194 + /// a fallback face with its own ascent would sit its glyphs on a different
195 + /// line and make a row of mixed scripts wander.
100 196 pub(crate) fn ascent(&self) -> f32 {
101 - let font = FontRef {
102 - data: &self.font_data,
103 - offset: self.font_offset,
104 - key: swash::CacheKey::new(),
105 - };
106 - let metrics = font.metrics(&[]).scale(self.px);
107 - metrics.ascent
108 - }
109 -
110 - /// Char → glyph id via the font's char map. No shaping; used for cell-
111 - /// aligned monospace layout where per-glyph advance is not consulted.
112 - pub(crate) fn glyph_id_for(&self, c: char) -> GlyphId {
113 - let font = FontRef {
114 - data: &self.font_data,
115 - offset: self.font_offset,
116 - key: swash::CacheKey::new(),
117 - };
118 - font.charmap().map(c)
197 + self.face(PRIMARY).metrics(&[]).scale(self.px).ascent
119 198 }
120 199 }
@@ -1,0 +1,68 @@
1 + //! Asking the system which font has a character the bundled one does not.
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.
7 + //!
8 + //! Rather than pick a second font ourselves and ship it, ask the machine. On
9 + //! Linux fontconfig is the thing that already knows, and already carries the
10 + //! user's own preferences and per-language rules, so a system that has been
11 + //! told to prefer a particular CJK face gets that face here too. Shop is
12 + //! Wayland-only, so fontconfig is present wherever shop runs, and it is
13 + //! consulted only when the bundled font misses, which for ordinary output is
14 + //! never.
15 +
16 + use fontconfig::{CharSet, Fontconfig, Pattern};
17 +
18 + /// A font file the system offered, and which face inside it.
19 + pub(crate) struct Match {
20 + pub(crate) path: String,
21 + pub(crate) index: i32,
22 + }
23 +
24 + pub(crate) struct Fallback {
25 + /// `None` when fontconfig could not initialise. Every lookup then misses,
26 + /// which is the behaviour shop had before this existed: tofu, not a crash.
27 + fc: Option<Fontconfig>,
28 + }
29 +
30 + impl Fallback {
31 + pub(crate) fn new() -> Self {
32 + let fc = Fontconfig::new();
33 + if fc.is_none() {
34 + tracing::warn!("fontconfig unavailable; characters outside the bundled font draw tofu");
35 + }
36 + Self { fc }
37 + }
38 +
39 + /// The font the system would use for `c`, or `None` if nothing has it.
40 + ///
41 + /// Asks by CHARACTER rather than by family: a charset of one is what makes
42 + /// this "who has this glyph" instead of "give me a font I named", which is
43 + /// the whole point of deferring to the system.
44 + pub(crate) fn find(&self, c: char) -> Option<Match> {
45 + let fc = self.fc.as_ref()?;
46 + let mut charset = CharSet::new(fc).ok()?;
47 + charset.add_char(c).ok()?;
48 +
49 + let mut pat = Pattern::new(fc).ok()?;
50 + pat.add_charset(charset).ok()?;
51 + // Rank monospace faces first. Not a filter — fontconfig always returns
52 + // its best match — but it is the difference between "Noto Sans CJK JP"
53 + // and "Noto Sans Mono CJK JP", and only the second one is built to sit
54 + // in a grid of cells.
55 + pat.add_integer(c"spacing", 100).ok()?;
56 + // The user's fontconfig rules, then the built-in defaults. Skipping
57 + // these would be asking the library while ignoring its configuration,
58 + // which is most of what makes the answer the system's rather than ours.
59 + pat.config_substitute().ok()?;
60 + pat.default_substitute();
61 +
62 + let found = pat.font_match().ok()?;
63 + Some(Match {
64 + path: found.filename().ok()?.to_string(),
65 + index: found.face_index().unwrap_or(0),
66 + })
67 + }
68 + }