diff --git a/Cargo.lock b/Cargo.lock index 0ba31cf..c23be88 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -23,6 +23,56 @@ version = "0.2.21" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "683d7910e743518b0e34f1186f92494becacb047c7b6bf616c96772180fef923" +[[package]] +name = "anstream" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "824a212faf96e9acacdbd09febd34438f8f711fb84e09a8916013cd7815ca28d" +dependencies = [ + "anstyle", + "anstyle-parse", + "anstyle-query", + "anstyle-wincon", + "colorchoice", + "is_terminal_polyfill", + "utf8parse", +] + +[[package]] +name = "anstyle" +version = "1.0.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "940b3a0ca603d1eade50a4846a2afffd5ef57a9feac2c0e2ec2e14f9ead76000" + +[[package]] +name = "anstyle-parse" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52ce7f38b242319f7cabaa6813055467063ecdc9d355bbb4ce0c68908cd8130e" +dependencies = [ + "utf8parse", +] + +[[package]] +name = "anstyle-query" +version = "1.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "40c48f72fd53cd289104fc64099abca73db4166ad86ea0b4341abe65af83dadc" +dependencies = [ + "windows-sys", +] + +[[package]] +name = "anstyle-wincon" +version = "3.0.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "291e6a250ff86cd4a820112fb8898808a366d8f9f58ce16d1f538353ad55747d" +dependencies = [ + "anstyle", + "once_cell_polyfill", + "windows-sys", +] + [[package]] name = "anyhow" version = "1.0.104" @@ -336,6 +386,29 @@ dependencies = [ "syn 2.0.119", ] +[[package]] +name = "cairo-rs" +version = "0.22.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df683f1d30b457964673a5541a4603208ef95ab6873d2a55871895cf310f3b56" +dependencies = [ + "bitflags 2.13.2", + "cairo-sys-rs", + "glib", + "libc", +] + +[[package]] +name = "cairo-sys-rs" +version = "0.22.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee548131103ad8f698c6725669647b9c757b3b66992dd6a026037596417bee21" +dependencies = [ + "glib-sys", + "libc", + "system-deps 9.0.0", +] + [[package]] name = "castaway" version = "0.2.4" @@ -364,6 +437,16 @@ dependencies = [ "nom", ] +[[package]] +name = "cfg-expr" +version = "0.20.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ba9e9ec16c447027685b1f897b720e18e9a8afd00bd7332c483537e38086c9f" +dependencies = [ + "smallvec", + "target-lexicon", +] + [[package]] name = "cfg-if" version = "1.0.5" @@ -384,7 +467,7 @@ checksum = "157a8ba7b480713b56f4c09fd13fc3e0a22a5dfab8097ba61cbc5feef950788a" dependencies = [ "glob", "libc", - "libloading", + "libloading 0.8.9", ] [[package]] @@ -396,6 +479,12 @@ dependencies = [ "unicode-width", ] +[[package]] +name = "colorchoice" +version = "1.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d07550c9036bf2ae0c684c4297d503f838287c83c53686d05370d0e139ae570" + [[package]] name = "compact_str" version = "0.9.1" @@ -492,7 +581,7 @@ dependencies = [ "mio", "parking_lot", "rustix", - "signal-hook", + "signal-hook 0.3.18", "signal-hook-mio", "winapi", ] @@ -566,6 +655,37 @@ dependencies = [ "syn 3.0.8", ] +[[package]] +name = "defmt" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e2953bfe4f93bbd20cc71198842756f77d161884c99ebbabc41d80231ded88d1" +dependencies = [ + "bitflags 1.3.2", + "defmt-macros", +] + +[[package]] +name = "defmt-macros" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bad9c72e7ca2137e0dc3813245a0d282fd6daad32fd800af018306a9169b5fe8" +dependencies = [ + "defmt-parser", + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "defmt-parser" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "10d60334b3b2e7c9d91ef8150abfb6fa4c1c39ebbcf4a81c2e346aad939fee3e" +dependencies = [ + "thiserror 2.0.21", +] + [[package]] name = "deltae" version = "0.3.2" @@ -616,7 +736,7 @@ version = "0.5.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ab8ecd87370524b461f8557c119c405552c396ed91fc0a8eec68679eab26f94a" dependencies = [ - "libloading", + "libloading 0.8.9", ] [[package]] @@ -628,6 +748,12 @@ dependencies = [ "litrs", ] +[[package]] +name = "downcast-rs" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "75b325c5dbd37f80359721ad39aca5a29fb04c89279657cffdda8736d0c0b9d2" + [[package]] name = "either" version = "1.19.0" @@ -661,6 +787,29 @@ dependencies = [ "syn 2.0.119", ] +[[package]] +name = "env_filter" +version = "2.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "900d271a03799a1ee8d1ca9b19893b48ca674a9284fefcfb85f05e74ed314217" +dependencies = [ + "log", + "regex", +] + +[[package]] +name = "env_logger" +version = "0.11.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "de671bd27a75a797dc9ae289ba1e77276e75e2026408aab65185384e2d5cd3f6" +dependencies = [ + "anstream", + "anstyle", + "env_filter", + "jiff", + "log", +] + [[package]] name = "equivalent" version = "1.0.2" @@ -731,6 +880,16 @@ dependencies = [ "simd-adler32", ] +[[package]] +name = "field-offset" +version = "0.3.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "38e2275cc4e4fc009b0669731a1e5ab7ebf11f469eaede2bab9309a5b4d6057f" +dependencies = [ + "memoffset", + "rustc_version", +] + [[package]] name = "filedescriptor" version = "0.8.3" @@ -794,12 +953,32 @@ dependencies = [ "pkg-config", ] +[[package]] +name = "futures-channel" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b1f9e3d69d39e4862ffed03ed071a76f9a13ba1d9109d355b0f0aa6b15e393c4" +dependencies = [ + "futures-core", +] + [[package]] name = "futures-core" version = "0.3.34" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "92d699e522242e69e3003b94ecc1f960f3a5e015aa7c5d7486e65ad01dd94f5e" +[[package]] +name = "futures-executor" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "031b47cf1a3c6cc8bc2fc76cd437f521619387907d469316e7c0bc278f1f5432" +dependencies = [ + "futures-core", + "futures-task", + "futures-util", +] + [[package]] name = "futures-io" version = "0.3.34" @@ -819,6 +998,17 @@ dependencies = [ "pin-project-lite", ] +[[package]] +name = "futures-macro" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9fb9654ba8355388abeb8dcb4fc62f511300867002afc858860463bdd9fe0c44" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.8", +] + [[package]] name = "futures-task" version = "0.3.34" @@ -832,11 +1022,123 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0d50a92467f8ba5dd6e3ee5d4bd04d73ab2e4e1c44474a0674821dfce14b79bc" dependencies = [ "futures-core", + "futures-macro", "futures-task", "pin-project-lite", "slab", ] +[[package]] +name = "gdk-pixbuf" +version = "0.22.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "25f420376dbee041b2db374ce4573892a36222bb3f6c0c43e24f0d67eae9b646" +dependencies = [ + "gdk-pixbuf-sys", + "gio", + "glib", + "libc", +] + +[[package]] +name = "gdk-pixbuf-sys" +version = "0.22.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9d5209294ba3775f9b650bf78bfadeba4332089f2329b3e2f6ce0a4957a45bff" +dependencies = [ + "gio-sys", + "glib-sys", + "gobject-sys", + "libc", + "system-deps 9.0.0", +] + +[[package]] +name = "gdk4" +version = "0.11.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e77c579bac9eff8b84c0625d5f533da6d69988c063062fe92c2e955ede8f28eb" +dependencies = [ + "cairo-rs", + "gdk-pixbuf", + "gdk4-sys", + "gio", + "gl", + "glib", + "libc", + "pango", +] + +[[package]] +name = "gdk4-sys" +version = "0.11.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3eff6238a30c1f198fb3c8d3d445fa7faf211d11d9075af0772e6e0ca32ae001" +dependencies = [ + "cairo-sys-rs", + "gdk-pixbuf-sys", + "gio-sys", + "glib-sys", + "gobject-sys", + "libc", + "pango-sys", + "pkg-config", + "system-deps 9.0.0", +] + +[[package]] +name = "gdk4-wayland" +version = "0.11.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "99cc5266dc4e8745a4c4683b26a0c54977a452809d572c940e39c6844663c0ec" +dependencies = [ + "gdk4", + "gdk4-wayland-sys", + "gio", + "glib", + "libc", + "wayland-backend", + "wayland-client", + "wayland-protocols", +] + +[[package]] +name = "gdk4-wayland-sys" +version = "0.11.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bb55bc5225519d34fdf1412db3755af88d1388a1c2a053d92e6751db13d96e68" +dependencies = [ + "glib-sys", + "libc", + "system-deps 9.0.0", +] + +[[package]] +name = "gdk4-x11" +version = "0.11.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "519725adee2c62e6ae4cc39e2ec9137b04f0c894f1c673ce17ab8753850534a3" +dependencies = [ + "gdk4", + "gdk4-x11-sys", + "gio", + "glib", + "libc", + "x11-dl", +] + +[[package]] +name = "gdk4-x11-sys" +version = "0.11.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cbf4849da10d077290e2bb8805278a0afd980af7928fbbb5a915821eb4b2b099" +dependencies = [ + "gdk4-sys", + "glib-sys", + "libc", + "system-deps 9.0.0", +] + [[package]] name = "generic-array" version = "0.14.7" @@ -905,9 +1207,31 @@ dependencies = [ name = "ghostty-apprt-gtk" version = "0.1.0" dependencies = [ + "bitflags 2.13.2", + "env_logger", + "gdk4-wayland", + "gdk4-x11", + "ghostty-apprt", "ghostty-config", - "ghostty-core", + "ghostty-datastruct", "ghostty-input", + "ghostty-os", + "ghostty-vt", + "glib-build-tools", + "glow", + "gtk4", + "gtk4-layer-shell", + "half", + "libadwaita", + "libc", + "libloading 0.9.0", + "log", + "signal-hook 0.4.5", + "thiserror 2.0.21", + "wayland-backend", + "wayland-client", + "wayland-protocols", + "wayland-protocols-plasma", ] [[package]] @@ -966,8 +1290,11 @@ version = "0.1.0" dependencies = [ "bitflags 2.13.2", "freetype-sys", + "ghostty-config", "ghostty-datastruct", + "ghostty-terminal", "ghostty-unicode", + "ghostty-vt", "harfbuzz-sys", "log", "png", @@ -1024,10 +1351,12 @@ dependencies = [ name = "ghostty-terminal" version = "0.1.0" dependencies = [ + "ghostty-config", "ghostty-datastruct", "ghostty-unicode", "ghostty-vt", "log", + "onig", ] [[package]] @@ -1057,6 +1386,105 @@ dependencies = [ "thiserror 2.0.21", ] +[[package]] +name = "gio" +version = "0.22.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4620fbafdf19dba814473e19f18e75290cb3338662f6ae35f0402affd3b2b699" +dependencies = [ + "futures-channel", + "futures-core", + "futures-io", + "futures-util", + "gio-sys", + "glib", + "libc", + "pin-project-lite", + "smallvec", +] + +[[package]] +name = "gio-sys" +version = "0.22.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6c28739f914c15b87a9856000bed9cbd5b3a8afbca5e982d9a299e1601422cb5" +dependencies = [ + "glib-sys", + "gobject-sys", + "libc", + "system-deps 9.0.0", + "windows-sys", +] + +[[package]] +name = "gl" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a94edab108827d67608095e269cf862e60d920f144a5026d3dbcfd8b877fb404" +dependencies = [ + "gl_generator", +] + +[[package]] +name = "gl_generator" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1a95dfc23a2b4a9a2f5ab41d194f8bfda3cabec42af4e39f08c339eb2a0c124d" +dependencies = [ + "khronos_api", + "log", + "xml-rs", +] + +[[package]] +name = "glib" +version = "0.22.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e4cf9e5bd8713f1990a92c422998826e0fb34824277b46bc59757373483c6694" +dependencies = [ + "bitflags 2.13.2", + "futures-channel", + "futures-core", + "futures-executor", + "futures-task", + "futures-util", + "gio-sys", + "glib-macros", + "glib-sys", + "gobject-sys", + "libc", + "memchr", + "smallvec", +] + +[[package]] +name = "glib-build-tools" +version = "0.22.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f9871f38b67853c358b8190f77b9f878eb27d933a950f1045b244c4559a9f5f0" + +[[package]] +name = "glib-macros" +version = "0.22.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c9597c68fa7bdf4154a3079642cd21c4dccac0b0bdd2897d82861523bf91e7b9" +dependencies = [ + "heck", + "proc-macro2", + "quote", + "syn 3.0.8", +] + +[[package]] +name = "glib-sys" +version = "0.22.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "99b38907e67e40dec9b60f858bcada952598719cb512a13eb13d6cdcd16f8295" +dependencies = [ + "libc", + "system-deps 9.0.0", +] + [[package]] name = "glob" version = "0.3.4" @@ -1075,6 +1503,149 @@ dependencies = [ "web-sys", ] +[[package]] +name = "gobject-sys" +version = "0.22.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "07595c9ba696bd9819cb6a8df39f08078f3dd679508fe052dd558492c2053834" +dependencies = [ + "glib-sys", + "libc", + "system-deps 9.0.0", +] + +[[package]] +name = "graphene-rs" +version = "0.22.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eb856b9c558971c3f13ab692358926da710b046932a4e087aedcc35b040d7dff" +dependencies = [ + "glib", + "graphene-sys", +] + +[[package]] +name = "graphene-sys" +version = "0.22.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "97842c7543828f98aa5583aa54fcda28aa84fa2b0f8c556142177f7bbb638057" +dependencies = [ + "glib-sys", + "libc", + "system-deps 9.0.0", +] + +[[package]] +name = "gsk4" +version = "0.11.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eee23d7425c5281ec5a2f40f7b69384aac66c15861d990d248e9d9c9368be464" +dependencies = [ + "cairo-rs", + "gdk4", + "glib", + "graphene-rs", + "gsk4-sys", + "libc", + "pango", +] + +[[package]] +name = "gsk4-sys" +version = "0.11.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "27900c92e47df410bbe43b51818104051e3409da43fb88547a41c0bafddfd39c" +dependencies = [ + "cairo-sys-rs", + "gdk4-sys", + "glib-sys", + "gobject-sys", + "graphene-sys", + "libc", + "pango-sys", + "system-deps 9.0.0", +] + +[[package]] +name = "gtk4" +version = "0.11.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e0f8226c94dd0b65ba2e8fcd8ff402d380e890f6e5a6488e652a0158e827b9bf" +dependencies = [ + "cairo-rs", + "field-offset", + "futures-channel", + "gdk-pixbuf", + "gdk4", + "gio", + "glib", + "graphene-rs", + "gsk4", + "gtk4-macros", + "gtk4-sys", + "libc", + "pango", +] + +[[package]] +name = "gtk4-layer-shell" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "17c28ea0f4676fdaaae7ff2413a24d0d35c8657424f84856c1103c73454c9da4" +dependencies = [ + "bitflags 2.13.2", + "gdk4", + "glib", + "glib-sys", + "gtk4", + "gtk4-layer-shell-sys", + "libc", +] + +[[package]] +name = "gtk4-layer-shell-sys" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bcf19bb884ef0ef55b9e6b2b369c39b4fcc0c41e3a0c1cbc8c267720338b690b" +dependencies = [ + "gdk4-sys", + "glib-sys", + "gtk4-sys", + "libc", + "system-deps 8.0.0", +] + +[[package]] +name = "gtk4-macros" +version = "0.11.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eb906c98f0b4cc9002594e337e83f40492aa6dca22c97242f13be5b046c5f338" +dependencies = [ + "proc-macro-crate", + "proc-macro2", + "quote", + "syn 3.0.8", +] + +[[package]] +name = "gtk4-sys" +version = "0.11.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bd14af0137f269bdecee73404157d2a852f9651af7d68bd1023d3b8b914a14c3" +dependencies = [ + "cairo-sys-rs", + "gdk-pixbuf-sys", + "gdk4-sys", + "gio-sys", + "glib-sys", + "gobject-sys", + "graphene-sys", + "gsk4-sys", + "libc", + "pango-sys", + "system-deps 9.0.0", +] + [[package]] name = "half" version = "2.7.1" @@ -1179,6 +1750,12 @@ dependencies = [ "syn 3.0.8", ] +[[package]] +name = "is_terminal_polyfill" +version = "1.70.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695" + [[package]] name = "itertools" version = "0.13.0" @@ -1203,6 +1780,43 @@ version = "1.0.18" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" +[[package]] +name = "jiff" +version = "0.2.38" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b2b005715dcbeb0089a3c0dab99f2ff1cc3b2525323552703d648585d342a383" +dependencies = [ + "defmt", + "jiff-core", + "jiff-static", + "log", + "portable-atomic", + "portable-atomic-util", + "serde_core", +] + +[[package]] +name = "jiff-core" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e52fe76043ccecc9005d2305ebaadf7d7fc0cc89ca6baa10a94d6bc68c7128c" +dependencies = [ + "defmt", + "log", +] + +[[package]] +name = "jiff-static" +version = "0.2.38" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2cc9817253cf7c7ee4684451bd327e88d6f3658014e54a29198625590650695c" +dependencies = [ + "jiff-core", + "proc-macro2", + "quote", + "syn 2.0.119", +] + [[package]] name = "js-sys" version = "0.3.106" @@ -1232,9 +1846,15 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6aae1df220ece3c0ada96b8153459b67eebe9ae9212258bb0134ae60416fdf76" dependencies = [ "libc", - "libloading", + "libloading 0.8.9", ] +[[package]] +name = "khronos_api" +version = "3.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e2db585e1d738fc771bf08a151420d3ed193d9d895a36df7f6f8a9456b911ddc" + [[package]] name = "konst" version = "0.2.20" @@ -1262,6 +1882,36 @@ version = "1.5.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "20870f649af7073d53e38067b2a84312175d56ea15217e1b15bc83506ec50afb" +[[package]] +name = "libadwaita" +version = "0.9.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85b9900e67182a4b5b1f157b448d94f0715c8b9770cce21cf000801917f53bfa" +dependencies = [ + "gdk4", + "gio", + "glib", + "gtk4", + "libadwaita-sys", + "pango", +] + +[[package]] +name = "libadwaita-sys" +version = "0.9.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "28d3c27642b389852aa99341bd4a4c19ec6f8a2b63ebdd7f5ba1952198079ccd" +dependencies = [ + "gdk4-sys", + "gio-sys", + "glib-sys", + "gobject-sys", + "gtk4-sys", + "libc", + "pango-sys", + "system-deps 7.0.8", +] + [[package]] name = "libc" version = "0.2.190" @@ -1278,6 +1928,16 @@ dependencies = [ "windows-link", ] +[[package]] +name = "libloading" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "754ca22de805bb5744484a5b151a9e1a8e837d5dc232c2d7d8c2e3492edc8b60" +dependencies = [ + "cfg-if", + "windows-link", +] + [[package]] name = "libm" version = "0.2.16" @@ -1604,6 +2264,12 @@ version = "1.21.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" +[[package]] +name = "once_cell_polyfill" +version = "1.70.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe" + [[package]] name = "onig" version = "6.5.3" @@ -1679,6 +2345,29 @@ dependencies = [ "libm", ] +[[package]] +name = "pango" +version = "0.22.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b2022dbbd82e1c42bd950a6f1df9b98c796e725dce5ec275e03cbf9772e4efa" +dependencies = [ + "gio", + "glib", + "pango-sys", +] + +[[package]] +name = "pango-sys" +version = "0.22.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ca02d64761b74d56cdd4b437db6d73dc95e6c0d40f27e1c707952c0f09052948" +dependencies = [ + "glib-sys", + "gobject-sys", + "libc", + "system-deps 9.0.0", +] + [[package]] name = "parking" version = "2.2.1" @@ -1860,6 +2549,15 @@ version = "1.15.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "05c8b63e8d9609db387f0324918f81d68fe27748f084ef092fb35954d0539a85" +[[package]] +name = "portable-atomic-util" +version = "0.2.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "10ab3eb7f3becc3a1cbc4f2c6f20267996cfc1a6467a873763411b136a122715" +dependencies = [ + "portable-atomic", +] + [[package]] name = "powerfmt" version = "0.2.1" @@ -1912,6 +2610,15 @@ dependencies = [ "cc", ] +[[package]] +name = "quick-xml" +version = "0.41.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e660451e55124f798a69a5af3f49ccfbefbd41910eefd25caf2393e1f3473ec1" +dependencies = [ + "memchr", +] + [[package]] name = "quote" version = "1.0.47" @@ -2133,6 +2840,12 @@ version = "1.0.23" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f" +[[package]] +name = "scoped-tls" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e1cf6437eb19a8f4a6cc0f7dca544973b0b78843adbfeb3683d1a94a0024a294" + [[package]] name = "scopeguard" version = "1.2.0" @@ -2186,6 +2899,15 @@ dependencies = [ "syn 3.0.8", ] +[[package]] +name = "serde_spanned" +version = "1.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4e7523beb55eece201a2356bee0bbca0d1ab466c14c07703b2e0ee6d42cb0c2c" +dependencies = [ + "serde_core", +] + [[package]] name = "sha2" version = "0.10.9" @@ -2219,6 +2941,16 @@ dependencies = [ "signal-hook-registry", ] +[[package]] +name = "signal-hook" +version = "0.4.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b29ffa27d3d59a049bb328258de100f8012848d1b08d444e08bbc02ac468435e" +dependencies = [ + "libc", + "signal-hook-registry", +] + [[package]] name = "signal-hook-mio" version = "0.2.5" @@ -2227,7 +2959,7 @@ checksum = "b75a19a7a740b25bc7944bdee6172368f988763b744e3d4dfe753f6b4ece40cc" dependencies = [ "libc", "mio", - "signal-hook", + "signal-hook 0.3.18", ] [[package]] @@ -2358,6 +3090,51 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "system-deps" +version = "7.0.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "396a35feb67335377e0251fcbc1092fc85c484bd4e3a7a54319399da127796e7" +dependencies = [ + "cfg-expr", + "heck", + "pkg-config", + "toml", + "version-compare", +] + +[[package]] +name = "system-deps" +version = "8.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "83779a5c956bcb6ba627a4ecf0a9d7625db47d7537e0892d97f712ac995648a3" +dependencies = [ + "cfg-expr", + "heck", + "pkg-config", + "toml", + "version-compare", +] + +[[package]] +name = "system-deps" +version = "9.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8a0dae2cbca1f0ff93795713cfe0a4c02f760e3c0b8ae2ab4ec4045808ff429f" +dependencies = [ + "cfg-expr", + "heck", + "pkg-config", + "toml", + "version-compare", +] + +[[package]] +name = "target-lexicon" +version = "0.13.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "adb6935a6f5c20170eeceb1a3835a49e12e19d792f6dd344ccc76a985ca5a6ca" + [[package]] name = "temp-dir" version = "0.1.16" @@ -2395,7 +3172,7 @@ dependencies = [ "bitflags 2.13.2", "parking_lot", "rustix", - "signal-hook", + "signal-hook 0.3.18", "windows-sys", ] @@ -2446,7 +3223,7 @@ dependencies = [ "pest_derive", "phf", "sha2", - "signal-hook", + "signal-hook 0.3.18", "siphasher", "terminfo", "termios", @@ -2549,6 +3326,21 @@ dependencies = [ "strict-num", ] +[[package]] +name = "toml" +version = "1.1.8+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "20489e00e4d8741d6be680764cc12e270655e375a20d1011e844a9c3379e678d" +dependencies = [ + "indexmap", + "serde_core", + "serde_spanned", + "toml_datetime", + "toml_parser", + "toml_writer", + "winnow", +] + [[package]] name = "toml_datetime" version = "1.1.2+spec-1.1.0" @@ -2579,6 +3371,12 @@ dependencies = [ "winnow", ] +[[package]] +name = "toml_writer" +version = "1.1.3+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "06bdbd8cfc056b8d2e2e85f29b56a3bdbecb527cef81eb39e3e7b98af4652770" + [[package]] name = "tracing" version = "0.1.44" @@ -2693,6 +3491,12 @@ version = "0.2.15" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "accd4ea62f7bb7a82fe23066fb0957d48ef677f6eeb8215f372f52e48bb32426" +[[package]] +name = "version-compare" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "03c2856837ef78f57382f06b2b8563a2f512f7185d732608fd9176cb3b8edf0e" + [[package]] name = "version_check" version = "0.9.5" @@ -2768,6 +3572,79 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "wayland-backend" +version = "0.3.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "38a91b4eaddff87b1cd1074985e3713da4af2c49742d1b356b2c01670a67a078" +dependencies = [ + "cc", + "downcast-rs", + "rustix", + "scoped-tls", + "smallvec", + "wayland-sys", +] + +[[package]] +name = "wayland-client" +version = "0.31.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3c36a0f861ad76d0901f2800b46321410d9f73f2ea88aac0650d86c32688073" +dependencies = [ + "bitflags 2.13.2", + "rustix", + "wayland-backend", + "wayland-scanner", +] + +[[package]] +name = "wayland-protocols" +version = "0.32.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "23d0c813de3daa2ed6520af85a3bd49b0e722a3078506899aa9686fea58dc4b6" +dependencies = [ + "bitflags 2.13.2", + "wayland-backend", + "wayland-client", + "wayland-scanner", +] + +[[package]] +name = "wayland-protocols-plasma" +version = "0.3.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2b6d8cf1eb2c1c31ed1f5643c88a6e53538129d4af80030c8cabd1f9fa884d91" +dependencies = [ + "bitflags 2.13.2", + "wayland-backend", + "wayland-client", + "wayland-protocols", + "wayland-scanner", +] + +[[package]] +name = "wayland-scanner" +version = "0.31.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "338e30461b3a2b67d70eb30a6d89f8e0c93a833e07d2ae89085cd070c4a00ac0" +dependencies = [ + "proc-macro2", + "quick-xml", + "quote", +] + +[[package]] +name = "wayland-sys" +version = "0.31.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d8eab23fefc9e41f8e841df4a9c707e8a8c4ed26e944ef69297184de2785e3be" +dependencies = [ + "dlib", + "log", + "pkg-config", +] + [[package]] name = "web-sys" version = "0.3.106" @@ -3006,6 +3883,23 @@ version = "0.57.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1ebf944e87a7c253233ad6766e082e3cd714b5d03812acc24c318f549614536e" +[[package]] +name = "x11-dl" +version = "2.21.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "38735924fedd5314a6e548792904ed8c6de6636285cb9fec04d5b1db85c1516f" +dependencies = [ + "libc", + "once_cell", + "pkg-config", +] + +[[package]] +name = "xml-rs" +version = "0.8.29" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e450f9b2ed1dff33c94c12589a87338689467b9c4f5d8a5710bd09a847d2c8a7" + [[package]] name = "yeslogic-fontconfig-sys" version = "6.0.1" diff --git a/crates/ghostty-config/src/font_types.rs b/crates/ghostty-config/src/font_types.rs index 111f834..33a0d0c 100644 --- a/crates/ghostty-config/src/font_types.rs +++ b/crates/ghostty-config/src/font_types.rs @@ -1,7 +1,7 @@ -//! Local ports of the pieces of the Zig font package that the config needs -//! (`font/face.zig` `Variation`, `font/CodepointMap.zig`, and +//! Canonical definitions of the pieces of the Zig font package that the config +//! needs (`font/face.zig` `Variation`, `font/CodepointMap.zig`, and //! `font/discovery.zig` `Descriptor`). `ghostty-font` depends on this crate, -//! so these live here and the font crate should re-use them. +//! so these live here and the font crate re-exports them. use std::hash::{Hash, Hasher}; diff --git a/crates/ghostty-config/src/metric_modifier.rs b/crates/ghostty-config/src/metric_modifier.rs index 24e54bb..3b1fbc8 100644 --- a/crates/ghostty-config/src/metric_modifier.rs +++ b/crates/ghostty-config/src/metric_modifier.rs @@ -81,7 +81,8 @@ impl MetricModifier { #[must_use] pub fn apply_i32(&self, v: i32) -> i32 { let r = self.apply_i64(i64::from(v), false); - i32::try_from(r).unwrap_or(if r < 0 { i32::MIN } else { i32::MAX }) + // Mirrors Zig: out-of-range yields maxInt * sign. + i32::try_from(r).unwrap_or(if r < 0 { -i32::MAX } else { i32::MAX }) } /// Apply to a float value. diff --git a/crates/ghostty-font/Cargo.toml b/crates/ghostty-font/Cargo.toml index 8140e6f..16b06ad 100644 --- a/crates/ghostty-font/Cargo.toml +++ b/crates/ghostty-font/Cargo.toml @@ -15,6 +15,8 @@ ghostty-unicode.workspace = true bitflags = "2.13.2" yeslogic-fontconfig-sys = "6.0.1" ghostty-datastruct.workspace = true +ghostty-config.workspace = true +ghostty-terminal.workspace = true # This crate wraps the freetype / harfbuzz / fontconfig C libraries through # their `-sys` crates; unsafe is denied by default and allowed on minimal, @@ -47,3 +49,6 @@ return_self_not_must_use = "allow" used_underscore_binding = "allow" large_stack_arrays = "allow" inline_always = "allow" + +[dev-dependencies] +ghostty-vt.workspace = true diff --git a/crates/ghostty-font/src/codepoint_map.rs b/crates/ghostty-font/src/codepoint_map.rs index 29e8e45..1238f63 100644 --- a/crates/ghostty-font/src/codepoint_map.rs +++ b/crates/ghostty-font/src/codepoint_map.rs @@ -3,89 +3,16 @@ //! font, the codepoint is rendered using the default font. //! Port of `font/CodepointMap.zig`. -use std::hash::{DefaultHasher, Hash, Hasher}; - -use crate::discovery::Descriptor; - -#[derive(Debug, Clone)] -pub struct Entry { - /// Unicode codepoint range (inclusive). Asserts range.0 <= range.1. - pub range: (u32, u32), - - /// The discovery descriptor of the font to use for this range. - pub descriptor: Descriptor, -} - -/// The list of entries. -/// -/// Note: we just do a linear search because we expect to always have very -/// few entries, so the overhead of a binary search is not worth it. This is -/// possible to defeat with some pathological inputs, but there is no realistic -/// scenario where this will be a problem except people trying to fuck around. -#[derive(Debug, Clone, Default)] -pub struct CodepointMap { - pub list: Vec, -} - -impl CodepointMap { - /// Add an entry to the map. - /// - /// For conflicting codepoints, entries added later take priority over - /// entries added earlier. - pub fn add(&mut self, entry: Entry) { - assert!(entry.range.0 <= entry.range.1); - self.list.push(entry); - } - - /// Get a descriptor for a codepoint. - pub fn get(&self, cp: u32) -> Option<&Descriptor> { - self.list - .iter() - .rev() - .find(|e| e.range.0 <= cp && cp <= e.range.1) - .map(|e| &e.descriptor) - } - - /// Hash with the given hasher. - pub fn hash_into(&self, hasher: &mut H) { - self.list.len().hash(hasher); - for entry in &self.list { - entry.range.hash(hasher); - hash_descriptor(&entry.descriptor, hasher); - } - } - - /// Returns a hash code that can be used to uniquely identify this - /// action. - pub fn hashcode(&self) -> u64 { - let mut hasher = DefaultHasher::new(); - self.hash_into(&mut hasher); - hasher.finish() - } -} - -/// Mirrors `Descriptor.hash` of the Zig discovery module (same fields, same -/// float handling) without depending on how the discovery module exposes it. -fn hash_descriptor(d: &Descriptor, hasher: &mut H) { - d.family.hash(hasher); - d.style.hash(hasher); - d.codepoint.hash(hasher); - d.size.to_bits().hash(hasher); - d.bold.hash(hasher); - d.italic.hash(hasher); - d.monospace.hash(hasher); - d.variations.len().hash(hasher); - for v in &d.variations { - v.id.hash(hasher); - // This is not correct, but we don't currently depend on the - // hash value being different based on decimal values of variations. - (v.value as i64).hash(hasher); - } -} +//! +//! The canonical definition lives in `ghostty-config` (the `font-codepoint-map` +//! option parses straight into it); it is re-exported here. +pub use ghostty_config::font_types::CodepointMap; +pub use ghostty_config::font_types::CodepointMapEntry as Entry; #[cfg(test)] mod tests { use super::*; + use ghostty_config::font_types::Descriptor; fn desc(family: &str) -> Descriptor { Descriptor { @@ -96,7 +23,7 @@ mod tests { fn entry(a: u32, b: u32, family: &str) -> Entry { Entry { - range: (a, b), + range: [a, b], descriptor: desc(family), } } diff --git a/crates/ghostty-font/src/config_types.rs b/crates/ghostty-font/src/config_types.rs index 30b6f43..6a8f297 100644 --- a/crates/ghostty-font/src/config_types.rs +++ b/crates/ghostty-font/src/config_types.rs @@ -1,59 +1,5 @@ -//! Small local mirrors of the `ghostty-config` types that the font engine -//! consumes. -//! -//! TODO(integration): `ghostty-config` should map its own -//! `FreetypeLoadFlags`, `FontSyntheticStyle` and `FontShapingBreak` onto -//! these (or this crate should re-export the config ones once the config -//! crate exists in the dependency graph). +//! The config types consumed by the font engine. The canonical definitions +//! live in `ghostty-config`; they are re-exported here for convenience. -/// See `freetype-load-flags` in the config. Defaults match upstream. -#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] -pub struct FreetypeLoadFlags { - pub hinting: bool, - pub force_autohint: bool, - pub monochrome: bool, - pub autohint: bool, - pub light: bool, -} - -impl Default for FreetypeLoadFlags { - fn default() -> Self { - Self { - hinting: true, - force_autohint: false, - monochrome: false, - autohint: true, - light: true, - } - } -} - -/// See `font-synthetic-style` in the config. -#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] -pub struct FontSyntheticStyle { - pub bold: bool, - pub italic: bool, - pub bold_italic: bool, -} - -impl Default for FontSyntheticStyle { - fn default() -> Self { - Self { - bold: true, - italic: true, - bold_italic: true, - } - } -} - -/// See `font-shaping-break` in the config. -#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] -pub struct FontShapingBreak { - pub cursor: bool, -} - -impl Default for FontShapingBreak { - fn default() -> Self { - Self { cursor: true } - } -} +pub use ghostty_config::types_a::{FontShapingBreak, FontStyle, FontSyntheticStyle}; +pub use ghostty_config::types_b::{FreetypeLoadFlags, GraphemeWidthMethod}; diff --git a/crates/ghostty-font/src/discovery.rs b/crates/ghostty-font/src/discovery.rs index 7ed1821..b0bdc61 100644 --- a/crates/ghostty-font/src/discovery.rs +++ b/crates/ghostty-font/src/discovery.rs @@ -1,7 +1,6 @@ //! Font discovery. Port of `font/discovery.zig` (fontconfig backend only; //! the CoreText discoverer is macOS-only and out of scope). -use std::hash::{Hash, Hasher}; use std::sync::Arc; use crate::collection::Collection; @@ -12,111 +11,44 @@ use crate::fontconfig as fc; /// Discover implementation for the compile options. pub type Discover = Fontconfig; -/// Descriptor is used to search for fonts. The only required field -/// is "family". The rest are ignored unless they're set to a non-zero -/// value. -#[derive(Debug, Clone, Default)] -pub struct Descriptor { - /// Font family to search for. This can be a fully qualified font - /// name such as "Fira Code", "monospace", "serif", etc. - /// - /// On systems that use fontconfig (Linux), this can be a full - /// fontconfig pattern, such as "Fira Code-14:bold". - pub family: Option, +/// Descriptor is used to search for fonts (canonical definition in `ghostty-config`). +pub use ghostty_config::font_types::Descriptor; - /// Specific font style to search for. This will filter the style - /// string the font advertises. The "bold/italic" booleans later in this - /// struct filter by the style trait the font has, not the string, so - /// these can be used in conjunction or not. - pub style: Option, - - /// A codepoint that this font must be able to render. - pub codepoint: u32, - - /// Font size in points that the font should support. For conversion - /// to pixels, we will use 72 DPI for Mac and 96 DPI for everything else. - /// (If pixel conversion is necessary, i.e. emoji fonts) - pub size: f32, - - /// True if we want to search specifically for a font that supports - /// specific styles. - pub bold: bool, - pub italic: bool, - pub monospace: bool, - - /// Variation axes to apply to the font. This also impacts searching - /// for fonts since fonts with the ability to set these variations - /// will be preferred, but not guaranteed. - pub variations: Vec, -} - -impl Hash for Descriptor { - /// Hash the descriptor with the given hasher. Covers the same fields - /// as the Zig implementation. - fn hash(&self, state: &mut H) { - self.family.hash(state); - self.style.hash(state); - self.codepoint.hash(state); - self.size.to_bits().hash(state); - self.bold.hash(state); - self.italic.hash(state); - self.monospace.hash(state); - self.variations.len().hash(state); - for variation in &self.variations { - variation.id.hash(state); - - // This is not correct, but we don't currently depend on the - // hash value being different based on decimal values of variations. - (variation.value as i64).hash(state); - } +/// Convert to Fontconfig pattern to use for lookup. The pattern does +/// not have defaults filled/substituted (Fontconfig thing) so callers +/// must still do this. (`Descriptor.toFcPattern`) +pub fn to_fc_pattern(desc: &Descriptor) -> fc::Pattern { + let mut pat = fc::Pattern::create(); + if let Some(family) = &desc.family { + assert!(pat.add_string(fc::Property::Family, family)); } -} - -impl Descriptor { - /// Returns a hash code that can be used to uniquely identify this - /// action. - pub fn hashcode(&self) -> u64 { - // `DefaultHasher::new()` uses fixed keys, so this is deterministic. - let mut hasher = std::collections::hash_map::DefaultHasher::new(); - self.hash(&mut hasher); - hasher.finish() + if let Some(style) = &desc.style { + assert!(pat.add_string(fc::Property::Style, style)); + } + if desc.codepoint > 0 { + let mut cs = fc::CharSet::create(); + assert!(cs.add_char(desc.codepoint)); + assert!(pat.add_charset(fc::Property::Charset, &cs)); + } + if desc.size > 0.0 { + assert!(pat.add_integer(fc::Property::Size, desc.size.round() as i32)); + } + if desc.bold { + assert!(pat.add_integer(fc::Property::Weight, fc::Weight::Bold as i32)); + } + if desc.italic { + assert!(pat.add_integer(fc::Property::Slant, fc::Slant::Italic as i32)); } - /// Convert to Fontconfig pattern to use for lookup. The pattern does - /// not have defaults filled/substituted (Fontconfig thing) so callers - /// must still do this. - pub fn to_fc_pattern(&self) -> fc::Pattern { - let mut pat = fc::Pattern::create(); - if let Some(family) = &self.family { - assert!(pat.add_string(fc::Property::Family, family)); - } - if let Some(style) = &self.style { - assert!(pat.add_string(fc::Property::Style, style)); - } - if self.codepoint > 0 { - let mut cs = fc::CharSet::create(); - assert!(cs.add_char(self.codepoint)); - assert!(pat.add_charset(fc::Property::Charset, &cs)); - } - if self.size > 0.0 { - assert!(pat.add_integer(fc::Property::Size, self.size.round() as i32)); - } - if self.bold { - assert!(pat.add_integer(fc::Property::Weight, fc::Weight::Bold as i32)); - } - if self.italic { - assert!(pat.add_integer(fc::Property::Slant, fc::Slant::Italic as i32)); - } + // For fontconfig, we always add monospace in the pattern. Since + // fontconfig sorts by closeness to the pattern, this doesn't fully + // exclude non-monospace but helps prefer it. + assert!(pat.add_integer(fc::Property::Spacing, fc::Spacing::Mono as i32)); - // For fontconfig, we always add monospace in the pattern. Since - // fontconfig sorts by closeness to the pattern, this doesn't fully - // exclude non-monospace but helps prefer it. - assert!(pat.add_integer(fc::Property::Spacing, fc::Spacing::Mono as i32)); - - pat - } + pat } +#[derive(Clone)] pub struct Fontconfig { fc_config: Arc, } @@ -145,7 +77,7 @@ impl Fontconfig { /// be used to build up the deferred fonts. pub fn discover(&self, desc: &Descriptor) -> Result { // Build our pattern that we'll search for - let mut pat = desc.to_fc_pattern(); + let mut pat = to_fc_pattern(desc); assert!(self.fc_config.substitute_with_pat_pattern(&mut pat)); pat.default_substitute(); diff --git a/crates/ghostty-font/src/face.rs b/crates/ghostty-font/src/face.rs index d706a11..dc1c1f0 100644 --- a/crates/ghostty-font/src/face.rs +++ b/crates/ghostty-font/src/face.rs @@ -11,8 +11,8 @@ use crate::metrics::Metrics; pub mod freetype; -pub use crate::config_types::FreetypeLoadFlags; pub use freetype::Face; +pub use ghostty_config::types_b::FreetypeLoadFlags; /// If a DPI can't be calculated, this DPI is used. This is probably /// wrong on modern devices so it is highly recommended you get the DPI @@ -118,51 +118,8 @@ impl DesiredSize { } } -/// A font variation setting. The best documentation for this I know of -/// is actually the CSS font-variation-settings property on MDN: -/// https://developer.mozilla.org/en-US/docs/Web/CSS/font-variation-settings -#[derive(Debug, Clone, Copy, PartialEq)] -pub struct Variation { - pub id: VariationId, - pub value: f64, -} - -/// A four byte OpenType variation axis tag (`wght`, `slnt`, ...). The -/// numeric value is the big-endian interpretation of the 4 characters -/// (Zig: `packed struct(u32) { d, c, b, a }`, i.e. `a` is the high byte). -#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] -pub struct VariationId { - pub a: u8, - pub b: u8, - pub c: u8, - pub d: u8, -} - -impl VariationId { - pub fn new(v: &[u8; 4]) -> VariationId { - VariationId { - a: v[0], - b: v[1], - c: v[2], - d: v[3], - } - } - - /// The raw 32-bit tag value. - pub fn as_u32(self) -> u32 { - u32::from_be_bytes([self.a, self.b, self.c, self.d]) - } - - pub fn from_u32(v: u32) -> VariationId { - let [a, b, c, d] = v.to_be_bytes(); - VariationId { a, b, c, d } - } - - /// Converts the ID to a string. - pub fn str(self) -> [u8; 4] { - [self.a, self.b, self.c, self.d] - } -} +/// Font variation types: canonical definitions live in `ghostty-config`. +pub use ghostty_config::font_types::{Variation, VariationId}; /// The size and position of a glyph. #[derive(Debug, Clone, Copy, PartialEq, Default)] @@ -622,26 +579,6 @@ impl Constraint { mod tests { use super::*; - #[test] - fn variation_id_wght_should_be_2003265652() { - let id = VariationId::new(b"wght"); - assert_eq!(2003265652, id.as_u32()); - assert_eq!(b"wght", &id.str()); - } - - #[test] - fn variation_id_slnt_should_be_1936486004() { - let id = VariationId { - a: b's', - b: b'l', - c: b'n', - d: b't', - }; - assert_eq!(1936486004, id.as_u32()); - assert_eq!(b"slnt", &id.str()); - assert_eq!(VariationId::from_u32(1936486004), id); - } - #[test] fn desired_size_pixels() { let s = DesiredSize { diff --git a/crates/ghostty-font/src/face/freetype.rs b/crates/ghostty-font/src/face/freetype.rs index f9c284b..b91f71e 100644 --- a/crates/ghostty-font/src/face/freetype.rs +++ b/crates/ghostty-font/src/face/freetype.rs @@ -188,7 +188,7 @@ impl Face { log::debug!("variation axes font={}", result.name()); if let Ok(axes) = result.face.mm_axes(&result.lib.inner.lib) { for axis in axes { - let id = super::VariationId::from_u32(axis.tag); + let id = super::VariationId(axis.tag); log::debug!( "variation axis: id={} min={} max={} def={}", String::from_utf8_lossy(&id.str()), @@ -321,7 +321,7 @@ impl Face { // variations, either. for (i, axis) in axes.iter().take(n).enumerate() { for v in vs { - if axis.tag == v.id.as_u32() { + if axis.tag == v.id.0 { coords[i] = (v.value * 65536.0) as i64; break; } @@ -1365,7 +1365,7 @@ mod tests { let o = opts(12.0, 96); face.set_variations( &[Variation { - id: super::super::VariationId::new(b"wght"), + id: super::super::VariationId::init(b"wght"), value: 800.0, }], o, @@ -1375,7 +1375,7 @@ mod tests { let mut face = init(&lib, embedded::INCONSOLATA, 12.0, 96); face.set_variations( &[Variation { - id: super::super::VariationId::new(b"wght"), + id: super::super::VariationId::init(b"wght"), value: 800.0, }], o, diff --git a/crates/ghostty-font/src/lib.rs b/crates/ghostty-font/src/lib.rs index 361752a..3f4ebe8 100644 --- a/crates/ghostty-font/src/lib.rs +++ b/crates/ghostty-font/src/lib.rs @@ -24,6 +24,7 @@ pub mod opentype; pub mod shape; pub mod shaper; pub mod shared_grid; +pub mod shared_grid_set; pub mod sprite; pub use atlas::Atlas; @@ -40,6 +41,7 @@ pub use library::Library; pub use metrics::Metrics; pub use shared_grid::SharedGrid; +pub use shared_grid_set::SharedGridSet; pub use sprite::{Face as SpriteFace, Sprite}; /// The styles that a family can take. diff --git a/crates/ghostty-font/src/metrics.rs b/crates/ghostty-font/src/metrics.rs index f22cbad..581f70b 100644 --- a/crates/ghostty-font/src/metrics.rs +++ b/crates/ghostty-font/src/metrics.rs @@ -537,127 +537,9 @@ fn add_float_to_int(int: &mut u32, float: f64) { /// little space as possible. pub type ModifierSet = HashMap; -/// Error returned by [`Modifier::parse`]. -#[derive(Debug, Clone, Copy, PartialEq, Eq, thiserror::Error)] -pub enum ModifierParseError { - #[error("invalid format")] - InvalidFormat, - #[error("value required")] - ValueRequired, -} - -/// A modifier to apply to a metrics value. The modifier value represents -/// a delta, so percent is a percentage to change, not a percentage of. -/// For example, "20%" is 20% larger, not 20% of the value. Likewise, -/// an absolute value of "20" is 20 larger, not literally 20. -#[derive(Debug, Clone, Copy, PartialEq)] -pub enum Modifier { - Percent(f64), - Absolute(i32), -} - -impl Modifier { - /// Parses the modifier value. If the value ends in "%" it is assumed - /// to be a percent, otherwise the value is parsed as an integer. - pub fn parse(input: &str) -> Result { - if input.is_empty() { - return Err(ModifierParseError::InvalidFormat); - } - - if let Some(num) = input.strip_suffix('%') { - let mut percent = parse_float(num).ok_or(ModifierParseError::InvalidFormat)?; - percent /= 100.0; - - if percent <= -1.0 { - return Ok(Modifier::Percent(0.0)); - } - if percent < 0.0 { - return Ok(Modifier::Percent(1.0 + percent)); - } - return Ok(Modifier::Percent(1.0 + percent)); - } - - parse_int_i32(input) - .map(Modifier::Absolute) - .ok_or(ModifierParseError::InvalidFormat) - } - - /// So it works with the config framework. - pub fn parse_cli(input: Option<&str>) -> Result { - Modifier::parse(input.ok_or(ModifierParseError::ValueRequired)?) - } - - /// Used by config formatter. Returns the value text of the config entry. - pub fn format_entry(&self) -> String { - match self { - Modifier::Percent(v) => format!("{}%", (v - 1.0) * 100.0), - Modifier::Absolute(v) => format!("{v}"), - } - } - - /// Apply a modifier to an unsigned 32-bit value. - pub fn apply_u32(&self, v: u32) -> u32 { - match *self { - Modifier::Percent(p) => { - let p_clamped = p.max(0.0); - (f64::from(v) * p_clamped).round() as u32 - } - Modifier::Absolute(abs) => { - let applied = i64::from(v).saturating_add(i64::from(abs)); - let clamped = applied.max(0); - u32::try_from(clamped).unwrap_or(u32::MAX) - } - } - } - - /// Apply a modifier to a signed 32-bit value. - pub fn apply_i32(&self, v: i32) -> i32 { - match *self { - Modifier::Percent(p) => { - let p_clamped = p.max(0.0); - (f64::from(v) * p_clamped).round() as i32 - } - Modifier::Absolute(abs) => { - let applied = i64::from(v).saturating_add(i64::from(abs)); - // Mirrors Zig: out-of-range yields maxInt * sign. - i32::try_from(applied).unwrap_or(if applied > 0 { i32::MAX } else { -i32::MAX }) - } - } - } - - /// Apply a modifier to a float value. - pub fn apply_f64(&self, v: f64) -> f64 { - match *self { - Modifier::Percent(p) => v * p.max(0.0), - Modifier::Absolute(abs) => v + f64::from(abs), - } - } -} - -impl FromStr for Modifier { - type Err = ModifierParseError; - fn from_str(s: &str) -> Result { - Modifier::parse(s) - } -} - -/// Zig `std.fmt.parseInt(i32, s, 10)`: optional sign, digits only -/// (underscores are only accepted with base 0, so not here). -fn parse_int_i32(s: &str) -> Option { - let digits = s.strip_prefix(['+', '-']).unwrap_or(s); - if digits.is_empty() || !digits.bytes().all(|b| b.is_ascii_digit()) { - return None; - } - s.parse::().ok() -} - -/// Approximation of Zig `std.fmt.parseFloat(f64, s)` for decimal input. -fn parse_float(s: &str) -> Option { - if s.is_empty() || s.contains(char::is_whitespace) { - return None; - } - s.parse::().ok() -} +/// A modifier to apply to a metrics value (canonical definition in +/// `ghostty-config`, which parses and formats the `adjust-*` options). +pub use ghostty_config::metric_modifier::MetricModifier as Modifier; /// Key is an enum of all the available metrics keys (every u32/i32/f64 /// field of [`Metrics`]). @@ -683,6 +565,27 @@ pub enum Key { } impl Key { + /// Every key, in declaration order. + pub const ALL: [Key; 17] = [ + Key::CellWidth, + Key::CellHeight, + Key::CellBaseline, + Key::UnderlinePosition, + Key::UnderlineThickness, + Key::StrikethroughPosition, + Key::StrikethroughThickness, + Key::OverlinePosition, + Key::OverlineThickness, + Key::BoxThickness, + Key::CursorThickness, + Key::CursorHeight, + Key::IconHeight, + Key::IconHeightSingle, + Key::FaceWidth, + Key::FaceHeight, + Key::FaceY, + ]; + /// The snake_case field name, matching the Zig `@tagName`. pub fn name(&self) -> &'static str { match self { @@ -737,20 +640,6 @@ impl FromStr for Key { mod tests { use super::*; - #[test] - fn formatconfig_percent() { - let p = Modifier::parse_cli(Some("24%")).unwrap(); - // Zig test checks the config-formatted line "a = 24%\n"; the config - // entry formatter lives in ghostty-config, so check the value text. - assert_eq!(p.format_entry(), "24%"); - } - - #[test] - fn formatconfig_absolute() { - let p = Modifier::parse_cli(Some("-30")).unwrap(); - assert_eq!(p.format_entry(), "-30"); - } - #[test] fn metrics_apply_modifiers() { let mut set = ModifierSet::new(); @@ -873,17 +762,11 @@ mod tests { #[test] fn modifier_parse_errors() { - assert_eq!(Err(ModifierParseError::InvalidFormat), Modifier::parse("")); - assert_eq!( - Err(ModifierParseError::InvalidFormat), - Modifier::parse("abc") - ); - assert_eq!(Err(ModifierParseError::InvalidFormat), Modifier::parse("%")); + assert!(Modifier::parse("").is_err()); + assert!(Modifier::parse("abc").is_err()); + assert!(Modifier::parse("%").is_err()); assert_eq!(Ok(Modifier::Percent(0.0)), Modifier::parse("-150%")); - assert_eq!( - Err(ModifierParseError::ValueRequired), - Modifier::parse_cli(None) - ); + assert!(Modifier::parse_cli_value(None).is_err()); } #[test] diff --git a/crates/ghostty-font/src/shape.rs b/crates/ghostty-font/src/shape.rs index 65ab3d8..e1fe4a9 100644 --- a/crates/ghostty-font/src/shape.rs +++ b/crates/ghostty-font/src/shape.rs @@ -3,10 +3,9 @@ //! The shaper implementation for the Linux backend is HarfBuzz //! ([`crate::shaper::harfbuzz::Shaper`]). -use crate::collection::Index; - pub use crate::shaper::cache::Cache; pub use crate::shaper::feature::{DEFAULT_FEATURES, Feature, FeatureList}; +pub use crate::shaper::run::{RunIterator, RunIteratorHook, RunOptions, TextRun}; pub use crate::shaper::{harfbuzz, noop}; /// Shaper implementation for our compile options (backend @@ -46,26 +45,3 @@ pub struct Options { /// support applying features globally. pub features: Vec, } - -/// A single text run. Port of `shaper/run.zig` `TextRun`. -/// -/// TODO(integration): `run.zig` (RunIterator over terminal rows) is deferred -/// to F2; the `grid: *SharedGrid` field is omitted here and should be added -/// when `SharedGrid` and the run iterator are wired in. -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub struct TextRun { - /// A unique hash for this run. This can be used to cache the shaping - /// results. - pub hash: u64, - - /// The offset in the row where this run started. This is added to the - /// X position of the final shaped cells to get the absolute position - /// in the row where they belong. - pub offset: u16, - - /// The total number of cells produced by this run. - pub cells: u16, - - /// The font index to use for the glyphs of this run. - pub font_index: Index, -} diff --git a/crates/ghostty-font/src/shaper/harfbuzz.rs b/crates/ghostty-font/src/shaper/harfbuzz.rs index 0efaab2..4ff32a2 100644 --- a/crates/ghostty-font/src/shaper/harfbuzz.rs +++ b/crates/ghostty-font/src/shaper/harfbuzz.rs @@ -1,6 +1,6 @@ //! HarfBuzz shaper. Port of `font/shaper/harfbuzz.zig`. //! -//! The Zig shaper is driven by `RunIterator` (shaper/run.zig, deferred to F2) +//! The Zig shaper is driven by `RunIterator` (shaper/run.rs) //! through the hooks `prepare` / `addCodepoint` / `finalize`; those hooks are //! exposed here as [`Shaper::prepare`], [`Shaper::add_codepoint`] and //! [`Shaper::finalize`]. [`Shaper::shape`] takes the face of the run's font @@ -12,7 +12,8 @@ use super::hb; use crate::face::freetype::Face; -use crate::shape::{self, Cell, DEFAULT_FEATURES, FeatureList}; +use crate::shape::{self, Cell, DEFAULT_FEATURES, FeatureList, TextRun}; +use crate::shared_grid::SharedGrid; #[derive(Debug, thiserror::Error)] pub enum ShapeError { @@ -22,6 +23,15 @@ pub enum ShapeError { HarfbuzzFailed, } +/// Errors from [`Shaper::shape_run`]. +#[derive(Debug, thiserror::Error)] +pub enum ShapeRunError { + #[error(transparent)] + GetFace(#[from] crate::collection::GetFaceError), + #[error(transparent)] + Shape(#[from] ShapeError), +} + #[derive(Debug, Clone, Copy)] struct Codepoint { cluster: u32, @@ -137,6 +147,28 @@ impl Shaper { self.shape(face) } + /// Shape the given text run (`shape(run)` in Zig). The run must be the + /// immediately previous run that was iterated since it shares state with + /// the shaper. The face is looked up in `grid` under its exclusive lock + /// (FreeType faces are not thread safe). + /// + /// The return value is only valid until the next shape call is called. + pub fn shape_run( + &mut self, + grid: &SharedGrid, + run: &TextRun, + ) -> Result<&[Cell], ShapeRunError> { + // We only do shaping if the font is not a special-case. For + // special-case fonts, the codepoint == glyph_index so we don't need + // to run any shaping. + if run.font_index.special().is_none() { + let mut guard = grid.write(); + let face = guard.resolver.collection.get_face(run.font_index)?; + return Ok(self.shape(Some(face))?); + } + Ok(self.shape(None)?) + } + /// Shape the current run (as built through the hooks). `face` is the face /// for the run's font index, or `None` if the font is a special-case /// font (sprite), for which codepoint == glyph_index. @@ -266,6 +298,11 @@ impl Shaper { mod tests { use super::*; use crate::face::{DesiredSize, FontData, Options}; + use crate::shaper::run::{RunIterator, RunOptions}; + use crate::shaper::test_util::{ + Term, TestFont, shape_all, test_shaper, test_shaper_with_discovered_font, + test_shaper_with_font, + }; use crate::{Library, embedded}; fn text(s: &str) -> Vec<(u32, u32)> { @@ -299,32 +336,6 @@ mod tests { s.shape(Some(&f)).unwrap(); } - #[test] - fn shape_inconsolata_ligs() { - let f = face(embedded::INCONSOLATA); - // Some of our tests rely on dlig being enabled by default. - let mut s = Shaper::new(&shape::Options { - features: vec!["dlig".to_string()], - }) - .unwrap(); - let cells = s.shape_text(Some(&f), &text(">=")).unwrap(); - assert_eq!(1, cells.len()); - let cells = s.shape_text(Some(&f), &text("===")).unwrap(); - assert_eq!(1, cells.len()); - } - - #[test] - fn shape_monaspace_ligs() { - let f = face(embedded::MONASPACE_NEON); - // Some of our tests rely on dlig being enabled by default. - let mut s = Shaper::new(&shape::Options { - features: vec!["dlig".to_string()], - }) - .unwrap(); - let cells = s.shape_text(Some(&f), &text("===")).unwrap(); - assert_eq!(1, cells.len()); - } - #[test] fn shape_ligatures_disabled_by_feature() { let f = face(embedded::INCONSOLATA); @@ -336,37 +347,6 @@ mod tests { assert_eq!(3, cells.len()); } - #[test] - fn shape_arabic_forced_ltr() { - let f = face(embedded::ARABIC); - let mut s = Shaper::new(&shape::Options::default()).unwrap(); - let arabic = include_str!("../../testdata/arabic.txt"); - let line = arabic.lines().next().unwrap(); - let t = text(line); - let n = t.len(); - let cells = s.shape_text(Some(&f), &t).unwrap(); - assert_eq!(n, cells.len()); - let mut x = cells[0].x; - for cell in &cells[1..] { - assert_eq!(x + 1, cell.x); - x = cell.x; - } - } - - #[test] - fn shape_combining_characters() { - let f = face(embedded::INCONSOLATA); - let mut s = Shaper::new(&shape::Options::default()).unwrap(); - // n + two combining diaeresis in cell 0, then 'a' in cell 1. - let t = [('n' as u32, 0), (0x0308, 0), (0x0308, 0), ('a' as u32, 1)]; - let cells = s.shape_text(Some(&f), &t).unwrap(); - assert_eq!(4, cells.len()); - assert_eq!(0, cells[0].x); - assert_eq!(0, cells[1].x); - assert_eq!(0, cells[2].x); - assert_eq!(1, cells[3].x); - } - #[test] fn shape_special_font_passthrough() { // Special fonts (sprite) are not shaped: glyph index == codepoint. @@ -397,4 +377,608 @@ mod tests { assert_eq!(0, cells[0].x); assert_eq!(3, cells[1].x); } + + /// Run all runs over `row` of `t` with the default options and return + /// the shaped runs. + fn run_row( + ts: &mut crate::shaper::test_util::TestShaper, + t: &Term, + y: u32, + ) -> Vec { + let cells = t.row(y); + shape_all(&mut ts.shaper, RunOptions::new(&ts.grid, &cells)) + } + + fn count_runs( + ts: &mut crate::shaper::test_util::TestShaper, + cells: &[crate::shaper::run::RunCell], + selection: Option<[u16; 2]>, + cursor_x: Option, + ) -> usize { + let mut opts = RunOptions::new(&ts.grid, cells); + opts.selection = selection; + opts.cursor_x = cursor_x; + shape_all(&mut ts.shaper, opts).len() + } + + #[test] + fn run_iterator() { + let mut ts = test_shaper(); + + { + let mut t = Term::new(5, 3); + t.print("ABCD"); + let cells = t.row(0); + let mut it = RunIterator::new(RunOptions::new(&ts.grid, &cells)); + let mut count = 0; + while it.next(&mut ts.shaper).unwrap().is_some() { + count += 1; + } + assert_eq!(1, count); + } + + // Spaces should be part of a run + { + let mut t = Term::new(10, 3); + t.print("ABCD EFG"); + let cells = t.row(0); + let mut it = RunIterator::new(RunOptions::new(&ts.grid, &cells)); + let mut count = 0; + while it.next(&mut ts.shaper).unwrap().is_some() { + count += 1; + } + assert_eq!(1, count); + } + + { + let mut t = Term::new(5, 3); + t.print("A\u{1F603}D"); + let cells = t.row(0); + let mut it = RunIterator::new(RunOptions::new(&ts.grid, &cells)); + let mut count = 0; + while it.next(&mut ts.shaper).unwrap().is_some() { + count += 1; + + // All runs should be exactly length 1 + assert_eq!(1, ts.shaper.hb_buf_len()); + } + assert_eq!(3, count); + } + } + + #[test] + fn run_iterator_empty_cells_with_background_set() { + use ghostty_terminal::page::{Cell, ContentTag}; + use ghostty_vt::color::Rgb; + + let mut ts = test_shaper(); + let mut t = Term::new(5, 3); + // Set red background and write A + t.bg_rgb(255, 0, 0); + t.print("A"); + + // Get our first row + for x in [1, 2] { + let mut cell = Cell::default(); + cell.set_content_tag(ContentTag::BgColorRgb); + cell.set_color_rgb(Rgb { + r: 0xFF, + g: 0, + b: 0, + }); + *t.cell_mut(x, 0) = cell; + } + + let cells = t.row(0); + let mut it = RunIterator::new(RunOptions::new(&ts.grid, &cells)); + { + let run = it.next(&mut ts.shaper).unwrap().unwrap(); + assert_eq!(3, ts.shaper.hb_buf_len()); + let shaped = ts.shaper.shape_run(&ts.grid, &run).unwrap(); + assert_eq!(3, shaped.len()); + } + assert!(it.next(&mut ts.shaper).unwrap().is_none()); + } + + #[test] + fn shape() { + let mut ts = test_shaper(); + // Thumbs up plain, thumbs up plain, medium skin tone + let mut t = Term::new(10, 3); + t.print("\u{1F44D}\u{1F44D}\u{1F3FD}"); + + let runs = run_row(&mut ts, &t, 0); + assert_eq!(1, runs.len()); + assert_eq!(3, runs[0].hb_len); + } + + #[test] + fn shape_inconsolata_ligs() { + let mut ts = test_shaper(); + + { + let mut t = Term::new(5, 3); + t.print(">="); + let runs = run_row(&mut ts, &t, 0); + assert_eq!(1, runs.len()); + assert_eq!(2, runs[0].run.cells); + assert_eq!(1, runs[0].cells.len()); + } + + { + let mut t = Term::new(5, 3); + t.print("==="); + let runs = run_row(&mut ts, &t, 0); + assert_eq!(1, runs.len()); + assert_eq!(3, runs[0].run.cells); + assert_eq!(1, runs[0].cells.len()); + } + } + + #[test] + fn shape_monaspace_ligs() { + let mut ts = test_shaper_with_font(TestFont::MonaspaceNeon); + + let mut t = Term::new(5, 3); + t.print("==="); + let runs = run_row(&mut ts, &t, 0); + assert_eq!(1, runs.len()); + assert_eq!(3, runs[0].run.cells); + assert_eq!(1, runs[0].cells.len()); + } + + // Ghostty doesn't currently support RTL and our renderers assume + // that cells are in strict LTR order. This means that we need to + // force RTL text to be LTR for rendering. This test ensures that + // we are correctly forcing RTL text to be LTR. + #[test] + fn shape_arabic_forced_ltr() { + let mut ts = test_shaper_with_font(TestFont::Arabic); + + let mut t = Term::new(120, 30); + t.print(include_str!("../../testdata/arabic.txt")); + + let runs = run_row(&mut ts, &t, 0); + assert_eq!(1, runs.len()); + assert_eq!(25, runs[0].run.cells); + let cells = &runs[0].cells; + assert_eq!(25, cells.len()); + let mut x = cells[0].x; + for cell in &cells[1..] { + assert_eq!(x + 1, cell.x); + x = cell.x; + } + } + + #[test] + fn shape_emoji_width() { + let mut ts = test_shaper(); + + let mut t = Term::new(5, 3); + t.print("\u{1F44D}"); + let runs = run_row(&mut ts, &t, 0); + assert_eq!(1, runs.len()); + assert_eq!(2, runs[0].run.cells); + assert_eq!(1, runs[0].cells.len()); + } + + #[test] + fn shape_emoji_width_long() { + use ghostty_terminal::page::{Cell, ContentTag}; + + let mut ts = test_shaper(); + + // Make a screen and add a long emoji sequence to it. + let mut t = Term::new(30, 3); + { + let pin = t + .screen + .pages + .pin(ghostty_terminal::point::Point::Active( + ghostty_terminal::point::Coordinate { x: 0, y: 1 }, + )) + .unwrap(); + let page = t.screen.page_mut(pin.node); + let y = usize::from(pin.y); + let mut cell = Cell::default(); + cell.set_content_tag(ContentTag::Codepoint); + cell.set_codepoint(0x1F9D4); // Person with beard + *page.get_cells_mut(y).unwrap().first_mut().unwrap() = cell; + let graphemes = [ + 0x1F3FB, // Light skin tone (Fitz 1-2) + 0x200D, // ZWJ + 0x2642, // Male sign + 0xFE0F, // Emoji presentation selector + ]; + page.set_graphemes(0, y, &graphemes).unwrap(); + } + + let runs = run_row(&mut ts, &t, 1); + assert_eq!(1, runs.len()); + assert_eq!(4, runs[0].hb_len); + assert_eq!(1, runs[0].cells.len()); + } + + #[test] + fn shape_variation_selector_vs15() { + let mut ts = test_shaper(); + + // Victory sign (default text) + VS15 to force text + let mut t = Term::new(10, 3); + t.print("\u{270C}\u{FE0E}"); + let runs = run_row(&mut ts, &t, 0); + assert_eq!(1, runs.len()); + assert_eq!(1, runs[0].hb_len); + assert_eq!(1, runs[0].cells.len()); + } + + #[test] + fn shape_variation_selector_vs16() { + let mut ts = test_shaper(); + + // Victory sign (default text) + VS16 to force color + let mut t = Term::new(10, 3); + t.print("\u{270C}\u{FE0F}"); + let runs = run_row(&mut ts, &t, 0); + assert_eq!(1, runs.len()); + assert_eq!(1, runs[0].hb_len); + assert_eq!(1, runs[0].cells.len()); + } + + #[test] + fn shape_with_empty_cells_in_between() { + let mut ts = test_shaper(); + + let mut t = Term::new(30, 3); + t.print("A"); + t.cursor_right(5); + t.print("B"); + + let runs = run_row(&mut ts, &t, 0); + assert_eq!(1, runs.len()); + assert_eq!(7, runs[0].cells.len()); + } + + #[test] + fn shape_combining_characters() { + let mut ts = test_shaper(); + + let mut t = Term::new(30, 3); + t.print("n\u{0308}\u{0308}a"); + + let runs = run_row(&mut ts, &t, 0); + assert_eq!(1, runs.len()); + let cells = &runs[0].cells; + assert_eq!(4, cells.len()); + assert_eq!(0, cells[0].x); + assert_eq!(0, cells[1].x); + assert_eq!(0, cells[2].x); + assert_eq!(1, cells[3].x); + } + + // This test exists because the string it uses causes HarfBuzz to output a + // non-monotonic run with our cluster level set to `characters`, which we need + // to handle by tracking the max cluster for the run. + #[test] + fn shape_devanagari_string() { + // We need a font that supports devanagari for this to work, if we can't + // find Arial Unicode MS, which is a system font on macOS, we just skip + // the test. + let Some(mut ts) = test_shaper_with_discovered_font("Arial Unicode MS") else { + return; + }; + + // Disable grapheme clustering + let mut t = Term::new(30, 3); + t.print("अपार्टमेंट"); + + let runs = run_row(&mut ts, &t, 0); + assert_eq!(1, runs.len()); + let cells = &runs[0].cells; + assert_eq!(8, cells.len()); + let xs: Vec = cells.iter().map(|c| c.x).collect(); + assert_eq!(vec![0, 1, 2, 4, 4, 5, 5, 6], xs); + } + + // This test fails on Linux if you have the "Noto Sans Tai Tham" font installed + // locally. Disabled upstream (`return error.SkipZigTest`). + #[test] + #[ignore = "skipped upstream: fails on Linux with Noto Sans Tai Tham installed"] + fn shape_tai_tham_vowels_position_differs_from_advance() { + // Note that while this test was necessary for CoreText, the old logic was + // working for HarfBuzz. Still we keep it to ensure it has the correct + // behavior. + let Some(mut ts) = test_shaper_with_discovered_font("Noto Sans Tai Tham") else { + return; + }; + + let mut t = Term::new(30, 3); + t.print_clustered("\u{1a2F}\u{1a70}"); // ᨯ ᩰ + + let runs = run_row(&mut ts, &t, 0); + assert_eq!(1, runs.len()); + let cells = &runs[0].cells; + assert_eq!(2, cells.len()); + assert_eq!(0, cells[0].x); + assert_eq!(0, cells[1].x); + + // The first glyph renders in the next cell. We expect the x_offset + // to equal the cell width. However, with FreeType the cell_width is + // computed from ASCII glyphs, and Noto Sans Tai Tham only has the + // space character in ASCII (with a 3px advance), so the cell_width + // metric doesn't match the actual Tai Tham glyph positioning. + assert_eq!(7, cells[0].x_offset); + assert_eq!(0, cells[1].x_offset); + } + + #[test] + fn shape_tibetan_characters() { + // We need a font that has multiple glyphs for this codepoint to reproduce + // the old broken behavior, and Noto Serif Tibetan is one of them. If we + // can't find it we just skip the test. + let Some(mut ts) = test_shaper_with_discovered_font("Noto Serif Tibetan") else { + return; + }; + + // Enable grapheme clustering + let mut t = Term::new(30, 3); + t.print_clustered("\u{0f00}"); // ༀ + + let runs = run_row(&mut ts, &t, 0); + assert_eq!(1, runs.len()); + let cells = &runs[0].cells; + assert_eq!(2, cells.len()); + assert_eq!(0, cells[0].x); + assert_eq!(0, cells[1].x); + + // The second glyph renders at the correct location + assert!(cells[1].x_offset < 2); + } + + // Disabled upstream (`return error.SkipZigTest`). + #[test] + #[ignore = "skipped upstream: fails on Linux with Noto Sans Tai Tham installed"] + fn shape_tai_tham_letters_run_offset_y_differs_from_zero() { + let Some(mut ts) = test_shaper_with_discovered_font("Noto Sans Tai Tham") else { + return; + }; + + let mut t = Term::new(30, 3); + // First grapheme cluster: HA, SAKOT. Second grapheme cluster, + // combining with the first in a ligature: YA, U. + t.print_clustered("\u{1a49}\u{1a60}\u{1a3f}\u{1a69}"); + + let runs = run_row(&mut ts, &t, 0); + assert_eq!(1, runs.len()); + let cells = &runs[0].cells; + assert_eq!(3, cells.len()); + assert_eq!(0, cells[0].x); + assert_eq!(0, cells[1].x); + assert_eq!(0, cells[2].x); // U from second grapheme + + // The U glyph renders at a y below zero + assert_eq!(-3, cells[2].y_offset); + } + + // Disabled upstream (`return error.SkipZigTest`). + #[test] + #[ignore = "skipped upstream (error.SkipZigTest)"] + fn shape_javanese_ligatures() { + let Some(mut ts) = test_shaper_with_discovered_font("Noto Sans Javanese") else { + return; + }; + + let mut t = Term::new(30, 3); + // NA, PANGKON; then HA, vowel sign SUKU combining in a ligature. + t.print_clustered("\u{a9a4}\u{a9c0}\u{a9b2}\u{a9b8}"); + + let cell_width = ts.grid.metrics().cell_width; + let runs = run_row(&mut ts, &t, 0); + assert_eq!(1, runs.len()); + let cells = &runs[0].cells; + assert_eq!(3, cells.len()); + assert_eq!(0, cells[0].x); + assert_eq!(0, cells[1].x); + assert_eq!(0, cells[2].x); + + // The vowel sign SUKU renders with correct x_offset + assert!(i64::from(cells[2].x_offset) > 3 * i64::from(cell_width)); + } + + #[test] + fn shape_chakma_vowel_sign_with_ligature_vowel_sign_renders_first() { + // We need a font that supports Chakma for this to work, if we can't find + // Noto Sans Chakma Regular we just skip the test. + let Some(mut ts) = test_shaper_with_discovered_font("Noto Sans Chakma") else { + return; + }; + + let mut t = Term::new(30, 3); + // BAA; TAA + Virama; YYAA + Vowel Sign U (ligature with the second). + t.print_clustered("\u{1111d}\u{11116}\u{11133}\u{11120}\u{1112c}"); + + let runs = run_row(&mut ts, &t, 0); + assert_eq!(1, runs.len()); + let cells = &runs[0].cells; + assert_eq!(4, cells.len()); + assert_eq!(0, cells[0].x); + // See the giant "We need to reset the `cell_offset`" comment, but here + // we should technically have the rest of these be `x` of 1, but that + // would require going back in the stream to adjust past cells, and + // we don't take on that complexity. + assert_eq!(0, cells[1].x); + assert_eq!(0, cells[2].x); + assert_eq!(0, cells[3].x); + + // The vowel sign U renders before the TAA: + assert!(cells[1].x_offset < cells[2].x_offset); + } + + #[test] + fn shape_bengali_ligatures_with_out_of_order_vowels() { + // Whereas this test in CoreText had everything shaping into one giant + // ligature, HarfBuzz splits it into a few clusters. It still looks okay + // (see #10332). + let Some(mut ts) = test_shaper_with_discovered_font("Arial Unicode MS") else { + return; + }; + + let mut t = Term::new(30, 3); + // RA + AA; SSA + Virama; TTA + Virama; RA + Vowel sign E. + t.print_clustered("\u{09b0}\u{09be}\u{09b7}\u{09cd}\u{099f}\u{09cd}\u{09b0}\u{09c7}"); + + let runs = run_row(&mut ts, &t, 0); + assert_eq!(1, runs.len()); + let cells = &runs[0].cells; + assert_eq!(8, cells.len()); + assert_eq!(0, cells[0].x); + assert_eq!(0, cells[1].x); + // Whereas CoreText puts everything all into the first cell (see the + // corresponding test), HarfBuzz splits into two clusters. + for c in &cells[2..] { + assert_eq!(2, c.x); + } + + // The vowel sign E renders before the SSA: + assert!(cells[2].x_offset < cells[3].x_offset); + } + + #[test] + fn shape_box_glyphs() { + let mut ts = test_shaper(); + + let mut t = Term::new(10, 3); + t.print("\u{2500}\u{2501}"); // horiz line + + let runs = run_row(&mut ts, &t, 0); + assert_eq!(1, runs.len()); + assert_eq!(2, runs[0].hb_len); + let cells = &runs[0].cells; + assert_eq!(2, cells.len()); + assert_eq!(0x2500, cells[0].glyph_index); + assert_eq!(0, cells[0].x); + assert_eq!(0x2501, cells[1].glyph_index); + assert_eq!(1, cells[1].x); + } + + #[test] + fn shape_selection_boundary() { + let mut ts = test_shaper(); + + let mut t = Term::new(10, 3); + t.print("a1b2c3d4e5"); + let cells = t.row(0); + + // Full line selection + assert_eq!(1, count_runs(&mut ts, &cells, Some([0, 9]), None)); + // Offset x, goes to end of line selection + assert_eq!(2, count_runs(&mut ts, &cells, Some([2, 9]), None)); + // Offset x, starts at beginning of line + assert_eq!(2, count_runs(&mut ts, &cells, Some([0, 3]), None)); + // Selection only subset of line + assert_eq!(3, count_runs(&mut ts, &cells, Some([1, 3]), None)); + // Selection only one character + assert_eq!(3, count_runs(&mut ts, &cells, Some([1, 1]), None)); + } + + #[test] + fn shape_cursor_boundary() { + let mut ts = test_shaper(); + + let mut t = Term::new(10, 3); + t.print("a1b2c3d4e5"); + let cells = t.row(0); + + // No cursor is full line + assert_eq!(1, count_runs(&mut ts, &cells, None, None)); + + // Cursor at index 0 is two runs + assert_eq!(2, count_runs(&mut ts, &cells, None, Some(0))); + // And without cursor splitting remains one + assert_eq!(1, count_runs(&mut ts, &cells, None, None)); + + // Cursor at index 1 is three runs + assert_eq!(3, count_runs(&mut ts, &cells, None, Some(1))); + assert_eq!(1, count_runs(&mut ts, &cells, None, None)); + + // Cursor at last col is two runs + assert_eq!(2, count_runs(&mut ts, &cells, None, Some(9))); + assert_eq!(1, count_runs(&mut ts, &cells, None, None)); + + // The font break config disables cursor splitting. + let mut opts = RunOptions::new(&ts.grid, &cells); + opts.cursor_x = Some(1); + opts.apply_break_config(ghostty_config::types_a::FontShapingBreak { cursor: false }); + assert_eq!(1, shape_all(&mut ts.shaper, opts).len()); + } + + #[test] + fn shape_cursor_boundary_and_colored_emoji() { + let mut ts = test_shaper(); + + let mut t = Term::new(3, 10); + t.print("\u{1F44D}\u{1F3FC}"); + let cells = t.row(0); + + // No cursor is full line + assert_eq!(1, count_runs(&mut ts, &cells, None, None)); + + // Cursor on emoji does not split it + assert_eq!(1, count_runs(&mut ts, &cells, None, Some(0))); + assert_eq!(1, count_runs(&mut ts, &cells, None, None)); + assert_eq!(1, count_runs(&mut ts, &cells, None, Some(1))); + assert_eq!(1, count_runs(&mut ts, &cells, None, None)); + } + + #[test] + fn shape_cell_attribute_change() { + use ghostty_vt::sgr::Attribute; + + let mut ts = test_shaper(); + + // Plain >= should shape into 1 run + { + let mut t = Term::new(10, 3); + t.print(">="); + assert_eq!(1, count_runs(&mut ts, &t.row(0), None, None)); + } + + // Bold vs regular should split + { + let mut t = Term::new(3, 10); + t.print(">"); + t.sgr(Attribute::Bold); + t.print("="); + assert_eq!(2, count_runs(&mut ts, &t.row(0), None, None)); + } + + // Changing fg color should split + { + let mut t = Term::new(3, 10); + t.fg_rgb(1, 2, 3); + t.print(">"); + t.fg_rgb(3, 2, 1); + t.print("="); + assert_eq!(2, count_runs(&mut ts, &t.row(0), None, None)); + } + + // Changing bg color should not split + { + let mut t = Term::new(3, 10); + t.bg_rgb(1, 2, 3); + t.print(">"); + t.bg_rgb(3, 2, 1); + t.print("="); + assert_eq!(1, count_runs(&mut ts, &t.row(0), None, None)); + } + + // Same bg color should not split + { + let mut t = Term::new(3, 10); + t.bg_rgb(1, 2, 3); + t.print(">"); + t.print("="); + assert_eq!(1, count_runs(&mut ts, &t.row(0), None, None)); + } + } } diff --git a/crates/ghostty-font/src/shaper/mod.rs b/crates/ghostty-font/src/shaper/mod.rs index 7d90123..353f143 100644 --- a/crates/ghostty-font/src/shaper/mod.rs +++ b/crates/ghostty-font/src/shaper/mod.rs @@ -1,9 +1,11 @@ //! Text shapers. Port of `font/shaper/`. -//! -//! `run.zig` (RunIterator over terminal rows) is deferred (F2). pub mod cache; pub mod feature; pub mod harfbuzz; mod hb; pub mod noop; +pub mod run; + +#[cfg(test)] +pub(crate) mod test_util; diff --git a/crates/ghostty-font/src/shaper/run.rs b/crates/ghostty-font/src/shaper/run.rs new file mode 100644 index 0000000..df5d063 --- /dev/null +++ b/crates/ghostty-font/src/shaper/run.rs @@ -0,0 +1,568 @@ +//! Text runs and the run iterator. Port of `font/shaper/run.zig`. +//! +//! A [`RunIterator`] walks one terminal row (given as a slice of +//! [`RunCell`]) and yields [`TextRun`]s: maximal sequences of cells that can +//! be shaped together (same font, same style apart from background, not +//! crossing a selection or cursor boundary, ...). The codepoints of each run +//! are fed to the shaper through the [`RunIteratorHook`] trait +//! (`prepare` / `add_codepoint` / `finalize`). + +#![allow(clippy::collapsible_if)] + +use std::hash::{DefaultHasher, Hash, Hasher}; + +use ghostty_config::types_a::FontShapingBreak; +use ghostty_terminal::page::{Cell, ContentTag, Page, Wide}; +use ghostty_terminal::style::{Color, Style as TermStyle}; + +use crate::collection::Index; +use crate::shared_grid::{Error as GridError, SharedGrid}; +use crate::{Presentation, Style}; + +/// The Kitty graphics unicode placeholder codepoint +/// (`terminal.kitty.graphics.unicode.placeholder`). +// TODO(integration): use the constant from ghostty-terminal once the kitty +// graphics module lands there. +pub const KITTY_PLACEHOLDER: u32 = 0x10EEEE; + +/// A single text run. A text run is only valid for one Shaper instance and +/// until the next run is created. A text run never goes across multiple +/// rows in a terminal, so it is guaranteed to always be one line. +/// +/// Deviation: the Zig struct carries a `grid: *SharedGrid` field. Here the +/// grid is not part of the run; shapers take it explicitly +/// (`harfbuzz::Shaper::shape_run(grid, run)`), which keeps `TextRun` `Copy` +/// and lifetime-free. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct TextRun { + /// A unique hash for this run. This can be used to cache the shaping + /// results. We don't provide a means to compare actual values if the + /// hash is the same, so we should continue to improve this hash to + /// lower the chance of hash collisions if they become a problem. If + /// there are hash collisions, it would result in rendering issues but + /// the core data would be correct. + /// + /// The hash is position-independent within the row by using relative + /// cluster positions. This allows identical runs in different positions + /// to share the same cache entry, improving cache efficiency. + pub hash: u64, + + /// The offset in the row where this run started. This is added to the + /// X position of the final shaped cells to get the absolute position + /// in the row where they belong. + pub offset: u16, + + /// The total number of cells produced by this run. + pub cells: u16, + + /// The font index to use for the glyphs of this run. + pub font_index: Index, +} + +/// One cell of a row to shape: the equivalent of one element of +/// `terminal.RenderState.Cell` (`raw`, `grapheme`, `style`). +// TODO(integration): replace with the render-state cell type from +// ghostty-terminal once `RenderState` is ported there. +#[derive(Debug, Clone, Default)] +pub struct RunCell { + /// The raw page cell. + pub raw: Cell, + /// The extra codepoints of the grapheme (empty unless + /// `raw.has_grapheme()`). + pub grapheme: Vec, + /// The resolved style (default style if the cell has no styling). + pub style: TermStyle, +} + +impl RunCell { + /// Build the run cells for row `y` of a page. + pub fn from_page_row(page: &Page, y: usize) -> Vec { + let Some(cells) = page.get_cells(y) else { + return Vec::new(); + }; + cells + .iter() + .enumerate() + .map(|(x, &raw)| { + let grapheme = if raw.has_grapheme() { + page.cell_index(x, y) + .ok() + .and_then(|i| page.lookup_grapheme(i)) + .map(<[u32]>::to_vec) + .unwrap_or_default() + } else { + Vec::new() + }; + let style = if raw.has_styling() { + page.style(raw) + } else { + TermStyle::default() + }; + RunCell { + raw, + grapheme, + style, + } + }) + .collect() + } +} + +/// Options for [`RunIterator`] (`shape.RunOptions`). +pub struct RunOptions<'a> { + /// The font state for the terminal screen. Cached values may be updated + /// during shaping (the grid has interior locking). + pub grid: &'a SharedGrid, + + /// The cells for the row to shape. + pub cells: &'a [RunCell], + + /// The x boundaries of the selection in this row. + pub selection: Option<[u16; 2]>, + + /// The cursor position within this row. This is used to break shaping + /// on cursor boundaries. This can be disabled by setting this to + /// None. + pub cursor_x: Option, +} + +impl<'a> RunOptions<'a> { + pub fn new(grid: &'a SharedGrid, cells: &'a [RunCell]) -> Self { + RunOptions { + grid, + cells, + selection: None, + cursor_x: None, + } + } + + /// Apply the font break configuration to the run. + pub fn apply_break_config(&mut self, config: FontShapingBreak) { + if !config.cursor { + self.cursor_x = None; + } + } +} + +/// The hooks a shaper provides to the run iterator +/// (`font.Shaper.RunIteratorHook`). +pub trait RunIteratorHook { + /// Called before a new run is started. + fn prepare(&mut self); + /// Called for each codepoint of the run, with the (run relative) cluster. + fn add_codepoint(&mut self, cp: u32, cluster: u32); + /// Called when the run is complete. + fn finalize(&mut self); +} + +impl RunIteratorHook for crate::shaper::harfbuzz::Shaper { + fn prepare(&mut self) { + Self::prepare(self); + } + fn add_codepoint(&mut self, cp: u32, cluster: u32) { + Self::add_codepoint(self, cp, cluster); + } + fn finalize(&mut self) { + Self::finalize(self); + } +} + +impl RunIteratorHook for crate::shaper::noop::Shaper { + fn prepare(&mut self) { + Self::prepare(self); + } + fn add_codepoint(&mut self, cp: u32, cluster: u32) { + Self::add_codepoint(self, cp, cluster); + } + fn finalize(&mut self) { + Self::finalize(self); + } +} + +/// RunIterator is an iterator that yields text runs. Unlike Zig, which +/// stores the hooks inside the iterator, the shaper is passed to +/// [`RunIterator::next`] so it can be used (to shape) between calls. +pub struct RunIterator<'a> { + pub opts: RunOptions<'a>, + i: usize, +} + +impl<'a> RunIterator<'a> { + pub fn new(opts: RunOptions<'a>) -> Self { + RunIterator { opts, i: 0 } + } + + pub fn next(&mut self, hooks: &mut dyn RunIteratorHook) -> Result, GridError> { + let cells = self.opts.cells; + + // Trim the right side of a row that might be empty + let max: usize = (0..cells.len()) + .rev() + .find(|&i| !cells[i].raw.is_empty()) + .map_or(0, |i| i + 1); + + // Invisible cells don't have any glyphs rendered, + // so we explicitly skip them in the shaping process. + while self.i < max + && (cells[self.i].raw.has_styling() && cells[self.i].style.flags.invisible) + { + self.i += 1; + } + + // We're over at the max + if self.i >= max { + return Ok(None); + } + + // Track the font for our current run + let mut current_font = Index::default(); + + // Allow the hook to prepare + hooks.prepare(); + + // Initialize our hash for this run. + let mut hasher = DefaultHasher::new(); + + // Let's get our style that we'll expect for the run. + let style: TermStyle = if cells[self.i].raw.has_styling() { + cells[self.i].style + } else { + TermStyle::default() + }; + + // Go through cell by cell and accumulate while we build our run. + let start = self.i; + let end = 'run: { + for j in start..max { + // Use relative cluster positions (offset from run start) to make + // the shaping cache position-independent. This ensures that runs + // with identical content but different starting positions in the + // row produce the same hash, enabling cache reuse. + let cluster = (j - start) as u32; + let cell = &cells[j]; + let raw = cell.raw; + + // If we have a selection and we're at a boundary point, then + // we break the run here. + if let Some(bounds) = self.opts.selection { + if j > start { + let (b0, b1) = (usize::from(bounds[0]), usize::from(bounds[1])); + if b0 > 0 && j == b0 { + break 'run j; + } + if b1 > 0 && j == b1 + 1 { + break 'run j; + } + } + } + + // If we're a spacer, then we ignore it + match raw.wide() { + Wide::Narrow | Wide::Wide => {} + Wide::SpacerHead | Wide::SpacerTail => continue, + } + + // If our cell attributes are changing, then we split the run. + // This prevents a single glyph for ">=" to be rendered with + // one color when the two components have different styling. + if j > start { + let prev_cell = cells[j - 1].raw; + + // If the prev cell and this cell are both plain + // codepoints then we check if they are commonly "bad" + // ligatures and spit the run if they are. + if prev_cell.content_tag() == ContentTag::Codepoint + && raw.content_tag() == ContentTag::Codepoint + { + let prev_cp = prev_cell.codepoint(); + let cp = raw.codepoint(); + match char::from_u32(prev_cp) { + // fl, fi + Some('f') if cp == u32::from('l') || cp == u32::from('i') => { + break 'run j; + } + // st + Some('s') if cp == u32::from('t') => break 'run j, + _ => {} + } + } + + // If the style is exactly the change then fast path out. + // Otherwise the style is different. We allow differing + // background styles but any other change results in a + // new run. + if prev_cell.style_id() != raw.style_id() { + let c1 = comparable_style(style); + let c2 = comparable_style(if raw.has_styling() { + cell.style + } else { + TermStyle::default() + }); + if c1 != c2 { + break 'run j; + } + } + } + + // Text runs break when font styles change so we need to get + // the proper style. + let font_style = if style.flags.bold { + if style.flags.italic { + Style::BoldItalic + } else { + Style::Bold + } + } else if style.flags.italic { + Style::Italic + } else { + Style::Regular + }; + + // Determine the presentation format for this glyph. + let presentation: Option = if raw.has_grapheme() { + // We only check the FIRST codepoint because I believe the + // presentation format must be directly adjacent to the codepoint. + let cps = &cell.grapheme; + assert_ne!(cps.len(), 0); + match cps[0] { + 0xFE0E => Some(Presentation::Text), + 0xFE0F => Some(Presentation::Emoji), + _ => None, + } + } else { + // If we're not a grapheme, our individual char could be + // an emoji so we want to check if we expect emoji presentation. + // The font grid index_for_codepoint we use below will do this + // automatically. + None + }; + + // If our cursor is on this line then we break the run around the + // cursor. This means that any row with a cursor has at least + // three breaks: before, exactly the cursor, and after. + // + // We do not break a cell that is exactly the grapheme. If there + // are cells following that contain joiners, we allow those to + // break. This creates an effect where hovering over an emoji + // such as a skin-tone emoji is fine, but hovering over the + // joiners will show the joiners allowing you to modify the + // emoji. + if !raw.has_grapheme() { + if let Some(cursor_x) = self.opts.cursor_x { + // Exactly: self.i is the cursor and we iterated once. This + // means that we started exactly at the cursor and did at + // exactly one iteration. Why exactly one? Because we may + // start at our cursor but do many if our cursor is exactly + // on an emoji. + if start == cursor_x && j == start + 1 { + break 'run j; + } + + // Before: up to and not including the cursor. This means + // that we started before the cursor (self.i < cursor_x) + // and j is now at the cursor meaning we haven't yet processed + // the cursor. + if start < cursor_x && j == cursor_x { + assert!(j > 0); + break 'run j; + } + + // After: after the cursor. We don't need to do anything + // special, we just let the run complete. + } + } + + // We need to find a font that supports this character. If + // there are additional zero-width codepoints (to form a single + // grapheme, i.e. combining characters), we need to find a font + // that supports all of them. + let (font_idx, fallback): (Index, Option) = 'font_info: { + // If we find a font that supports this entire grapheme + // then we use that. + if let Some(idx) = self.index_for_cell(cell, font_style, presentation)? { + break 'font_info (idx, None); + } + + // Otherwise we need a fallback character. Prefer the + // official replacement character. + if let Some(idx) = self.opts.grid.get_index(0xFFFD, font_style, presentation)? { + break 'font_info (idx, Some(0xFFFD)); + } + + // Fallback to space + if let Some(idx) = + self.opts + .grid + .get_index(u32::from(' '), font_style, presentation)? + { + break 'font_info (idx, Some(u32::from(' '))); + } + + // We can't render at all. This is a bug, we should always + // have a font that can render a space. + unreachable!("no font can render a space"); + }; + + if j == start { + current_font = font_idx; + } + + // If our fonts are not equal, then we're done with our run. + if font_idx != current_font { + break 'run j; + } + + // If we're a fallback character, add that and continue; we + // don't want to add the entire grapheme. + if let Some(cp) = fallback { + add_codepoint(hooks, &mut hasher, cp, cluster); + continue; + } + + // If we're a Kitty unicode placeholder then we add a blank. + if raw.codepoint() == KITTY_PLACEHOLDER { + add_codepoint(hooks, &mut hasher, u32::from(' '), cluster); + continue; + } + + // Add all the codepoints for our grapheme + add_codepoint( + hooks, + &mut hasher, + if raw.codepoint() == 0 { + u32::from(' ') + } else { + raw.codepoint() + }, + cluster, + ); + if raw.has_grapheme() { + for &cp in &cell.grapheme { + // Do not send presentation modifiers + if cp == 0xFE0E || cp == 0xFE0F { + continue; + } + add_codepoint(hooks, &mut hasher, cp, cluster); + } + } + } + max + }; + + // Finalize our buffer + hooks.finalize(); + + // Add our length to the hash as an additional mechanism to avoid collisions + (end - start).hash(&mut hasher); + + // Add our font index + current_font.hash(&mut hasher); + + // Move our cursor. + self.i = end; + + Ok(Some(TextRun { + hash: hasher.finish(), + offset: start as u16, + cells: (end - start) as u16, + font_index: current_font, + })) + } + + /// Find a font index that supports the grapheme for the given cell, + /// or None if no such font exists. + /// + /// This is used to find a font that supports the entire grapheme. + /// We look for fonts that support each individual codepoint and then + /// find the common font amongst all candidates. + fn index_for_cell( + &self, + cell: &RunCell, + style: Style, + presentation: Option, + ) -> Result, GridError> { + let raw = cell.raw; + let grid = self.opts.grid; + if raw.is_empty() || raw.codepoint() == 0 || raw.codepoint() == KITTY_PLACEHOLDER { + return grid.get_index(u32::from(' '), style, presentation); + } + + // Get the font index for the primary codepoint. + let primary_cp = raw.codepoint(); + let Some(primary) = grid.get_index(primary_cp, style, presentation)? else { + return Ok(None); + }; + + // Easy, and common: we aren't a multi-codepoint grapheme, so + // we just return whatever index for the cell codepoint. + if !raw.has_grapheme() { + return Ok(Some(primary)); + } + + // If this is a grapheme, we need to find a font that supports + // all of the codepoints in the grapheme. + let graphemes = &cell.grapheme; + let mut candidates: Vec = Vec::with_capacity(graphemes.len() + 1); + candidates.push(primary); + + for &cp in graphemes { + // Ignore Emoji ZWJs + if cp == 0xFE0E || cp == 0xFE0F || cp == 0x200D { + continue; + } + + // Find a font that supports this codepoint. If none support this + // then the whole grapheme can't be rendered so we return None. + // + // We explicitly do not require the additional grapheme components + // to support the base presentation, since it is common for emoji + // fonts to support the base emoji with emoji presentation but not + // certain ZWJ-combined characters like the male and female signs. + let Some(idx) = grid.get_index(cp, style, None)? else { + return Ok(None); + }; + candidates.push(idx); + } + + // We need to find a candidate that has ALL of our codepoints + for &idx in &candidates { + if !grid.has_codepoint(idx, primary_cp, presentation) { + continue; + } + let all = graphemes.iter().all(|&cp| { + // Ignore Emoji ZWJs + cp == 0xFE0E || cp == 0xFE0F || cp == 0x200D || grid.has_codepoint(idx, cp, None) + }); + if all { + // We have a candidate that supports all of our codepoints. + return Ok(Some(idx)); + } + } + + Ok(None) + } +} + +fn add_codepoint( + hooks: &mut dyn RunIteratorHook, + hasher: &mut DefaultHasher, + cp: u32, + cluster: u32, +) { + cp.hash(hasher); + cluster.hash(hasher); + hooks.add_codepoint(cp, cluster); +} + +/// Returns a style that when compared must be identical for a run to +/// continue. +fn comparable_style(style: TermStyle) -> TermStyle { + let mut s = style; + + // We allow background colors to differ because we'll just paint the + // cell background whatever the style is, and wherever the glyph + // lands on top of it will be the color of the glyph. + s.bg_color = Color::None; + + s +} diff --git a/crates/ghostty-font/src/shaper/test_util.rs b/crates/ghostty-font/src/shaper/test_util.rs new file mode 100644 index 0000000..0e9f1de --- /dev/null +++ b/crates/ghostty-font/src/shaper/test_util.rs @@ -0,0 +1,236 @@ +//! Test helpers shared by the shaper tests (the Zig `testShaper*` helpers +//! plus a tiny terminal emulation on top of `ghostty_terminal::screen::Screen`). +//! +//! TODO(integration): the Zig tests drive a full `Terminal` through a VT +//! stream and read a `RenderState`. `Terminal` is not available in +//! ghostty-terminal yet, so these tests print through `Screen` instead +//! (`Term`), which covers everything the tests need (text, grapheme +//! clusters, SGR, cursor movement). + +#![allow(clippy::collapsible_if)] + +use ghostty_terminal::page::Cell; +use ghostty_terminal::point::{Coordinate, Point}; +use ghostty_terminal::screen::{Options, Screen}; +use ghostty_vt::color::Rgb; +use ghostty_vt::sgr::Attribute; + +use crate::codepoint_resolver::CodepointResolver; +use crate::collection::{AddOptions, Collection, LoadOptions, SizeAdjustment}; +use crate::discovery::{Descriptor, Fontconfig}; +use crate::face::{DesiredSize, FontData, Options as FaceOptions}; +use crate::shaper::harfbuzz::Shaper; +use crate::shaper::run::{RunCell, RunIterator, RunOptions}; +use crate::{Face, Library, SharedGrid, Style, embedded, shape}; + +#[derive(Clone, Copy)] +pub(crate) enum TestFont { + Inconsolata, + MonaspaceNeon, + Arabic, +} + +pub(crate) struct TestShaper { + pub shaper: Shaper, + pub grid: SharedGrid, +} + +fn face_opts() -> FaceOptions { + FaceOptions::new(DesiredSize::new(12.0)) +} + +fn add_regular(c: &mut Collection, face: Face) { + c.add( + face, + AddOptions { + style: Style::Regular, + fallback: false, + size_adjustment: SizeAdjustment::None, + }, + ) + .unwrap(); +} + +/// Helper to return a fully initialized shaper. +pub(crate) fn test_shaper() -> TestShaper { + test_shaper_with_font(TestFont::Inconsolata) +} + +pub(crate) fn test_shaper_with_font(font_req: TestFont) -> TestShaper { + let test_font = match font_req { + TestFont::Inconsolata => embedded::INCONSOLATA, + TestFont::MonaspaceNeon => embedded::MONASPACE_NEON, + TestFont::Arabic => embedded::ARABIC, + }; + + let lib = Library::init().unwrap(); + let mut c = Collection::new(); + c.load_options = Some(LoadOptions::new(lib.clone())); + + add_regular( + &mut c, + Face::init(&lib, FontData::Static(test_font), face_opts()).unwrap(), + ); + add_regular( + &mut c, + Face::init(&lib, FontData::Static(embedded::EMOJI), face_opts()).unwrap(), + ); + add_regular( + &mut c, + Face::init(&lib, FontData::Static(embedded::EMOJI_TEXT), face_opts()).unwrap(), + ); + + let grid = SharedGrid::new(CodepointResolver::new(c)).unwrap(); + let shaper = Shaper::new(&shape::Options { + // Some of our tests rely on dlig being enabled by default + features: vec!["dlig".to_string()], + }) + .unwrap(); + + TestShaper { shaper, grid } +} + +/// Returns None (the test is skipped, like `error.SkipZigTest`) if the font +/// can't be discovered. +pub(crate) fn test_shaper_with_discovered_font(font_req: &str) -> Option { + let lib = Library::init().unwrap(); + let mut c = Collection::new(); + c.load_options = Some(LoadOptions::new(lib.clone())); + + // Discover and add our font to the collection. + { + let disco = Fontconfig::init(); + let mut it = disco + .discover(&Descriptor { + family: Some(font_req.to_string()), + size: 12.0, + monospace: false, + ..Descriptor::default() + }) + .ok()?; + let mut face = it.next().ok()??; + + // Check which font was discovered - skip if it doesn't match the request + let name = face.name().unwrap_or_else(|_| "(unknown)".to_string()); + if !name.contains(font_req) { + return None; + } + + let loaded = face.load(&lib, face_opts()).ok()?; + add_regular(&mut c, loaded); + } + + let grid = SharedGrid::new(CodepointResolver::new(c)).unwrap(); + let shaper = Shaper::new(&shape::Options::default()).unwrap(); + Some(TestShaper { shaper, grid }) +} + +/// A minimal terminal on top of a [`Screen`]. +pub(crate) struct Term { + pub screen: Screen, +} + +impl Term { + pub fn new(cols: u16, rows: u16) -> Term { + Term { + screen: Screen::new(Options::new(cols, rows, 0)).unwrap(), + } + } + + /// Print a string with grapheme clustering disabled (the default of + /// `Terminal`: `grapheme_cluster` mode off). Zero-width codepoints + /// still combine with the previous cell. + pub fn print(&mut self, s: &str) { + self.print_mode(s, false); + } + + /// Print a string with grapheme clustering enabled + /// (`t.modes.set(.grapheme_cluster, true)`): codepoints that do not + /// break the grapheme cluster are attached to the cell of the cluster's + /// first codepoint. + pub fn print_clustered(&mut self, s: &str) { + self.print_mode(s, true); + } + + fn print_mode(&mut self, s: &str, clustering: bool) { + let mut state = ghostty_unicode::BreakState::default(); + let mut prev: Option = None; + let mut base: Option = None; + for ch in s.chars() { + let cp = ch as u32; + if clustering { + if let (Some(p), Some(pin)) = (prev, base) { + if !ghostty_unicode::grapheme_break(p, cp, &mut state) { + let page = self.screen.page_mut(pin.node); + page.append_grapheme(usize::from(pin.x), usize::from(pin.y), cp) + .unwrap(); + prev = Some(cp); + continue; + } + } + } + // A new cluster starts at the cursor (wide chars never wrap in + // these tests). + base = Some(self.screen.cursor_pin()); + state = ghostty_unicode::BreakState::default(); + self.screen + .test_write_string(ch.encode_utf8(&mut [0; 4])) + .unwrap(); + prev = Some(cp); + } + } + + pub fn sgr(&mut self, attr: Attribute<'_>) { + self.screen.set_attribute(attr).unwrap(); + } + + pub fn fg_rgb(&mut self, r: u8, g: u8, b: u8) { + self.sgr(Attribute::DirectColorFg(Rgb { r, g, b })); + } + + pub fn bg_rgb(&mut self, r: u8, g: u8, b: u8) { + self.sgr(Attribute::DirectColorBg(Rgb { r, g, b })); + } + + pub fn cursor_right(&mut self, n: u16) { + self.screen.cursor_right(n); + } + + /// Mutable access to the cell at (x, y) of the active area. + pub fn cell_mut(&mut self, x: u16, y: u32) -> &mut Cell { + self.screen + .pages + .get_cell_mut(Point::Active(Coordinate { x, y })) + .unwrap() + } + + /// The run cells of row `y` of the active area (`state.row_data.get(y).cells`). + pub fn row(&self, y: u32) -> Vec { + let pin = self + .screen + .pages + .pin(Point::Active(Coordinate { x: 0, y })) + .unwrap(); + RunCell::from_page_row(self.screen.page(pin.node), usize::from(pin.y)) + } +} + +/// One iterated run together with its shaped cells and the number of +/// codepoints that were in the HarfBuzz buffer before shaping. +pub(crate) struct Shaped { + pub run: crate::shape::TextRun, + pub hb_len: usize, + pub cells: Vec, +} + +/// Iterate all runs of `opts`, shaping each one. +pub(crate) fn shape_all(shaper: &mut Shaper, opts: RunOptions<'_>) -> Vec { + let mut it = RunIterator::new(opts); + let mut out = Vec::new(); + while let Some(run) = it.next(shaper).unwrap() { + let hb_len = shaper.hb_buf_len(); + let cells = shaper.shape_run(it.opts.grid, &run).unwrap().to_vec(); + out.push(Shaped { run, hb_len, cells }); + } + out +} diff --git a/crates/ghostty-font/src/shared_grid_set.rs b/crates/ghostty-font/src/shared_grid_set.rs new file mode 100644 index 0000000..abd6d2a --- /dev/null +++ b/crates/ghostty-font/src/shared_grid_set.rs @@ -0,0 +1,729 @@ +//! This structure contains a set of SharedGrid structures keyed by +//! unique font configuration. Port of `font/SharedGridSet.zig`. +//! +//! Most terminals (surfaces) will share the same font configuration. +//! This structure allows expensive font information such as +//! the font atlas, glyph cache, font faces, etc. to be shared. +//! +//! This structure is thread-safe when the operations are documented +//! as thread-safe. + +use std::collections::HashMap; +use std::hash::{DefaultHasher, Hash, Hasher}; +use std::sync::{Arc, Mutex, MutexGuard}; + +use ghostty_config::config::Config; +use ghostty_config::font_types::Variation; +use ghostty_config::font_types::VariationId; +use ghostty_config::types_a::{ + FontStyle, FontSyntheticStyle, RepeatableCodepointMap, RepeatableFontVariation, + RepeatableString, +}; +use ghostty_config::types_b::FreetypeLoadFlags; + +use crate::codepoint_map::CodepointMap; +use crate::codepoint_resolver::{CodepointResolver, StyleStatus}; +use crate::collection::{self, AddOptions, Collection, LoadOptions, SizeAdjustment}; +use crate::discovery::{Descriptor, Discover, Fontconfig}; +use crate::face::{self, DesiredSize, FontData}; +use crate::library::{Library, LibraryError}; +use crate::metrics::{Key as MetricsKey, Modifier, ModifierSet}; +use crate::shared_grid::{self, SharedGrid}; +use crate::{Face, Style, embedded}; + +/// Errors from [`SharedGridSet::ref_`]. +#[derive(Debug, thiserror::Error)] +pub enum RefError { + #[error(transparent)] + Discover(#[from] crate::discovery::DiscoverError), + #[error("font discovery failed: {0}")] + DiscoverNext(#[from] crate::fontconfig::Error), + #[error(transparent)] + Add(#[from] collection::AddError), + #[error(transparent)] + AddDeferred(#[from] collection::AddDeferredError), + #[error(transparent)] + Complete(#[from] collection::CompleteError), + #[error(transparent)] + GetFace(#[from] collection::GetFaceError), + #[error(transparent)] + Face(#[from] face::freetype::FaceError), + #[error(transparent)] + Grid(#[from] shared_grid::InitError), + #[error(transparent)] + Load(#[from] crate::deferred_face::LoadError), +} + +pub type InitError = LibraryError; + +/// Ref-counted SharedGrid. +struct ReffedGrid { + grid: Arc, + ref_count: u32, +} + +struct Inner { + /// The map of font configurations to SharedGrid instances. As in + /// Zig, key equality is equality of the key hashcode. + map: HashMap, + + /// Font discovery mechanism. Initialized once and kept for the + /// lifetime of the set because some font discovery mechanisms + /// (i.e. fontconfig) are unsafe to reinit. + font_discover: Option, +} + +pub struct SharedGridSet { + /// The font library that is used for all font groups. + font_lib: Library, + + /// Lock to protect multi-threaded access to the map. + inner: Mutex, +} + +impl SharedGridSet { + /// Initialize a new SharedGridSet. + pub fn new() -> Result { + Ok(SharedGridSet { + font_lib: Library::init()?, + inner: Mutex::new(Inner { + map: HashMap::new(), + font_discover: None, + }), + }) + } + + fn lock(&self) -> MutexGuard<'_, Inner> { + self.inner + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) + } + + /// Returns the number of cached grids. + pub fn count(&self) -> usize { + self.lock().map.len() + } + + /// Initialize a SharedGrid for the given font configuration. If the + /// SharedGrid is not present it will be initialized with a ref count of + /// 1. If it is present, the ref count will be incremented. + /// + /// (Zig: `ref`; renamed since `ref` is a Rust keyword.) The returned + /// key must be passed to [`SharedGridSet::deref`] when done. The grid + /// is shared through an `Arc`; the set drops its own reference when the + /// ref count reaches zero. + pub fn ref_( + &self, + config: &DerivedConfig, + font_size: DesiredSize, + ) -> Result<(Key, Arc), RefError> { + let key = Key::new(config, font_size); + + let mut inner = self.lock(); + if let Some(entry) = inner.map.get_mut(&key) { + log::debug!("found cached grid for font config"); + + // Increment our ref count and return the cache + entry.ref_count += 1; + return Ok((key, Arc::clone(&entry.grid))); + } + + log::debug!("initializing new grid for font config"); + + // A new font config, initialize the cache. + // Build our collection. This is the expensive operation that + // involves finding fonts, loading them (maybe, some are deferred), + // etc. + let c = self.collection(&mut inner, &key, font_size, config)?; + + // Setup our enabled/disabled styles + let mut styles = StyleStatus::init_fill(true); + styles.set(Style::Bold, config.font_style_bold != FontStyle::False); + styles.set(Style::Italic, config.font_style_italic != FontStyle::False); + styles.set( + Style::BoldItalic, + config.font_style_bold_italic != FontStyle::False, + ); + + // Init our resolver which just requires setting fields. + let mut resolver = CodepointResolver::new(c); + resolver.styles = styles; + resolver.discover = Some(Self::discover(&mut inner).clone()); + resolver.codepoint_map = Some(key.codepoint_map.clone()); + + let grid = Arc::new(SharedGrid::new(resolver)?); + inner.map.insert( + key.clone(), + ReffedGrid { + grid: Arc::clone(&grid), + ref_count: 1, + }, + ); + Ok((key, grid)) + } + + /// Builds the Collection for the given configuration key and + /// initial font size. + fn collection( + &self, + inner: &mut Inner, + key: &Key, + size: DesiredSize, + config: &DerivedConfig, + ) -> Result { + let load_options = LoadOptions { + library: self.font_lib.clone(), + size, + freetype_load_flags: key.freetype_load_flags, + }; + + let mut c = Collection::new(); + c.load_options = Some(load_options.clone()); + c.metric_modifiers.clone_from(&key.metric_modifiers); + + // Search for fonts + { + let disco = Self::discover(inner); + for style in Style::ALL { + for desc in key.descriptors_for_style(style) { + { + let mut disco_it = disco.discover(desc)?; + if let Some(face) = disco_it.next()? { + log::info!( + "font {style:?}: {}", + face.name().unwrap_or_else(|_| "".into()) + ); + + c.add_deferred( + face, + AddOptions { + style, + fallback: false, + // No size adjustment for primary fonts. + size_adjustment: SizeAdjustment::None, + }, + )?; + + continue; + } + } + + // If there are variation configurations and we didn't find + // the font, then we retry the discovery with all stylistic + // bits set to false. This is because some fonts may not + // set the stylistic bit in their table but still support + // axes to mimic the style. At the time of writing, Berkeley + // Mono Variable is like this. See #2140. + if style != Style::Regular && !desc.variations.is_empty() { + let mut copy = desc.clone(); + copy.bold = false; + copy.italic = false; + let mut disco_it = disco.discover(©)?; + if let Some(face) = disco_it.next()? { + log::info!( + "font {style:?}: {}", + face.name().unwrap_or_else(|_| "".into()) + ); + + c.add_deferred( + face, + AddOptions { + style, + fallback: false, + // No size adjustment for primary fonts. + size_adjustment: SizeAdjustment::None, + }, + )?; + + continue; + } + } + + log::warn!( + "font-family {style:?} not found: {}", + desc.family.as_deref().unwrap_or("") + ); + } + } + } + + // Complete our styles to ensure we have something to satisfy every + // possible style request. We do this before adding our built-in font + // because we want to ensure our built-in styles are fallbacks to + // the configured styles. + c.complete_styles(config.font_synthetic_style)?; + + let fallback = |style: Style, size_adjustment: SizeAdjustment| AddOptions { + style, + fallback: true, + size_adjustment, + }; + let face_opts = load_options.face_options(); + let wght_700 = [Variation { + id: VariationId::init(b"wght"), + value: 700.0, + }]; + + // Our built-in font will be used as a backup + c.add( + Face::init( + &self.font_lib, + FontData::Static(embedded::VARIABLE), + face_opts, + )?, + fallback(Style::Regular, crate::DEFAULT_FALLBACK_ADJUSTMENT), + )?; + let idx = c.add( + Face::init( + &self.font_lib, + FontData::Static(embedded::VARIABLE), + face_opts, + )?, + fallback(Style::Bold, crate::DEFAULT_FALLBACK_ADJUSTMENT), + )?; + c.get_face(idx)?.set_variations(&wght_700, face_opts)?; + c.add( + Face::init( + &self.font_lib, + FontData::Static(embedded::VARIABLE_ITALIC), + face_opts, + )?, + fallback(Style::Italic, crate::DEFAULT_FALLBACK_ADJUSTMENT), + )?; + let idx = c.add( + Face::init( + &self.font_lib, + FontData::Static(embedded::VARIABLE_ITALIC), + face_opts, + )?, + fallback(Style::BoldItalic, crate::DEFAULT_FALLBACK_ADJUSTMENT), + )?; + c.get_face(idx)?.set_variations(&wght_700, face_opts)?; + + // Nerd-font symbols fallback. + c.add( + Face::init( + &self.font_lib, + FontData::Static(embedded::SYMBOLS_NERD_FONT), + face_opts, + )?, + // No size adjustment for the symbols font. + fallback(Style::Regular, SizeAdjustment::None), + )?; + + // Emoji fallback. (Zig skips this on macOS, which is out of scope.) + c.add( + Face::init(&self.font_lib, FontData::Static(embedded::EMOJI), face_opts)?, + // No size adjustment for emojis. + fallback(Style::Regular, SizeAdjustment::None), + )?; + c.add( + Face::init( + &self.font_lib, + FontData::Static(embedded::EMOJI_TEXT), + face_opts, + )?, + fallback(Style::Regular, SizeAdjustment::None), + )?; + + Ok(c) + } + + /// Decrement the ref count for the given key. If the ref count is zero, + /// the grid will be deinitialized and removed from the map. + pub fn deref(&self, key: &Key) { + let mut inner = self.lock(); + let Some(entry) = inner.map.get_mut(key) else { + return; + }; + assert!(entry.ref_count >= 1); + + // If we have more than one reference, decrement and return. + if entry.ref_count > 1 { + entry.ref_count -= 1; + return; + } + + // We are at a zero ref count so deinit the group and remove. + inner.map.remove(key); + } + + /// Initialize once and return the font discovery mechanism. This remains + /// initialized throughout the lifetime of the application because some + /// font discovery mechanisms (i.e. fontconfig) are unsafe to reinit. + fn discover(inner: &mut Inner) -> &Fontconfig { + inner.font_discover.get_or_insert_with(Fontconfig::init) + } +} + +/// This is the configuration required to create a key without having +/// to keep the full Ghostty configuration around. +#[derive(Debug, Clone)] +pub struct DerivedConfig { + pub font_family: RepeatableString, + pub font_family_bold: RepeatableString, + pub font_family_italic: RepeatableString, + pub font_family_bold_italic: RepeatableString, + pub font_style: FontStyle, + pub font_style_bold: FontStyle, + pub font_style_italic: FontStyle, + pub font_style_bold_italic: FontStyle, + pub font_variation: RepeatableFontVariation, + pub font_variation_bold: RepeatableFontVariation, + pub font_variation_italic: RepeatableFontVariation, + pub font_variation_bold_italic: RepeatableFontVariation, + pub font_codepoint_map: RepeatableCodepointMap, + pub font_synthetic_style: FontSyntheticStyle, + pub adjust_cell_width: Option, + pub adjust_cell_height: Option, + pub adjust_font_baseline: Option, + pub adjust_underline_position: Option, + pub adjust_underline_thickness: Option, + pub adjust_strikethrough_position: Option, + pub adjust_strikethrough_thickness: Option, + pub adjust_overline_position: Option, + pub adjust_overline_thickness: Option, + pub adjust_cursor_thickness: Option, + pub adjust_cursor_height: Option, + pub adjust_box_thickness: Option, + pub adjust_icon_height: Option, + pub freetype_load_flags: FreetypeLoadFlags, +} + +impl DerivedConfig { + /// Initialize a DerivedConfig from a config. (Cloning a DerivedConfig + /// is `Clone::clone`.) + pub fn new(config: &Config) -> DerivedConfig { + DerivedConfig { + font_family: config.font_family.clone(), + font_family_bold: config.font_family_bold.clone(), + font_family_italic: config.font_family_italic.clone(), + font_family_bold_italic: config.font_family_bold_italic.clone(), + font_style: config.font_style.clone(), + font_style_bold: config.font_style_bold.clone(), + font_style_italic: config.font_style_italic.clone(), + font_style_bold_italic: config.font_style_bold_italic.clone(), + font_variation: config.font_variation.clone(), + font_variation_bold: config.font_variation_bold.clone(), + font_variation_italic: config.font_variation_italic.clone(), + font_variation_bold_italic: config.font_variation_bold_italic.clone(), + font_codepoint_map: config.font_codepoint_map.clone(), + font_synthetic_style: config.font_synthetic_style, + adjust_cell_width: config.adjust_cell_width, + adjust_cell_height: config.adjust_cell_height, + adjust_font_baseline: config.adjust_font_baseline, + adjust_underline_position: config.adjust_underline_position, + adjust_underline_thickness: config.adjust_underline_thickness, + adjust_strikethrough_position: config.adjust_strikethrough_position, + adjust_strikethrough_thickness: config.adjust_strikethrough_thickness, + adjust_overline_position: config.adjust_overline_position, + adjust_overline_thickness: config.adjust_overline_thickness, + adjust_cursor_thickness: config.adjust_cursor_thickness, + adjust_cursor_height: config.adjust_cursor_height, + adjust_box_thickness: config.adjust_box_thickness, + adjust_icon_height: config.adjust_icon_height, + freetype_load_flags: config.freetype_load_flags, + } + } +} + +/// The key used to uniquely identify a font configuration. +#[derive(Debug, Clone)] +pub struct Key { + /// The descriptors used for all the fonts added to the + /// initial group, including all styles. This is hashed + /// in order so the order matters. All users of the struct + /// should ensure that the order is consistent. + pub descriptors: Vec, + + /// These are the offsets into the descriptors array for + /// each style. For example, bold is from + /// offsets[Style::Bold as usize - 1] to + /// offsets[Style::Bold as usize]. + pub style_offsets: [usize; 4], + + /// The codepoint map configuration. + pub codepoint_map: CodepointMap, + + /// The metric modifier set configuration. + pub metric_modifiers: ModifierSet, + + /// The configured font size for this key. We don't use this + /// directly but it is used as part of the hash for the + /// font grid. + pub font_size: DesiredSize, + + /// The freetype load flags configuration. + pub freetype_load_flags: FreetypeLoadFlags, +} + +impl Key { + pub fn new(config: &DerivedConfig, font_size: DesiredSize) -> Key { + let mut descriptors: Vec = Vec::new(); + for family in &config.font_family.list { + descriptors.push(Descriptor { + family: Some(family.clone()), + style: config.font_style.name_value().map(str::to_string), + size: font_size.points, + variations: config.font_variation.list.clone(), + ..Descriptor::default() + }); + } + + // In all the styled cases below, we prefer to specify an exact + // style via the `font-style` configuration. If a style is not + // specified, we use the discovery mechanism to search for a + // style category such as bold, italic, etc. We can't specify both + // because the latter will restrict the search to only that. If + // a user says `font-style = italic` for the bold face for example, + // no results would be found if we restrict to ALSO searching for + // italic. + for family in &config.font_family_bold.list { + let style = config.font_style_bold.name_value().map(str::to_string); + descriptors.push(Descriptor { + family: Some(family.clone()), + bold: style.is_none(), + style, + size: font_size.points, + variations: config.font_variation_bold.list.clone(), + ..Descriptor::default() + }); + } + for family in &config.font_family_italic.list { + let style = config.font_style_italic.name_value().map(str::to_string); + descriptors.push(Descriptor { + family: Some(family.clone()), + italic: style.is_none(), + style, + size: font_size.points, + variations: config.font_variation_italic.list.clone(), + ..Descriptor::default() + }); + } + for family in &config.font_family_bold_italic.list { + let style = config + .font_style_bold_italic + .name_value() + .map(str::to_string); + descriptors.push(Descriptor { + family: Some(family.clone()), + bold: style.is_none(), + italic: style.is_none(), + style, + size: font_size.points, + variations: config.font_variation_bold_italic.list.clone(), + ..Descriptor::default() + }); + } + + // Setup the codepoint map + let codepoint_map = config.font_codepoint_map.map.clone(); + + // Metric modifiers + let mut metric_modifiers = ModifierSet::new(); + for (key, m) in [ + (MetricsKey::CellWidth, config.adjust_cell_width), + (MetricsKey::CellHeight, config.adjust_cell_height), + (MetricsKey::CellBaseline, config.adjust_font_baseline), + ( + MetricsKey::UnderlinePosition, + config.adjust_underline_position, + ), + ( + MetricsKey::UnderlineThickness, + config.adjust_underline_thickness, + ), + ( + MetricsKey::StrikethroughPosition, + config.adjust_strikethrough_position, + ), + ( + MetricsKey::StrikethroughThickness, + config.adjust_strikethrough_thickness, + ), + ( + MetricsKey::OverlinePosition, + config.adjust_overline_position, + ), + ( + MetricsKey::OverlineThickness, + config.adjust_overline_thickness, + ), + (MetricsKey::CursorThickness, config.adjust_cursor_thickness), + (MetricsKey::CursorHeight, config.adjust_cursor_height), + (MetricsKey::BoxThickness, config.adjust_box_thickness), + (MetricsKey::IconHeight, config.adjust_icon_height), + ] { + if let Some(m) = m { + metric_modifiers.insert(key, m); + } + } + + let regular_offset = config.font_family.list.len(); + let bold_offset = regular_offset + config.font_family_bold.list.len(); + let italic_offset = bold_offset + config.font_family_italic.list.len(); + let bold_italic_offset = italic_offset + config.font_family_bold_italic.list.len(); + + Key { + descriptors, + style_offsets: [ + regular_offset, + bold_offset, + italic_offset, + bold_italic_offset, + ], + codepoint_map, + metric_modifiers, + font_size, + freetype_load_flags: config.freetype_load_flags, + } + } + + /// Get the descriptors for the given font style that can be + /// used with discovery. + pub fn descriptors_for_style(&self, style: Style) -> &[Descriptor] { + let idx = style as usize; + let start = if idx == 0 { + 0 + } else { + self.style_offsets[idx - 1] + }; + let end = self.style_offsets[idx]; + &self.descriptors[start..end] + } + + /// Hash the key with the given hasher. + pub fn hash_into(&self, hasher: &mut H) { + self.font_size.points.to_bits().hash(hasher); + self.font_size.xdpi.hash(hasher); + self.font_size.ydpi.hash(hasher); + self.descriptors.len().hash(hasher); + for d in &self.descriptors { + d.hash(hasher); + } + self.codepoint_map.hashcode().hash(hasher); + self.metric_modifiers.len().hash(hasher); + self.freetype_load_flags.hash(hasher); + if !self.metric_modifiers.is_empty() { + for key in MetricsKey::ALL { + if let Some(value) = self.metric_modifiers.get(&key) { + key.hash(hasher); + // Modifier::hash: tag, then the value (floats are + // bit-cast, as in Zig). + match *value { + Modifier::Percent(v) => { + 0u8.hash(hasher); + v.to_bits().hash(hasher); + } + Modifier::Absolute(v) => { + 1u8.hash(hasher); + v.hash(hasher); + } + } + } + } + } + } + + /// Returns a hash code that can be used to uniquely identify this + /// key. + pub fn hashcode(&self) -> u64 { + let mut hasher = DefaultHasher::new(); + self.hash_into(&mut hasher); + hasher.finish() + } +} + +// As in Zig's `Map` context, key equality is hashcode equality. +impl PartialEq for Key { + fn eq(&self, other: &Self) -> bool { + self.hashcode() == other.hashcode() + } +} +impl Eq for Key {} +impl Hash for Key { + fn hash(&self, state: &mut H) { + self.hashcode().hash(state); + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn keycfg() -> DerivedConfig { + DerivedConfig::new(&Config::default()) + } + + #[test] + fn key() { + let keycfg = keycfg(); + let k = Key::new(&keycfg, DesiredSize::new(12.0)); + let k2 = Key::new(&keycfg, DesiredSize::new(12.0)); + assert!(k.hashcode() > 0); + assert_eq!(k.hashcode(), k2.hashcode()); + } + + #[test] + fn key_different_font_points() { + let keycfg = keycfg(); + let k = Key::new(&keycfg, DesiredSize::new(12.0)); + let k2 = Key::new(&keycfg, DesiredSize::new(16.0)); + assert_ne!(k.hashcode(), k2.hashcode()); + } + + #[test] + fn key_different_font_dpi() { + let keycfg = keycfg(); + let mut a = DesiredSize::new(12.0); + a.xdpi = 1; + let mut b = DesiredSize::new(12.0); + b.xdpi = 2; + let k = Key::new(&keycfg, a); + let k2 = Key::new(&keycfg, b); + assert_ne!(k.hashcode(), k2.hashcode()); + } + + #[test] + fn key_descriptors_and_modifiers() { + let mut cfg = Config::default(); + cfg.font_family.list = vec!["A".into(), "B".into()]; + cfg.font_family_bold.list = vec!["C".into()]; + cfg.adjust_cell_width = Some(Modifier::Percent(1.1)); + let d = DerivedConfig::new(&cfg); + let k = Key::new(&d, DesiredSize::new(12.0)); + assert_eq!(k.style_offsets, [2, 3, 3, 3]); + assert_eq!(k.descriptors_for_style(Style::Regular).len(), 2); + let bold = k.descriptors_for_style(Style::Bold); + assert_eq!(bold.len(), 1); + assert!(bold[0].bold); + assert_eq!(k.metric_modifiers.len(), 1); + let plain = Key::new(&keycfg(), DesiredSize::new(12.0)); + assert_ne!(k.hashcode(), plain.hashcode()); + } + + #[test] + fn shared_grid_set() { + let set = SharedGridSet::new().unwrap(); + let keycfg = keycfg(); + + // Get a grid for the given config + let (key1, grid1) = set.ref_(&keycfg, DesiredSize::new(12.0)).unwrap(); + assert_eq!(1, set.count()); + + // Get another + let (key2, grid2) = set.ref_(&keycfg, DesiredSize::new(12.0)).unwrap(); + assert_eq!(1, set.count()); + + // They should be pointer equivalent + assert!(Arc::ptr_eq(&grid1, &grid2)); + + // If I deref grid2 then we should still have a count of 1 + set.deref(&key2); + assert_eq!(1, set.count()); + + // If I deref grid1 then we should have a count of 0 + set.deref(&key1); + assert_eq!(0, set.count()); + } +} diff --git a/docs/rewrite/INTEGRATION_TODO.md b/docs/rewrite/INTEGRATION_TODO.md index 1974271..7222978 100644 --- a/docs/rewrite/INTEGRATION_TODO.md +++ b/docs/rewrite/INTEGRATION_TODO.md @@ -6,12 +6,10 @@ Collected from agent reports; resolve during integration phases. - [ ] os `locale::ensure_locale` no-op → call setlocale from apprt-gtk (unsafe allowed) at startup, as Ghostty's global.zig does. - [ ] os `passwd` Flatpak branch needs termio Pty. - [ ] os `open::OpenUrlKind` → replace with core apprt action type. -- [ ] font `sprite/face.rs`: local `Presentation`/`RenderOptions` stand-ins → real font types (Phase 3). -- [ ] font crate: re-add deps on config/unicode/terminal in Phase 3. - [ ] terminal crate: re-add deps on unicode/datastruct once those land. - [ ] input: key_encode::Options / paste::Options from terminal modes (Zig `fromTerminal`) — in terminal or core. - [ ] input: `binding::set::EntryFormatter` must be implemented by config formatter. -- [ ] config: font_types.rs (Variation, CodepointMap, Descriptor), clipboard_codepoint_map::Entry, CursorStyle are local copies → unify with font/terminal. +- [ ] config: clipboard_codepoint_map::Entry and CursorStyle are local copies → unify with terminal/formatter (font types are unified: canonical in ghostty-config, re-exported by ghostty-font). - [ ] config: url::REGEX needs onig (lookbehind) in renderer/core link matching. - [ ] config: theme::open needs resources dir from ghostty_os. - NOTE: Ghostty 1.3.1 is installed at /usr/bin/ghostty — use it as reference oracle (e.g. +show-config, +list-actions, terminfo). diff --git a/docs/rewrite/notes/ghostty-font-F2.md b/docs/rewrite/notes/ghostty-font-F2.md new file mode 100644 index 0000000..d0dfb2b --- /dev/null +++ b/docs/rewrite/notes/ghostty-font-F2.md @@ -0,0 +1,30 @@ +# ghostty-font — F2 (config integration, run iterator, SharedGridSet, terminal-dependent shaper tests) + +## Done +* Deps `ghostty-config`, `ghostty-terminal` re-added (+ dev-dep `ghostty-vt` for SGR/Rgb in tests). +* Type unification (canonical definitions live in `ghostty-config`, font re-exports): + * `face::{Variation, VariationId}` = `ghostty_config::font_types` (`VariationId(u32)`; `VariationId::init(b"wght")`, `.0`, `.str()`). + * `discovery::Descriptor` = config's; `Descriptor::toFcPattern` is now the free fn `discovery::to_fc_pattern(&Descriptor)`. + * `codepoint_map::{CodepointMap, Entry}` = config's `CodepointMap`/`CodepointMapEntry` (`range: [u32; 2]`). + * `metrics::Modifier` = `ghostty_config::metric_modifier::MetricModifier` (type alias; `ModifierParseError` and `Modifier::format_entry/parse_cli` removed, use `parse`/`parse_cli_value` and the config `ConfigField` impl). `ModifierSet`/`Key` stay in font (+ new `Key::ALL`). Config's `apply_i32` out-of-range now yields `-i32::MAX` like Zig (only config edit besides doc comment in `font_types.rs`). + * `config_types.rs` is now only re-exports (`FreetypeLoadFlags`, `FontSyntheticStyle`, `FontShapingBreak`, `FontStyle`, `GraphemeWidthMethod`); `face::FreetypeLoadFlags` re-exports config's. + * INTEGRATION_TODO: removed the three font lines; config line narrowed to clipboard map / CursorStyle. +* `shaper/run.rs` (run.zig): `TextRun`, `RunCell`, `RunOptions`, `RunIteratorHook` trait (impl for harfbuzz + noop shapers), `RunIterator`. Full logic ported: invisible-cell skipping, right trim, selection/cursor splitting, spacer skipping, "fl/fi/st" ligature splits, style change splitting (bg ignored), font-style/presentation selection, grapheme font search (`index_for_cell`), U+FFFD / space fallback, Kitty placeholder, relative-cluster position-independent hash. `shape.rs` re-exports `TextRun/RunIterator/RunOptions/RunIteratorHook`. +* `shared_grid_set.rs` (SharedGridSet.zig): `SharedGridSet` (`new`, `ref_` (Zig `ref`), `deref(&Key)`, `count`), `DerivedConfig::new(&Config)`, `Key` (descriptors, style offsets, codepoint map, metric modifiers, font size, freetype flags; hashcode-equality like the Zig map), collection building incl. discovery, variation-retry, built-in JetBrains Mono / Nerd symbols / emoji fallbacks. Grids are `Arc`; the set drops its Arc at ref-count 0. `discovery::Fontconfig` is now `Clone` (Arc inside) so the resolver shares the set's discoverer. +* `harfbuzz::Shaper::shape_run(&SharedGrid, &TextRun)` = Zig `shape(run)` (takes the grid write lock to get the face). + +## Tests +* SharedGridSet.zig: 5/5 (Key, Key different font points, Key different font DPI, SharedGridSet + extra `key_descriptors_and_modifiers`). +* All 23 deferred harfbuzz tests ported (in `shaper/harfbuzz.rs`), plus F1's terminal-free ones kept. Font crate: 157 pass, 3 ignored. Ignored (always `error.SkipZigTest` upstream, bodies ported from the commented-out Zig): `shape_tai_tham_vowels_position_differs_from_advance`, `shape_tai_tham_letters_run_offset_y_differs_from_zero`, `shape_javanese_ligatures`. +* Tests needing a discovered system font (Devanagari + Bengali = "Arial Unicode MS", Chakma = "Noto Sans Chakma", Tibetan = "Noto Serif Tibetan") return early (Zig `SkipZigTest`) when the font is missing; only Tibetan is installed on this machine (verified running); Devanagari/Chakma/Bengali are therefore not exercised here. +* Removed duplicate tests that moved to config: F1's `variation_id_*` in face.rs and `formatconfig_*` in metrics.rs (still covered by ghostty-config tests); adapted `modifier_parse_errors`. + +## Deviations / TODO(integration) +* `Terminal`/`RenderState` do not exist (usable) in ghostty-terminal yet, so the tests use a small `Term` helper over `Screen` (`shaper/test_util.rs`): `test_write_string` for text, own grapheme-cluster attach via `ghostty_unicode::grapheme_break` for tests that enable `grapheme_cluster` mode, `Screen::set_attribute` for SGR, `cursor_right` for `\e[5C`. `RunCell::from_page_row(page, y)` replaces `RenderState` cells (`TODO(integration)`: switch to the terminal render-state cell type). Once `Terminal` is final the tests can be rewritten to drive it with the VT stream. +* `TextRun` has no `grid` field (keeps it `Copy`); the iterator's `RunOptions.grid` is `&SharedGrid`. `RunIterator::next(&mut shaper)` takes the shaper (hooks) per call instead of storing it, so the shaper can shape between calls. +* Run hash uses std `DefaultHasher` (Zig: Wyhash) — only used as a cache key. +* `KITTY_PLACEHOLDER` (0x10EEEE) is a local const (TODO(integration): terminal kitty graphics unicode module). +* macOS Apple Emoji branch in the collection builder is omitted (Linux only); discovery is always available. +* `SharedGridSet::ref_` holds the set lock while building the collection (as Zig). +* `shared_grid::CellSize` still stands in for `renderer::CellSize`. +* Quality gate: `cargo build/test` ok; clippy with `-D warnings` for ghostty-font passes with `--no-deps` (the dependency ghostty-terminal currently fails clippy in its in-progress `terminal.rs`, not my crate); fmt applied.