max / shop
- Co-Authored-By
- Claude Opus 5 (1M context) <noreply@anthropic.com>
8 files changed,
+360 insertions,
-107 deletions
| @@ -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", |
| @@ -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" |
| @@ -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 | + | } |