From bab49ee596c354be35f8e2e606caa82ef2242d9e Mon Sep 17 00:00:00 2001 From: ddidderr Date: Mon, 12 Oct 2026 06:02:39 +0200 Subject: [PATCH] feat(macos): port Ghostty's macOS code paths (Metal, CoreText, Darwin) Ports the macOS-specific Zig code behind cfg(target_os = "macos"): - renderer: a GraphicsApi trait with generic::Renderer; Metal backend (all renderer/metal/* files, shaders.metal verbatim, runtime library compile, IOSurface layer, CVDisplayLink), shadertoy -> MSL via naga; OpenGL remains the backend elsewhere. - font: CoreText face, shaper and discovery with build.rs backend selection (coretext default; coretext-freetype / coretext-harfbuzz / coretext-noshape features), no fontconfig/freetype/harfbuzz on Apple. - input: KeymapDarwin via UCKeyTranslate, option-as-alt, mac keycodes. - os: macos, mach, cf_release_thread, NSProcessInfo args and macOS branches; config: macOS defaults, keybinds, Application Support paths. - termio/core: login(1) wrapping, openpty/execvpe, Darwin child exit handling, os_log logging. Compile-checked for aarch64/x86_64-apple-darwin (clippy -D warnings clean) using the cross setup in docs/rewrite/notes/macos-M1.md; cannot run here. The embedded runtime/C API (embedded.zig) is the next step. Also includes the cross-platform integration edits shared by the crash, verification, Windows and macOS work (Cargo target-gating, thread crash-context hooks, apprt-gtk surface/IME refactor, termio/os cfg gates), a cfg fix so ghostty-config builds on wasm32, and an allow for the intentional recursion in the crash overflow test. Test Plan: - cargo test --workspace (3224 passed) - cargo clippy --workspace --all-targets -- -D warnings - cargo check --target aarch64-apple-darwin -p ghostty-renderer (with the documented cross env) --- Cargo.lock | 263 +++++ Cargo.toml | 14 + crates/ghostty-apprt-gtk/Cargo.toml | 1 + crates/ghostty-apprt-gtk/src/class/surface.rs | 247 +++-- crates/ghostty-apprt/src/runtime_api.rs | 7 + crates/ghostty-cli/Cargo.toml | 1 + crates/ghostty-config/src/config.rs | 135 ++- crates/ghostty-config/src/edit.rs | 19 +- crates/ghostty-config/src/file_load.rs | 129 ++- crates/ghostty-config/src/keybinds.rs | 434 ++++++-- crates/ghostty-config/src/path.rs | 3 +- crates/ghostty-core/Cargo.toml | 4 + crates/ghostty-core/src/global.rs | 124 ++- crates/ghostty-core/src/surface/clipboard.rs | 1 + crates/ghostty-core/src/surface/keyboard.rs | 32 +- crates/ghostty-core/src/surface/mod.rs | 104 +- crates/ghostty-core/src/surface_mouse.rs | 32 +- crates/ghostty-font/Cargo.toml | 40 +- crates/ghostty-font/build.rs | 60 ++ crates/ghostty-font/src/backend.rs | 97 +- crates/ghostty-font/src/codepoint_resolver.rs | 9 +- crates/ghostty-font/src/collection.rs | 5 +- crates/ghostty-font/src/coretext_util.rs | 165 +++ crates/ghostty-font/src/deferred_face.rs | 2 +- .../src/deferred_face_coretext.rs | 202 ++++ crates/ghostty-font/src/discovery.rs | 262 ++--- crates/ghostty-font/src/discovery_coretext.rs | 525 ++++++++++ crates/ghostty-font/src/face.rs | 21 +- crates/ghostty-font/src/face/coretext.rs | 884 ++++++++++++++++ crates/ghostty-font/src/face/coretext_math.rs | 956 ++++++++++++++++++ crates/ghostty-font/src/lib.rs | 19 +- crates/ghostty-font/src/library.rs | 28 +- crates/ghostty-font/src/shape.rs | 19 +- crates/ghostty-font/src/shaper/cf_release.rs | 81 ++ crates/ghostty-font/src/shaper/coretext.rs | 499 +++++++++ .../ghostty-font/src/shaper/coretext_cells.rs | 400 ++++++++ crates/ghostty-font/src/shaper/harfbuzz.rs | 2 +- crates/ghostty-font/src/shaper/hb.rs | 2 +- crates/ghostty-font/src/shaper/mod.rs | 11 +- crates/ghostty-font/src/shaper/noop.rs | 40 +- crates/ghostty-font/src/shaper/run.rs | 14 + crates/ghostty-font/src/shared_grid_set.rs | 67 +- crates/ghostty-input/Cargo.toml | 36 +- crates/ghostty-input/src/key.rs | 34 +- crates/ghostty-input/src/key_encode.rs | 278 ++++- crates/ghostty-input/src/key_mods.rs | 99 +- crates/ghostty-input/src/keycodes.rs | 60 +- crates/ghostty-input/src/keymap_darwin.rs | 422 ++++++++ crates/ghostty-input/src/keymap_noop.rs | 53 + crates/ghostty-input/src/lib.rs | 9 + crates/ghostty-input/src/platform.rs | 51 + crates/ghostty-os/Cargo.toml | 26 +- crates/ghostty-os/src/args.rs | 19 + crates/ghostty-os/src/cf_release_thread.rs | 193 ++++ crates/ghostty-os/src/desktop.rs | 85 +- crates/ghostty-os/src/env.rs | 57 +- crates/ghostty-os/src/file.rs | 16 +- crates/ghostty-os/src/homedir.rs | 70 ++ crates/ghostty-os/src/hostname.rs | 24 +- crates/ghostty-os/src/i18n.rs | 14 + crates/ghostty-os/src/lib.rs | 99 +- crates/ghostty-os/src/locale.rs | 169 +++- crates/ghostty-os/src/mach.rs | 176 ++++ crates/ghostty-os/src/macos.rs | 191 ++++ crates/ghostty-os/src/mouse.rs | 29 +- crates/ghostty-os/src/open.rs | 60 +- crates/ghostty-os/src/passwd.rs | 6 + crates/ghostty-os/src/path.rs | 40 +- crates/ghostty-os/src/pipe.rs | 62 +- crates/ghostty-os/src/resourcesdir.rs | 27 +- crates/ghostty-os/src/systemd.rs | 22 + crates/ghostty-os/src/temp_dir.rs | 56 +- crates/ghostty-os/src/xdg.rs | 14 +- crates/ghostty-renderer/Cargo.toml | 21 +- crates/ghostty-renderer/shaders/shaders.metal | 853 ++++++++++++++++ crates/ghostty-renderer/src/backend.rs | 28 +- crates/ghostty-renderer/src/display_link.rs | 197 ++++ crates/ghostty-renderer/src/generic.rs | 666 +++++++----- crates/ghostty-renderer/src/graphics_api.rs | 334 ++++++ crates/ghostty-renderer/src/image.rs | 149 +-- crates/ghostty-renderer/src/lib.rs | 23 +- crates/ghostty-renderer/src/metal/buffer.rs | 174 ++++ crates/ghostty-renderer/src/metal/frame.rs | 143 +++ .../src/metal/io_surface_layer.rs | 193 ++++ crates/ghostty-renderer/src/metal/mod.rs | 522 ++++++++++ crates/ghostty-renderer/src/metal/pipeline.rs | 168 +++ .../ghostty-renderer/src/metal/render_pass.rs | 198 ++++ crates/ghostty-renderer/src/metal/sampler.rs | 53 + crates/ghostty-renderer/src/metal/shaders.rs | 209 ++++ crates/ghostty-renderer/src/metal/target.rs | 199 ++++ crates/ghostty-renderer/src/metal/texture.rs | 156 +++ crates/ghostty-renderer/src/metal_layout.rs | 778 ++++++++++++++ crates/ghostty-renderer/src/opengl/api.rs | 249 +++++ crates/ghostty-renderer/src/opengl/buffer.rs | 2 +- crates/ghostty-renderer/src/opengl/frame.rs | 34 +- crates/ghostty-renderer/src/opengl/gl.rs | 21 +- crates/ghostty-renderer/src/opengl/mod.rs | 139 +-- .../src/opengl/render_pass.rs | 43 +- crates/ghostty-renderer/src/opengl/shaders.rs | 239 +---- crates/ghostty-renderer/src/shader_types.rs | 233 +++++ crates/ghostty-renderer/src/shadertoy.rs | 120 ++- crates/ghostty-renderer/src/thread.rs | 152 ++- crates/ghostty-renderer/tests/egl_headless.rs | 4 +- crates/ghostty-renderer/tests/mock_api.rs | 398 ++++++++ crates/ghostty-terminal/Cargo.toml | 6 + .../src/kitty/graphics_image.rs | 32 +- crates/ghostty-terminal/src/search/thread.rs | 12 + crates/ghostty-termio/Cargo.toml | 28 +- crates/ghostty-termio/src/command.rs | 104 +- crates/ghostty-termio/src/exec.rs | 313 +++++- crates/ghostty-termio/src/lib.rs | 9 +- crates/ghostty-termio/src/mailbox.rs | 37 +- crates/ghostty-termio/src/pty.rs | 93 +- .../ghostty-termio/src/shell_integration.rs | 29 +- crates/ghostty-termio/src/thread.rs | 20 +- crates/ghostty-termio/src/wakeup.rs | 72 +- .../ghostty-termio/tests/exec_integration.rs | 1 + crates/ghostty-termio/tests/pty_command.rs | 1 + docs/rewrite/STATUS.md | 2 +- docs/rewrite/notes/macos-M1-font.md | 56 + docs/rewrite/notes/macos-M1-renderer.md | 92 ++ docs/rewrite/notes/macos-M1.md | 96 ++ justfile | 13 + src/main.rs | 7 + 124 files changed, 14936 insertions(+), 1242 deletions(-) create mode 100644 crates/ghostty-font/build.rs create mode 100644 crates/ghostty-font/src/coretext_util.rs create mode 100644 crates/ghostty-font/src/deferred_face_coretext.rs create mode 100644 crates/ghostty-font/src/discovery_coretext.rs create mode 100644 crates/ghostty-font/src/face/coretext.rs create mode 100644 crates/ghostty-font/src/face/coretext_math.rs create mode 100644 crates/ghostty-font/src/shaper/cf_release.rs create mode 100644 crates/ghostty-font/src/shaper/coretext.rs create mode 100644 crates/ghostty-font/src/shaper/coretext_cells.rs create mode 100644 crates/ghostty-input/src/keymap_darwin.rs create mode 100644 crates/ghostty-input/src/keymap_noop.rs create mode 100644 crates/ghostty-input/src/platform.rs create mode 100644 crates/ghostty-os/src/cf_release_thread.rs create mode 100644 crates/ghostty-os/src/mach.rs create mode 100644 crates/ghostty-os/src/macos.rs create mode 100644 crates/ghostty-renderer/shaders/shaders.metal create mode 100644 crates/ghostty-renderer/src/display_link.rs create mode 100644 crates/ghostty-renderer/src/graphics_api.rs create mode 100644 crates/ghostty-renderer/src/metal/buffer.rs create mode 100644 crates/ghostty-renderer/src/metal/frame.rs create mode 100644 crates/ghostty-renderer/src/metal/io_surface_layer.rs create mode 100644 crates/ghostty-renderer/src/metal/mod.rs create mode 100644 crates/ghostty-renderer/src/metal/pipeline.rs create mode 100644 crates/ghostty-renderer/src/metal/render_pass.rs create mode 100644 crates/ghostty-renderer/src/metal/sampler.rs create mode 100644 crates/ghostty-renderer/src/metal/shaders.rs create mode 100644 crates/ghostty-renderer/src/metal/target.rs create mode 100644 crates/ghostty-renderer/src/metal/texture.rs create mode 100644 crates/ghostty-renderer/src/metal_layout.rs create mode 100644 crates/ghostty-renderer/src/opengl/api.rs create mode 100644 crates/ghostty-renderer/src/shader_types.rs create mode 100644 crates/ghostty-renderer/tests/mock_api.rs create mode 100644 docs/rewrite/notes/macos-M1-font.md create mode 100644 docs/rewrite/notes/macos-M1-renderer.md create mode 100644 docs/rewrite/notes/macos-M1.md diff --git a/Cargo.lock b/Cargo.lock index c4b560a..7eb4c2f 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -341,6 +341,15 @@ dependencies = [ "generic-array", ] +[[package]] +name = "block2" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cdeb9d870516001442e364c5220d3574d2da8dc765554b4a617230d33fa58ef5" +dependencies = [ + "objc2", +] + [[package]] name = "blocking" version = "1.7.0" @@ -784,6 +793,18 @@ dependencies = [ "crypto-common", ] +[[package]] +name = "dispatch2" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e0e367e4e7da84520dedcac1901e4da967309406d1e51017ae1abfb97adbd38" +dependencies = [ + "bitflags 2.13.2", + "block2", + "libc", + "objc2", +] + [[package]] name = "dlib" version = "0.5.3" @@ -1291,6 +1312,7 @@ dependencies = [ "wayland-client", "wayland-protocols", "wayland-protocols-plasma", + "wayland-sys", ] [[package]] @@ -1321,6 +1343,7 @@ dependencies = [ "flate2", "ghostty-apprt", "ghostty-config", + "ghostty-crash", "ghostty-font", "ghostty-input", "ghostty-os", @@ -1355,6 +1378,7 @@ dependencies = [ "crossbeam-channel", "ghostty-apprt", "ghostty-config", + "ghostty-crash", "ghostty-datastruct", "ghostty-font", "ghostty-input", @@ -1367,10 +1391,23 @@ dependencies = [ "ghostty-vt", "log", "onig", + "oslog", "png", "thiserror 2.0.21", ] +[[package]] +name = "ghostty-crash" +version = "0.1.0" +dependencies = [ + "ghostty-os", + "libc", + "log", + "serde_json", + "tempfile", + "thiserror 2.0.21", +] + [[package]] name = "ghostty-datastruct" version = "0.1.0" @@ -1387,11 +1424,17 @@ dependencies = [ "freetype-sys", "ghostty-config", "ghostty-datastruct", + "ghostty-os", "ghostty-terminal", "ghostty-unicode", "ghostty-vt", "harfbuzz-sys", "log", + "objc2", + "objc2-core-foundation", + "objc2-core-graphics", + "objc2-core-text", + "objc2-foundation", "png", "thiserror 2.0.21", "tiny-skia", @@ -1405,6 +1448,8 @@ dependencies = [ "ghostty-unicode", "ghostty-vt", "indexmap", + "log", + "objc2-core-foundation", "thiserror 2.0.21", ] @@ -1433,13 +1478,20 @@ dependencies = [ name = "ghostty-os" version = "0.1.0" dependencies = [ + "crossbeam-channel", "gettext-rs", "ghostty-vt", + "libc", "log", "nix 0.31.3", + "objc2", + "objc2-app-kit", + "objc2-core-foundation", + "objc2-foundation", "rustix", "temp-env", "thiserror 2.0.21", + "windows-sys", "zbus", ] @@ -1447,12 +1499,16 @@ dependencies = [ name = "ghostty-renderer" version = "0.1.0" dependencies = [ + "block2", "bytemuck", "crossbeam-channel", + "dispatch2", "ghostty-config", + "ghostty-crash", "ghostty-datastruct", "ghostty-font", "ghostty-input", + "ghostty-os", "ghostty-terminal", "ghostty-unicode", "ghostty-vt", @@ -1461,6 +1517,14 @@ dependencies = [ "khronos-egl", "log", "naga", + "objc2", + "objc2-core-foundation", + "objc2-core-graphics", + "objc2-core-video", + "objc2-foundation", + "objc2-io-surface", + "objc2-metal", + "objc2-quartz-core", "onig", "png", "thiserror 2.0.21", @@ -1475,8 +1539,12 @@ dependencies = [ "ghostty-cli", "ghostty-config", "ghostty-core", + "ghostty-crash", "ghostty-os", "log", + "rustix", + "serde_json", + "tempfile", ] [[package]] @@ -1489,6 +1557,7 @@ dependencies = [ "ghostty-config", "ghostty-datastruct", "ghostty-onig", + "ghostty-os", "ghostty-unicode", "ghostty-vt", "log", @@ -1505,6 +1574,7 @@ dependencies = [ "crossbeam-channel", "ghostty-apprt", "ghostty-config", + "ghostty-crash", "ghostty-datastruct", "ghostty-os", "ghostty-renderer", @@ -1513,9 +1583,13 @@ dependencies = [ "libc", "log", "nix 0.31.3", + "objc2-core-foundation", + "objc2-foundation", "rustix", "temp-env", "thiserror 2.0.21", + "windows-sys", + "zbus", ] [[package]] @@ -1532,6 +1606,27 @@ dependencies = [ "thiserror 2.0.21", ] +[[package]] +name = "ghostty-vt-capi" +version = "0.1.0" +dependencies = [ + "ghostty-input", + "ghostty-vt", + "log", +] + +[[package]] +name = "ghostty-wasm" +version = "0.1.0" +dependencies = [ + "ghostty-config", + "ghostty-font", + "ghostty-os", + "ghostty-terminal", + "log", + "temp-env", +] + [[package]] name = "gio" version = "0.22.10" @@ -2379,6 +2474,58 @@ dependencies = [ "objc_id", ] +[[package]] +name = "objc2" +version = "0.6.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "08849bbd4767dfae9457696856ae1c84fe4e0281bbe4a7abff2d0e06fb7981f8" +dependencies = [ + "objc2-encode", +] + +[[package]] +name = "objc2-app-kit" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d49e936b501e5c5bf01fda3a9452ff86dc3ea98ad5f283e1455153142d97518c" +dependencies = [ + "bitflags 2.13.2", + "block2", + "libc", + "objc2", + "objc2-cloud-kit", + "objc2-core-data", + "objc2-core-foundation", + "objc2-core-graphics", + "objc2-core-image", + "objc2-core-text", + "objc2-core-video", + "objc2-foundation", + "objc2-quartz-core", +] + +[[package]] +name = "objc2-cloud-kit" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "73ad74d880bb43877038da939b7427bba67e9dd42004a18b809ba7d87cee241c" +dependencies = [ + "bitflags 2.13.2", + "objc2", + "objc2-foundation", +] + +[[package]] +name = "objc2-core-data" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b402a653efbb5e82ce4df10683b6b28027616a2715e90009947d50b8dd298fa" +dependencies = [ + "bitflags 2.13.2", + "objc2", + "objc2-foundation", +] + [[package]] name = "objc2-core-foundation" version = "0.3.2" @@ -2386,6 +2533,10 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2a180dd8642fa45cdb7dd721cd4c11b1cadd4929ce112ebd8b9f5803cc79d536" dependencies = [ "bitflags 2.13.2", + "block2", + "dispatch2", + "libc", + "objc2", ] [[package]] @@ -2394,7 +2545,24 @@ version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e022c9d066895efa1345f8e33e584b9f958da2fd4cd116792e15e07e4720a807" dependencies = [ + "bitflags 2.13.2", + "block2", + "dispatch2", + "libc", + "objc2", "objc2-core-foundation", + "objc2-io-surface", + "objc2-metal", +] + +[[package]] +name = "objc2-core-image" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e5d563b38d2b97209f8e861173de434bd0214cf020e3423a52624cd1d989f006" +dependencies = [ + "objc2", + "objc2-foundation", ] [[package]] @@ -2404,7 +2572,90 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0cde0dfb48d25d2b4862161a4d5fcc0e3c24367869ad306b0c9ec0073bfed92d" dependencies = [ "bitflags 2.13.2", + "block2", + "libc", + "objc2", "objc2-core-foundation", + "objc2-core-graphics", +] + +[[package]] +name = "objc2-core-video" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d425caf1df73233f29fd8a5c3e5edbc30d2d4307870f802d18f00d83dc5141a6" +dependencies = [ + "bitflags 2.13.2", + "block2", + "objc2", + "objc2-core-foundation", + "objc2-core-graphics", + "objc2-io-surface", + "objc2-metal", +] + +[[package]] +name = "objc2-encode" +version = "4.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ef25abbcd74fb2609453eb695bd2f860d389e457f67dc17cafc8b8cbc89d0c33" + +[[package]] +name = "objc2-foundation" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3e0adef53c21f888deb4fa59fc59f7eb17404926ee8a6f59f5df0fd7f9f3272" +dependencies = [ + "bitflags 2.13.2", + "block2", + "libc", + "objc2", + "objc2-core-foundation", +] + +[[package]] +name = "objc2-io-surface" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "180788110936d59bab6bd83b6060ffdfffb3b922ba1396b312ae795e1de9d81d" +dependencies = [ + "bitflags 2.13.2", + "libc", + "objc2", + "objc2-core-foundation", + "objc2-foundation", +] + +[[package]] +name = "objc2-metal" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a0125f776a10d00af4152d74616409f0d4a2053a6f57fa5b7d6aa2854ac04794" +dependencies = [ + "bitflags 2.13.2", + "block2", + "dispatch2", + "objc2", + "objc2-core-foundation", + "objc2-foundation", + "objc2-io-surface", +] + +[[package]] +name = "objc2-quartz-core" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "96c1358452b371bf9f104e21ec536d37a650eb10f7ee379fff67d2e08d537f1f" +dependencies = [ + "bitflags 2.13.2", + "block2", + "libc", + "objc2", + "objc2-core-foundation", + "objc2-core-graphics", + "objc2-core-video", + "objc2-foundation", + "objc2-metal", ] [[package]] @@ -2470,6 +2721,15 @@ dependencies = [ "pin-project-lite", ] +[[package]] +name = "oslog" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "80d2043d1f61d77cb2f4b1f7b7b2295f40507f5f8e9d1c8bf10a1ca5f97a3969" +dependencies = [ + "cc", +] + [[package]] name = "palette" version = "0.7.7" @@ -3052,6 +3312,8 @@ version = "1.0.154" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e7e9cc8b1b85264074fbcc02a88680c4096b1e47df8f739dceb03bf482f04bd6" dependencies = [ + "foldhash", + "indexmap", "itoa", "memchr", "serde", @@ -3813,6 +4075,7 @@ checksum = "d8eab23fefc9e41f8e841df4a9c707e8a8c4ed26e944ef69297184de2785e3be" dependencies = [ "dlib", "log", + "once_cell", "pkg-config", ] diff --git a/Cargo.toml b/Cargo.toml index 3242474..f15f242 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -8,15 +8,26 @@ description = "A Rust rewrite of the Ghostty terminal emulator" name = "ghostty" path = "src/main.rs" +[features] +# Mirrors upstream's `-Dsentry` build option (off by default on Linux): +# enables the local crash-report handler (`ghostty-crash`). +sentry = ["ghostty-crash/sentry"] + [dependencies] ghostty-core.workspace = true ghostty-config.workspace = true ghostty-apprt-gtk.workspace = true ghostty-apprt.workspace = true ghostty-cli.workspace = true +ghostty-crash.workspace = true ghostty-os.workspace = true log = "0.4" +[dev-dependencies] +rustix = { version = "1", features = ["process"] } +serde_json = "1" +tempfile = "3" + [lints] workspace = true @@ -50,8 +61,11 @@ ghostty-core = { path = "crates/ghostty-core" } ghostty-apprt-gtk = { path = "crates/ghostty-apprt-gtk" } ghostty-apprt = { path = "crates/ghostty-apprt" } ghostty-cli = { path = "crates/ghostty-cli" } +ghostty-crash = { path = "crates/ghostty-crash" } ghostty-bench = { path = "crates/ghostty-bench" } ghostty-inspector = { path = "crates/ghostty-inspector" } +ghostty-vt-capi = { path = "crates/ghostty-vt-capi" } +ghostty-wasm = { path = "crates/ghostty-wasm" } [workspace.lints.rust] unsafe_code = "forbid" diff --git a/crates/ghostty-apprt-gtk/Cargo.toml b/crates/ghostty-apprt-gtk/Cargo.toml index 850db83..4037b7b 100644 --- a/crates/ghostty-apprt-gtk/Cargo.toml +++ b/crates/ghostty-apprt-gtk/Cargo.toml @@ -31,6 +31,7 @@ signal-hook = "0.4.5" libloading = "0.9.0" dear-imgui-glow = "0.19" glow = "0.18" +wayland-sys = { version = "0.31.11", features = ["client", "dlopen"] } [build-dependencies] glib-build-tools = "0.22" diff --git a/crates/ghostty-apprt-gtk/src/class/surface.rs b/crates/ghostty-apprt-gtk/src/class/surface.rs index 45c1d99..e3ace17 100644 --- a/crates/ghostty-apprt-gtk/src/class/surface.rs +++ b/crates/ghostty-apprt-gtk/src/class/surface.rs @@ -102,6 +102,31 @@ pub enum IMKeyEvent { NotComposing, } +/// Input method signals for [`Surface::debug_im`]. +#[derive(Debug, Clone, PartialEq)] +pub enum DebugIm { + PreeditStart, + PreeditChanged, + PreeditEnd, + Commit(String), +} + +/// What the simulated input method does with a key press in +/// [`Surface::debug_im_key`]. +#[derive(Debug, Clone, PartialEq)] +pub enum DebugImKey { + /// Handled; starts/updates the preedit (dead key, compose). + Preedit, + /// Handled; commit then preedit-end (ibus order). + Commit(String), + /// Handled; preedit-end then commit (simple order). + CommitSimple(String), + /// Handled; commit without composing (plain character translation). + HandledCommit(String), + /// Not handled and no signals. + Pass, +} + /// Overrides applied on top of the config when the core surface is created /// (Zig: the anonymous struct taking by `Surface.new`). #[derive(Debug, Clone, Default)] @@ -623,6 +648,36 @@ impl Surface { return false; }; + // The input method handling lives in `im_filter` (shared with the + // debug replay, which drives it with a simulated input method). + if self.im_filter(|| imp.im_context.filter_keypress(&event)) { + return true; + } + + // We always reset the length of the im buffer. There's only one + // scenario we reach this point with im_len > 0 and that's if we + // received a commit event from the input method. We don't want to + // keep that state around since we've handled it here. + let result = self.key_event_encode( + action, + &KeySource::Event { + event: &event, + key_event: &key_event, + }, + keyval, + keycode, + gtk_mods, + ); + imp.im_buf.borrow_mut().clear(); + result + } + + /// The input method half of [`Surface::key_event`]. `filter` runs the + /// event through the input method context (`filter_keypress`) and returns + /// whether it was handled. Returns true if the key event must not be + /// encoded. + fn im_filter(&self, filter: impl FnOnce() -> bool) -> bool { + let imp = self.imp(); // The block below is all related to input method handling. See the // function comment for some high level details and then the comments // within the block for more specifics. @@ -651,7 +706,7 @@ impl Surface { } else { IMKeyEvent::NotComposing }); - let handled = imp.im_context.filter_keypress(&event); + let handled = filter(); let in_keyevent = imp.in_keyevent.replace(IMKeyEvent::False); // Pass the event through the input method which returns true if @@ -720,22 +775,7 @@ impl Surface { // work. } - // We always reset the length of the im buffer. There's only one - // scenario we reach this point with im_len > 0 and that's if we - // received a commit event from the input method. We don't want to - // keep that state around since we've handled it here. - let result = self.key_event_encode( - action, - &KeySource::Event { - event: &event, - key_event: &key_event, - }, - keyval, - keycode, - gtk_mods, - ); - imp.im_buf.borrow_mut().clear(); - result + false } /// Debug input replay (`GHOSTTY_DEBUG_INPUT_SCRIPT`): feed a synthesized @@ -1002,8 +1042,57 @@ impl Surface { } } + /// Horizontal (surface unit) scroll: accumulate and switch tabs once the + /// threshold is reached (`ecMouseScrollHorizontal` after its unit check). + fn scroll_horizontal_impl(&self, x: f64) -> bool { + let this = self; + let imp = this.imp(); + + imp.pending_horizontal_scroll + .set(imp.pending_horizontal_scroll.get() + x); + + if imp.pending_horizontal_scroll.get().abs() < 120.0 { + if let Some(v) = imp.pending_horizontal_scroll_reset.take() { + v.remove(); + } + let weak = this.downgrade(); + let id = glib::timeout_add_local(std::time::Duration::from_millis(500), move || { + if let Some(this) = weak.upgrade() { + let imp = this.imp(); + imp.pending_horizontal_scroll.set(0.0); + let _ = imp.pending_horizontal_scroll_reset.take(); + } + glib::ControlFlow::Break + }); + imp.pending_horizontal_scroll_reset.set(Some(id)); + return true; + } + + let _ = this.activate_action( + if imp.pending_horizontal_scroll.get() < 0.0 { + "tab.next-page" + } else { + "tab.previous-page" + }, + None, + ); + + if let Some(v) = imp.pending_horizontal_scroll_reset.take() { + v.remove(); + } + + imp.pending_horizontal_scroll.set(0.0); + + true + } + + /// Debug input replay: horizontal scroll in surface units (touchpad). + pub fn debug_hscroll(&self, dx: f64) { + self.scroll_horizontal_impl(dx); + } + /// Debug input replay: wheel scroll (vertical goes through the same - /// handler as the scroll controller; horizontal tab switching is skipped). + /// handler as the scroll controller; see `debug_hscroll` for horizontal). pub fn debug_scroll(&self, dy: f64) { self.scroll_vertical_impl(0.0, dy); } @@ -1032,6 +1121,80 @@ impl Surface { self.imp().im_context.emit_by_name::<()>("commit", &[&text]); } + /// Debug input replay: drive the surface's real input method signal + /// handlers (the functions the template connects to the IM context's + /// `preedit-start`/`preedit-changed`/`preedit-end`/`commit` signals) the + /// way an input method would. `ctx` is the context passed as the signal + /// emitter (so `preedit_string()` reports a real preedit and attributes). + pub fn debug_im(&self, ctx: >k::IMContext, step: DebugIm) { + use imp::Surface as H; + match step { + DebugIm::PreeditStart => H::im_preedit_start(ctx, self), + DebugIm::PreeditChanged => H::im_preedit_changed(ctx, self), + DebugIm::PreeditEnd => H::im_preedit_end(ctx, self), + DebugIm::Commit(text) => H::im_commit(ctx, &text, self), + } + } + + /// Debug input replay: whether the surface believes it is composing. + pub fn debug_im_composing(&self) -> bool { + self.imp().im_composing.get() + } + + /// Debug input replay: a key press+release that goes through + /// [`Surface::im_filter`] (the real `in_keyevent`/`im_composing` + /// bookkeeping) with a simulated input method. `ctx` carries the preedit + /// string. Returns whether the press was consumed by the input method. + pub fn debug_im_key( + &self, + ctx: >k::IMContext, + sim: &DebugImKey, + keyval: u32, + keycode: u32, + state: gdk::ModifierType, + ) -> bool { + let consumed = self.im_filter(|| { + use imp::Surface as H; + match sim { + DebugImKey::Preedit => { + // A dead key / compose start: preedit-start (once) then + // preedit-changed, event handled. + if !self.imp().im_composing.get() { + H::im_preedit_start(ctx, self); + } + H::im_preedit_changed(ctx, self); + true + } + DebugImKey::Commit(text) => { + // ibus order: commit THEN preedit-end. + H::im_commit(ctx, text, self); + H::im_preedit_end(ctx, self); + true + } + DebugImKey::CommitSimple(text) => { + // gtk-im-simple order: preedit-end THEN commit. + H::im_preedit_end(ctx, self); + H::im_commit(ctx, text, self); + true + } + DebugImKey::HandledCommit(text) => { + // Plain character translation: commit, handled. + H::im_commit(ctx, text, self); + true + } + DebugImKey::Pass => false, + } + }); + if !consumed { + self.debug_key_event(KeyAction::Press, keyval, keycode, state); + } + // The release also goes through the input method. + if !self.im_filter(|| false) { + self.debug_key_event(KeyAction::Release, keyval, keycode, state); + } + consumed + } + fn key_event_encode( &self, action: KeyAction, @@ -3389,56 +3552,18 @@ mod imp { _y: f64, this: &super::Surface, ) -> bool { - let imp = this.imp(); - match ec.unit() { gdk::ScrollUnit::Surface => {} _ => return false, } - imp.pending_horizontal_scroll - .set(imp.pending_horizontal_scroll.get() + x); - - if imp.pending_horizontal_scroll.get().abs() < 120.0 { - if let Some(v) = imp.pending_horizontal_scroll_reset.take() { - v.remove(); - } - let weak = this.downgrade(); - let id = - glib::timeout_add_local(std::time::Duration::from_millis(500), move || { - if let Some(this) = weak.upgrade() { - let imp = this.imp(); - imp.pending_horizontal_scroll.set(0.0); - let _ = imp.pending_horizontal_scroll_reset.take(); - } - glib::ControlFlow::Break - }); - imp.pending_horizontal_scroll_reset.set(Some(id)); - return true; - } - - let _ = this.activate_action( - if imp.pending_horizontal_scroll.get() < 0.0 { - "tab.next-page" - } else { - "tab.previous-page" - }, - None, - ); - - if let Some(v) = imp.pending_horizontal_scroll_reset.take() { - v.remove(); - } - - imp.pending_horizontal_scroll.set(0.0); - - true + this.scroll_horizontal_impl(x) } // ---- input method ---- #[template_callback] - fn im_preedit_start(_ctx: >k::IMMulticontext, this: &super::Surface) { + pub(super) fn im_preedit_start(_ctx: >k::IMContext, this: &super::Surface) { // Start our composing state for the input method and reset our // input buffer to empty. let imp = this.imp(); @@ -3447,7 +3572,7 @@ mod imp { } #[template_callback] - fn im_preedit_changed(ctx: >k::IMMulticontext, this: &super::Surface) { + pub(super) fn im_preedit_changed(ctx: >k::IMContext, this: &super::Surface) { let imp = this.imp(); // Any preedit change should mark that we're composing. Its @@ -3474,7 +3599,7 @@ mod imp { } #[template_callback] - fn im_preedit_end(_ctx: >k::IMMulticontext, this: &super::Surface) { + pub(super) fn im_preedit_end(_ctx: >k::IMContext, this: &super::Surface) { // End our composing state for GTK, allowing us to commit the text. let imp = this.imp(); imp.im_composing.set(false); @@ -3487,7 +3612,7 @@ mod imp { } #[template_callback] - fn im_commit(_ctx: >k::IMMulticontext, str: &str, this: &super::Surface) { + pub(super) fn im_commit(_ctx: >k::IMContext, str: &str, this: &super::Surface) { let imp = this.imp(); // We need to handle commit specially if we're in a key event. diff --git a/crates/ghostty-apprt/src/runtime_api.rs b/crates/ghostty-apprt/src/runtime_api.rs index f3cbd49..4f1409f 100644 --- a/crates/ghostty-apprt/src/runtime_api.rs +++ b/crates/ghostty-apprt/src/runtime_api.rs @@ -270,6 +270,13 @@ pub trait RtApp { /// `App.performAction`. Returns whether the action was performed. /// Runs on the main thread. fn perform_action(&self, target: Target, action: Action) -> bool; + + /// The current keyboard layout (embedded `App.keyboardLayout`, used on + /// macOS to derive the default `macos-option-as-alt`). Runtimes that + /// can't tell report `Unknown`. + fn keyboard_layout(&self) -> ghostty_input::KeyboardLayout { + ghostty_input::KeyboardLayout::Unknown + } } /// Implemented by this crate (GTK `Surface`); handed to core. Mirrors diff --git a/crates/ghostty-cli/Cargo.toml b/crates/ghostty-cli/Cargo.toml index 19d292c..cf13756 100644 --- a/crates/ghostty-cli/Cargo.toml +++ b/crates/ghostty-cli/Cargo.toml @@ -8,6 +8,7 @@ license.workspace = true flate2 = "1.1.10" ghostty-apprt.workspace = true ghostty-config.workspace = true +ghostty-crash.workspace = true ghostty-font.workspace = true ghostty-input.workspace = true ghostty-os.workspace = true diff --git a/crates/ghostty-config/src/config.rs b/crates/ghostty-config/src/config.rs index ab4c6da..451368b 100644 --- a/crates/ghostty-config/src/config.rs +++ b/crates/ghostty-config/src/config.rs @@ -204,7 +204,13 @@ crate::config_struct! { /// On Linux with GTK, font size is scaled according to both display-wide and /// text-specific scaling factors, which are often managed by your desktop /// environment (e.g. the GNOME display scale and large text settings). - "font-size" => font_size: f32 = 12.0, + "font-size" => font_size: f32 = if cfg!(target_os = "macos") { + // On macOS we default a little bigger since this tends to look + // better. This is purely subjective but this is easy to modify. + 13.0 + } else { + 12.0 + }, /// A repeatable configuration to set one or more font variations values for /// a variable font. A variable font is a single font, usually with a filename @@ -339,7 +345,11 @@ crate::config_struct! { /// but without any of the darkening artifacts. /// /// Available since: 1.1.0 - "alpha-blending" => alpha_blending: AlphaBlending = AlphaBlending::LinearCorrected, + "alpha-blending" => alpha_blending: AlphaBlending = if cfg!(target_os = "macos") { + AlphaBlending::Native + } else { + AlphaBlending::LinearCorrected + }, /// All of the configurations behavior adjust various metrics determined by the /// font. The values can be integers (1, -1, etc.) or a percentage (20%, -15%, @@ -2353,7 +2363,11 @@ crate::config_struct! { /// paste is always enabled even if this is `false`. /// /// The default value is true on Linux and macOS. - "copy-on-select" => copy_on_select: CopyOnSelect = CopyOnSelect::True, + "copy-on-select" => copy_on_select: CopyOnSelect = if cfg!(any(target_os = "linux", target_os = "macos")) { + CopyOnSelect::True + } else { + CopyOnSelect::False + }, /// The action to take when the user right-clicks on the terminal surface. /// @@ -2433,7 +2447,7 @@ crate::config_struct! { /// On Linux, if this is `true`, Ghostty can delay quitting fully until a /// configurable amount of time has passed after the last window is closed. /// See the documentation of `quit-after-last-window-closed-delay`. - "quit-after-last-window-closed" => quit_after_last_window_closed: bool = true, + "quit-after-last-window-closed" => quit_after_last_window_closed: bool = cfg!(target_os = "linux"), /// Controls how long Ghostty will stay running after the last open surface has /// been closed. This only has an effect if `quit-after-last-window-closed` is @@ -2649,7 +2663,7 @@ crate::config_struct! { /// accessible than on macOS, meaning that it is more preferable to keep the /// quick terminal open until the user has completed their task. /// This default may change in the future. - "quick-terminal-autohide" => quick_terminal_autohide: bool = false, + "quick-terminal-autohide" => quick_terminal_autohide: bool = cfg!(target_os = "macos"), /// This configuration option determines the behavior of the quick terminal /// when switching between macOS spaces. macOS spaces are virtual desktops @@ -3430,7 +3444,11 @@ crate::config_struct! { /// * `always` - Always use cgroups. /// * `single-instance` - Enable cgroups only for Ghostty instances launched /// as single-instance applications (see gtk-single-instance). - "linux-cgroup" => linux_cgroup: LinuxCgroup = LinuxCgroup::SingleInstance, + "linux-cgroup" => linux_cgroup: LinuxCgroup = if cfg!(target_os = "linux") { + LinuxCgroup::SingleInstance + } else { + LinuxCgroup::Never + }, /// Memory limit for any individual terminal process (tab, split, window, /// etc.) in bytes. If this is unset then no memory limit will be set. @@ -3947,6 +3965,48 @@ impl Config { } }; + // On macOS load the app support directory as well + #[cfg(target_os = "macos")] + { + let legacy_app_support_path = crate::file_load::legacy_default_app_support_path() + .map_err(|e| Error::Other(e.to_string()))?; + let app_support_path = crate::file_load::preferred_app_support_path() + .map_err(|e| Error::Other(e.to_string()))?; + let app_support_loaded = { + let legacy_app_support_action = self.load_optional_file(&legacy_app_support_path); + + // The app support path and legacy may be the same, since we + // use the `preferred` call above. If its the same, avoid + // a double-load. + let app_support_action = if legacy_app_support_path == app_support_path { + OptionalFileAction::NotFound + } else { + self.load_optional_file(&app_support_path) + }; + + if app_support_action != OptionalFileAction::NotFound + && legacy_app_support_action != OptionalFileAction::NotFound + { + log::warn!( + "both config files `{legacy_app_support_path}` and `{app_support_path}` exist." + ); + log::warn!("loading them both in that order"); + true + } else { + app_support_action != OptionalFileAction::NotFound + || legacy_app_support_action != OptionalFileAction::NotFound + } + }; + + // If both files are not found, then we create a template file. + // For macOS, we only create the template file in the app support + if !app_support_loaded && !xdg_loaded { + if let Err(err) = Self::write_config_template(&app_support_path) { + log::warn!("error creating template config file err={err}"); + } + } + } + #[cfg(not(target_os = "macos"))] if !xdg_loaded { if let Err(err) = Self::write_config_template(&xdg_path) { log::warn!("error creating template config file err={err}"); @@ -3957,10 +4017,14 @@ impl Config { /// Load and parse the CLI args. pub fn load_cli_args(&mut self) -> Result<(), Error> { - let argv: Vec = std::env::args_os().collect(); + // (On macOS this reads NSProcessInfo, libc argc/argv are not valid + // for NSApplicationMain-based applications.) + let argv: Vec = ghostty_os::args::iterator().collect(); - // Fast-path if we are Linux/BSD and have no args. - if argv.len() <= 1 { + // Fast-path if we are Linux/BSD and have no args. Everything else + // (Windows, macOS) has to at least try because it may not use + // std.os.argv. + if cfg!(any(target_os = "linux", target_os = "freebsd")) && argv.len() <= 1 { return Ok(()); } @@ -3975,19 +4039,9 @@ impl Config { // styling, etc. based on the command. // // See: https://github.com/Vladimir-csp/xdg-terminal-exec + #[cfg(any(target_os = "linux", target_os = "freebsd"))] { - #[cfg(unix)] use std::os::unix::ffi::OsStrExt; - #[cfg(not(unix))] - trait OsStrExt { - fn as_bytes(&self) -> &[u8]; - } - #[cfg(not(unix))] - impl OsStrExt for std::ffi::OsString { - fn as_bytes(&self) -> &[u8] { - self.as_encoded_bytes() - } - } let cargs: Vec = argv .iter() .filter_map(|a| std::ffi::CString::new(a.as_bytes()).ok()) @@ -4533,7 +4587,31 @@ impl Config { } } - // We need the passwd entry for the remainder + self.platform_defaults(wd) + } + + /// Windows has no passwd database: default to cmd.exe and the + /// %HOMEDRIVE%%HOMEPATH% home directory. + #[cfg(windows)] + #[allow(clippy::unnecessary_wraps)] + fn platform_defaults(&mut self, wd: &mut WorkingDirectory) -> Result<(), Error> { + if self.command.is_none() { + log::warn!("no default shell found, will default to using cmd"); + self.command = Some(Command::Shell("cmd.exe".to_owned())); + } + + if *wd == WorkingDirectory::Home { + *wd = match ghostty_os::homedir::home() { + Ok(Some(home)) => WorkingDirectory::Path(home), + _ => WorkingDirectory::Inherit, + }; + } + Ok(()) + } + + /// The passwd-based part of [`Config::lookup_shell_defaults`]. + #[cfg(not(windows))] + fn platform_defaults(&mut self, wd: &mut WorkingDirectory) -> Result<(), Error> { let pw = ghostty_os::passwd::get()?; if self.command.is_none() { if let Some(sh) = pw.shell { @@ -4645,6 +4723,21 @@ fn file_kind_name(meta: &std::fs::Metadata) -> &'static str { /// We should keep the set of behaviors that depend on this as small /// as possible because magic sucks, but each place is well documented. fn probable_cli_environment() -> bool { + // Windows has its own problems, just ignore it for now since its not a + // real supported target. + if cfg!(windows) { + return false; + } + + // On macOS, we don't want to detect `open` calls as CLI envs. + // Our desktop detection on macOS is very accurate due to how + // processes are launched on macOS, so if we detect we're launched + // from the app bundle then we're not in a CLI environment. + #[cfg(target_os = "macos")] + if ghostty_os::launched_from_desktop() { + return false; + } + // If we have TERM_PROGRAM set to a non-empty value, we assume // a graphical terminal environment. if let Some(v) = std::env::var_os("TERM_PROGRAM") { diff --git a/crates/ghostty-config/src/edit.rs b/crates/ghostty-config/src/edit.rs index 47d28d5..2b7167c 100644 --- a/crates/ghostty-config/src/edit.rs +++ b/crates/ghostty-config/src/edit.rs @@ -9,8 +9,8 @@ use crate::file_load; /// On Linux, this will use the file at the XDG config path. This is the /// only valid path for Linux so we don't need to check for other paths. /// -/// (On macOS both XDG and AppSupport paths are valid; macOS is not -/// supported by this port.) +/// On macOS, both XDG and AppSupport paths are valid; the AppSupport paths +/// come first. /// /// The config file (and its directory) is created if it doesn't exist. pub fn open_path() -> io::Result { @@ -80,8 +80,15 @@ fn config_path() -> io::Result { /// Returns a list of possible paths the main config file could be /// in for the current OS. fn config_path_candidates() -> io::Result> { - Ok(vec![ - file_load::default_xdg_path().map_err(xdg_err)?, - file_load::legacy_default_xdg_path().map_err(xdg_err)?, - ]) + let mut paths = Vec::with_capacity(4); + + #[cfg(target_os = "macos")] + { + paths.push(file_load::default_app_support_path().map_err(xdg_err)?); + paths.push(file_load::legacy_default_app_support_path().map_err(xdg_err)?); + } + + paths.push(file_load::default_xdg_path().map_err(xdg_err)?); + paths.push(file_load::legacy_default_xdg_path().map_err(xdg_err)?); + Ok(paths) } diff --git a/crates/ghostty-config/src/file_load.rs b/crates/ghostty-config/src/file_load.rs index 6844e72..a1f702b 100644 --- a/crates/ghostty-config/src/file_load.rs +++ b/crates/ghostty-config/src/file_load.rs @@ -1,6 +1,9 @@ //! Default config file locations and opening (port of `config/file_load.zig`). //! -//! macOS Application Support paths are not supported (Linux only). +//! On macOS the Application Support paths are used in addition to XDG; the +//! helpers that only need `open` + two candidate paths are platform +//! independent (and unit tested everywhere), the Application Support lookup +//! itself uses `NSFileManager` and is macOS-only. use std::fs::File; @@ -40,13 +43,94 @@ pub fn preferred_xdg_path() -> Result { Ok(xdg_path) } +/// Default path for the macOS Application Support configuration file. +#[cfg(target_os = "macos")] +pub fn default_app_support_path() -> Result { + app_support_dir("config.ghostty") +} + +/// Ghostty <1.3.0 default path for the macOS Application Support +/// configuration file. +#[cfg(target_os = "macos")] +pub fn legacy_default_app_support_path() -> Result { + app_support_dir("config") +} + +#[cfg(target_os = "macos")] +fn app_support_dir(sub_path: &str) -> Result { + ghostty_os::macos::app_support_dir(sub_path) + .map(|p| p.to_string_lossy().into_owned()) + .map_err(|_| xdg::Error::AppleApiFailed) +} + +/// Preferred default path for the macOS Application Support configuration +/// file. +#[cfg(target_os = "macos")] +pub fn preferred_app_support_path() -> Result { + Ok(preferred_of( + default_app_support_path()?, + legacy_default_app_support_path()?, + )) +} + +/// Pick between a new path and its legacy path: the new one if it exists, +/// else the legacy one if it exists, else the new one. +#[cfg(any(target_os = "macos", test))] +fn preferred_of(new: String, legacy: String) -> String { + // If the new path exists, use that. + if open(&new).is_ok() { + return new; + } + + // Try the legacy path + if open(&legacy).is_ok() { + return legacy; + } + + // Legacy path and new path both don't exist. Return the new one. + new +} + +/// macOS: prefer the Application Support file if it exists, else the XDG +/// one if it exists, else Application Support. +#[cfg(any(target_os = "macos", test))] +fn preferred_macos( + app_support_path: String, + xdg_path: &dyn Fn() -> Result, +) -> Result { + // macOS prefers the Application Support directory if it exists. + if open(&app_support_path).is_ok() { + return Ok(app_support_path); + } + + // Try the XDG path if it exists + let xdg_path = xdg_path()?; + if open(&xdg_path).is_ok() { + return Ok(xdg_path); + } + + // If neither file exists, use app support + Ok(app_support_path) +} + /// Returns the path to the preferred default configuration file. /// This is the file where users should place their configuration. /// /// This doesn't create or populate the file with any default /// contents; downstream callers must handle this. +#[cfg(target_os = "macos")] pub fn preferred_default_file_path() -> Result { - // All supported platforms use XDG only + preferred_macos(preferred_app_support_path()?, &preferred_xdg_path) +} + +/// Returns the path to the preferred default configuration file. +/// This is the file where users should place their configuration. +/// +/// This doesn't create or populate the file with any default +/// contents; downstream callers must handle this. +#[cfg(not(target_os = "macos"))] +pub fn preferred_default_file_path() -> Result { + // All other platforms use XDG only preferred_xdg_path() } @@ -123,6 +207,47 @@ mod tests { std::fs::remove_file(&p).unwrap(); } + #[test] + fn preferred_of_prefers_existing_new_then_legacy() { + let dir = std::env::temp_dir().join(format!("ghostty-pref-{}", std::process::id())); + std::fs::create_dir_all(&dir).unwrap(); + let new = dir.join("config.ghostty").to_str().unwrap().to_owned(); + let legacy = dir.join("config").to_str().unwrap().to_owned(); + let _ = std::fs::remove_file(&new); + let _ = std::fs::remove_file(&legacy); + + // Neither exists: the new one. + assert_eq!(preferred_of(new.clone(), legacy.clone()), new); + // Only legacy exists: the legacy one. + std::fs::write(&legacy, "a=b").unwrap(); + assert_eq!(preferred_of(new.clone(), legacy.clone()), legacy); + // Both exist: the new one. + std::fs::write(&new, "a=b").unwrap(); + assert_eq!(preferred_of(new.clone(), legacy.clone()), new); + std::fs::remove_dir_all(&dir).unwrap(); + } + + #[test] + fn preferred_macos_order() { + let dir = std::env::temp_dir().join(format!("ghostty-pref-mac-{}", std::process::id())); + std::fs::create_dir_all(&dir).unwrap(); + let app = dir.join("app").to_str().unwrap().to_owned(); + let xdg_p = dir.join("xdg").to_str().unwrap().to_owned(); + let _ = std::fs::remove_file(&app); + let _ = std::fs::remove_file(&xdg_p); + let xdg_fn = || Ok(xdg_p.clone()); + + // Neither exists: app support. + assert_eq!(preferred_macos(app.clone(), &xdg_fn).unwrap(), app); + // XDG exists only: XDG. + std::fs::write(&xdg_p, "a=b").unwrap(); + assert_eq!(preferred_macos(app.clone(), &xdg_fn).unwrap(), xdg_p); + // App support exists: app support wins. + std::fs::write(&app, "a=b").unwrap(); + assert_eq!(preferred_macos(app.clone(), &xdg_fn).unwrap(), app); + std::fs::remove_dir_all(&dir).unwrap(); + } + #[test] fn default_paths_end_with_expected_names() { if let (Ok(a), Ok(b)) = (default_xdg_path(), legacy_default_xdg_path()) { diff --git a/crates/ghostty-config/src/keybinds.rs b/crates/ghostty-config/src/keybinds.rs index d510365..16170b1 100644 --- a/crates/ghostty-config/src/keybinds.rs +++ b/crates/ghostty-config/src/keybinds.rs @@ -12,6 +12,7 @@ use ghostty_input::{ CopyToClipboard, Flags, InspectorMode, + NavigateSearch, Set, SplitDirection, SplitFocusDirection, @@ -22,7 +23,6 @@ use ghostty_input::{ WriteScreen, set::{EntryFormatter as BindingEntryFormatter, Value}, }, - ctrl_or_super, }; use crate::{ @@ -93,23 +93,32 @@ fn to_arg_error(e: ghostty_input::binding::Error) -> ArgError { } impl Keybinds { - /// Reset to the default keybinds (Zig: `init`). Linux/GTK variant. - #[allow(clippy::too_many_lines)] + /// Reset to the default keybinds (Zig: `init`) for the build target. pub fn init(&mut self) { + self.init_for(ghostty_input::platform::is_darwin()); + } + + /// Reset to the default keybinds for Darwin (`true`) or non-Darwin + /// (`false`). Zig selects with `builtin.target.os.tag.isDarwin()` at + /// compile time; the parameter lets both variants be tested anywhere. + #[allow(clippy::too_many_lines)] + pub fn init_for(&mut self, darwin: bool) { self.set = Set::default(); self.tables = Vec::new(); self.chain_target = ChainTarget::Root; let none = Mods::default(); let cs = mods(true, true, false, false); + // `inputpkg.ctrlOrSuper` for the requested platform. + let cos = |m: Mods| ghostty_input::key::ctrl_or_super_for(m, darwin); let set = &mut self.set; // keybinds for opening and reloading config set.put( - uni(',', ctrl_or_super(mods(false, true, false, false))), + uni(',', cos(mods(false, true, false, false))), Action::ReloadConfig, ); - set.put(uni(',', ctrl_or_super(none)), Action::OpenConfig); + set.put(uni(',', cos(none)), Action::OpenConfig); { set.put( @@ -124,44 +133,51 @@ impl Keybinds { // The order of these blocks is important. The *last* added keybind for a given action is // what will display in the menu. We want the more typical keybinds after this block to be // the standard - set.put( - phys(Key::Insert, mods(true, false, false, false)), - Action::CopyToClipboard(CopyToClipboard::Mixed), - ); - set.put( - phys(Key::Insert, mods(false, true, false, false)), - Action::PasteFromClipboard, - ); + if !darwin { + set.put( + phys(Key::Insert, mods(true, false, false, false)), + Action::CopyToClipboard(CopyToClipboard::Mixed), + ); + set.put( + phys(Key::Insert, mods(false, true, false, false)), + Action::PasteFromClipboard, + ); + } // On macOS we default to super but Linux ctrl+shift since // ctrl+c is to kill the process. + let m = if darwin { + mods(false, false, false, true) + } else { + cs + }; set.put_flags( - uni('c', cs), + uni('c', m), Action::CopyToClipboard(CopyToClipboard::Mixed), performable(), ); - set.put_flags(uni('v', cs), Action::PasteFromClipboard, performable()); + set.put_flags(uni('v', m), Action::PasteFromClipboard, performable()); } // Increase font size mapping for keyboards with dedicated plus keys (like german) // Note: this order matters below because the C API will only return // the last keybinding for a given action. The macOS app uses this to // set the expected keybind for the menu. - set.put(uni('=', ctrl_or_super(none)), Action::IncreaseFontSize(1.0)); - set.put(uni('+', ctrl_or_super(none)), Action::IncreaseFontSize(1.0)); - set.put(uni('-', ctrl_or_super(none)), Action::DecreaseFontSize(1.0)); - set.put(uni('0', ctrl_or_super(none)), Action::ResetFontSize); + set.put(uni('=', cos(none)), Action::IncreaseFontSize(1.0)); + set.put(uni('+', cos(none)), Action::IncreaseFontSize(1.0)); + set.put(uni('-', cos(none)), Action::DecreaseFontSize(1.0)); + set.put(uni('0', cos(none)), Action::ResetFontSize); set.put( uni('j', mods(true, true, false, true)), Action::WriteScreenFile(WriteScreen::COPY), ); set.put( - uni('j', ctrl_or_super(mods(false, true, false, false))), + uni('j', cos(mods(false, true, false, false))), Action::WriteScreenFile(WriteScreen::PASTE), ); set.put( - uni('j', ctrl_or_super(mods(false, true, true, false))), + uni('j', cos(mods(false, true, true, false))), Action::WriteScreenFile(WriteScreen::OPEN), ); @@ -181,92 +197,94 @@ impl Keybinds { } // Tabs common to all platforms + let ctrl = mods(true, false, false, false); set.put(phys(Key::Tab, cs), Action::PreviousTab); - set.put( - phys(Key::Tab, mods(true, false, false, false)), - Action::NextTab, - ); + set.put(phys(Key::Tab, ctrl), Action::NextTab); // Windowing - set.put(uni('n', cs), Action::NewWindow); - set.put(uni('w', cs), Action::CloseSurface); - set.put(uni('q', cs), Action::Quit); - set.put( - phys(Key::F4, mods(false, false, true, false)), - Action::CloseWindow, - ); - set.put(uni('t', cs), Action::NewTab); - set.put(uni('w', cs), Action::CloseTab(CloseTabMode::This)); - set.put_flags(phys(Key::ArrowLeft, cs), Action::PreviousTab, performable()); - set.put_flags(phys(Key::ArrowRight, cs), Action::NextTab, performable()); - let ctrl = mods(true, false, false, false); - set.put_flags(phys(Key::PageUp, ctrl), Action::PreviousTab, performable()); - set.put_flags(phys(Key::PageDown, ctrl), Action::NextTab, performable()); - set.put(uni('o', cs), Action::NewSplit(SplitDirection::Right)); - set.put(uni('e', cs), Action::NewSplit(SplitDirection::Down)); - let ctrl_super = mods(true, false, false, true); - set.put_flags( - uni('[', ctrl_super), - Action::GotoSplit(SplitFocusDirection::Previous), - performable(), - ); - set.put_flags( - uni(']', ctrl_super), - Action::GotoSplit(SplitFocusDirection::Next), - performable(), - ); - let ctrl_alt = mods(true, false, true, false); - for (k, d) in [ - (Key::ArrowUp, SplitFocusDirection::Up), - (Key::ArrowDown, SplitFocusDirection::Down), - (Key::ArrowLeft, SplitFocusDirection::Left), - (Key::ArrowRight, SplitFocusDirection::Right), - ] { - set.put_flags(phys(k, ctrl_alt), Action::GotoSplit(d), performable()); - } - - // Resizing splits - let super_ctrl_shift = mods(true, true, false, true); - for (k, d) in [ - (Key::ArrowUp, SplitResizeDirection::Up), - (Key::ArrowDown, SplitResizeDirection::Down), - (Key::ArrowLeft, SplitResizeDirection::Left), - (Key::ArrowRight, SplitResizeDirection::Right), - ] { + if !darwin { + set.put(uni('n', cs), Action::NewWindow); + set.put(uni('w', cs), Action::CloseSurface); + set.put(uni('q', cs), Action::Quit); + set.put( + phys(Key::F4, mods(false, false, true, false)), + Action::CloseWindow, + ); + set.put(uni('t', cs), Action::NewTab); + set.put(uni('w', cs), Action::CloseTab(CloseTabMode::This)); + set.put_flags(phys(Key::ArrowLeft, cs), Action::PreviousTab, performable()); + set.put_flags(phys(Key::ArrowRight, cs), Action::NextTab, performable()); + set.put_flags(phys(Key::PageUp, ctrl), Action::PreviousTab, performable()); + set.put_flags(phys(Key::PageDown, ctrl), Action::NextTab, performable()); + set.put(uni('o', cs), Action::NewSplit(SplitDirection::Right)); + set.put(uni('e', cs), Action::NewSplit(SplitDirection::Down)); + let ctrl_super = mods(true, false, false, true); set.put_flags( - phys(k, super_ctrl_shift), - Action::ResizeSplit(SplitResizeParameter(d, 10)), + uni('[', ctrl_super), + Action::GotoSplit(SplitFocusDirection::Previous), performable(), ); + set.put_flags( + uni(']', ctrl_super), + Action::GotoSplit(SplitFocusDirection::Next), + performable(), + ); + let ctrl_alt = mods(true, false, true, false); + for (k, d) in [ + (Key::ArrowUp, SplitFocusDirection::Up), + (Key::ArrowDown, SplitFocusDirection::Down), + (Key::ArrowLeft, SplitFocusDirection::Left), + (Key::ArrowRight, SplitFocusDirection::Right), + ] { + set.put_flags(phys(k, ctrl_alt), Action::GotoSplit(d), performable()); + } + + // Resizing splits + let super_ctrl_shift = mods(true, true, false, true); + for (k, d) in [ + (Key::ArrowUp, SplitResizeDirection::Up), + (Key::ArrowDown, SplitResizeDirection::Down), + (Key::ArrowLeft, SplitResizeDirection::Left), + (Key::ArrowRight, SplitResizeDirection::Right), + ] { + set.put_flags( + phys(k, super_ctrl_shift), + Action::ResizeSplit(SplitResizeParameter(d, 10)), + performable(), + ); + } + + // Viewport scrolling + set.put(phys(Key::Home, shift), Action::ScrollToTop); + set.put(phys(Key::End, shift), Action::ScrollToBottom); + set.put(phys(Key::PageUp, shift), Action::ScrollPageUp); + set.put(phys(Key::PageDown, shift), Action::ScrollPageDown); + + // Semantic prompts + set.put(phys(Key::PageUp, cs), Action::JumpToPrompt(-1)); + set.put(phys(Key::PageDown, cs), Action::JumpToPrompt(1)); + + // Search + set.put_flags(uni('f', cs), Action::StartSearch, performable()); + set.put_flags(phys(Key::Escape, none), Action::EndSearch, performable()); + + // Inspector, matching Chromium + set.put(uni('i', cs), Action::Inspector(InspectorMode::Toggle)); + + // Terminal + set.put(uni('a', cs), Action::SelectAll); + + // Selection clipboard paste + set.put(phys(Key::Insert, shift), Action::PasteFromSelection); } - - // Viewport scrolling - set.put(phys(Key::Home, shift), Action::ScrollToTop); - set.put(phys(Key::End, shift), Action::ScrollToBottom); - set.put(phys(Key::PageUp, shift), Action::ScrollPageUp); - set.put(phys(Key::PageDown, shift), Action::ScrollPageDown); - - // Semantic prompts - set.put(phys(Key::PageUp, cs), Action::JumpToPrompt(-1)); - set.put(phys(Key::PageDown, cs), Action::JumpToPrompt(1)); - - // Search - set.put_flags(uni('f', cs), Action::StartSearch, performable()); - set.put_flags(phys(Key::Escape, none), Action::EndSearch, performable()); - - // Inspector, matching Chromium - set.put(uni('i', cs), Action::Inspector(InspectorMode::Toggle)); - - // Terminal - set.put(uni('a', cs), Action::SelectAll); - - // Selection clipboard paste - set.put(phys(Key::Insert, shift), Action::PasteFromSelection); - { // On macOS we default to super but everywhere else // is alt. - let m = mods(false, false, true, false); + let m = if darwin { + mods(false, false, false, true) + } else { + mods(false, false, true, false) + }; // Cmd/Alt+N for goto tab N let digits = [ @@ -279,6 +297,14 @@ impl Keybinds { Key::Digit7, Key::Digit8, ]; + // On macOS we keep these not performable so that the keyboard + // shortcuts in tabs work. In the future the correct fix is to + // fix the reverse mapping lookup to allow us to lookup + // performable keybinds conditionally. + let tab_flags = Flags { + performable: !darwin, + ..Flags::default() + }; for (i, key) in digits.into_iter().enumerate() { let n = i + 1; let c = char::from(b'0' + n as u8); @@ -287,30 +313,149 @@ impl Keybinds { // this shortcut. Namely, AZERTY doesn't produce unicode `N` // for their digit keys (they're on shifted keys on the same // physical keys). - set.put_flags(phys(key, m), Action::GotoTab(n), performable()); + set.put_flags(phys(key, m), Action::GotoTab(n), tab_flags); // Important: this must be the LAST binding set so that the // libghostty trigger API returns this one for the action, // so that things like the macOS tab bar key equivalent label // work properly. - set.put_flags(uni(c, m), Action::GotoTab(n), performable()); + set.put_flags(uni(c, m), Action::GotoTab(n), tab_flags); } - set.put_flags(uni('9', m), Action::LastTab, performable()); + // See comment above with the numeric goto_tab + set.put_flags(uni('9', m), Action::LastTab, tab_flags); } // Toggle fullscreen - set.put( - phys(Key::Enter, ctrl_or_super(none)), - Action::ToggleFullscreen, - ); + set.put(phys(Key::Enter, cos(none)), Action::ToggleFullscreen); // Toggle zoom a split - set.put( - phys(Key::Enter, ctrl_or_super(shift)), - Action::ToggleSplitZoom, - ); + set.put(phys(Key::Enter, cos(shift)), Action::ToggleSplitZoom); // Toggle command palette, matches VSCode - set.put(uni('p', ctrl_or_super(shift)), Action::ToggleCommandPalette); + set.put(uni('p', cos(shift)), Action::ToggleCommandPalette); + + // Mac-specific keyboard bindings. + if darwin { + Self::init_mac_bindings(set); + } + } + + /// The Mac-specific default keybinds (tail of Zig `Keybinds.init`). + #[allow(clippy::too_many_lines)] + fn init_mac_bindings(set: &mut Set) { + let sup = mods(false, false, false, true); + let sup_shift = mods(false, true, false, true); + let sup_alt = mods(false, false, true, true); + let sup_ctrl = mods(true, false, false, true); + + set.put(uni('q', sup), Action::Quit); + set.put_flags(uni('k', sup), Action::ClearScreen, performable()); + set.put(uni('a', sup), Action::SelectAll); + + // Undo/redo + set.put_flags(uni('t', sup_shift), Action::Undo, performable()); + set.put_flags(uni('z', sup), Action::Undo, performable()); + set.put_flags(uni('z', sup_shift), Action::Redo, performable()); + + // Viewport scrolling + set.put(phys(Key::Home, sup), Action::ScrollToTop); + set.put(phys(Key::End, sup), Action::ScrollToBottom); + set.put(phys(Key::PageUp, sup), Action::ScrollPageUp); + set.put(phys(Key::PageDown, sup), Action::ScrollPageDown); + set.put_flags(uni('j', sup), Action::ScrollToSelection, performable()); + + // Semantic prompts + set.put(phys(Key::ArrowUp, sup_shift), Action::JumpToPrompt(-1)); + set.put(phys(Key::ArrowDown, sup_shift), Action::JumpToPrompt(1)); + + // Mac windowing + set.put(uni('n', sup), Action::NewWindow); + set.put(uni('w', sup), Action::CloseSurface); + set.put(uni('w', sup_alt), Action::CloseTab(CloseTabMode::This)); + set.put(uni('w', sup_shift), Action::CloseWindow); + set.put( + uni('w', mods(false, true, true, true)), + Action::CloseAllWindows, + ); + set.put(uni('t', sup), Action::NewTab); + set.put(uni('[', sup_shift), Action::PreviousTab); + set.put(uni(']', sup_shift), Action::NextTab); + set.put(uni('d', sup), Action::NewSplit(SplitDirection::Right)); + set.put(uni('d', sup_shift), Action::NewSplit(SplitDirection::Down)); + set.put( + uni('[', sup), + Action::GotoSplit(SplitFocusDirection::Previous), + ); + set.put(uni(']', sup), Action::GotoSplit(SplitFocusDirection::Next)); + for (k, d) in [ + (Key::ArrowUp, SplitFocusDirection::Up), + (Key::ArrowDown, SplitFocusDirection::Down), + (Key::ArrowLeft, SplitFocusDirection::Left), + (Key::ArrowRight, SplitFocusDirection::Right), + ] { + set.put(phys(k, sup_alt), Action::GotoSplit(d)); + } + for (k, d) in [ + (Key::ArrowUp, SplitResizeDirection::Up), + (Key::ArrowDown, SplitResizeDirection::Down), + (Key::ArrowLeft, SplitResizeDirection::Left), + (Key::ArrowRight, SplitResizeDirection::Right), + ] { + set.put( + phys(k, sup_ctrl), + Action::ResizeSplit(SplitResizeParameter(d, 10)), + ); + } + set.put(uni('=', sup_ctrl), Action::EqualizeSplits); + + // Jump to prompt, matches Terminal.app + set.put(phys(Key::ArrowUp, sup), Action::JumpToPrompt(-1)); + set.put(phys(Key::ArrowDown, sup), Action::JumpToPrompt(1)); + + // Search + set.put_flags(uni('f', sup), Action::StartSearch, performable()); + set.put_flags(uni('e', sup), Action::SearchSelection, performable()); + set.put_flags(uni('f', sup_shift), Action::EndSearch, performable()); + set.put_flags( + phys(Key::Escape, Mods::default()), + Action::EndSearch, + performable(), + ); + set.put_flags( + uni('g', sup), + Action::NavigateSearch(NavigateSearch::Next), + performable(), + ); + set.put_flags( + uni('g', sup_shift), + Action::NavigateSearch(NavigateSearch::Previous), + performable(), + ); + + // Inspector, matching Chromium + set.put(uni('i', sup_alt), Action::Inspector(InspectorMode::Toggle)); + + // Alternate keybind, common to Mac programs + set.put(uni('f', sup_ctrl), Action::ToggleFullscreen); + + // Selection clipboard paste, matches Terminal.app + set.put(uni('v', sup_shift), Action::PasteFromSelection); + + // "Natural text editing" keybinds. This forces these keys to go back + // to legacy encoding (not fixterms). It seems macOS users more than + // others are used to these keys so we set them as defaults. If + // people want to get back to the fixterm encoding they can set + // the keybinds to `unbind`. + set.put(phys(Key::ArrowRight, sup), Action::Text("\\x05".to_owned())); + set.put(phys(Key::ArrowLeft, sup), Action::Text("\\x01".to_owned())); + set.put(phys(Key::Backspace, sup), Action::Text("\\x15".to_owned())); + set.put( + phys(Key::ArrowLeft, mods(false, false, true, false)), + Action::Esc("b".to_owned()), + ); + set.put( + phys(Key::ArrowRight, mods(false, false, true, false)), + Action::Esc("f".to_owned()), + ); } fn table_index(&self, name: &str) -> Option { @@ -850,4 +995,75 @@ mod tests { } assert_eq!(kb.set.bindings.len(), again.set.bindings.len()); } + + fn lookup(kb: &Keybinds, trigger: Trigger) -> Option<&Value> { + kb.set.get(trigger) + } + + #[test] + fn default_keybinds_darwin_use_super() { + let mut kb = Keybinds::default(); + kb.init_for(true); + let sup = mods(false, false, false, true); + + // cmd+c copies (performable), cmd+q quits, cmd+t opens a tab. + for (c, want) in [ + ('c', Action::CopyToClipboard(CopyToClipboard::Mixed)), + ('q', Action::Quit), + ('t', Action::NewTab), + ('1', Action::GotoTab(1)), + ] { + let Some(Value::Leaf(l)) = lookup(&kb, uni(c, sup)) else { + panic!("missing super+{c}"); + }; + assert_eq!(l.action, want); + } + + // tab shortcuts are NOT performable on macOS. + let Some(Value::Leaf(l)) = lookup(&kb, uni('1', sup)) else { + panic!() + }; + assert!(!l.flags.performable); + let Some(Value::Leaf(l)) = lookup(&kb, uni('c', sup)) else { + panic!() + }; + assert!(l.flags.performable); + + // No ctrl+shift+n window binding on macOS, no ctrl+insert copy. + assert!(lookup(&kb, uni('n', mods(true, true, false, false))).is_none()); + assert!(lookup(&kb, phys(Key::Insert, mods(true, false, false, false))).is_none()); + + // Natural text editing. + let Some(Value::Leaf(l)) = lookup(&kb, phys(Key::ArrowLeft, sup)) else { + panic!() + }; + assert_eq!(l.action, Action::Text("\\x01".to_owned())); + + // ctrlOrSuper picks super: super+comma opens the config. + let Some(Value::Leaf(l)) = lookup(&kb, uni(',', sup)) else { + panic!() + }; + assert_eq!(l.action, Action::OpenConfig); + + // Formatted defaults re-parse to the same number of bindings. + let out = fmt_kb("keybind", &kb); + let mut again = Keybinds::default(); + for line in out.lines() { + let v = line.strip_prefix("keybind = ").unwrap(); + again.parse_cli(Some(v)).unwrap(); + } + assert_eq!(kb.set.bindings.len(), again.set.bindings.len()); + } + + #[test] + fn default_keybinds_non_darwin_use_ctrl_shift() { + let mut kb = Keybinds::default(); + kb.init_for(false); + let cs = mods(true, true, false, false); + let Some(Value::Leaf(l)) = lookup(&kb, uni('c', cs)) else { + panic!() + }; + assert_eq!(l.action, Action::CopyToClipboard(CopyToClipboard::Mixed)); + assert!(lookup(&kb, uni('q', mods(false, false, false, true))).is_none()); + } } diff --git a/crates/ghostty-config/src/path.rs b/crates/ghostty-config/src/path.rs index b780038..7e1ce2d 100644 --- a/crates/ghostty-config/src/path.rs +++ b/crates/ghostty-config/src/path.rs @@ -120,7 +120,8 @@ impl Path { // Check if the path starts with a tilde and expand it to the // home directory. We explicitly look for "~/" because we don't // support alternate users such as "~alice/" - if path.starts_with("~/") { + // (Windows isn't supported yet: Zig skips this there.) + if !cfg!(windows) && path.starts_with("~/") { match ghostty_os::expand_home(&path) { Ok(expanded) => { log::debug!("expanding file path from home directory: path={expanded}"); diff --git a/crates/ghostty-core/Cargo.toml b/crates/ghostty-core/Cargo.toml index 8a45cac..098637c 100644 --- a/crates/ghostty-core/Cargo.toml +++ b/crates/ghostty-core/Cargo.toml @@ -9,6 +9,7 @@ base64 = "0.23.1" crossbeam-channel = "0.5.17" ghostty-apprt.workspace = true ghostty-config.workspace = true +ghostty-crash.workspace = true ghostty-datastruct.workspace = true ghostty-font.workspace = true ghostty-inspector.workspace = true @@ -26,3 +27,6 @@ thiserror = "2" [lints] workspace = true + +[target.'cfg(target_vendor = "apple")'.dependencies] +oslog = { version = "0.2.0", default-features = false } diff --git a/crates/ghostty-core/src/global.rs b/crates/ghostty-core/src/global.rs index 55e59e1..596fa81 100644 --- a/crates/ghostty-core/src/global.rs +++ b/crates/ghostty-core/src/global.rs @@ -15,11 +15,18 @@ use ghostty_os::{file::Rlimit, resourcesdir::ResourcesDir}; pub struct Logging { /// Whether to log to stderr. pub stderr: bool, + + /// Whether to log to macOS's unified logging. Enabled by default on + /// macOS. + pub macos: bool, } impl Default for Logging { fn default() -> Self { - Logging { stderr: true } + Logging { + stderr: true, + macos: cfg!(target_vendor = "apple"), + } } } @@ -32,8 +39,18 @@ impl Logging { #[must_use] pub fn parse(v: &str) -> Logging { match v { - "1" | "t" | "T" | "true" => return Logging { stderr: true }, - "0" | "f" | "F" | "false" => return Logging { stderr: false }, + "1" | "t" | "T" | "true" => { + return Logging { + stderr: true, + macos: true, + }; + } + "0" | "f" | "F" | "false" => { + return Logging { + stderr: false, + macos: false, + }; + } _ => {} } let mut out = Logging::default(); @@ -45,8 +62,7 @@ impl Logging { }; match name { "stderr" => out.stderr = value, - // The only other field (macOS unified logging) is a no-op. - "macos" => {} + "macos" => out.macos = value, _ => return Logging::default(), } } @@ -141,6 +157,14 @@ pub fn init(opts: InitOptions) -> &'static GlobalState { // As early as possible, initialize our resource limits. let rlimits = ResourceLimits::init(); + // Initialize our crash reporting. + if let Err(err) = ghostty_crash::init(&ghostty_crash::Options { + release: ghostty_termio::stream_handler::VERSION_STRING.to_owned(), + ..ghostty_crash::Options::default() + }) { + log::warn!("sentry init failed, no crash capture available err={err}"); + } + // Find our resources directory once for the app so every launch // hereafter can use this cached value. let discover = opts @@ -173,12 +197,60 @@ pub fn init(opts: InitOptions) -> &'static GlobalState { /// `info(scope): message` on stderr. struct Logger { stderr: bool, + /// macOS unified logging (`os_log`), only effective on Apple targets. + macos: bool, max: log::LevelFilter, } +/// The scope shown in log output: the crate name without the `ghostty_` +/// prefix (Zig scopes are `@tagName(scope)`). +fn log_scope(target: &str) -> &str { + let target = target.strip_prefix("ghostty_").unwrap_or(target); + target.split("::").next().unwrap_or(target) +} + +/// The prefix used for macOS unified logging messages: nothing for the +/// default scope, `scope: ` otherwise. +#[cfg_attr(not(any(target_vendor = "apple", test)), allow(dead_code))] +fn macos_message(scope: &str, args: &std::fmt::Arguments<'_>) -> String { + if scope.is_empty() || scope == "default" { + format!("{args}") + } else { + format!("{scope}: {args}") + } +} + +/// Bundle id used as the unified logging subsystem +/// (`build_config.bundle_id`). +#[cfg(target_vendor = "apple")] +const BUNDLE_ID: &str = ghostty_os::i18n::BUNDLE_ID; + +#[cfg(target_vendor = "apple")] +fn log_macos(record: &log::Record<'_>) { + // On Mac, we use unified logging. To view this: + // + // sudo log stream --level debug --predicate 'subsystem=="com.mitchellh.ghostty"' + // + // macOS logging is thread safe so no need for locks/mutexes. + // + // Convert our levels to Mac levels. + let level = match record.level() { + log::Level::Debug | log::Level::Trace => oslog::Level::Debug, + log::Level::Info => oslog::Level::Info, + log::Level::Warn => oslog::Level::Error, + log::Level::Error => oslog::Level::Fault, + }; + let scope = log_scope(record.target()); + + // Initialize a logger. This is slow to do on every operation but we + // shouldn't be logging too much. + let logger = oslog::OsLog::new(BUNDLE_ID, scope); + logger.with_level(level, &macos_message(scope, record.args())); +} + impl log::Log for Logger { fn enabled(&self, metadata: &log::Metadata<'_>) -> bool { - self.stderr && metadata.level() <= self.max + (self.stderr || self.macos) && metadata.level() <= self.max } fn log(&self, record: &log::Record<'_>) { @@ -186,19 +258,23 @@ impl log::Log for Logger { return; } + #[cfg(target_vendor = "apple")] + if self.macos { + log_macos(record); + } + + // skip if we are not logging to stderr + if !self.stderr { + return; + } + let level = match record.level() { log::Level::Error => "error", log::Level::Warn => "warning", log::Level::Info => "info", log::Level::Debug | log::Level::Trace => "debug", }; - let target = record.target(); - let scope = target - .strip_prefix("ghostty_") - .unwrap_or(target) - .split("::") - .next() - .unwrap_or(target); + let scope = log_scope(record.target()); let _ = writeln!( std::io::stderr().lock(), "{level}({scope}): {}", @@ -223,6 +299,7 @@ fn init_logger(logging: Logging) { }; let logger = Box::new(Logger { stderr: logging.stderr, + macos: logging.macos, max, }); if log::set_boxed_logger(logger).is_ok() { @@ -230,16 +307,37 @@ fn init_logger(logging: Logging) { } } +/// Cleans up the global state. This doesn't _need_ to be called (Zig: +/// `GlobalState.deinit`); it deactivates crash capture ("flush crash logs"). +pub fn deinit() { + ghostty_crash::deinit(); +} + #[cfg(test)] mod tests { use super::*; + #[test] + fn macos_log_message_prefix() { + assert_eq!( + macos_message("font", &format_args!("hi {}", 1)), + "font: hi 1" + ); + assert_eq!(macos_message("default", &format_args!("hi")), "hi"); + assert_eq!(log_scope("ghostty_font::collection"), "font"); + assert_eq!(log_scope("other::x"), "other"); + } + #[test] fn logging_parse() { assert!(Logging::parse("stderr").stderr); assert!(!Logging::parse("no-stderr").stderr); // Unmentioned fields keep their default (stderr on). assert!(Logging::parse("macos").stderr); + assert!(!Logging::parse("no-macos").macos); + assert!(Logging::parse("macos").macos); + assert!(Logging::parse("true").macos); + assert!(!Logging::parse("0").macos); assert!(!Logging::parse("false").stderr); assert!(!Logging::parse("0").stderr); assert!(Logging::parse("true").stderr); diff --git a/crates/ghostty-core/src/surface/clipboard.rs b/crates/ghostty-core/src/surface/clipboard.rs index 38687d0..c1c77d0 100644 --- a/crates/ghostty-core/src/surface/clipboard.rs +++ b/crates/ghostty-core/src/surface/clipboard.rs @@ -390,6 +390,7 @@ impl Surface { /// `textCallback`: text input that is pasted verbatim. pub fn text_callback(&self, text: &str) -> Result<(), ClipboardError> { + let _crash = self.crash_guard(); self.complete_clipboard_paste(text.as_bytes(), true) } } diff --git a/crates/ghostty-core/src/surface/keyboard.rs b/crates/ghostty-core/src/surface/keyboard.rs index 980eb77..d6101bd 100644 --- a/crates/ghostty-core/src/surface/keyboard.rs +++ b/crates/ghostty-core/src/surface/keyboard.rs @@ -906,6 +906,33 @@ impl Surface { } } + /// Build the key encoding options (Zig: `encodeKeyOpts`). On macOS the + /// `macos-option-as-alt` value comes from the config, or is detected from + /// the keyboard layout when unset. + fn encode_key_opts(&self) -> key_encode::Options { + let mut opts = { + let t = self.lock_terminal(); + encode_options_from_terminal(&t) + }; + if !cfg!(target_os = "macos") { + return opts; + } + + opts.macos_option_as_alt = self.cfg().macos_option_as_alt.unwrap_or_else(|| { + // If we don't have alt pressed, it doesn't matter what this + // config is so we can just say "false" and avoid more expensive + // checks below. + if !self.mouse.borrow().mods.alt { + return ghostty_input::OptionAsAlt::False; + } + + // Alt is pressed, we're on macOS. We break some encapsulation + // here and assume libghostty for ease... + self.rt_app.keyboard_layout().detect_option_as_alt() + }); + opts + } + /// Encodes the key event into a write request. Returns `None` if /// nothing was encoded. fn encode_key( @@ -914,10 +941,7 @@ impl Surface { insp_ev: Option<&mut inspector::KeyEvent>, ) -> Option { // Build our encoding options, which requires the lock. - let opts = { - let t = self.lock_terminal(); - encode_options_from_terminal(&t) - }; + let opts = self.encode_key_opts(); let mut data: Vec = Vec::with_capacity(64); if let Err(err) = key_encode::encode(&mut data, event, &opts) { diff --git a/crates/ghostty-core/src/surface/mod.rs b/crates/ghostty-core/src/surface/mod.rs index 7662959..2f75a07 100644 --- a/crates/ghostty-core/src/surface/mod.rs +++ b/crates/ghostty-core/src/surface/mod.rs @@ -90,8 +90,11 @@ use ghostty_font::{ use ghostty_input::{Mods, MouseButton, MouseButtonState, ScrollMods}; use ghostty_renderer::{ Health, - Renderer, - generic::Host as RendererHost, + // The generic renderer over the OpenGL API (the GTK runtime's graphics + // API). `ghostty_renderer::Renderer` is the platform default, which is + // Metal on Apple targets; the embedded (macOS) runtime provides its own + // Metal-based surface setup (TODO(integration): L1). + generic::{Host as RendererHost, Renderer}, message::{FontGridChange, Message as RendererMessage}, opengl::OpenGL, options::Options as RendererOptions, @@ -258,6 +261,9 @@ pub struct Surface { /// All our sizing information. pub(crate) size: Cell, + /// The dimensions recorded in crash reports (see `ghostty_crash`). + crash_dims: Arc, + /// The configuration derived from the main config. config: RefCell>, @@ -468,7 +474,7 @@ impl Surface { let renderer_state = Arc::clone(termio.renderer_state()); // Create the renderer thread - let render_thread = ghostty_renderer::Thread::new( + let mut render_thread = ghostty_renderer::Thread::new( &config, Arc::clone(&renderer), Arc::clone(&renderer_state), @@ -478,9 +484,14 @@ impl Surface { let _ = renderer_link.handle.set(renderer_handle.clone()); // Create the IO thread - let io_thread = IoThread::new()?; + let mut io_thread = IoThread::new()?; let io_stop = io_thread.stop_handle(); + // Crash context shared with the renderer and IO threads. + let crash_dims = ghostty_crash::SurfaceDims::new(); + render_thread.crash_dims = Some(Arc::clone(&crash_dims)); + io_thread.crash_dims = Some(Arc::clone(&crash_dims)); + let surface = Rc::new(Surface { id, app: Rc::clone(&app), @@ -504,6 +515,7 @@ impl Surface { keyboard: RefCell::new(keyboard::Keyboard::default()), pressed_key: RefCell::new(None), size: Cell::new(size), + crash_dims, config: RefCell::new(derived_config), config_conditional_state: Cell::new(app.config_conditional_state.get()), child_exited: Cell::new(false), @@ -971,8 +983,10 @@ impl Surface { // If our runtime was below some threshold then we assume that this // was an abnormal exit and we show an error message. if info.runtime_ms <= u64::from(self.cfg().abnormal_command_exit_runtime_ms) { - // If the exit code is 0 then it was a good exit. - if info.exit_code != 0 { + // On macOS, our exit code detection doesn't work, possibly + // because of our `login` wrapper. More investigation required. + // Elsewhere, if the exit code is 0 then it was a good exit. + if is_abnormal_exit(info.exit_code, cfg!(target_vendor = "apple")) { log::warn!("abnormal process exit detected, showing error message"); // Try and show a GUI message. If it returns true, don't do @@ -1084,13 +1098,17 @@ impl Surface { t.print_string(&runtime_str)?; t.set_attribute(Attribute::Unset)?; - let exit_code_str = info.exit_code.to_string(); - t.carriage_return(); - t.linefeed()?; - t.print_string("Exit Code: ")?; - t.set_attribute(Attribute::Fg8(ghostty_vt::color::Name::RED))?; - t.print_string(&exit_code_str)?; - t.set_attribute(Attribute::Unset)?; + // We don't print this on macOS because the exit code is always 0 + // due to the way we launch the process. + if !cfg!(target_vendor = "apple") { + let exit_code_str = info.exit_code.to_string(); + t.carriage_return(); + t.linefeed()?; + t.print_string("Exit Code: ")?; + t.set_attribute(Attribute::Fg8(ghostty_vt::color::Name::RED))?; + t.print_string(&exit_code_str)?; + t.set_attribute(Attribute::Unset)?; + } t.carriage_return(); t.linefeed()?; @@ -1436,6 +1454,31 @@ impl Surface { // ------------------------------------------------------------------ // Fonts and sizes + /// Publish the current size for crash reports (Zig reads the surface + /// size at crash time). + fn sync_crash_dims(&self) { + let s = self.size.get(); + let g = s.grid(); + self.crash_dims.set(ghostty_crash::DimsSnapshot { + screen_width: s.screen.width, + screen_height: s.screen.height, + grid_columns: u32::from(g.columns), + grid_rows: u32::from(g.rows), + cell_width: s.cell.width, + cell_height: s.cell.height, + }); + } + + /// Crash context for the main thread while inside a surface callback + /// (Zig: `crashThreadState`). + pub(crate) fn crash_guard(&self) -> ghostty_crash::ThreadGuard { + self.sync_crash_dims(); + ghostty_crash::ThreadGuard::new( + ghostty_crash::ThreadType::Main, + Some(Arc::clone(&self.crash_dims)), + ) + } + /// Change the cell size for the terminal grid. This can happen as a /// result of changing the font size at runtime. fn set_cell_size(&self, size: CellSize) { @@ -1444,6 +1487,7 @@ impl Surface { s.cell = size; self.size.set(s); self.balance_padding_if_needed(); + self.sync_crash_dims(); // Notify the terminal self.queue_io(IoMessage::Resize(self.size.get())); @@ -1545,6 +1589,8 @@ impl Surface { ); } + self.sync_crash_dims(); + // Mail the IO thread self.queue_io(IoMessage::Resize(s)); } @@ -1624,6 +1670,7 @@ impl Surface { /// Callback for when the surface is fully visible or not, regardless of /// focus state. pub fn occlusion_callback(&self, visible: bool) -> Result<(), CoreError> { + let _crash = self.crash_guard(); self.renderer_handle.send(RendererMessage::Visible(visible)); self.queue_render(); Ok(()) @@ -1666,6 +1713,7 @@ impl Surface { /// Schedule a render (Zig: `refreshCallback`). pub fn refresh_callback(&self) -> Result<(), CoreError> { + let _crash = self.crash_guard(); // The point of this callback is to schedule a render, so do that. self.queue_render(); Ok(()) @@ -1837,6 +1885,13 @@ impl Drop for Surface { /// (`rt_surface.defaultTermioEnv()`, which already removed the variables it /// does not want to leak); only if it provides nothing (the Zig error path) /// do we fall back to the process environment (`getEnvMap`). +/// Whether a quick child exit counts as abnormal. On Darwin our exit code +/// detection doesn't work (the `login` wrapper), so every quick exit is +/// treated as abnormal; elsewhere only non-zero exit codes are. +fn is_abnormal_exit(exit_code: u32, darwin: bool) -> bool { + darwin || exit_code != 0 +} + fn rt_surface_env(rt: &dyn RtSurface) -> ghostty_os::EnvMap { let rt_env = rt.default_termio_env(); if rt_env.is_empty() { @@ -1854,14 +1909,17 @@ impl CoreSurface for Surface { } fn key_callback(&self, event: ghostty_input::KeyEvent<'_>) -> Result { + let _crash = self.crash_guard(); Surface::key_callback(self, event) } fn preedit_callback(&self, text: Option<&str>) -> Result<(), CoreError> { + let _crash = self.crash_guard(); Surface::preedit_callback(self, text) } fn focus_callback(&self, focused: bool) -> Result<(), CoreError> { + let _crash = self.crash_guard(); Surface::focus_callback(self, focused) } @@ -1871,26 +1929,32 @@ impl CoreSurface for Surface { button: MouseButton, mods: Mods, ) -> Result { + let _crash = self.crash_guard(); Surface::mouse_button_callback(self, state, button, mods) } fn cursor_pos_callback(&self, pos: CursorPos, mods: Option) -> Result<(), CoreError> { + let _crash = self.crash_guard(); Surface::cursor_pos_callback(self, pos, mods) } fn scroll_callback(&self, xoff: f64, yoff: f64, mods: ScrollMods) -> Result<(), CoreError> { + let _crash = self.crash_guard(); Surface::scroll_callback(self, xoff, yoff, mods) } fn mouse_pressure_callback(&self, stage: ghostty_input::MousePressureStage, pressure: f64) { + let _crash = self.crash_guard(); Surface::mouse_pressure_callback(self, stage, pressure); } fn size_callback(&self, size: SurfaceSize) -> Result<(), CoreError> { + let _crash = self.crash_guard(); Surface::size_callback(self, size) } fn content_scale_callback(&self, scale: ContentScale) -> Result<(), CoreError> { + let _crash = self.crash_guard(); Surface::content_scale_callback(self, scale) } @@ -1903,6 +1967,7 @@ impl CoreSurface for Surface { } fn color_scheme_callback(&self, scheme: ColorScheme) -> Result<(), CoreError> { + let _crash = self.crash_guard(); Surface::color_scheme_callback(self, scheme) } @@ -2012,3 +2077,16 @@ impl CoreSurface for Surface { Surface::render_inspector(self, ui); } } + +#[cfg(test)] +mod darwin_tests { + use super::*; + + #[test] + fn abnormal_exit_detection_per_platform() { + assert!(!is_abnormal_exit(0, false)); + assert!(is_abnormal_exit(1, false)); + assert!(is_abnormal_exit(0, true)); + assert!(is_abnormal_exit(1, true)); + } +} diff --git a/crates/ghostty-core/src/surface_mouse.rs b/crates/ghostty-core/src/surface_mouse.rs index b08ae45..451dc6a 100644 --- a/crates/ghostty-core/src/surface_mouse.rs +++ b/crates/ghostty-core/src/surface_mouse.rs @@ -103,13 +103,43 @@ fn is_mouse_mode_override_state(mods: Mods) -> bool { /// cause a rectangle select. #[must_use] pub fn is_rectangle_select_state(mods: Mods) -> bool { - mods.ctrl_or_super() && mods.alt + is_rectangle_select_state_for(mods, ghostty_input::platform::is_darwin()) +} + +/// [`is_rectangle_select_state`] for an explicit platform (Zig picks with +/// `builtin.target.os.tag.isDarwin()` at comptime): on Darwin option alone +/// selects a rectangle, elsewhere it is ctrl+alt. +#[must_use] +pub fn is_rectangle_select_state_for(mods: Mods, darwin: bool) -> bool { + if darwin { + mods.alt + } else { + mods.ctrl_or_super() && mods.alt + } } #[cfg(test)] mod tests { use super::*; + #[test] + fn rectangle_select_state_per_platform() { + let alt = Mods { + alt: true, + ..Mods::default() + }; + let ctrl_alt = Mods { + alt: true, + ctrl: true, + ..Mods::default() + }; + // macOS: alt alone. + assert!(is_rectangle_select_state_for(alt, true)); + // Elsewhere: ctrl+alt. + assert!(!is_rectangle_select_state_for(alt, false)); + assert!(is_rectangle_select_state_for(ctrl_alt, false)); + } + fn m( physical_key: Key, mouse_event: MouseEvents, diff --git a/crates/ghostty-font/Cargo.toml b/crates/ghostty-font/Cargo.toml index 1aa80df..ec69a43 100644 --- a/crates/ghostty-font/Cargo.toml +++ b/crates/ghostty-font/Cargo.toml @@ -18,14 +18,50 @@ ghostty-terminal.workspace = true # The C font libraries are unavailable on wasm32, where the web_canvas # backend (browser Canvas API through JS imports) is used instead # (Zig: `font/backend.zig` `Backend.default`). -[target.'cfg(not(target_arch = "wasm32"))'.dependencies] +[target.'cfg(not(any(target_arch = "wasm32", target_vendor = "apple")))'.dependencies] +yeslogic-fontconfig-sys = "6.0.1" + +[target.'cfg(not(any(target_arch = "wasm32", target_vendor = "apple", windows)))'.dependencies] freetype-sys = "0.23.0" harfbuzz-sys = "0.8.0" -yeslogic-fontconfig-sys = "6.0.1" + +# Windows has no system freetype/harfbuzz to find via pkg-config (Zig builds +# them from source): build them from the bundled sources. Fontconfig (the +# default Zig backend there too, also built from source) must be provided +# by the Windows toolchain (vcpkg / MSYS2) when linking. +[target.'cfg(windows)'.dependencies] +freetype-sys = { version = "0.23.0", features = ["bundled"] } +harfbuzz-sys = { version = "0.8.0", features = ["bundled"] } [target.'cfg(target_arch = "wasm32")'.dependencies] ghostty-os.workspace = true +# Apple targets use CoreText (`coretext` backend) by default; FreeType and +# HarfBuzz are only linked for the `coretext-freetype` / `coretext-harfbuzz` +# backends (see `build.rs` and the `[features]` table). +[target.'cfg(target_vendor = "apple")'.dependencies] +# Like upstream (which builds them from source) the bundled copies are used, +# so no system library is needed on macOS. +freetype-sys = { version = "0.23.0", optional = true, features = ["bundled"] } +harfbuzz-sys = { version = "0.8.0", optional = true, default-features = false, features = ["coretext", "bundled"] } +objc2 = "0.6.5" +objc2-core-foundation = "0.3.2" +objc2-core-graphics = "0.3.2" +objc2-core-text = "0.3.2" +objc2-foundation = "0.3.2" + +[features] +default = [] +# CoreText for discovery, FreeType for rendering, HarfBuzz for shaping +# (Zig `-Dfont-backend=coretext_freetype`; Apple targets only). +coretext-freetype = ["dep:freetype-sys", "dep:harfbuzz-sys", "harfbuzz-sys/freetype"] +# CoreText for discovery and rendering, HarfBuzz for shaping +# (Zig `-Dfont-backend=coretext_harfbuzz`; Apple targets only). +coretext-harfbuzz = ["dep:harfbuzz-sys"] +# CoreText for discovery and rendering, no shaping +# (Zig `-Dfont-backend=coretext_noshape`; Apple targets only). +coretext-noshape = [] + # This crate wraps the freetype / harfbuzz / fontconfig C libraries through # their `-sys` crates; unsafe is denied by default and allowed on minimal, # `// SAFETY:`-commented items only. diff --git a/crates/ghostty-font/build.rs b/crates/ghostty-font/build.rs new file mode 100644 index 0000000..284c483 --- /dev/null +++ b/crates/ghostty-font/build.rs @@ -0,0 +1,60 @@ +//! Emits the font backend `cfg`s (port of `font/backend.zig`'s `Backend` +//! helpers `hasFreetype`/`hasCoretext`/`hasFontconfig`/`hasHarfbuzz`). +//! +//! Backends (see `src/backend.rs`): +//! * Linux & other non-Apple, non-wasm targets: `fontconfig_freetype`. +//! * wasm32: `web_canvas`. +//! * Apple targets: `coretext` (default), or via cargo features +//! `coretext-freetype`, `coretext-harfbuzz`, `coretext-noshape`. +//! +//! Emitted cfgs: +//! * `font_freetype` - FreeType face / library are compiled. +//! * `font_fontconfig` - fontconfig discovery is compiled. +//! * `font_harfbuzz` - the HarfBuzz shaper is compiled. +//! * `font_coretext` - CoreText discovery/deferred face (Apple targets). +//! * `font_coretext_face` - the CoreText `Face` is the font face. +//! * `font_coretext_shaper` - the CoreText shaper is the shaper. +//! * `font_noshape` - the no-op shaper is the shaper. + +fn main() { + println!("cargo:rerun-if-changed=build.rs"); + for c in [ + "font_freetype", + "font_fontconfig", + "font_harfbuzz", + "font_coretext", + "font_coretext_face", + "font_coretext_shaper", + "font_noshape", + ] { + println!("cargo:rustc-check-cfg=cfg({c})"); + } + + let arch = std::env::var("CARGO_CFG_TARGET_ARCH").unwrap_or_default(); + let vendor = std::env::var("CARGO_CFG_TARGET_VENDOR").unwrap_or_default(); + let feat = |n: &str| std::env::var_os(format!("CARGO_FEATURE_{n}")).is_some(); + + let wasm = arch == "wasm32"; + let apple = vendor == "apple"; + let ct_ft = feat("CORETEXT_FREETYPE"); + let ct_hb = feat("CORETEXT_HARFBUZZ"); + let ct_noshape = feat("CORETEXT_NOSHAPE"); + + let on = |c: &str, v: bool| { + if v { + println!("cargo:rustc-cfg={c}"); + } + }; + + let freetype = !wasm && (!apple || ct_ft); + let harfbuzz = !wasm && (!apple || ct_ft || ct_hb); + on("font_freetype", freetype); + on("font_fontconfig", !wasm && !apple); + on("font_harfbuzz", harfbuzz); + on("font_coretext", apple); + on("font_coretext_face", apple && !ct_ft); + // The shaper is CoreText only for the plain `coretext` backend; + // `coretext_noshape` uses the no-op shaper. + on("font_coretext_shaper", apple && !harfbuzz && !ct_noshape); + on("font_noshape", apple && !harfbuzz && ct_noshape); +} diff --git a/crates/ghostty-font/src/backend.rs b/crates/ghostty-font/src/backend.rs index 0c527ad..8e96212 100644 --- a/crates/ghostty-font/src/backend.rs +++ b/crates/ghostty-font/src/backend.rs @@ -1,9 +1,19 @@ //! Font backend selection. Port of `font/backend.zig`. //! -//! On Linux (and other non-wasm targets) [`OPTIONS`] selects -//! [`Backend::FontconfigFreetype`] (fontconfig discovery, FreeType rendering, -//! HarfBuzz shaping). The other variants are kept for fidelity with the Zig -//! enum and its helper predicates. +//! [`OPTIONS`] selects the backend for the target (`build.rs` emits the +//! matching `cfg`s): +//! +//! * Linux and other non-Apple, non-wasm targets: +//! [`Backend::FontconfigFreetype`] (fontconfig discovery, FreeType +//! rendering, HarfBuzz shaping). +//! * Apple targets: [`Backend::Coretext`] by default. macOS also supports +//! [`Backend::CoretextFreetype`], [`Backend::CoretextHarfbuzz`] and +//! [`Backend::CoretextNoshape`], selected with the cargo features +//! `coretext-freetype`, `coretext-harfbuzz` and `coretext-noshape` +//! (Zig: `-Dfont-backend=...`). "There is no scenario that is the default" +//! for those; they're only used by people who want to self-compile Ghostty +//! and prefer the freetype aesthetic. +//! * wasm32: [`Backend::WebCanvas`]. #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] pub enum Backend { @@ -31,7 +41,9 @@ impl Backend { // with `wasm_target == .browser`). if cfg!(target_arch = "wasm32") { Backend::WebCanvas - } else if cfg!(target_os = "macos") { + } else if cfg!(target_vendor = "apple") { + // macOS also supports "coretext_freetype" but there is no + // scenario that is the default. Backend::Coretext } else { Backend::FontconfigFreetype @@ -76,11 +88,45 @@ pub struct Options { pub backend: Backend, } -#[cfg(not(target_arch = "wasm32"))] +#[cfg(all(not(target_arch = "wasm32"), not(target_vendor = "apple")))] pub const OPTIONS: Options = Options { backend: Backend::FontconfigFreetype, }; +#[cfg(all(target_vendor = "apple", feature = "coretext-freetype"))] +pub const OPTIONS: Options = Options { + backend: Backend::CoretextFreetype, +}; + +#[cfg(all( + target_vendor = "apple", + feature = "coretext-harfbuzz", + not(feature = "coretext-freetype") +))] +pub const OPTIONS: Options = Options { + backend: Backend::CoretextHarfbuzz, +}; + +#[cfg(all( + target_vendor = "apple", + feature = "coretext-noshape", + not(feature = "coretext-freetype"), + not(feature = "coretext-harfbuzz") +))] +pub const OPTIONS: Options = Options { + backend: Backend::CoretextNoshape, +}; + +#[cfg(all( + target_vendor = "apple", + not(feature = "coretext-freetype"), + not(feature = "coretext-harfbuzz"), + not(feature = "coretext-noshape") +))] +pub const OPTIONS: Options = Options { + backend: Backend::Coretext, +}; + #[cfg(target_arch = "wasm32")] pub const OPTIONS: Options = Options { backend: Backend::WebCanvas, @@ -99,7 +145,44 @@ mod tests { assert!(!b.has_coretext()); } - #[cfg(not(target_arch = "wasm32"))] + #[test] + fn coretext_capabilities() { + let b = Backend::Coretext; + assert!(!b.has_freetype()); + assert!(!b.has_fontconfig()); + assert!(!b.has_harfbuzz()); + assert!(b.has_coretext()); + + let b = Backend::CoretextFreetype; + assert!(b.has_freetype()); + assert!(!b.has_fontconfig()); + assert!(b.has_harfbuzz()); + assert!(b.has_coretext()); + + let b = Backend::CoretextHarfbuzz; + assert!(!b.has_freetype()); + assert!(b.has_harfbuzz()); + assert!(b.has_coretext()); + + let b = Backend::CoretextNoshape; + assert!(!b.has_freetype()); + assert!(!b.has_harfbuzz()); + assert!(b.has_coretext()); + } + + #[test] + fn default_backend_per_target() { + let b = Backend::default_for_target(); + if cfg!(target_vendor = "apple") { + assert_eq!(b, Backend::Coretext); + } else if cfg!(target_arch = "wasm32") { + assert_eq!(b, Backend::WebCanvas); + } else { + assert_eq!(b, Backend::FontconfigFreetype); + } + } + + #[cfg(all(not(target_arch = "wasm32"), not(target_vendor = "apple")))] #[test] fn linux_backend_capabilities() { let b = OPTIONS.backend; diff --git a/crates/ghostty-font/src/codepoint_resolver.rs b/crates/ghostty-font/src/codepoint_resolver.rs index d928f07..cc46cb8 100644 --- a/crates/ghostty-font/src/codepoint_resolver.rs +++ b/crates/ghostty-font/src/codepoint_resolver.rs @@ -19,7 +19,7 @@ use crate::{ Style, collection::{AddOptions, Collection, Entry, GetFaceError, Index, PresentationMode, Special}, discovery::Discover, - face::{RenderOptions, freetype::RenderError as FaceRenderError}, + face::{RenderError as FaceRenderError, RenderOptions}, sprite, }; @@ -257,7 +257,7 @@ impl CodepointResolver { monospace: false, ..Default::default() }; - let mut disco_it = disco.discover_fallback(&self.collection, &desc).ok()?; + let mut disco_it = disco.discover_fallback(&mut self.collection, &desc).ok()?; loop { let deferred_face = match disco_it.next() { @@ -490,6 +490,8 @@ mod tests { c.load_options = Some(LoadOptions::new(lib.clone())); add(&mut c, &lib, embedded::REGULAR, Style::Regular, 12.0); + // Coretext doesn't support Noto's format + #[cfg(not(font_coretext))] add(&mut c, &lib, embedded::EMOJI, Style::Regular, 12.0); add(&mut c, &lib, embedded::EMOJI_TEXT, Style::Regular, 12.0); @@ -515,7 +517,8 @@ mod tests { .get_index(0x270C, Style::Regular, Some(Presentation::Text)) .unwrap(); assert_eq!(Style::Regular, idx.style); - assert_eq!(2, idx.idx); + let text_idx = if cfg!(font_coretext) { 1 } else { 2 }; + assert_eq!(text_idx, idx.idx); } { let idx = r diff --git a/crates/ghostty-font/src/collection.rs b/crates/ghostty-font/src/collection.rs index 4d6d8e9..5022b7d 100644 --- a/crates/ghostty-font/src/collection.rs +++ b/crates/ghostty-font/src/collection.rs @@ -124,7 +124,7 @@ pub enum GetFaceError { #[error("face load failed: {0}")] Load(#[from] crate::deferred_face::LoadError), #[error("face error: {0}")] - Face(#[from] face::freetype::FaceError), + Face(#[from] face::FaceError), } #[derive(Debug, Clone, Copy, PartialEq, Eq, thiserror::Error)] @@ -158,7 +158,7 @@ enum SyntheticError { #[error(transparent)] GetFace(#[from] GetFaceError), #[error(transparent)] - Face(#[from] face::freetype::FaceError), + Face(#[from] face::FaceError), } impl Collection { @@ -1270,6 +1270,7 @@ mod tests { assert!(!c.has_codepoint(idx, '๐Ÿฅธ' as u32, PresentationMode::Any)); } + #[cfg(not(font_coretext))] #[test] fn has_codepoint_emoji_default_graphical() { let lib = Library::init().unwrap(); diff --git a/crates/ghostty-font/src/coretext_util.rs b/crates/ghostty-font/src/coretext_util.rs new file mode 100644 index 0000000..8b929b5 --- /dev/null +++ b/crates/ghostty-font/src/coretext_util.rs @@ -0,0 +1,165 @@ +//! Small helpers over the CoreText / CoreFoundation bindings shared by the +//! CoreText face, shaper and discovery (the Zig `macos` package conveniences +//! they rely on). Apple targets only. +#![allow(unsafe_code, clippy::trivially_copy_pass_by_ref)] + +use std::{ops::Deref, ptr::NonNull}; + +use objc2_core_foundation::{CFData, CFRetained, CFString}; +use objc2_core_text::{CTFont, CTFontTableOptions}; + +use crate::face::coretext_math::utf16_units; + +/// A retained CoreFoundation object that may be moved/shared across threads. +/// +/// CoreText font objects (`CTFont`, `CTFontDescriptor`) are immutable and +/// documented as thread safe by Apple ("CTFont ... are thread-safe"), but the +/// generated bindings conservatively do not mark them `Send`/`Sync`. +pub struct CfBox(pub(crate) CFRetained); + +// SAFETY: see the type docs; the wrapped objects are immutable CoreText +// objects, safe to use from any thread. +unsafe impl Send for CfBox {} +// SAFETY: as above. +unsafe impl Sync for CfBox {} + +impl CfBox { + pub(crate) fn new(v: CFRetained) -> Self { + CfBox(v) + } +} + +impl Deref for CfBox { + type Target = T; + fn deref(&self) -> &T { + &self.0 + } +} + +impl Clone for CfBox { + fn clone(&self) -> Self { + CfBox(self.0.clone()) + } +} + +/// Convert a `CFString` to a Rust `String`. +pub(crate) fn cf_to_string(s: &CFString) -> String { + s.to_string() +} + +/// Copy the sfnt table with the given tag, if present. +pub(crate) fn font_table(font: &CTFont, tag: &[u8; 4]) -> Option> { + // SAFETY: plain CoreText call with valid arguments. + unsafe { font.table(u32::from_be_bytes(*tag), CTFontTableOptions::NoOptions) } +} + +/// Copy the bytes of the sfnt table with the given tag, if present. +pub(crate) fn font_table_bytes(font: &CTFont, tag: &[u8; 4]) -> Option> { + font_table(font, tag).map(|d| d.to_vec()) +} + +/// `CTFontGetGlyphsForCharacters` for a single UTF-32 codepoint. Returns the +/// glyph ID of the first UTF-16 unit and whether *all* units mapped to glyphs +/// (the C function's return value). +pub(crate) fn glyphs_for_codepoint(font: &CTFont, cp: u32) -> (u16, bool) { + // Turn UTF-32 into UTF-16 for the CT API + let (mut unichars, len) = utf16_units(cp); + let mut glyphs = [0u16; 2]; + // SAFETY: both buffers have at least `len` (<= 2) elements and outlive + // the call. + let ok = unsafe { + font.glyphs_for_characters( + NonNull::new_unchecked(unichars.as_mut_ptr()), + NonNull::new_unchecked(glyphs.as_mut_ptr()), + len as isize, + ) + }; + (glyphs[0], ok) +} + +/// A font variation axis as reported by CoreText. +#[derive(Debug, Clone, PartialEq)] +pub(crate) struct Axis { + pub name: String, + pub id: u32, + pub min: f64, + pub max: f64, + pub default: f64, +} + +/// The variation axes of a font (`kCTFontVariationAxesAttribute`), or `None` +/// if the font has none. +pub(crate) fn variation_axes(font: &CTFont) -> Option> { + use objc2_core_foundation::{CFArray, CFDictionary, CFNumber, CFType}; + use objc2_core_text::{ + kCTFontVariationAxisDefaultValueKey, + kCTFontVariationAxisIdentifierKey, + kCTFontVariationAxisMaximumValueKey, + kCTFontVariationAxisMinimumValueKey, + kCTFontVariationAxisNameKey, + }; + + // SAFETY: plain CoreText call. + let axes = unsafe { font.variation_axes() }?; + // SAFETY: the documented element type of the axes array is CFDictionary. + let axes: &CFArray = unsafe { axes.cast_unchecked() }; + + let mut out = Vec::with_capacity(axes.len()); + for i in 0..axes.len() { + let Some(dict) = axes.get(i) else { continue }; + // SAFETY: the values stored under these keys are CFNumber / CFString + // as documented for the variation axis keys. + let (names, nums) = unsafe { + ( + dict.cast_unchecked::(), + dict.cast_unchecked::(), + ) + }; + // SAFETY: the keys are valid static CFStrings. + let (k_name, k_id, k_min, k_max, k_def) = unsafe { + ( + kCTFontVariationAxisNameKey, + kCTFontVariationAxisIdentifierKey, + kCTFontVariationAxisMinimumValueKey, + kCTFontVariationAxisMaximumValueKey, + kCTFontVariationAxisDefaultValueKey, + ) + }; + let _ = std::marker::PhantomData::; + let name = names.get(k_name).map(|s| s.to_string()).unwrap_or_default(); + let id = nums + .get(k_id) + .and_then(|n| n.as_i32()) + .map_or(0, |v| v as u32); + let min = nums.get(k_min).and_then(|n| n.as_f64()).unwrap_or(0.0); + let max = nums.get(k_max).and_then(|n| n.as_f64()).unwrap_or(0.0); + let default = nums.get(k_def).and_then(|n| n.as_f64()).unwrap_or(0.0); + out.push(Axis { + name, + id, + min, + max, + default, + }); + } + Some(out) +} + +/// The current value of each variation axis of the font instance +/// (`kCTFontVariationAttribute`), keyed by axis id. +pub(crate) fn variation_values(font: &CTFont) -> Option> { + use objc2_core_foundation::{CFDictionary, CFNumber}; + + // SAFETY: plain CoreText call. + let values = unsafe { font.variation() }?; + // SAFETY: the variation dictionary maps CFNumber axis ids to CFNumber + // values. + let values: &CFDictionary = unsafe { values.cast_unchecked() }; + let (keys, vals) = values.to_vecs(); + Some( + keys.iter() + .zip(vals.iter()) + .filter_map(|(k, v)| Some((k.as_i32()? as u32, v.as_f64()?))) + .collect(), + ) +} diff --git a/crates/ghostty-font/src/deferred_face.rs b/crates/ghostty-font/src/deferred_face.rs index 1721a67..2f4b86f 100644 --- a/crates/ghostty-font/src/deferred_face.rs +++ b/crates/ghostty-font/src/deferred_face.rs @@ -20,7 +20,7 @@ pub enum LoadError { #[error(transparent)] Fontconfig(#[from] fc::Error), #[error(transparent)] - Face(#[from] face::freetype::FaceError), + Face(#[from] face::FaceError), } /// Fontconfig specific data. This is only present if building with fontconfig. diff --git a/crates/ghostty-font/src/deferred_face_coretext.rs b/crates/ghostty-font/src/deferred_face_coretext.rs new file mode 100644 index 0000000..679819a --- /dev/null +++ b/crates/ghostty-font/src/deferred_face_coretext.rs @@ -0,0 +1,202 @@ +//! A deferred face represents a single font face with all the information +//! necessary to load it, but defers loading the full face until it is +//! needed. +//! +//! This allows us to have many fallback fonts to look for glyphs, but +//! only load them if they're really needed. +//! +//! Port of `font/DeferredFace.zig` (CoreText variant; Apple targets). +#![allow(unsafe_code)] + +use objc2_core_foundation::CFRetained; +use objc2_core_text::{CTFont, CTFontSymbolicTraits}; + +use crate::{ + Face, + Library, + Presentation, + coretext_util::{self as util, CfBox}, + face::{self, Options, Variation}, +}; + +#[derive(Debug, thiserror::Error)] +pub enum LoadError { + #[error(transparent)] + Face(#[from] face::FaceError), + /// `coretext_freetype`: the font has no file we could hand to FreeType. + #[error("font has no file")] + FontHasNoFile, + /// `coretext_freetype`: the font path could not be decoded. + #[error("font path can't be decoded")] + FontPathCantDecode, +} + +/// CoreText specific data. +pub struct CoreText { + /// The initialized font + pub font: CfBox, + + /// Variations to apply to this font. We apply the variations to the + /// search descriptor but sometimes when the font collection is + /// made the variation axes are reset so we have to reapply them. + pub variations: Vec, +} + +#[derive(Default)] +pub struct DeferredFace { + /// CoreText + pub ct: Option, +} + +impl DeferredFace { + pub fn from_coretext(font: CFRetained, variations: Vec) -> DeferredFace { + DeferredFace { + ct: Some(CoreText { + font: CfBox::new(font), + variations, + }), + } + } + + /// Returns the family name of the font. + pub fn family_name(&self) -> Result { + if let Some(ct) = &self.ct { + // SAFETY: plain CoreText call. + return Ok(util::cf_to_string(&*unsafe { ct.font.family_name() })); + } + Ok(String::new()) + } + + /// Returns the name of this face. + pub fn name(&self) -> Result { + if let Some(ct) = &self.ct { + // SAFETY: plain CoreText call. + return Ok(util::cf_to_string(&*unsafe { ct.font.display_name() })); + } + Ok(String::new()) + } + + /// Load the deferred font face. + #[cfg(not(font_freetype))] + pub fn load(&mut self, _lib: &Library, opts: Options) -> Result { + let ct = self + .ct + .as_ref() + .expect("deferred face has no coretext data"); + let mut face = Face::init_font_copy(&ct.font, opts)?; + face.set_variations(&ct.variations, opts)?; + Ok(face) + } + + /// Load the deferred font face (`coretext_freetype`: FreeType renders the + /// file CoreText found). + #[cfg(font_freetype)] + pub fn load(&mut self, lib: &Library, opts: Options) -> Result { + use objc2_core_foundation::{CFString, CFURL}; + use objc2_core_text::kCTFontURLAttribute; + + let ct = self + .ct + .as_ref() + .expect("deferred face has no coretext data"); + + // Get the URL for the font so we can get the filepath + // SAFETY: valid static CFString key. + let url = + unsafe { ct.font.attribute(kCTFontURLAttribute) }.ok_or(LoadError::FontHasNoFile)?; + let url = url + .downcast::() + .map_err(|_| LoadError::FontHasNoFile)?; + + // Get the path from the URL + let path = url.path().ok_or(LoadError::FontHasNoFile)?; + + // URL decode the path + let blank = CFString::from_str(""); + let decoded = + CFURL::new_string_by_replacing_percent_escapes(None, Some(&path), Some(&blank)) + .ok_or(LoadError::FontPathCantDecode)?; + let path = util::cf_to_string(&decoded); + + // Face index 0 is not always correct. We don't ship this configuration + // in a release build. Users should use the pure CoreText builds. + let mut face = Face::init_file(lib, &path, 0, opts)?; + face.set_variations(&ct.variations, opts)?; + Ok(face) + } + + /// Returns true if this face can satisfy the given codepoint and + /// presentation. If presentation is null, then it just checks if the + /// codepoint is present at all. + /// + /// This does not require the face to be loaded (the CT font is used). + pub fn has_codepoint(&self, cp: u32, p: Option) -> bool { + // If we are using coretext, we check the loaded CT font. + let Some(ct) = &self.ct else { + // This is unreachable because discovery mechanisms terminate, and + // if we're not using a discovery mechanism, the face MUST be loaded. + unreachable!("deferred face without coretext data"); + }; + + // This presentation check isn't as detailed as isColorGlyph + // because forced presentation modes are only used for emoji and + // emoji should always have color glyphs set. This can be + // more correct by using the isColorGlyph logic but I'd want + // to find a font that actually requires this so we can write + // a test for it before changing it. + if let Some(desired_p) = p { + // SAFETY: plain CoreText call. + let traits = unsafe { ct.font.symbolic_traits() }; + let actual_p = if traits.contains(CTFontSymbolicTraits::TraitColorGlyphs) { + Presentation::Emoji + } else { + Presentation::Text + }; + if actual_p != desired_p { + return false; + } + } + + util::glyphs_for_codepoint(&ct.font, cp).1 + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::discovery::{Descriptor, Discover}; + + #[test] + fn coretext() { + // Get a deferred face from CoreText + let mut def = { + let disco = Discover::init(); + let mut it = disco + .discover(&Descriptor { + family: Some("Monaco".into()), + size: 12.0, + ..Default::default() + }) + .unwrap(); + it.next().unwrap().unwrap() + }; + + // Verify we can get the name + let n = def.name().unwrap(); + assert_ne!("", n); + + // Load it and verify it works + let lib = Library::init().unwrap(); + let face = def + .load(&lib, Options::new(face::DesiredSize::new(12.0))) + .unwrap(); + assert!(face.glyph_index(' ' as u32).is_some()); + } + + #[test] + fn empty_deferred_face_has_empty_names() { + let def = DeferredFace::default(); + assert_eq!("", def.family_name().unwrap()); + assert_eq!("", def.name().unwrap()); + } +} diff --git a/crates/ghostty-font/src/discovery.rs b/crates/ghostty-font/src/discovery.rs index 418e657..892195d 100644 --- a/crates/ghostty-font/src/discovery.rs +++ b/crates/ghostty-font/src/discovery.rs @@ -1,144 +1,160 @@ -//! Font discovery. Port of `font/discovery.zig` (fontconfig backend only; -//! the CoreText discoverer is macOS-only and out of scope). +//! Font discovery. Port of `font/discovery.zig`: the fontconfig backend +//! (Linux, in this file) and the CoreText backend (Apple targets, in +//! `discovery_coretext.rs`). -use std::sync::Arc; - -use crate::{ - collection::Collection, - deferred_face::{DeferredFace, Fontconfig as DeferredFontconfig}, - face::Variation, - fontconfig as fc, -}; - -/// Discover implementation for the compile options. -pub type Discover = Fontconfig; +#[cfg(font_fontconfig)] +pub use fc_impl::*; +#[cfg(font_coretext)] +#[path = "discovery_coretext.rs"] +mod coretext_impl; +#[cfg(font_coretext)] +pub use coretext_impl::*; /// Descriptor is used to search for fonts (canonical definition in `ghostty-config`). pub use ghostty_config::font_types::Descriptor; -/// 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)); - } - 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)); +#[cfg(font_fontconfig)] +mod fc_impl { + use std::sync::Arc; + + use super::Descriptor; + use crate::{ + collection::Collection, + deferred_face::{DeferredFace, Fontconfig as DeferredFontconfig}, + face::Variation, + fontconfig as fc, + }; + + /// Discover implementation for the compile options. + pub type Discover = Fontconfig; + + /// The error type of [`DiscoverIterator::next`]. + pub type NextError = fc::Error; + + /// 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)); + } + 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)); + } + + // 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 } - // 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)); + #[derive(Clone)] + pub struct Fontconfig { + fc_config: Arc, + } - pat -} + #[derive(Debug, thiserror::Error)] + pub enum DiscoverError { + #[error("fontconfig failed")] + FontConfigFailed, + #[error(transparent)] + Fontconfig(#[from] fc::Error), + } -#[derive(Clone)] -pub struct Fontconfig { - fc_config: Arc, -} + impl Fontconfig { + pub fn init() -> Fontconfig { + // safe to call multiple times and concurrently + fc::init(); + Fontconfig { + fc_config: Arc::new( + fc::Config::init_load_config_and_fonts() + .expect("FcInitLoadConfigAndFonts returned null"), + ), + } + } -#[derive(Debug, thiserror::Error)] -pub enum DiscoverError { - #[error("fontconfig failed")] - FontConfigFailed, - #[error(transparent)] - Fontconfig(#[from] fc::Error), -} + /// Discover fonts from a descriptor. This returns an iterator that can + /// 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 = to_fc_pattern(desc); + assert!(self.fc_config.substitute_with_pat_pattern(&mut pat)); + pat.default_substitute(); -impl Fontconfig { - pub fn init() -> Fontconfig { - // safe to call multiple times and concurrently - fc::init(); - Fontconfig { - fc_config: Arc::new( - fc::Config::init_load_config_and_fonts() - .expect("FcInitLoadConfigAndFonts returned null"), - ), + // Search + let set = self + .fc_config + .font_sort(&mut pat, false) + .map_err(|_| DiscoverError::FontConfigFailed)?; + + Ok(DiscoverIterator { + config: Arc::clone(&self.fc_config), + pattern: pat, + set, + variations: desc.variations.clone(), + i: 0, + }) + } + + pub fn discover_fallback( + &self, + _collection: &mut Collection, + desc: &Descriptor, + ) -> Result { + self.discover(desc) } } - /// Discover fonts from a descriptor. This returns an iterator that can - /// 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 = to_fc_pattern(desc); - assert!(self.fc_config.substitute_with_pat_pattern(&mut pat)); - pat.default_substitute(); - - // Search - let set = self - .fc_config - .font_sort(&mut pat, false) - .map_err(|_| DiscoverError::FontConfigFailed)?; - - Ok(DiscoverIterator { - config: Arc::clone(&self.fc_config), - pattern: pat, - set, - variations: desc.variations.clone(), - i: 0, - }) + pub struct DiscoverIterator { + config: Arc, + pattern: fc::Pattern, + set: fc::FontSet, + variations: Vec, + i: usize, } - pub fn discover_fallback( - &self, - _collection: &Collection, - desc: &Descriptor, - ) -> Result { - self.discover(desc) - } -} + impl DiscoverIterator { + #[allow(clippy::should_implement_trait)] + pub fn next(&mut self) -> Result, fc::Error> { + if self.i >= self.set.len() { + return Ok(None); + } -pub struct DiscoverIterator { - config: Arc, - pattern: fc::Pattern, - set: fc::FontSet, - variations: Vec, - i: usize, -} + // Get the copied pattern from our fontset that has the + // attributes configured for rendering. + let font_pattern = + self.config + .font_render_prepare(&mut self.pattern, &self.set, self.i)?; -impl DiscoverIterator { - #[allow(clippy::should_implement_trait)] - pub fn next(&mut self) -> Result, fc::Error> { - if self.i >= self.set.len() { - return Ok(None); + // The charset and langset must exist (they are used for lookups). + font_pattern.charset_has_char(0)?; + font_pattern.langset_has_lang("")?; + + // Increment after we return + self.i += 1; + + Ok(Some(DeferredFace::from_fontconfig(DeferredFontconfig { + pattern: font_pattern, + variations: self.variations.clone(), + }))) } - - // Get the copied pattern from our fontset that has the - // attributes configured for rendering. - let font_pattern = self - .config - .font_render_prepare(&mut self.pattern, &self.set, self.i)?; - - // The charset and langset must exist (they are used for lookups). - font_pattern.charset_has_char(0)?; - font_pattern.langset_has_lang("")?; - - // Increment after we return - self.i += 1; - - Ok(Some(DeferredFace::from_fontconfig(DeferredFontconfig { - pattern: font_pattern, - variations: self.variations.clone(), - }))) } } @@ -178,6 +194,7 @@ mod tests { assert_ne!(a.hashcode(), b.hashcode()); } + #[cfg(font_fontconfig)] #[test] fn fontconfig() { let fc = Fontconfig::init(); @@ -190,6 +207,7 @@ mod tests { .unwrap(); } + #[cfg(font_fontconfig)] #[test] fn fontconfig_codepoint() { let fc = Fontconfig::init(); diff --git a/crates/ghostty-font/src/discovery_coretext.rs b/crates/ghostty-font/src/discovery_coretext.rs new file mode 100644 index 0000000..b1cecab --- /dev/null +++ b/crates/ghostty-font/src/discovery_coretext.rs @@ -0,0 +1,525 @@ +//! CoreText font discovery (Apple targets). Port of the `CoreText` struct in +//! `font/discovery.zig` plus `Descriptor.toCoreTextDescriptor`. +#![allow(unsafe_code)] + +use std::ptr; + +use objc2_core_foundation::{ + CFArray, + CFCharacterSet, + CFDictionary, + CFNumber, + CFRange, + CFRetained, + CFString, + CFType, + kCFNull, +}; +use objc2_core_text::{ + CTFont, + CTFontCollection, + CTFontDescriptor, + kCTFontCharacterSetAttribute, + kCTFontFamilyNameAttribute, + kCTFontSizeAttribute, + kCTFontStyleNameAttribute, + kCTFontSymbolicTrait, + kCTFontTraitsAttribute, +}; + +use super::Descriptor; +use crate::{ + Style, + collection::{Collection, Index}, + coretext_util::{self as util, CfBox}, + deferred_face::DeferredFace, + face::{ + Variation, + coretext_math::{self as math, Score}, + }, + opentype, +}; + +/// Discover implementation for the compile options. +pub type Discover = CoreText; + +/// The error type of [`DiscoverIterator::next`]. CoreText iteration can't +/// fail, so this is uninhabited. +#[derive(Debug, thiserror::Error)] +pub enum NextError {} + +#[derive(Debug, thiserror::Error)] +pub enum DiscoverError { + #[error("coretext descriptor creation failed")] + DescriptorFailed, +} + +/// Retain a value as a `CFType`. +fn as_type + objc2_core_foundation::Type>(v: &T) -> CFRetained { + let r: &CFType = v.as_ref(); + objc2_core_foundation::Type::retain(r) +} + +/// Convert to Core Text font descriptor to use for lookup or +/// conversion to a specific font (`Descriptor.toCoreTextDescriptor`). +pub fn to_coretext_descriptor( + desc: &Descriptor, +) -> Result, DiscoverError> { + let mut keys: Vec<&CFString> = Vec::new(); + // Owned values; we collect references below. + let mut values: Vec> = Vec::new(); + + // Family + if let Some(family) = &desc.family { + let family = CFString::from_str(family); + // SAFETY: valid static key. + keys.push(unsafe { kCTFontFamilyNameAttribute }); + values.push(as_type(&*family)); + } + + // Style + if let Some(style) = &desc.style { + let style = CFString::from_str(style); + // SAFETY: valid static key. + keys.push(unsafe { kCTFontStyleNameAttribute }); + values.push(as_type(&*style)); + } + + // Codepoint support + if desc.codepoint > 0 { + // SAFETY: plain CoreFoundation call with a valid range. + let cs = unsafe { + CFCharacterSet::with_characters_in_range( + None, + CFRange { + location: desc.codepoint as isize, + length: 1, + }, + ) + } + .ok_or(DiscoverError::DescriptorFailed)?; + // SAFETY: valid static key. + keys.push(unsafe { kCTFontCharacterSetAttribute }); + values.push(as_type(&*cs)); + } + + // Set our size attribute if set + if desc.size > 0.0 { + let size = CFNumber::new_i32(desc.size.round() as i32); + // SAFETY: valid static key. + keys.push(unsafe { kCTFontSizeAttribute }); + values.push(as_type(&*size)); + } + + // Build our traits. If we set any, then we store it in the attributes + // otherwise we do nothing. + let traits = math::symbolic_traits_value(desc.bold, desc.italic, desc.monospace); + if traits > 0 { + // Setting traits is a pain. We have to create a nested dictionary + // of the symbolic traits value, and set that in our attributes. + let traits_num = CFNumber::new_i32(traits as i32); + // SAFETY: valid static key. + let k = unsafe { kCTFontSymbolicTrait }; + let nested = + CFDictionary::::from_slices(&[k], &[&*as_type(&*traits_num)]); + // SAFETY: valid static key. + keys.push(unsafe { kCTFontTraitsAttribute }); + values.push(as_type(&*nested)); + } + + let value_refs: Vec<&CFType> = values.iter().map(|v| &**v).collect(); + let attrs = CFDictionary::::from_slices(&keys, &value_refs); + + // SAFETY: valid attributes dictionary. + Ok(unsafe { CTFontDescriptor::with_attributes(attrs.as_opaque()) }) +} + +/// The CoreText discoverer. It has no state. +#[derive(Debug, Clone, Default)] +pub struct CoreText; + +impl CoreText { + pub fn init() -> CoreText { + // Required for the "interface" but does nothing for CoreText. + CoreText + } + + /// Discover fonts from a descriptor. This returns an iterator that can + /// be used to build up the deferred fonts. + pub fn discover(&self, desc: &Descriptor) -> Result { + // Build our pattern that we'll search for + let ct_desc = to_coretext_descriptor(desc)?; + + // Our descriptors have to be in an array + let desc_arr = CFArray::from_objects(&[&*ct_desc]); + + // Build our collection + // SAFETY: valid array, no options. + let set = + unsafe { CTFontCollection::with_font_descriptors(Some(desc_arr.as_opaque()), None) }; + // SAFETY: plain CoreText call. + let list = unsafe { set.matching_font_descriptors() }; + + // Copy and sort our descriptors + let mut items: Vec> = match list { + Some(list) => { + // SAFETY: the matching list contains CTFontDescriptors. + let typed: &CFArray = unsafe { list.cast_unchecked() }; + typed.to_vec() + } + None => Vec::new(), + }; + sort_matching_descriptors(desc, &mut items); + + Ok(DiscoverIterator { + list: items.into_iter().map(CfBox::new).collect(), + variations: desc.variations.clone(), + i: 0, + }) + } + + pub fn discover_fallback( + &self, + collection: &mut Collection, + desc: &Descriptor, + ) -> Result { + // If we have a codepoint within the CJK unified ideographs block + // then we fallback to macOS to find a font that supports it because + // there isn't a better way manually with CoreText that I can find that + // properly takes into account system locale. + // + // References: + // - http://unicode.org/charts/PDF/U4E00.pdf + // - https://chromium.googlesource.com/chromium/src/+/main/third_party/blink/renderer/platform/fonts/LocaleInFonts.md#unified-han-ideographs + if (0x4E00..=0x9FFF).contains(&desc.codepoint) + && let Some(han) = self.discover_codepoint(collection, desc) + { + return Ok(DiscoverIterator { + list: vec![CfBox::new(han)], + variations: desc.variations.clone(), + i: 0, + }); + } + + let it = self.discover(desc)?; + + // If our normal discovery doesn't find anything and we have a specific + // codepoint, then fallback to using CTFontCreateForString to find a + // matching font CoreText wants to use. See: + // https://github.com/ghostty-org/ghostty/issues/2499 + if it.list.is_empty() + && desc.codepoint > 0 + && let Some(ct_desc) = self.discover_codepoint(collection, desc) + { + return Ok(DiscoverIterator { + list: vec![CfBox::new(ct_desc)], + variations: desc.variations.clone(), + i: 0, + }); + } + + Ok(it) + } + + /// Discover a font for a specific codepoint using the CoreText + /// CTFontCreateForString API. + #[cfg(font_freetype)] + #[allow(clippy::unused_self)] + fn discover_codepoint( + &self, + _collection: &mut Collection, + _desc: &Descriptor, + ) -> Option> { + // If we have freetype, we can't use CoreText to find a font + // that supports a specific codepoint because we need to + // have a CoreText font to be able to do so. + None + } + + /// Discover a font for a specific codepoint using the CoreText + /// CTFontCreateForString API. + #[cfg(not(font_freetype))] + #[allow(clippy::unused_self)] + fn discover_codepoint( + &self, + collection: &mut Collection, + desc: &Descriptor, + ) -> Option> { + assert!(desc.codepoint > 0); + + // Get our original font. This is dependent on the requested style + // from the descriptor. + let original_style = { + // In all the styles below, we try to match it but if we don't + // we always fall back to some other option. The order matters + // here. + if desc.bold && desc.italic && collection.count(Style::BoldItalic) > 0 { + Style::BoldItalic + } else if desc.bold && collection.count(Style::Bold) > 0 { + Style::Bold + } else if desc.italic && collection.count(Style::Italic) > 0 { + Style::Italic + } else { + Style::Regular + } + }; + let original = collection + .get_face(Index { + style: original_style, + idx: 0, + }) + .ok()?; + + // We need a CFString for the character. + let ch = char::from_u32(desc.codepoint)?; + let str = CFString::from_str(ch.encode_utf8(&mut [0u8; 4])); + + // Get our range length for CTFontCreateForString. It looks like + // the range uses UTF-16 codepoints and not UTF-32 codepoints. + let range_len = math::utf16_units(desc.codepoint).1; + + // Get our font + // SAFETY: valid string and range. + let font = unsafe { + original.ct_font().for_string( + &str, + CFRange { + location: 0, + length: range_len as isize, + }, + ) + }; + + // Do not allow the last resort font to go through. This is the + // last font used by CoreText if it can't find anything else and + // only contains replacement characters. + // SAFETY: plain CoreText call. + let name = util::cf_to_string(&*unsafe { font.post_script_name() }); + // If the name is "LastResort" then we don't want to use it. + if name == "LastResort" { + return None; + } + + // Get the descriptor + // SAFETY: plain CoreText call. + Some(unsafe { font.font_descriptor() }) + } +} + +fn sort_matching_descriptors(desc: &Descriptor, list: &mut [CFRetained]) { + // Compute the scores once. Higher score is "less" (earlier). + let mut scored: Vec<(u32, CFRetained)> = list + .iter() + .map(|d| (score(desc, d).int(), d.clone())) + .collect(); + scored.sort_by_key(|a| std::cmp::Reverse(a.0)); + for (slot, (_, d)) in list.iter_mut().zip(scored) { + *slot = d; + } +} + +/// Score a CoreText descriptor against the requested descriptor +/// (`Score.score`). +fn score(desc: &Descriptor, ct_desc: &CTFontDescriptor) -> Score { + let mut this = Score::default(); + + // We always load the font if we can since some things can only be + // inspected on the font itself. + // SAFETY: valid descriptor, null matrix is allowed. + let font: CFRetained = + unsafe { CTFont::with_font_descriptor(ct_desc, 12.0, ptr::null()) }; + + // We prefer fonts with more glyphs, all else being equal. + // SAFETY: plain CoreText call. + this.glyph_count = u16::try_from(unsafe { font.glyph_count() }).unwrap_or(u16::MAX); + + // If we're searching for a codepoint, then we + // prioritize fonts that have that codepoint. + if desc.codepoint > 0 { + this.codepoint = util::glyphs_for_codepoint(&font, desc.codepoint).1; + } + + // Get our symbolic traits for the descriptor so we can + // compare boolean attributes like bold, monospace, etc. + let symbolic_traits: u32 = (|| { + // SAFETY: valid static key. + let traits = unsafe { ct_desc.attribute(kCTFontTraitsAttribute) }?; + let traits = traits.downcast::().ok()?; + // SAFETY: the traits dictionary maps CFString keys to CFNumber values. + let traits: &CFDictionary = unsafe { traits.cast_unchecked() }; + // SAFETY: valid static key. + let symbolic = traits.get(unsafe { kCTFontSymbolicTrait })?; + symbolic.as_i32().map(|v| v as u32) + })() + .unwrap_or(0); + + this.monospace = symbolic_traits & math::TRAIT_MONOSPACE != 0; + + // We try to derived data from the font itself, which is generally + // more reliable than only using the symbolic traits for this. + let (is_bold, is_italic) = { + // We start with initial guesses based on the symbolic traits, + // but refine these with more information if we can get it. + let mut is_italic = symbolic_traits & math::TRAIT_ITALIC != 0; + let mut is_bold = symbolic_traits & math::TRAIT_BOLD != 0; + + // Read the 'head' table out of the font data if it's available. + if let Some(head) = util::font_table_bytes(&font, b"head").and_then(|d| { + opentype::Head::init(&d) + .inspect_err(|e| log::warn!("error parsing head table: {e}")) + .ok() + }) { + is_bold = is_bold || (head.macStyle & 1 == 1); + is_italic = is_italic || (head.macStyle & 2 == 2); + } + + // Read the 'OS/2' table out of the font data if it's available. + if let Some(os2) = util::font_table_bytes(&font, b"OS/2").and_then(|d| { + opentype::OS2::init(&d) + .inspect_err(|e| log::warn!("error parsing OS/2 table: {e}")) + .ok() + }) { + is_bold = is_bold || os2.fsSelection.bold; + is_italic = is_italic || os2.fsSelection.italic; + } + + // Check if we have variation axes in our descriptor, if we + // do then we can derive weight italic-ness or both from them. + if let Some(axes) = util::variation_axes(&font) { + // Copy the variation values for this instance of the font. + // if there are none then we just skip. + if let Some(values) = util::variation_values(&font) { + let list = axes.into_iter().map(|axis| { + let val = values.get(&axis.id).copied().unwrap_or(axis.default); + (axis.name, val) + }); + (is_bold, is_italic) = math::bold_italic_from_axes(is_bold, is_italic, list); + } + } + + (is_bold, is_italic) + }; + + this.bold = desc.bold == is_bold; + this.italic = desc.italic == is_italic; + + // Get the style string from the font. + let style_str: String = (|| { + // SAFETY: valid static key. + let style = unsafe { ct_desc.attribute(kCTFontStyleNameAttribute) }?; + style.downcast::().ok().map(|s| s.to_string()) + })() + .unwrap_or_default(); + + // The first string in this slice will be used for the exact match, + // and for the fuzzy match, all matching substrings will increase + // the rank. + let desired_styles = math::desired_styles(desc.style.as_deref(), desc.bold, desc.italic); + let (exact, fuzzy) = math::style_scores(&style_str, &desired_styles); + this.exact_style = exact; + this.fuzzy_style = fuzzy; + + this +} + +pub struct DiscoverIterator { + list: Vec>, + variations: Vec, + i: usize, +} + +impl DiscoverIterator { + #[allow(clippy::should_implement_trait)] + pub fn next(&mut self) -> Result, NextError> { + if self.i >= self.list.len() { + return Ok(None); + } + + // Get our descriptor. We need to remove the character set + // limitation because we may have used that to filter but we + // don't want it anymore because it'll restrict the characters + // available. + let desc = { + // We create a copy, overwriting the character set attribute. + // SAFETY: reading the extern static constant. + let null: &CFType = match unsafe { kCFNull } { + Some(n) => n.as_ref(), + None => unreachable!("kCFNull is never null"), + }; + // SAFETY: valid static key. + let key = unsafe { kCTFontCharacterSetAttribute }; + let attrs = CFDictionary::::from_slices(&[key], &[null]); + // SAFETY: valid dictionary. + unsafe { self.list[self.i].copy_with_attributes(attrs.as_opaque()) } + }; + + // Create our font. We need a size to initialize it so we use size + // 12 but we will alter the size later. + // SAFETY: valid descriptor, null matrix is allowed. + let font = unsafe { CTFont::with_font_descriptor(&desc, 12.0, ptr::null()) }; + + // Increment after we return + self.i += 1; + + Ok(Some(DeferredFace::from_coretext( + font, + self.variations.clone(), + ))) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn coretext() { + let ct = CoreText::init(); + let mut it = ct + .discover(&Descriptor { + family: Some("Monaco".into()), + size: 12.0, + ..Default::default() + }) + .unwrap(); + let face = it.next().unwrap().unwrap(); + assert!(face.has_codepoint('A' as u32, None)); + } + + #[test] + fn coretext_codepoint() { + let ct = CoreText::init(); + let mut it = ct + .discover(&Descriptor { + codepoint: 'A' as u32, + size: 12.0, + ..Default::default() + }) + .unwrap(); + + // The first result should have the codepoint. Later ones may not + // because the results are sorted. + let face = it.next().unwrap().unwrap(); + assert!(face.has_codepoint('A' as u32, None)); + + // Should have other codepoints too + assert!(face.has_codepoint('B' as u32, None)); + } + + #[test] + fn coretext_sorting() { + let ct = CoreText::init(); + let mut it = ct + .discover(&Descriptor { + family: Some("Menlo".into()), + size: 12.0, + ..Default::default() + }) + .unwrap(); + + // Exercise the scoring/sorting path; the first result for the + // regular style must be the regular face. + let first = it.next().unwrap().unwrap(); + assert!(first.name().unwrap().contains("Menlo")); + } +} diff --git a/crates/ghostty-font/src/face.rs b/crates/ghostty-font/src/face.rs index c070eb5..191db5e 100644 --- a/crates/ghostty-font/src/face.rs +++ b/crates/ghostty-font/src/face.rs @@ -1,6 +1,7 @@ //! Face types shared by all face implementations. Port of `font/face.zig`. //! -//! The concrete FreeType face lives in [`freetype`] (`font/face/freetype.zig`). +//! The concrete FreeType face lives in [`freetype`] (`font/face/freetype.zig`), +//! the CoreText face (Apple targets) in [`coretext`] (`font/face/coretext.zig`). // `(a + b) / 2` is kept verbatim from upstream (f64::midpoint rounds differently). #![allow(clippy::manual_midpoint)] @@ -9,14 +10,23 @@ use std::sync::Arc; use crate::metrics::Metrics; -#[cfg(not(target_arch = "wasm32"))] +#[cfg(font_coretext_face)] +pub mod coretext; +pub mod coretext_math; +#[cfg(font_freetype)] pub mod freetype; // The pure (non-JS) parts of the web_canvas face compile everywhere so they // can be unit tested natively; the JS-backed `Face` is wasm32-only. pub mod web_canvas; -#[cfg(not(target_arch = "wasm32"))] -pub use freetype::Face; +#[cfg(all(font_coretext_face, font_harfbuzz))] +pub use coretext::HbFont; +#[cfg(font_coretext_face)] +pub use coretext::{Face, FaceError, RenderError}; +#[cfg(font_freetype)] +pub use freetype::HbFont; +#[cfg(font_freetype)] +pub use freetype::{Face, FaceError, RenderError}; pub use ghostty_config::types_b::FreetypeLoadFlags; #[cfg(target_arch = "wasm32")] pub use web_canvas::Face; @@ -24,6 +34,9 @@ pub use web_canvas::Face; /// 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 /// using whatever platform method you can. +#[cfg(target_os = "macos")] +pub const DEFAULT_DPI: u16 = 72; +#[cfg(not(target_os = "macos"))] pub const DEFAULT_DPI: u16 = 96; pub const FREETYPE_LOAD_FLAGS_DEFAULT: FreetypeLoadFlags = FreetypeLoadFlags { diff --git a/crates/ghostty-font/src/face/coretext.rs b/crates/ghostty-font/src/face/coretext.rs new file mode 100644 index 0000000..6c733b4 --- /dev/null +++ b/crates/ghostty-font/src/face/coretext.rs @@ -0,0 +1,884 @@ +//! CoreText font face (macOS / iOS). Port of `font/face/coretext.zig`. +//! +//! Compile-checked on Linux with `cargo check --target aarch64-apple-darwin`; +//! it cannot be run here. The platform-independent math (metrics priority, +//! glyph bearings/canvas size, synthetic bold width) lives in +//! [`super::coretext_math`] and is unit tested everywhere. +#![allow(unsafe_code)] + +use std::ptr::{self, NonNull}; + +use objc2_core_foundation::{CFData, CFNumber, CFRetained, CFString, CGAffineTransform, CGPoint}; +use objc2_core_graphics::{ + CGBitmapContextCreate, + CGColorSpace, + CGContext, + CGImageAlphaInfo, + CGImageByteOrderInfo, + CGTextDrawingMode, + kCGColorSpaceDisplayP3, + kCGColorSpaceLinearGray, +}; +use objc2_core_text::{CTFont, CTFontOrientation, CTFontSymbolicTraits}; + +use super::{ + DesiredSize, + FontData, + Options, + RenderOptions, + Variation, + coretext_math::{self as math, CtMetrics, Measurements, Rect, synthetic_bold_line_width}, +}; +use crate::{ + atlas::{self, Atlas}, + coretext_util::{self as util, CfBox}, + glyph::Glyph, + library::Library, + metrics::FaceMetrics, + opentype, +}; + +#[derive(Debug, thiserror::Error)] +pub enum FaceError { + /// CoreText could not create a font from the given data. + #[error("font initialization failed")] + FontInitFailure, + /// The `SVG ` table is malformed. + #[error("invalid SVG table")] + InvalidSvgTable, + /// The face has no backing file (used by the `coretext_freetype` backend). + #[error("font has no file")] + FontHasNoFile, + /// The font file path could not be decoded. + #[error("font path can't be decoded")] + FontPathCantDecode, + /// Creating a font variation failed. + #[error("variation failed")] + VariationFailed, +} + +#[derive(Debug, thiserror::Error)] +pub enum RenderError { + #[error(transparent)] + Atlas(#[from] atlas::Error), + /// The atlas format does not match the glyph's color depth. + #[error("invalid atlas format")] + InvalidAtlasFormat, + /// A CoreGraphics color space or bitmap context could not be created. + #[error("coregraphics resource creation failed")] + ContextFailed, + /// The glyph index doesn't fit a CoreText `CGGlyph` (16 bits). + #[error("invalid glyph index")] + InvalidGlyph, +} + +/// A harfbuzz font owned by a [`Face`] (only used by the +/// `coretext_harfbuzz` backend). +#[cfg(font_harfbuzz)] +pub struct HbFont(NonNull); + +// SAFETY: hb_font_t is thread-safe for shaping when not mutated; Ghostty uses +// it behind the collection lock exactly like upstream. +#[cfg(font_harfbuzz)] +unsafe impl Send for HbFont {} +// SAFETY: see above. +#[cfg(font_harfbuzz)] +unsafe impl Sync for HbFont {} + +#[cfg(font_harfbuzz)] +impl HbFont { + /// The raw `hb_font_t` pointer (valid while the `Face` lives). + pub fn as_ptr(&self) -> *mut harfbuzz_sys::hb_font_t { + self.0.as_ptr() + } +} + +#[cfg(font_harfbuzz)] +impl Drop for HbFont { + fn drop(&mut self) { + // SAFETY: we own one reference to the hb font. + unsafe { harfbuzz_sys::hb_font_destroy(self.0.as_ptr()) }; + } +} + +pub struct Face { + /// Our font face + font: CfBox, + + /// Harfbuzz font corresponding to this face. We only use this + /// if we're using Harfbuzz. + #[cfg(font_harfbuzz)] + hb_font: HbFont, + + /// Set quirks.disableDefaultFontFeatures + pub quirks_disable_default_font_features: bool, + + /// True if this font face should be rasterized with a synthetic bold + /// effect. This is used for fonts that don't have a bold variant. + pub synthetic_bold: Option, + + /// If the face can possibly be colored, then this is the state + /// used to check for color information. This is None if the font + /// can't possibly be colored (i.e. doesn't have SVG, sbix, etc + /// tables). + color: Option, + + /// The current size this font is set to. + pub size: DesiredSize, +} + +impl std::fmt::Debug for Face { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("Face") + .field("size", &self.size) + .field("synthetic_bold", &self.synthetic_bold) + .finish_non_exhaustive() + } +} + +/// The matrix applied to a regular font to auto-italicize it. +fn italic_skew() -> CGAffineTransform { + CGAffineTransform { + a: 1.0, + b: 0.0, + c: math::ITALIC_SKEW_C, // approx. tan(15) + d: 1.0, + tx: 0.0, + ty: 0.0, + } +} + +impl Face { + /// Initialize a CoreText-based font from a TTF/TTC in memory. + pub fn init(_lib: &Library, source: FontData, opts: Options) -> Result { + let data = match source { + FontData::Static(s) => CFData::from_static_bytes(s), + FontData::Shared(s) => CFData::from_bytes(&s), + }; + + // SAFETY: `data` is a valid CFData. + let desc = unsafe { objc2_core_text::CTFontManagerCreateFontDescriptorFromData(&data) } + .ok_or(FaceError::FontInitFailure)?; + + // SAFETY: valid descriptor, null matrix is allowed. + let ct_font = unsafe { CTFont::with_font_descriptor(&desc, 12.0, ptr::null()) }; + + Self::init_font_copy(&ct_font, opts) + } + + /// Initialize a CoreText-based face from another initialized font face + /// but with a new size. This is often how CoreText fonts are initialized + /// because the font is loaded at a default size during discovery, and then + /// adjusted to the final size for final load. + pub fn init_font_copy(base: &CTFont, opts: Options) -> Result { + // Create a copy. The copyWithAttributes docs say the size is in points, + // but we need to scale the points by the DPI and to do that we use our + // function called "pixels". + // SAFETY: null matrix and no descriptor are allowed. + let ct_font = + unsafe { base.copy_with_attributes(f64::from(opts.size.pixels()), ptr::null(), None) }; + + Self::init_font(ct_font, opts) + } + + /// Initialize a face with a CTFont. This takes ownership of the CTFont. + pub fn init_font(ct_font: CFRetained, opts: Options) -> Result { + // SAFETY: plain CoreText call. + let traits = unsafe { ct_font.symbolic_traits() }; + + #[cfg(font_harfbuzz)] + let hb_font = { + // SAFETY: valid CTFont; hb takes its own reference. + let p = unsafe { harfbuzz_sys::coretext::hb_coretext_font_create(&ct_font) }; + let hb = NonNull::new(p) + .map(HbFont) + .ok_or(FaceError::FontInitFailure)?; + // 26.6 fixed point scale + let scale = (f64::from(opts.size.pixels()) * 64.0).round() as i32; + // SAFETY: valid hb font. + unsafe { harfbuzz_sys::hb_font_set_scale(hb.as_ptr(), scale, scale) }; + hb + }; + + let color = if traits.contains(CTFontSymbolicTraits::TraitColorGlyphs) { + Some(ColorState::new(&ct_font)?) + } else { + None + }; + + let result = Face { + font: CfBox::new(ct_font), + #[cfg(font_harfbuzz)] + hb_font, + quirks_disable_default_font_features: false, + synthetic_bold: None, + color, + size: opts.size, + }; + + // In debug mode, we output information about available variation axes, + // if they exist. + if cfg!(debug_assertions) + && let Some(axes) = util::variation_axes(&result.font) + { + log::debug!("variation axes font={}", result.name()); + for axis in axes { + log::debug!( + "variation axis: name={} id={} min={} max={} def={}", + axis.name, + String::from_utf8_lossy(&axis.id.to_be_bytes()), + axis.min, + axis.max, + axis.default, + ); + } + } + + Ok(result) + } + + /// Return a new face that is the same as this but has a transformation + /// matrix applied to italicize it. + pub fn synthetic_italic(&self, opts: Options) -> Result { + let skew = italic_skew(); + // SAFETY: `skew` outlives the call. + let ct_font = unsafe { self.font.copy_with_attributes(0.0, &raw const skew, None) }; + Self::init_font(ct_font, opts) + } + + /// Return a new face that is the same as this but applies a synthetic + /// bold effect to it. This is useful for fonts that don't have a bold + /// variant. + pub fn synthetic_bold(&self, opts: Options) -> Result { + // SAFETY: null matrix and no descriptor are allowed. + let ct_font = unsafe { self.font.copy_with_attributes(0.0, ptr::null(), None) }; + let mut face = Self::init_font(ct_font, opts)?; + face.synthetic_bold = Some(synthetic_bold_line_width(f64::from(opts.size.points))); + Ok(face) + } + + /// Returns the font name (the family name). + pub fn name(&self) -> String { + // SAFETY: plain CoreText call. + util::cf_to_string(&*unsafe { self.font.family_name() }) + } + + /// Resize the font in-place. If this succeeds, the caller is responsible + /// for clearing any glyph caches, font atlas data, etc. + pub fn set_size(&mut self, opts: Options) -> Result<(), FaceError> { + // We just create a copy and replace ourself + let face = Self::init_font_copy(&self.font, opts)?; + *self = face; + Ok(()) + } + + /// Set the variation axes for this font. This will modify this font + /// in-place. + pub fn set_variations(&mut self, vs: &[Variation], opts: Options) -> Result<(), FaceError> { + // If we have no variations, we don't need to do anything. + if vs.is_empty() { + return Ok(()); + } + + // Create a new font descriptor with all the variations set. + // SAFETY: plain CoreText call. + let mut desc = unsafe { self.font.font_descriptor() }; + for v in vs { + let id = CFNumber::new_i32(v.id.0 as i32); + // SAFETY: valid number and descriptor. + desc = unsafe { desc.copy_with_variation(&id, v.value) }; + } + + // Put our current size in the opts so that we don't change size. + let mut new_opts = opts; + new_opts.size = self.size; + + // Initialize a font based on these attributes. + // SAFETY: null matrix is allowed. + let ct_font = unsafe { + self.font + .copy_with_attributes(0.0, ptr::null(), Some(&desc)) + }; + let face = Self::init_font(ct_font, new_opts)?; + *self = face; + Ok(()) + } + + /// Returns true if the face has any glyphs that are colorized. + /// To determine if an individual glyph is colorized you must use + /// `is_color_glyph`. + pub fn has_color(&self) -> bool { + self.color.is_some() + } + + /// Returns true if the given glyph ID is colorized. + pub fn is_color_glyph(&self, glyph_id: u32) -> bool { + match &self.color { + Some(c) => c.is_color_glyph(glyph_id), + None => false, + } + } + + /// Returns the glyph index for the given Unicode code point. If this + /// face doesn't support this glyph, None is returned. + pub fn glyph_index(&self, cp: u32) -> Option { + let (glyph, ok) = util::glyphs_for_codepoint(&self.font, cp); + if !ok { + return None; + } + Some(u32::from(glyph)) + } + + /// Render a glyph using the glyph index. The rendered glyph is stored in + /// the given texture atlas. + pub fn render_glyph( + &self, + atlas: &mut Atlas, + glyph_index: u32, + opts: RenderOptions, + ) -> Result { + let mut glyph = [u16::try_from(glyph_index).map_err(|_| RenderError::InvalidGlyph)?]; + // SAFETY: one valid glyph in the buffer. + let ct_rect = unsafe { + self.font.bounding_rects_for_glyphs( + CTFontOrientation::Horizontal, + NonNull::new_unchecked(glyph.as_mut_ptr()), + ptr::null_mut(), + 1, + ) + }; + // This is in a coordinate space with (0.0, 0.0) + // in the bottom left and +Y pointing up. + let rect = Rect { + x: ct_rect.origin.x, + y: ct_rect.origin.y, + width: ct_rect.size.width, + height: ct_rect.size.height, + }; + + // Determine whether this is a color glyph. + let is_color = self.is_color_glyph(glyph_index); + // And whether it's (probably) a bitmap (sbix). + let sbix = is_color && self.color.as_ref().is_some_and(|c| c.sbix); + + let Some(layout) = math::glyph_layout(rect, sbix, self.synthetic_bold, &opts) else { + return Ok(Glyph { + width: 0, + height: 0, + offset_x: 0, + offset_y: 0, + atlas_x: 0, + atlas_y: 0, + }); + }; + + // Settings that are specific to if we are rendering text or emoji. + let depth = math::bitmap_depth(is_color); + let (space, context_opts) = if is_color { + // SAFETY: valid static CFString constant. + let space = CGColorSpace::with_name(Some(unsafe { kCGColorSpaceDisplayP3 })); + ( + space, + CGImageByteOrderInfo::Order32Little.0 | CGImageAlphaInfo::PremultipliedFirst.0, + ) + } else { + // SAFETY: valid static CFString constant. + let space = CGColorSpace::with_name(Some(unsafe { kCGColorSpaceLinearGray })); + (space, CGImageAlphaInfo::Only.0) + }; + let space = space.ok_or(RenderError::ContextFailed)?; + + // This is just a safety check. + if u32::from(atlas.format.depth()) != depth { + log::warn!( + "font atlas color depth doesn't equal font color depth atlas={} font={}", + atlas.format.depth(), + depth, + ); + return Err(RenderError::InvalidAtlasFormat); + } + + // Our buffer for rendering. We could cache this but glyph rasterization + // usually stabilizes pretty quickly and is very infrequent so I think + // the allocation overhead is acceptable compared to the cost of + // caching it forever or having to deal with a cache lifetime. + let width = layout.px_width as usize; + let height = layout.px_height as usize; + let mut buf = vec![0u8; width * height * depth as usize]; + + // SAFETY: `buf` is `width * height * depth` bytes, matching the + // bytes-per-row and dimensions given; it outlives the context (the + // context is dropped before `buf` at the end of this function). + let ctx = unsafe { + CGBitmapContextCreate( + buf.as_mut_ptr().cast(), + width, + height, + 8, + width * depth as usize, + Some(&space), + context_opts, + ) + } + .ok_or(RenderError::ContextFailed)?; + let c = Some(&*ctx); + + // Perform an initial fill. This ensures that we don't have any + // uninitialized pixels in the bitmap. + if is_color { + CGContext::set_rgb_fill_color(c, 0.0, 0.0, 0.0, 0.0); + } else { + CGContext::set_gray_fill_color(c, 0.0, 0.0); + } + CGContext::fill_rect( + c, + objc2_core_foundation::CGRect::new( + CGPoint::new(0.0, 0.0), + objc2_core_foundation::CGSize::new(width as f64, height as f64), + ), + ); + + // "Font smoothing" is what we call "thickening", it's an attempt + // to compensate for optical thinning of fonts, but at this point + // it's just something that makes the text look closer to system + // applications if users want that. + CGContext::set_allows_font_smoothing(c, true); + CGContext::set_should_smooth_fonts(c, opts.thicken); + + // Subpixel positioning allows glyphs to be placed at non-integer + // coordinates. We need this for our alignment. + CGContext::set_allows_font_subpixel_positioning(c, true); + CGContext::set_should_subpixel_position_fonts(c, true); + + // We don't want subpixel quantization, since we very carefully + // manage the position of our glyphs ourselves, and dont want to + // mess that up. + CGContext::set_allows_font_subpixel_quantization(c, false); + CGContext::set_should_subpixel_quantize_fonts(c, false); + + // Anti-aliasing is self explanatory. + CGContext::set_allows_antialiasing(c, true); + CGContext::set_should_antialias(c, true); + + // Set our color for drawing + if is_color { + CGContext::set_rgb_fill_color(c, 1.0, 1.0, 1.0, 1.0); + CGContext::set_rgb_stroke_color(c, 1.0, 1.0, 1.0, 1.0); + } else { + let strength = f64::from(opts.thicken_strength); + CGContext::set_gray_fill_color(c, strength / 255.0, 1.0); + CGContext::set_gray_stroke_color(c, strength / 255.0, 1.0); + } + + // If we are drawing with synthetic bold then use a fill stroke + // which strokes the outlines of the glyph making a more bold look. + if let Some(line_width) = self.synthetic_bold { + CGContext::set_text_drawing_mode(c, CGTextDrawingMode::FillStroke); + CGContext::set_line_width(c, line_width); + } + + // Translate our drawing context so that when we draw our + // glyph the bottom/left edge is at the correct sub-pixel + // position. The bottom/left edges are guaranteed to be at + // exactly [0, 0] relative to this because when we call to + // `drawGlyphs`, we pass the negated bearings. + CGContext::translate_ctm( + c, + layout.frac_x + f64::from(layout.canvas_padding), + layout.frac_y + f64::from(layout.canvas_padding), + ); + + // Scale the drawing context so that when we draw + // our glyph it's stretched to the constrained size. + CGContext::scale_ctm(c, layout.scale_x, layout.scale_y); + + // Draw our glyph. + // + // We offset the position by the negated bearings so that the + // glyph is drawn at exactly [0, 0], which is then offset to + // the appropriate fractional position by the translation we + // did before scaling. + let mut position = CGPoint::new(-layout.rect_x, -layout.rect_y); + // SAFETY: one glyph and one position, valid context. + unsafe { + self.font.draw_glyphs( + NonNull::new_unchecked(glyph.as_mut_ptr()), + NonNull::from(&mut position), + 1, + &ctx, + ); + } + + // The context must be released before we read the buffer. + drop(ctx); + + // Write our rasterized glyph to the atlas. + let region = atlas.reserve(layout.px_width, layout.px_height)?; + atlas.set(region, &buf); + + Ok(Glyph { + width: layout.px_width, + height: layout.px_height, + offset_x: layout.offset_x(), + offset_y: layout.offset_y(), + atlas_x: region.x, + atlas_y: region.y, + }) + } + + /// Get the `FaceMetrics` for this face. + pub fn get_metrics(&self) -> FaceMetrics { + let ct_font: &CTFont = &self.font; + + // Read the 'head' table out of the font data. + // + // macOS bitmap-only fonts use a 'bhed' tag rather than 'head', but + // the table format is byte-identical to the 'head' table, so if we + // can't find 'head' we try 'bhed' instead before failing. + // + // ref: https://fontforge.org/docs/techref/bitmaponlysfnt.html + let head = util::font_table_bytes(ct_font, b"head") + .or_else(|| util::font_table_bytes(ct_font, b"bhed")) + .and_then(|d| { + opentype::Head::init(&d) + .inspect_err(|e| log::warn!("error parsing head table: {e}")) + .ok() + }); + + // Read the 'post' table out of the font data. + let post = util::font_table_bytes(ct_font, b"post").and_then(|d| { + opentype::Post::init(&d) + .inspect_err(|e| log::warn!("error parsing post table: {e}")) + .ok() + }); + + // Read the 'OS/2' table out of the font data if it's available. + let os2 = util::font_table_bytes(ct_font, b"OS/2").and_then(|d| { + opentype::OS2::init(&d) + .inspect_err(|e| log::warn!("error parsing OS/2 table: {e}")) + .ok() + }); + + // Read the 'hhea' table out of the font data. + let hhea = util::font_table_bytes(ct_font, b"hhea").and_then(|d| { + opentype::Hhea::init(&d) + .inspect_err(|e| log::warn!("error parsing hhea table: {e}")) + .ok() + }); + + // SAFETY: plain CoreText getters. + let (units_per_em, px_per_em, ct) = unsafe { + ( + head.map_or_else( + || f64::from(ct_font.units_per_em()), + |h| f64::from(h.unitsPerEm), + ), + ct_font.size(), + CtMetrics { + ascent: ct_font.ascent(), + descent: ct_font.descent(), + leading: ct_font.leading(), + cap_height: ct_font.cap_height(), + x_height: ct_font.x_height(), + }, + ) + }; + + // Cell width is calculated by calculating the widest width of the + // visible ASCII characters. Usually 'M' is widest but we just take + // whatever is widest. + // + // ASCII height is calculated as the height of the overall bounding + // box of the same characters. + let (cell_width, ascii_height) = { + // All the visible ASCII chars + let mut unichars: Vec = (32u16..127).collect(); + let n = unichars.len(); + + // Get our glyph IDs for the ASCII chars + let mut glyphs = vec![0u16; n]; + // SAFETY: both buffers have `n` elements. + unsafe { + ct_font.glyphs_for_characters( + NonNull::new_unchecked(unichars.as_mut_ptr()), + NonNull::new_unchecked(glyphs.as_mut_ptr()), + n as isize, + ); + } + + // Get all our advances + let mut advances = vec![objc2_core_foundation::CGSize::new(0.0, 0.0); n]; + // SAFETY: buffers have `n` elements. + unsafe { + ct_font.advances_for_glyphs( + CTFontOrientation::Horizontal, + NonNull::new_unchecked(glyphs.as_mut_ptr()), + advances.as_mut_ptr(), + n as isize, + ); + } + + // Find the maximum advance + let max = advances.iter().fold(0.0f64, |m, a| a.width.max(m)); + + // Get the overall bounding rect for the glyphs + // SAFETY: `glyphs` has `n` elements. + let rect = unsafe { + ct_font.bounding_rects_for_glyphs( + CTFontOrientation::Horizontal, + NonNull::new_unchecked(glyphs.as_mut_ptr()), + ptr::null_mut(), + n as isize, + ) + }; + + (max, rect.size.height) + }; + + // Measure "ๆฐด" (CJK water ideograph, U+6C34) for our ic width. + let ic_width = self.glyph_index('ๆฐด' as u32).and_then(|glyph| { + let mut g = [glyph as u16]; + // SAFETY: one valid glyph. + let (advance, bounds) = unsafe { + ( + ct_font.advances_for_glyphs( + CTFontOrientation::Horizontal, + NonNull::new_unchecked(g.as_mut_ptr()), + ptr::null_mut(), + 1, + ), + ct_font.bounding_rects_for_glyphs( + CTFontOrientation::Horizontal, + NonNull::new_unchecked(g.as_mut_ptr()), + ptr::null_mut(), + 1, + ), + ) + }; + + // If the advance of the glyph is less than the width of the actual + // glyph then we just treat it as invalid since it's probably wrong + // and using it for size normalization will instead make the font + // way too big. + // + // This can sometimes happen if there's a CJK font that has been + // patched with the nerd fonts patcher and it butchers the advance + // values so the advance ends up half the width of the actual glyph. + if !math::ic_width_is_valid(bounds.size.width, advance) { + log::warn!( + "(getMetrics) Width of glyph 'ๆฐด' for font \"{}\" is greater than its advance ({} > {}), discarding ic_width metric.", + self.name(), + bounds.size.width, + advance, + ); + return None; + } + + Some(advance) + }); + + math::face_metrics( + px_per_em, + units_per_em, + hhea.as_ref(), + os2.as_ref(), + post.as_ref(), + &ct, + Measurements { + cell_width, + ascii_height, + ic_width, + }, + ) + } + + /// Copy the font table data for the given tag. + pub fn copy_table(&self, tag: &[u8; 4]) -> Result>, FaceError> { + Ok(util::font_table_bytes(&self.font, tag)) + } + + /// The underlying CTFont (for shapers). + pub(crate) fn ct_font(&self) -> &CTFont { + &self.font + } + + /// The harfbuzz font corresponding to this face (for the HarfBuzz shaper). + #[cfg(font_harfbuzz)] + pub fn hb_font(&self) -> &HbFont { + &self.hb_font + } + + /// The `FontHasNoFile`-style path of this face's file (for the + /// `coretext_freetype` backend this is done by the deferred face). + pub fn font_descriptor(&self) -> CFRetained { + // SAFETY: plain CoreText call. + unsafe { self.font.font_descriptor() } + } +} + +/// The state associated with a font face that may have colorized glyphs. +/// This is used to determine if a specific glyph ID is colorized. +struct ColorState { + /// True if there is an sbix font table. For now, the mere presence + /// of an sbix font table causes us to assume the glyph is colored. + /// We can improve this later. + sbix: bool, + + /// The SVG font table data (if any), which we can use to determine + /// if a glyph is present in the SVG table. + svg: Option, +} + +/// An owned copy of the `SVG ` table and the glyph range it covers. +struct SvgTable { + data: Vec, +} + +impl ColorState { + fn new(f: &CTFont) -> Result { + // sbix is true if the table exists in the font data at all. + // In the future we probably want to actually parse it and + // check for glyphs. + let sbix = util::font_table(f, b"sbix").is_some_and(|d| !d.is_empty()); + + // Read the SVG table out of the font data. + let svg = match util::font_table_bytes(f, b"SVG ") { + None => None, + Some(data) => { + // Validate the table up front like Zig. + opentype::SVG::init(&data).map_err(|_| FaceError::InvalidSvgTable)?; + Some(SvgTable { data }) + } + }; + + Ok(ColorState { sbix, svg }) + } + + /// Returns true if the given glyph ID is colored. + fn is_color_glyph(&self, glyph_id: u32) -> bool { + // Our font system uses 32-bit glyph IDs for special values but + // actual fonts only contain 16-bit glyph IDs so if we can't cast + // into it it must be false. + let Ok(glyph_u16) = u16::try_from(glyph_id) else { + return false; + }; + + // sbix is always true for now + if self.sbix { + return true; + } + + // if we have svg data, check it + if let Some(svg) = &self.svg + && let Ok(table) = opentype::SVG::init(&svg.data) + && table.has_glyph(glyph_u16) + { + return true; + } + + false + } +} + +/// Create a CTFont by name at the given size (test/dev helper equivalent of +/// `FontDescriptor.createWithNameAndSize` + `Font.createWithFontDescriptor`). +pub fn font_with_name(name: &str, size: f64) -> CFRetained { + let cf_name = CFString::from_str(name); + // SAFETY: valid name; null matrix is allowed. + unsafe { CTFont::with_name(&cf_name, size, ptr::null()) } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::{embedded, metrics::Metrics}; + + fn opts(points: f32) -> Options { + Options::new(DesiredSize::new(points)) + } + + fn render_ascii(face: &Face) { + let mut atlas = Atlas::new(512, atlas::Format::Grayscale); + for i in 32u32..127 { + let g = face.glyph_index(i).unwrap(); + face.render_glyph( + &mut atlas, + g, + RenderOptions::new(Metrics::calc(face.get_metrics())), + ) + .unwrap(); + } + } + + #[test] + fn monaco_ascii() { + let font = font_with_name("Monaco", 12.0); + let face = Face::init_font_copy(&font, opts(12.0)).unwrap(); + render_ascii(&face); + } + + #[test] + fn name() { + let font = font_with_name("Menlo", 12.0); + let face = Face::init_font_copy(&font, opts(12.0)).unwrap(); + assert_eq!(face.name(), "Menlo"); + } + + #[test] + fn emoji() { + let font = font_with_name("Apple Color Emoji", 12.0); + let face = Face::init_font_copy(&font, opts(18.0)).unwrap(); + let id = face.glyph_index('๐Ÿฅธ' as u32).unwrap(); + assert!(face.is_color_glyph(id)); + } + + #[test] + fn in_memory() { + let lib = Library::init().unwrap(); + let face = Face::init(&lib, FontData::Static(embedded::REGULAR), opts(12.0)).unwrap(); + render_ascii(&face); + } + + #[test] + fn variable() { + let lib = Library::init().unwrap(); + let face = Face::init(&lib, FontData::Static(embedded::VARIABLE), opts(12.0)).unwrap(); + render_ascii(&face); + } + + #[test] + fn variable_set_variation() { + let lib = Library::init().unwrap(); + let mut face = Face::init(&lib, FontData::Static(embedded::VARIABLE), opts(12.0)).unwrap(); + face.set_variations( + &[Variation { + id: super::super::VariationId::init(b"wght"), + value: 400.0, + }], + opts(12.0), + ) + .unwrap(); + render_ascii(&face); + } + + #[test] + fn svg_font_table() { + let lib = Library::init().unwrap(); + let face = Face::init(&lib, FontData::Static(embedded::JULIA_MONO), opts(12.0)).unwrap(); + let table = face.copy_table(b"SVG ").unwrap().unwrap(); + assert_ne!(table.len(), 0); + } + + #[test] + fn glyph_index_colored_vs_text() { + let lib = Library::init().unwrap(); + let face = Face::init(&lib, FontData::Static(embedded::JULIA_MONO), opts(12.0)).unwrap(); + let glyph = face.glyph_index('A' as u32).unwrap(); + assert_eq!(glyph, 4); + assert!(!face.is_color_glyph(glyph)); + + let glyph = face.glyph_index(0xE800).unwrap(); + assert_eq!(glyph, 11482); + assert!(face.is_color_glyph(glyph)); + } +} diff --git a/crates/ghostty-font/src/face/coretext_math.rs b/crates/ghostty-font/src/face/coretext_math.rs new file mode 100644 index 0000000..9f30abe --- /dev/null +++ b/crates/ghostty-font/src/face/coretext_math.rs @@ -0,0 +1,956 @@ +//! Platform-independent math extracted from the CoreText face +//! (`font/face/coretext.zig`) so it can be unit tested on every platform. +//! +//! The CoreText face itself only exists on Apple targets; everything here is +//! plain arithmetic on values that the face reads from CoreText / the +//! OpenType tables: +//! +//! * [`face_metrics`]: the `getMetrics` table-priority logic (OS/2 typo +//! metrics vs. hhea vs. OS/2 win metrics, underline/strikethrough +//! fallbacks, cap/ex heights). +//! * [`synthetic_bold_line_width`]: the synthetic bold stroke width. +//! * [`glyph_layout`]: the bearing / canvas-size computation of +//! `renderGlyph`. + +use super::{Constraint, ConstraintSize, GlyphSize, RenderOptions}; +use crate::{ + metrics::FaceMetrics, + opentype::{Hhea, OS2, Post}, +}; + +/// The matrix applied to a regular font to auto-italicize it +/// (`Face.italic_skew`): `a=1, b=0, c=tan(15deg) (approx), d=1, tx=ty=0`. +pub const ITALIC_SKEW_C: f64 = 0.267949; + +/// Metrics CoreText itself reports (used as fallbacks when a table is +/// missing). `descent` is CoreText's positive-down value. +#[derive(Debug, Clone, Copy, PartialEq, Default)] +pub struct CtMetrics { + pub ascent: f64, + /// `CTFontGetDescent`: positive distance below the baseline. + pub descent: f64, + pub leading: f64, + pub cap_height: f64, + pub x_height: f64, +} + +/// Measurements taken by drawing/measuring glyphs. +#[derive(Debug, Clone, Copy, PartialEq, Default)] +pub struct Measurements { + /// The widest advance of the visible ASCII characters. + pub cell_width: f64, + /// Height of the overall bounding box of the visible ASCII characters. + pub ascii_height: f64, + /// Advance of U+6C34 (None if absent or considered invalid). + pub ic_width: Option, +} + +/// The line width used for synthetic bold given the font size in points. +/// +/// To determine our synthetic bold line width we get a multiplier from the +/// font size in points. This is a heuristic that is based on the fact that +/// a line width of 1 looks good at a certain point size. We want to scale +/// that up roughly linearly with the font size. +pub fn synthetic_bold_line_width(points: f64) -> f64 { + (points / 14.0).max(1.0) +} + +/// Returns `(ascent, descent, line_gap)` in pixels, +Y up (`descent` is +/// normally negative), following the table priority of `getMetrics`. +pub fn vertical_metrics( + hhea: Option<&Hhea>, + os2: Option<&OS2>, + px_per_unit: f64, + ct: &CtMetrics, +) -> (f64, f64, f64) { + // If we couldn't get the hhea table, rely on metrics from CoreText. + let Some(hhea) = hhea else { + return (ct.ascent, -ct.descent, ct.leading); + }; + + let hhea_ascent = f64::from(hhea.ascender); + let hhea_descent = f64::from(hhea.descender); + let hhea_line_gap = f64::from(hhea.lineGap); + + // If our font has no OS/2 table, then we just + // blindly use the metrics from the hhea table. + let Some(os2) = os2 else { + return ( + hhea_ascent * px_per_unit, + hhea_descent * px_per_unit, + hhea_line_gap * px_per_unit, + ); + }; + + let os2_ascent = f64::from(os2.sTypoAscender); + let os2_descent = f64::from(os2.sTypoDescender); + let os2_line_gap = f64::from(os2.sTypoLineGap); + + // If the font says to use typo metrics, trust it. + if os2.fsSelection.use_typo_metrics { + return ( + os2_ascent * px_per_unit, + os2_descent * px_per_unit, + os2_line_gap * px_per_unit, + ); + } + + // Otherwise we prefer the height metrics from 'hhea' if they + // are available, or else OS/2 sTypo* metrics, and if all else + // fails then we use OS/2 usWin* metrics. + // + // This is not "standard" behavior, but it's our best bet to + // account for fonts being... just weird. It's pretty much what + // FreeType does to get its generic ascent and descent metrics. + if hhea.ascender != 0 || hhea.descender != 0 { + return ( + hhea_ascent * px_per_unit, + hhea_descent * px_per_unit, + hhea_line_gap * px_per_unit, + ); + } + + if os2_ascent != 0.0 || os2_descent != 0.0 { + return ( + os2_ascent * px_per_unit, + os2_descent * px_per_unit, + os2_line_gap * px_per_unit, + ); + } + + let win_ascent = f64::from(os2.usWinAscent); + let win_descent = f64::from(os2.usWinDescent); + ( + win_ascent * px_per_unit, + // usWinDescent is *positive* -> down unlike sTypoDescender + // and hhea.Descender, so we flip its sign to fix this. + -win_descent * px_per_unit, + 0.0, + ) +} + +/// Compute the `FaceMetrics` from the (optionally present) OpenType tables, +/// CoreText's own metrics and the measurements. `px_per_em` is +/// `CTFontGetSize` and `units_per_em` the `head` table value (or +/// `CTFontGetUnitsPerEm` if there is no `head`/`bhed` table). +pub fn face_metrics( + px_per_em: f64, + units_per_em: f64, + hhea: Option<&Hhea>, + os2: Option<&OS2>, + post: Option<&Post>, + ct: &CtMetrics, + measured: Measurements, +) -> FaceMetrics { + let px_per_unit = px_per_em / units_per_em; + + let (ascent, descent, line_gap) = vertical_metrics(hhea, os2, px_per_unit, ct); + + let (underline_position, underline_thickness) = match post { + None => (None, None), + Some(post) => { + // Some fonts have degenerate 'post' tables where the underline + // thickness (and often position) are 0. We consider them null + // if this is the case and use our own fallbacks when we calculate. + let has_broken_underline = post.underlineThickness == 0; + + // If the underline position isn't 0 then we do use it, + // even if the thickness is't properly specified. + let pos = if has_broken_underline && post.underlinePosition == 0 { + None + } else { + Some(f64::from(post.underlinePosition) * px_per_unit) + }; + + let thick = if has_broken_underline { + None + } else { + Some(f64::from(post.underlineThickness) * px_per_unit) + }; + + (pos, thick) + } + }; + + // Similar logic to the underline above. + let (strikethrough_position, strikethrough_thickness) = match os2 { + None => (None, None), + Some(os2) => { + let has_broken_strikethrough = os2.yStrikeoutSize == 0; + + let pos = if has_broken_strikethrough && os2.yStrikeoutPosition == 0 { + None + } else { + Some(f64::from(os2.yStrikeoutPosition) * px_per_unit) + }; + + let thick = if has_broken_strikethrough { + None + } else { + Some(f64::from(os2.yStrikeoutSize) * px_per_unit) + }; + + (pos, thick) + } + }; + + // We fall back to whatever CoreText does if the + // OS/2 table doesn't specify a cap or ex height. + let (cap_height, ex_height) = match os2 { + None => (ct.cap_height, ct.x_height), + Some(os2) => ( + os2.sCapHeight + .map_or(ct.cap_height, |v| f64::from(v) * px_per_unit), + os2.sxHeight + .map_or(ct.x_height, |v| f64::from(v) * px_per_unit), + ), + }; + + FaceMetrics { + px_per_em, + + cell_width: measured.cell_width, + + ascent, + descent, + line_gap, + + underline_position, + underline_thickness, + + strikethrough_position, + strikethrough_thickness, + + cap_height: Some(cap_height), + ex_height: Some(ex_height), + ascii_height: Some(measured.ascii_height), + ic_width: measured.ic_width, + } +} + +/// Is the advance of the "ๆฐด" glyph usable as the ic width? If the advance +/// is less than the width of the actual glyph then it is probably wrong +/// (e.g. a CJK font patched by the nerd fonts patcher) and using it for size +/// normalization would make the font way too big. +pub fn ic_width_is_valid(glyph_width: f64, advance: f64) -> bool { + glyph_width <= advance || glyph_width.is_nan() || advance.is_nan() +} + +/// A CoreText bounding rect (origin is bottom-left, +Y up). +#[derive(Debug, Clone, Copy, PartialEq, Default)] +pub struct Rect { + pub x: f64, + pub y: f64, + pub width: f64, + pub height: f64, +} + +/// The result of laying out a glyph for rasterization. +#[derive(Debug, Clone, Copy, PartialEq)] +pub struct GlyphLayout { + /// Whole pixel bearings of the final glyph. + pub px_x: i32, + pub px_y: i32, + /// Canvas size (includes padding and the fractional offset). + pub px_width: u32, + pub px_height: u32, + /// Extra pixels around the glyph for font smoothing. + pub canvas_padding: u32, + /// Fractional part of the pixel bearings, added as an offset when + /// rasterizing. + pub frac_x: f64, + pub frac_y: f64, + /// Scale applied to the context so the glyph is stretched to the + /// constrained size. + pub scale_x: f64, + pub scale_y: f64, + /// The (possibly bold-adjusted) bounding rect origin; the glyph is drawn + /// at the negated origin. + pub rect_x: f64, + pub rect_y: f64, +} + +impl GlyphLayout { + /// `Glyph.offset_y`: distance from the bottom of the cell to the top of + /// the glyph's bounding box. + pub fn offset_y(&self) -> i32 { + self.px_y + self.px_height as i32 + } + + /// `Glyph.offset_x`: distance from the left of the cell to the left of + /// the glyph's bounding box. + pub fn offset_x(&self) -> i32 { + self.px_x + } +} + +/// The bearing / size computation from `renderGlyph`. +/// +/// `rect` is `CTFontGetBoundingRectsForGlyphs` for the glyph. Returns `None` +/// if the glyph is too small to render (the caller then returns a zero-sized +/// `Glyph`). +pub fn glyph_layout( + mut rect: Rect, + is_sbix: bool, + synthetic_bold: Option, + opts: &RenderOptions, +) -> Option { + // If we're rendering a synthetic bold then we will gain 50% of + // the line width on every edge, which means we should increase + // our width and height by the line width and subtract half from + // our origin points. + // + // We don't add extra size if it's a sbix color font though, + // since bitmaps aren't affected by synthetic bold. + if !is_sbix && let Some(line_width) = synthetic_bold { + rect.width += line_width; + rect.height += line_width; + rect.x -= line_width / 2.0; + rect.y -= line_width / 2.0; + } + + // If our rect is smaller than a quarter pixel in either axis + // then it has no outlines or they're too small to render. + if rect.width < 0.25 || rect.height < 0.25 { + return None; + } + + let metrics = &opts.grid_metrics; + let cell_width = f64::from(metrics.cell_width); + let cell_height = f64::from(metrics.cell_height); + + // Next we apply any constraints to get the final size of the glyph. + let constraint: &Constraint = &opts.constraint; + + // We need to add the baseline position before passing to the constrain + // function since it operates on cell-relative positions, not baseline. + let cell_baseline = f64::from(metrics.cell_baseline); + + let glyph_size = constraint.constrain( + GlyphSize { + width: rect.width, + height: rect.height, + x: rect.x, + y: rect.y + cell_baseline, + }, + metrics, + opts.constraint_width, + ); + + let mut x = glyph_size.x; + let mut y = glyph_size.y; + let mut width = glyph_size.width; + let mut height = glyph_size.height; + + // We center all glyphs within the pixel-rounded and adjusted + // cell width if it's larger than the face width, so that they + // aren't weirdly off to the left. + // + // We don't do this if the glyph has a stretch constraint, + // since in that case the position was already calculated with the + // new cell width in mind. + if constraint.size != ConstraintSize::Stretch { + // We add half the difference to re-center. + let dx = (cell_width - metrics.face_width) / 2.0; + x += dx; + if dx < 0.0 { + // For negative diff (cell narrower than advance), we remove the + // integer part and only keep the fractional adjustment needed + // for consistent subpixel positioning. + x -= dx.trunc(); + } + } + + // If this is a bitmap glyph, it will always render as full pixels, + // not fractional pixels, so we need to quantize its position and + // size accordingly to align to full pixels so we get good results. + if is_sbix { + width = cell_width - (cell_width - width - x).round() - x.round(); + height = cell_height - (cell_height - height - y).round() - y.round(); + x = x.round(); + y = y.round(); + } + + // We make an assumption that font smoothing ("thicken") + // adds no more than 1 extra pixel to any edge. We don't + // add extra size if it's a sbix color font though, since + // bitmaps aren't affected by smoothing. + let canvas_padding: u32 = u32::from(opts.thicken && !is_sbix); + + // Our whole-pixel bearings for the final glyph. + // The fractional portion will be included in the rasterized position. + let px_x = (x.floor() as i32) - canvas_padding as i32; + let px_y = (y.floor() as i32) - canvas_padding as i32; + + // We keep track of the fractional part of the pixel bearings, which + // we will add as an offset when rasterizing to make sure we get the + // correct sub-pixel position. + let frac_x = x - x.floor(); + let frac_y = y - y.floor(); + + // Add the fractional pixel to the width and height and take + // the ceiling to get a canvas size that will definitely fit + // our drawn glyph, including the fractional offset and font smoothing. + let px_width = ((width + frac_x).ceil() as u32) + (2 * canvas_padding); + let px_height = ((height + frac_y).ceil() as u32) + (2 * canvas_padding); + + Some(GlyphLayout { + px_x, + px_y, + px_width, + px_height, + canvas_padding, + frac_x, + frac_y, + scale_x: width / rect.width, + scale_y: height / rect.height, + rect_x: rect.x, + rect_y: rect.y, + }) +} + +/// `kCTFontTraitBold` bit of the symbolic traits. +pub const TRAIT_BOLD: u32 = 1 << 1; +/// `kCTFontTraitItalic` bit of the symbolic traits. +pub const TRAIT_ITALIC: u32 = 1; +/// `kCTFontTraitMonoSpace` bit of the symbolic traits. +pub const TRAIT_MONOSPACE: u32 = 1 << 10; +/// `kCTFontTraitColorGlyphs` bit of the symbolic traits. +pub const TRAIT_COLOR_GLYPHS: u32 = 1 << 13; + +/// The symbolic traits value for a descriptor (0 if no trait is set, in +/// which case the traits attribute is not added; see +/// `Descriptor.toCoreTextDescriptor`). +pub fn symbolic_traits_value(bold: bool, italic: bool, monospace: bool) -> u32 { + (if bold { TRAIT_BOLD } else { 0 }) + | (if italic { TRAIT_ITALIC } else { 0 }) + | (if monospace { TRAIT_MONOSPACE } else { 0 }) +} + +/// Number of bytes per pixel and whether the glyph is rendered in color, +/// for the atlas format check in `renderGlyph`. +pub fn bitmap_depth(is_color: bool) -> u32 { + if is_color { 4 } else { 1 } +} + +/// Decode a UTF-32 codepoint into UTF-16 code units +/// (`stringGetSurrogatePairForLongCharacter`). Returns the units and how many +/// are valid (1 or 2). Codepoints outside the Unicode range yield a single +/// replacement unit. +pub fn utf16_units(cp: u32) -> ([u16; 2], usize) { + match char::from_u32(cp) { + Some(c) => { + let mut buf = [0u16; 2]; + let n = c.encode_utf16(&mut buf).len(); + (buf, n) + } + None => ([0xFFFD, 0], 1), + } +} + +/// The score used to sort CoreText discovery results, as a plain integer +/// (`Score.int()` in discovery.zig). Fields from least to most significant: +/// `glyph_count: u16`, `fuzzy_style: u8`, `bold`, `italic`, `exact_style`, +/// `monospace`, `codepoint` (1 bit each). Higher is better. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] +pub struct Score { + pub glyph_count: u16, + pub fuzzy_style: u8, + pub bold: bool, + pub italic: bool, + pub exact_style: bool, + pub monospace: bool, + pub codepoint: bool, +} + +impl Score { + pub fn int(self) -> u32 { + u32::from(self.glyph_count) + | (u32::from(self.fuzzy_style) << 16) + | (u32::from(self.bold) << 24) + | (u32::from(self.italic) << 25) + | (u32::from(self.exact_style) << 26) + | (u32::from(self.monospace) << 27) + | (u32::from(self.codepoint) << 28) + } +} + +/// The style-name strings the discovery score matches against (the first +/// is used for the exact match, all are used for the fuzzy match). +pub fn desired_styles(style: Option<&str>, bold: bool, italic: bool) -> Vec<&str> { + if let Some(s) = style { + return vec![s]; + } + + // If we don't have an explicitly desired style name, we base + // it on the bold and italic properties, this isn't ideal since + // fonts may use style names other than these, but it helps in + // some edge cases. + if bold { + if italic { + return vec!["bold italic", "bold", "italic", "oblique"]; + } + return vec!["bold", "upright"]; + } else if italic { + return vec!["italic", "regular", "oblique"]; + } + vec!["regular", "upright"] +} + +/// Compute `(exact_style, fuzzy_style)` for a font's style string against +/// the desired styles (see `Score.score`). +pub fn style_scores(style_str: &str, desired: &[&str]) -> (bool, u8) { + let exact = style_str.eq_ignore_ascii_case(desired[0]); + + // Our "fuzzy match" score is 0 if the desired style isn't present + // in the string, otherwise we give higher priority for styles that + // have fewer characters not in the desired_styles list. + let mut fuzzy: u8 = style_str.len().min(255) as u8; + let lower = style_str.to_ascii_lowercase(); + for s in desired { + if lower.contains(&s.to_ascii_lowercase()) { + fuzzy = fuzzy.saturating_sub(s.len().min(255) as u8); + } + } + (exact, u8::MAX.saturating_sub(fuzzy)) +} + +/// Derive `(is_bold, is_italic)` for a variation axis list entry, following +/// the thresholds in `Score.score`. `axes` yields `(name, value)`. +pub fn bold_italic_from_axes( + mut is_bold: bool, + mut is_italic: bool, + axes: impl IntoIterator, +) -> (bool, bool) { + // If we see the 'ital' value then we ignore 'slnt'. + let mut ital_seen = false; + for (name, val) in axes { + if name == "wght" { + // Somewhat subjective threshold, we consider fonts + // bold if they have a 'wght' set greater than 600. + is_bold = val > 600.0; + continue; + } + if name == "ital" { + is_italic = val > 0.5; + ital_seen = true; + continue; + } + if !ital_seen && name == "slnt" { + // Arbitrary threshold of anything more than a 5 + // degree clockwise slant is considered italic. + is_italic = val <= -5.0; + } + } + (is_bold, is_italic) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::{ + face::{DesiredSize, RenderOptions}, + metrics::Metrics, + opentype::{ + os2::FSSelection, + sfnt::{Fixed, Version16Dot16}, + }, + }; + + fn hhea(ascender: i16, descender: i16, line_gap: i16) -> Hhea { + Hhea { + majorVersion: 1, + minorVersion: 0, + ascender, + descender, + lineGap: line_gap, + advanceWidthMax: 0, + minLeftSideBearing: 0, + minRightSideBearing: 0, + xMaxExtent: 0, + caretSlopeRise: 0, + caretSlopeRun: 0, + caretOffset: 0, + reserved0: 0, + reserved1: 0, + reserved2: 0, + reserved3: 0, + metricDataFormat: 0, + numberOfHMetrics: 0, + } + } + + fn os2(typo: (i16, i16, i16), win: (u16, u16), use_typo: bool) -> OS2 { + OS2 { + version: 4, + xAvgCharWidth: 0, + usWeightClass: 400, + usWidthClass: 5, + fsType: 0, + ySubscriptXSize: 0, + ySubscriptYSize: 0, + ySubscriptXOffset: 0, + ySubscriptYOffset: 0, + ySuperscriptXSize: 0, + ySuperscriptYSize: 0, + ySuperscriptXOffset: 0, + ySuperscriptYOffset: 0, + yStrikeoutSize: 50, + yStrikeoutPosition: 300, + sFamilyClass: 0, + panose: [0; 10], + ulUnicodeRange1: 0, + ulUnicodeRange2: 0, + ulUnicodeRange3: 0, + ulUnicodeRange4: 0, + achVendID: [0; 4], + fsSelection: FSSelection { + use_typo_metrics: use_typo, + ..FSSelection::from_u16(0) + }, + usFirstCharIndex: 0, + usLastCharIndex: 0, + sTypoAscender: typo.0, + sTypoDescender: typo.1, + sTypoLineGap: typo.2, + usWinAscent: win.0, + usWinDescent: win.1, + ulCodePageRange1: None, + ulCodePageRange2: None, + sxHeight: Some(500), + sCapHeight: Some(700), + usDefaultChar: None, + usBreakChar: None, + usMaxContext: None, + usLowerOpticalPointSize: None, + usUpperOpticalPointSize: None, + } + } + + fn post(pos: i16, thick: i16) -> Post { + Post { + version: Version16Dot16::from_u32(0x0002_0000), + italicAngle: Fixed(0), + underlinePosition: pos, + underlineThickness: thick, + isFixedPitch: 1, + minMemType42: 0, + maxMemType42: 0, + minMemType1: 0, + maxMemType1: 0, + } + } + + #[test] + fn symbolic_traits() { + assert_eq!(symbolic_traits_value(false, false, false), 0); + assert_eq!(symbolic_traits_value(true, false, false), 2); + assert_eq!(symbolic_traits_value(false, true, false), 1); + assert_eq!(symbolic_traits_value(true, true, true), 2 | 1 | 1024); + } + + #[test] + fn bold_line_width() { + assert_eq!(synthetic_bold_line_width(12.0), 1.0); + assert_eq!(synthetic_bold_line_width(14.0), 1.0); + assert_eq!(synthetic_bold_line_width(28.0), 2.0); + } + + #[test] + fn vertical_no_hhea_uses_coretext() { + let ct = CtMetrics { + ascent: 10.0, + descent: 3.0, + leading: 1.0, + ..Default::default() + }; + assert_eq!(vertical_metrics(None, None, 0.01, &ct), (10.0, -3.0, 1.0)); + } + + #[test] + fn vertical_hhea_only() { + let h = hhea(800, -200, 100); + let (a, d, g) = vertical_metrics(Some(&h), None, 0.01, &CtMetrics::default()); + assert_eq!((a, d, g), (8.0, -2.0, 1.0)); + } + + #[test] + fn vertical_typo_when_flagged() { + let h = hhea(800, -200, 100); + let o = os2((900, -300, 0), (1000, 400), true); + let (a, d, g) = vertical_metrics(Some(&h), Some(&o), 0.01, &CtMetrics::default()); + assert_eq!((a, d, g), (9.0, -3.0, 0.0)); + } + + #[test] + fn vertical_prefers_hhea_then_typo_then_win() { + let o = os2((900, -300, 20), (1000, 400), false); + // hhea non-zero wins over typo. + let h = hhea(800, -200, 100); + let (a, ..) = vertical_metrics(Some(&h), Some(&o), 0.01, &CtMetrics::default()); + assert_eq!(a, 8.0); + // hhea zero -> typo. + let h = hhea(0, 0, 0); + let (a, d, g) = vertical_metrics(Some(&h), Some(&o), 0.01, &CtMetrics::default()); + assert_eq!((a, d, g), (9.0, -3.0, 0.2)); + // both zero -> win metrics with flipped descent sign. + let o = os2((0, 0, 20), (1000, 400), false); + let (a, d, g) = vertical_metrics(Some(&h), Some(&o), 0.01, &CtMetrics::default()); + assert_eq!((a, d, g), (10.0, -4.0, 0.0)); + } + + #[test] + fn metrics_underline_and_strikeout() { + let h = hhea(800, -200, 0); + let o = os2((0, 0, 0), (0, 0), false); + let p = post(-100, 50); + let m = face_metrics( + 10.0, + 1000.0, + Some(&h), + Some(&o), + Some(&p), + &CtMetrics::default(), + Measurements { + cell_width: 6.0, + ascii_height: 9.0, + ic_width: Some(12.0), + }, + ); + assert_eq!(m.underline_position, Some(-1.0)); + assert_eq!(m.underline_thickness, Some(0.5)); + assert_eq!(m.strikethrough_position, Some(3.0)); + assert_eq!(m.strikethrough_thickness, Some(0.5)); + assert_eq!(m.cap_height, Some(7.0)); + assert_eq!(m.ex_height, Some(5.0)); + assert_eq!(m.cell_width, 6.0); + assert_eq!(m.ascii_height, Some(9.0)); + assert_eq!(m.ic_width, Some(12.0)); + } + + #[test] + fn metrics_broken_underline_is_none() { + let p = post(0, 0); + let m = face_metrics( + 10.0, + 1000.0, + None, + None, + Some(&p), + &CtMetrics { + cap_height: 7.0, + x_height: 5.0, + ..Default::default() + }, + Measurements::default(), + ); + assert_eq!(m.underline_position, None); + assert_eq!(m.underline_thickness, None); + // position set but thickness broken: keep position only + let p = post(-100, 0); + let m = face_metrics( + 10.0, + 1000.0, + None, + None, + Some(&p), + &CtMetrics::default(), + Measurements::default(), + ); + assert_eq!(m.underline_position, Some(-1.0)); + assert_eq!(m.underline_thickness, None); + assert_eq!(m.strikethrough_position, None); + // no OS/2: CoreText cap/ex heights. + assert_eq!(m.cap_height, Some(0.0)); + } + + #[test] + fn ic_width_validity() { + assert!(ic_width_is_valid(10.0, 12.0)); + assert!(ic_width_is_valid(12.0, 12.0)); + assert!(!ic_width_is_valid(13.0, 12.0)); + } + + fn opts() -> RenderOptions { + let mut m = Metrics::init(); + m.cell_width = 10; + m.cell_height = 20; + m.cell_baseline = 4; + m.face_width = 10.0; + m.face_height = 20.0; + let _ = DesiredSize::new(12.0); + RenderOptions::new(m) + } + + #[test] + fn layout_tiny_glyph_is_none() { + let r = Rect { + x: 0.0, + y: 0.0, + width: 0.2, + height: 5.0, + }; + assert!(glyph_layout(r, false, None, &opts()).is_none()); + } + + #[test] + fn layout_basic() { + let r = Rect { + x: 1.5, + y: -2.25, + width: 6.0, + height: 10.0, + }; + let l = glyph_layout(r, false, None, &opts()).unwrap(); + assert_eq!(l.px_x, 1); + // y is cell-relative: -2.25 + cell_baseline (4) = 1.75 + assert_eq!(l.px_y, 1); + assert!((l.frac_x - 0.5).abs() < 1e-9); + assert!((l.frac_y - 0.75).abs() < 1e-9); + assert_eq!(l.px_width, 7); + assert_eq!(l.px_height, 11); + assert_eq!(l.canvas_padding, 0); + assert_eq!(l.scale_x, 1.0); + assert_eq!(l.offset_y(), 12); + assert_eq!(l.offset_x(), 1); + } + + #[test] + fn layout_thicken_pads_canvas() { + let r = Rect { + x: 1.0, + y: 0.0, + width: 5.0, + height: 5.0, + }; + let mut o = opts(); + o.thicken = true; + let l = glyph_layout(r, false, None, &o).unwrap(); + assert_eq!(l.canvas_padding, 1); + assert_eq!(l.px_x, 0); + assert_eq!(l.px_width, 7); + // sbix glyphs never pad + let l = glyph_layout(r, true, None, &o).unwrap(); + assert_eq!(l.canvas_padding, 0); + } + + #[test] + fn layout_synthetic_bold_grows_rect() { + let r = Rect { + x: 2.0, + y: 2.0, + width: 4.0, + height: 4.0, + }; + let l = glyph_layout(r, false, Some(2.0), &opts()).unwrap(); + assert_eq!(l.rect_x, 1.0); + assert_eq!(l.rect_y, 1.0); + assert_eq!(l.px_width, 6); + // sbix ignores synthetic bold + let l = glyph_layout(r, true, Some(2.0), &opts()).unwrap(); + assert_eq!(l.rect_x, 2.0); + } + + #[test] + fn layout_centers_in_wider_cell() { + let mut o = opts(); + o.grid_metrics.cell_width = 12; + o.grid_metrics.face_width = 10.0; + let r = Rect { + x: 0.0, + y: 0.0, + width: 5.0, + height: 5.0, + }; + let l = glyph_layout(r, false, None, &o).unwrap(); + // dx = (12 - 10)/2 = 1 + assert_eq!(l.px_x, 1); + } + + #[test] + fn utf16() { + assert_eq!(utf16_units('A' as u32), (['A' as u16, 0], 1)); + let (u, n) = utf16_units(0x1F978); + assert_eq!(n, 2); + assert_eq!(u, [0xD83E, 0xDD78]); + assert_eq!(utf16_units(0xD800).1, 1); + } + + #[test] + fn score_ordering() { + let a = Score { + codepoint: true, + ..Default::default() + }; + let b = Score { + monospace: true, + glyph_count: u16::MAX, + fuzzy_style: 255, + bold: true, + italic: true, + exact_style: true, + ..Default::default() + }; + assert!(a.int() > b.int()); + let c = Score { + glyph_count: 5, + ..Default::default() + }; + let d = Score { + glyph_count: 7, + ..Default::default() + }; + assert!(d.int() > c.int()); + } + + #[test] + fn desired_style_lists() { + assert_eq!(desired_styles(Some("Light"), true, true), vec!["Light"]); + assert_eq!( + desired_styles(None, true, true), + vec!["bold italic", "bold", "italic", "oblique"] + ); + assert_eq!(desired_styles(None, true, false), vec!["bold", "upright"]); + assert_eq!( + desired_styles(None, false, true), + vec!["italic", "regular", "oblique"] + ); + assert_eq!( + desired_styles(None, false, false), + vec!["regular", "upright"] + ); + } + + #[test] + fn style_score_fuzzy_and_exact() { + let d = desired_styles(None, false, false); + let (exact, fuzzy) = style_scores("Regular", &d); + assert!(exact); + assert_eq!(fuzzy, 255); + let (exact, fuzzy) = style_scores("Semi Regular Extra", &d); + assert!(!exact); + // len 18, "regular" (7) subtracted => 11 => 255 - 11 + assert_eq!(fuzzy, 255 - 11); + let (_, fuzzy) = style_scores("Light", &d); + assert_eq!(fuzzy, 255 - 5); + } + + #[test] + fn axes_bold_italic() { + let r = bold_italic_from_axes( + false, + false, + [("wght".to_owned(), 700.0), ("slnt".to_owned(), -10.0)], + ); + assert_eq!(r, (true, true)); + // ital seen => slnt ignored + let r = bold_italic_from_axes( + true, + true, + [("ital".to_owned(), 0.0), ("slnt".to_owned(), -10.0)], + ); + assert_eq!(r, (true, false)); + let r = bold_italic_from_axes(false, false, [("wght".to_owned(), 600.0)]); + assert_eq!(r, (false, false)); + } +} diff --git a/crates/ghostty-font/src/lib.rs b/crates/ghostty-font/src/lib.rs index 0fc7ac8..75392ab 100644 --- a/crates/ghostty-font/src/lib.rs +++ b/crates/ghostty-font/src/lib.rs @@ -1,7 +1,9 @@ -//! Font engine: metrics, atlas, sprite font, faces (FreeType), discovery -//! (fontconfig), collections, codepoint resolution, shaping (HarfBuzz) and -//! the shared glyph grid. Port of `ZIG/src/font/` for the Linux backend -//! (`fontconfig_freetype`). +//! Font engine: metrics, atlas, sprite font, faces (FreeType / CoreText), +//! discovery (fontconfig / CoreText), collections, codepoint resolution, +//! shaping (HarfBuzz / CoreText) and the shared glyph grid. Port of +//! `ZIG/src/font/`. The backend is selected per target (see `backend.rs` and +//! `build.rs`): `fontconfig_freetype` on Linux, `coretext` on Apple targets, +//! `web_canvas` on wasm32. #![cfg_attr(test, allow(clippy::unwrap_used))] @@ -13,7 +15,12 @@ pub mod codepoint_resolver; #[cfg(not(target_arch = "wasm32"))] pub mod collection; pub mod config_types; -#[cfg(not(target_arch = "wasm32"))] +#[cfg(font_coretext)] +pub mod coretext_util; +#[cfg(font_fontconfig)] +pub mod deferred_face; +#[cfg(font_coretext)] +#[path = "deferred_face_coretext.rs"] pub mod deferred_face; #[cfg(target_arch = "wasm32")] #[path = "deferred_face_web_canvas.rs"] @@ -23,7 +30,7 @@ pub mod discovery; #[cfg(not(target_arch = "wasm32"))] pub mod embedded; pub mod face; -#[cfg(not(target_arch = "wasm32"))] +#[cfg(font_fontconfig)] pub mod fontconfig; pub mod glyph; #[cfg(not(target_arch = "wasm32"))] diff --git a/crates/ghostty-font/src/library.rs b/crates/ghostty-font/src/library.rs index 83a4b0b..bd113ec 100644 --- a/crates/ghostty-font/src/library.rs +++ b/crates/ghostty-font/src/library.rs @@ -1,24 +1,31 @@ //! A library represents the shared state that the underlying font -//! library implementation(s) require per-process. Port of `font/library.zig` -//! (the `FreetypeLibrary` variant). +//! library implementation(s) require per-process. Port of `font/library.zig`. +//! +//! * FreeType backends: the `FT_Library` (plus a face-creation mutex). +//! * Backends without a library (CoreText, Canvas): an empty library +//! (Zig: `NoopLibrary`). use std::sync::{Arc, Mutex}; +#[cfg(font_freetype)] use crate::face::freetype::ft; #[derive(Debug, thiserror::Error)] pub enum LibraryError { + #[cfg(font_freetype)] #[error("freetype error {0}")] Freetype(i32), } -/// Shared FreeType library state. Cheap to clone. +/// Shared font library state. Cheap to clone. #[derive(Clone)] pub struct Library { + #[cfg_attr(not(font_freetype), allow(dead_code))] pub(crate) inner: Arc, } pub(crate) struct LibraryInner { + #[cfg(font_freetype)] pub(crate) lib: ft::Lib, /// Mutex to be held any time the library is @@ -27,6 +34,7 @@ pub(crate) struct LibraryInner { } impl Library { + #[cfg(font_freetype)] pub fn init() -> Result { let lib = ft::Lib::new().map_err(LibraryError::Freetype)?; Ok(Library { @@ -37,8 +45,20 @@ impl Library { }) } + /// The CoreText backend has no per-process library state. + #[cfg(not(font_freetype))] + #[allow(clippy::unnecessary_wraps)] + pub fn init() -> Result { + Ok(Library { + inner: Arc::new(LibraryInner { + mutex: Mutex::new(()), + }), + }) + } + /// Lock the library mutex (poisoning is ignored: the guarded state is - /// only FreeType's own). + /// only the font library's own). + #[cfg_attr(not(font_freetype), allow(dead_code))] pub(crate) fn lock(&self) -> std::sync::MutexGuard<'_, ()> { self.inner .mutex diff --git a/crates/ghostty-font/src/shape.rs b/crates/ghostty-font/src/shape.rs index 996467d..89319cb 100644 --- a/crates/ghostty-font/src/shape.rs +++ b/crates/ghostty-font/src/shape.rs @@ -1,20 +1,29 @@ //! Shaping types shared by all shapers. Port of `font/shape.zig`. //! //! The shaper implementation for the Linux backend is HarfBuzz -//! ([`crate::shaper::harfbuzz::Shaper`]). +//! (`crate::shaper::harfbuzz::Shaper`), for the macOS backend CoreText +//! (`crate::shaper::coretext::Shaper`). +#[cfg(font_coretext_shaper)] +pub use crate::shaper::coretext; +#[cfg(font_coretext_shaper)] +pub use crate::shaper::coretext::{ShapeError, ShapeRunError, Shaper}; pub use crate::shaper::feature::{DEFAULT_FEATURES, Feature, FeatureList}; +#[cfg(font_harfbuzz)] +pub use crate::shaper::harfbuzz; /// Shaper implementation for our compile options (backend -/// `fontconfig_freetype` => HarfBuzz). -#[cfg(not(target_arch = "wasm32"))] -pub use crate::shaper::harfbuzz::Shaper; +/// `fontconfig_freetype`, `coretext_freetype`, `coretext_harfbuzz` => +/// HarfBuzz; `coretext` => CoreText; `coretext_noshape` => no-op). +#[cfg(font_harfbuzz)] +pub use crate::shaper::harfbuzz::{ShapeError, ShapeRunError, Shaper}; +#[cfg(font_noshape)] +pub use crate::shaper::noop::{ShapeError, ShapeRunError, Shaper}; /// Shaper implementation for the `web_canvas` backend (wasm32). #[cfg(target_arch = "wasm32")] pub use crate::shaper::web_canvas::Shaper; #[cfg(not(target_arch = "wasm32"))] pub use crate::shaper::{ cache::Cache, - harfbuzz, noop, run::{RunIterator, RunIteratorHook, RunOptions, TextRun}, }; diff --git a/crates/ghostty-font/src/shaper/cf_release.rs b/crates/ghostty-font/src/shaper/cf_release.rs new file mode 100644 index 0000000..8571495 --- /dev/null +++ b/crates/ghostty-font/src/shaper/cf_release.rs @@ -0,0 +1,81 @@ +//! Dedicated thread for releasing CoreFoundation objects. +//! +//! Pools of CF objects (strings, attributed strings, typesetters, lines, +//! fonts) are sent here so that their (excessively slow) release callback +//! logic doesn't block the shaping/renderer thread. Equivalent of +//! `os/cf_release_thread.zig` for the shaper's needs: a mailbox of owned +//! pools and a thread that drops them. +//! +//! TODO(integration): this is a self-contained version living in the font +//! crate; it can be replaced by `ghostty_os::cf_release_thread` once the os +//! crate's port lands and the font crate depends on it. +#![allow(unsafe_code)] + +use std::{ + sync::mpsc::{Sender, channel}, + thread::JoinHandle, +}; + +use objc2_core_foundation::{CFRetained, CFType}; + +use crate::coretext_util::CfBox; + +/// An owned CF object queued for release. +pub(crate) type Pooled = CfBox; + +/// Convert any retained CF object into a pool entry. +pub(crate) fn pooled(v: CFRetained) -> Pooled { + // SAFETY: every CF object is a CFType; the retain count is carried over + // by the cast and released exactly once when the `Pooled` drops. + CfBox::new(unsafe { CFRetained::cast_unchecked::(v) }) +} + +pub(crate) struct ReleaseThread { + tx: Option>>, + handle: Option>, +} + +impl ReleaseThread { + pub(crate) fn new() -> Option { + let (tx, rx) = channel::>(); + let handle = std::thread::Builder::new() + .name("cf_release".into()) + .spawn(move || { + // Dropping each pool releases every object in it. + while let Ok(pool) = rx.recv() { + drop(pool); + } + log::debug!("cf release thread exited"); + }) + .inspect_err(|e| log::warn!("error spawning cf release thread: {e}")) + .ok()?; + Some(ReleaseThread { + tx: Some(tx), + handle: Some(handle), + }) + } + + /// Send a pool for release. If the thread is gone the pool is released + /// inline. + pub(crate) fn release(&self, pool: Vec) { + if pool.is_empty() { + return; + } + if let Some(tx) = &self.tx + && let Err(e) = tx.send(pool) + { + // Receiver gone: release here. + drop(e.0); + } + } +} + +impl Drop for ReleaseThread { + fn drop(&mut self) { + // Closing the channel stops the thread after it drains the mailbox. + self.tx.take(); + if let Some(h) = self.handle.take() { + let _ = h.join(); + } + } +} diff --git a/crates/ghostty-font/src/shaper/coretext.rs b/crates/ghostty-font/src/shaper/coretext.rs new file mode 100644 index 0000000..505aef2 --- /dev/null +++ b/crates/ghostty-font/src/shaper/coretext.rs @@ -0,0 +1,499 @@ +//! CoreText shaper (Apple targets). Port of `font/shaper/coretext.zig`. +//! +//! CoreText shaping differs in subtle ways from HarfBuzz so it may result +//! in inconsistent rendering across platforms. But it also fixes many +//! issues (some macOS specific): +//! +//! - Theta hat offset is incorrect in HarfBuzz but correct by default +//! on macOS applications using CoreText. (See: +//! ) +//! +//! - Hyphens (U+2010) can be synthesized by CoreText but not by HarfBuzz. +//! See: +//! +//! The API mirrors [`crate::shaper::harfbuzz::Shaper`]: the run iterator +//! drives `prepare` / `add_codepoint` / `finalize` through +//! [`RunIteratorHook`](crate::shaper::run::RunIteratorHook) and the caller +//! shapes with [`Shaper::shape_run`]. The cluster/ligature heuristics live +//! in [`super::coretext_cells`] (unit tested on every platform). +#![allow(unsafe_code)] + +use std::ptr::{self, NonNull}; + +use objc2_core_foundation::{ + CFArray, + CFAttributedString, + CFDictionary, + CFNumber, + CFRange, + CFRetained, + CFString, + CFType, + CGPoint, + CGSize, +}; +use objc2_core_text::{ + CTFont, + CTFontDescriptor, + CTLine, + CTRun, + CTRunStatus, + CTTypesetter, + kCTFontAttributeName, + kCTFontFeatureSettingsAttribute, + kCTFontOpenTypeFeatureTag, + kCTFontOpenTypeFeatureValue, + kCTTypesetterOptionForcedEmbeddingLevel, +}; + +use super::{ + cf_release::{Pooled, ReleaseThread, pooled}, + coretext_cells::{self as cells, CtRun, RunGlyph, RunState}, +}; +use crate::{ + coretext_util::CfBox, + face::Face, + shape::{self, Cell, DEFAULT_FEATURES, Feature, FeatureList, TextRun}, + shared_grid::SharedGrid, +}; + +#[derive(Debug, thiserror::Error)] +pub enum ShapeError { + #[error("coretext object creation failed")] + CoreTextFailed, +} + +/// Errors from [`Shaper::shape_run`]. +#[derive(Debug, thiserror::Error)] +pub enum ShapeRunError { + #[error(transparent)] + GetFace(#[from] crate::collection::GetFaceError), + #[error(transparent)] + Shape(#[from] ShapeError), +} + +/// Create a CoreFoundation Dictionary suitable for settings the font +/// features of a CoreText font (`makeFeaturesDict`). +fn make_features_dict(feats: &[Feature]) -> CfBox { + let mut items: Vec>> = Vec::new(); + + for feat in feats { + // Keys can only be ASCII. + let key = CFString::from_str(&String::from_utf8_lossy(&feat.tag)); + let value = CFNumber::new_i32(feat.value as i32); + + // SAFETY: valid static keys. + let (k_tag, k_val) = unsafe { (kCTFontOpenTypeFeatureTag, kCTFontOpenTypeFeatureValue) }; + let dict = CFDictionary::::from_slices( + &[k_tag, k_val], + &[key.as_ref(), value.as_ref()], + ); + items.push(dict); + } + + let item_refs: Vec<&CFDictionary> = items.iter().map(|d| &**d).collect(); + let list = CFArray::from_objects(&item_refs); + + // SAFETY: valid static key. + let k = unsafe { kCTFontFeatureSettingsAttribute }; + let dict = CFDictionary::::from_slices(&[k], &[list.as_ref()]); + // SAFETY: the attribute dictionary is used as an untyped CFDictionary. + CfBox::new(unsafe { CFRetained::cast_unchecked::(dict) }) +} + +/// Shaper that uses CoreText. +pub struct Shaper { + /// The string used for shaping the current run. + run_state: RunState, + + /// CoreFoundation Dictionary which represents our font feature settings. + features: CfBox, + /// A version of the features dictionary with the default features excluded. + features_no_default: CfBox, + + /// The shared memory used for shaping results. + cell_buf: Vec, + + /// Cached attributes dict for creating CTTypesetter objects. + /// The values in this never change so we can avoid overhead + /// by just creating it once and saving it for reuse. + typesetter_attr_dict: CfBox, + + /// List where we cache fonts, so we don't have to remake them for + /// every single shaping operation. + /// + /// Fonts are cached as attribute dictionaries to be applied directly to + /// attributed strings. + cached_fonts: Vec>>, + + /// The grid that our cached fonts correspond to. + /// If the grid changes then we need to reset our cache. + cached_font_grid: usize, + + /// The list of CoreFoundation objects to release on the dedicated + /// release thread. This is built up over the course of shaping and + /// sent to the release thread when `end_frame` is called. + cf_release_pool: Vec, + + /// Dedicated thread for releasing CoreFoundation objects. Some objects, + /// such as those produced by CoreText, have excessively slow release + /// callback logic. + cf_release_thread: Option, +} + +impl Shaper { + /// The cell buffer holds the shaped results. + pub fn new(opts: &shape::Options) -> Result { + let mut feature_list = FeatureList::default(); + for feature_str in &opts.features { + feature_list.append_from_string(feature_str); + } + + // We need to construct two attrs dictionaries for font features; + // one without the default features included, and one with them. + let feats = &feature_list.features; + let mut feats_df: Vec = DEFAULT_FEATURES.to_vec(); + feats_df.extend_from_slice(feats); + + let features = make_features_dict(&feats_df); + let features_no_default = make_features_dict(feats); + + // For now we only support LTR text. If we shape RTL text then + // rendering will be very wrong so we need to explicitly force + // LTR no matter what. + // + // See: https://github.com/mitchellh/ghostty/issues/1737 + // See: https://github.com/mitchellh/ghostty/issues/1442 + // + // We used to do this by setting the writing direction attribute + // on the attributed string we used, but it seems like that will + // still allow some weird results, for example a single space at + // the end of a line composed of RTL characters will be cause it + // to output a run containing just that space, BEFORE it outputs + // the rest of the line as a separate run, very weirdly with the + // "right to left" flag set in the single space run's run status... + // + // So instead what we do is use a CTTypesetter to create our line, + // using the kCTTypesetterOptionForcedEmbeddingLevel attribute to + // force CoreText not to try doing any sort of BiDi, instead just + // treat all text as embedding level 0 (left to right). + let typesetter_attr_dict = { + let num = CFNumber::new_i32(0); + // SAFETY: valid static key. + let k = unsafe { kCTTypesetterOptionForcedEmbeddingLevel }; + let d = CFDictionary::::from_slices(&[k], &[num.as_ref()]); + // SAFETY: used as an untyped CFDictionary. + CfBox::new(unsafe { CFRetained::cast_unchecked::(d) }) + }; + + Ok(Shaper { + run_state: RunState::default(), + features, + features_no_default, + cell_buf: Vec::new(), + typesetter_attr_dict, + cached_fonts: Vec::new(), + cached_font_grid: 0, + cf_release_pool: Vec::new(), + cf_release_thread: ReleaseThread::new(), + }) + } + + /// Send the release pool to the release thread. + pub fn end_frame(&mut self) { + if self.cf_release_pool.is_empty() { + return; + } + + // Get all the items in the pool as an owned vec so we can + // send it to the dedicated release thread. + let items = std::mem::take(&mut self.cf_release_pool); + match &self.cf_release_thread { + Some(t) => t.release(items), + // No thread: release inline. + None => drop(items), + } + } + + // ---- RunIterator hooks ---- + + pub fn prepare(&mut self) { + self.run_state.reset(); + } + + pub fn add_codepoint(&mut self, cp: u32, cluster: u32) { + self.run_state.add_codepoint(cp, cluster); + } + + #[allow(clippy::unused_self)] + pub fn finalize(&mut self) {} + + /// Convenience: shape `text` as a single run where every item is a + /// `(codepoint, cluster)` pair. + pub fn shape_text( + &mut self, + face: Option<&Face>, + text: &[(u32, u32)], + ) -> Result<&[Cell], ShapeError> { + self.prepare(); + for &(cp, cluster) in text { + self.add_codepoint(cp, cluster); + } + self.finalize(); + 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. + /// + /// Note that this will accumulate garbage in the release pool. The + /// caller must ensure you're properly calling `end_frame` to release + /// all the objects. + pub fn shape_run( + &mut self, + grid: &SharedGrid, + run: &TextRun, + ) -> Result<&[Cell], ShapeRunError> { + // Special fonts aren't shaped and their codepoint == glyph so we + // can just return the codepoints as-is. + if run.font_index.special().is_some() { + cells::special_cells(&self.run_state.codepoints, &mut self.cell_buf); + return Ok(&self.cell_buf); + } + + let attr_dict = self.get_font(grid, run.font_index)?; + Ok(self.shape_with_attrs(&attr_dict)?) + } + + /// Shape the current run (as built through the hooks) with the given + /// face (uncached), or pass `None` for a special-case font (sprite). + pub fn shape(&mut self, face: Option<&Face>) -> Result<&[Cell], ShapeError> { + let Some(face) = face else { + cells::special_cells(&self.run_state.codepoints, &mut self.cell_buf); + return Ok(&self.cell_buf); + }; + let attr_dict = self.make_attr_dict(face); + self.shape_with_attrs(&attr_dict) + } + + fn shape_with_attrs(&mut self, attr_dict: &CFDictionary) -> Result<&[Cell], ShapeError> { + let state = &self.run_state; + + // Make room for the attributed string, CTTypesetter, and CTLine. + self.cf_release_pool.reserve(4); + + // SAFETY: `unichars` is a valid buffer of `len` UTF-16 units. + let str = unsafe { + CFString::with_characters(None, state.unichars.as_ptr(), state.unichars.len() as isize) + } + .ok_or(ShapeError::CoreTextFailed)?; + + // Create an attributed string from our string + // SAFETY: valid string and attribute dictionary. + let attr_str = unsafe { CFAttributedString::new(None, Some(&str), Some(attr_dict)) } + .ok_or(ShapeError::CoreTextFailed)?; + + // Create a typesetter from the attributed string and the cached + // attr dict. (See comment in new for more info on the attr dict.) + // SAFETY: valid string and options dictionary. + let typesetter = unsafe { + CTTypesetter::with_attributed_string_and_options( + &attr_str, + Some(&self.typesetter_attr_dict), + ) + } + .ok_or(ShapeError::CoreTextFailed)?; + + // Create a line from the typesetter + // SAFETY: a zero length range means the whole string. + let line: CFRetained = unsafe { + typesetter.line(CFRange { + location: 0, + length: 0, + }) + }; + + // CoreText, despite our insistence with an enforced embedding level, + // may sometimes output runs that are non-monotonic. + // + // CoreText may generate multiple runs even though our input to + // CoreText is already split into runs by our own run iterator. + // The runs as far as I can tell are always sequential to each + // other so we can iterate over them and just append to our + // cell buffer. + // SAFETY: plain CoreText call. + let glyph_runs = unsafe { line.glyph_runs() }; + // SAFETY: the glyph runs array contains CTRun objects. + let glyph_runs: &CFArray = unsafe { glyph_runs.cast_unchecked() }; + let mut runs: Vec = Vec::with_capacity(glyph_runs.len()); + for i in 0..glyph_runs.len() { + let Some(ctrun) = glyph_runs.get(i) else { + continue; + }; + runs.push(read_run(&ctrun)); + } + + cells::cells_from_runs(&self.run_state.codepoints, &runs, &mut self.cell_buf); + + // The objects are released on the release thread (see `end_frame`). + self.cf_release_pool.push(pooled(str)); + self.cf_release_pool.push(pooled(attr_str)); + self.cf_release_pool.push(pooled(typesetter)); + self.cf_release_pool.push(pooled(line)); + + Ok(&self.cell_buf) + } + + /// Build an attr dict (kCTFontAttributeName -> font with our features) + /// for the given face. + fn make_attr_dict(&mut self, face: &Face) -> CfBox { + let attrs = if face.quirks_disable_default_font_features { + &self.features_no_default + } else { + &self.features + }; + + // SAFETY: valid attributes dictionary. + let desc = unsafe { CTFontDescriptor::with_attributes(attrs) }; + // SAFETY: valid descriptor, null matrix is allowed. + let copied: CFRetained = unsafe { + face.ct_font() + .copy_with_attributes(0.0, ptr::null(), Some(&desc)) + }; + + // Get our font and use that get the attributes to set for the + // attributed string so the whole string uses the same font. + // SAFETY: valid static key. + let k = unsafe { kCTFontAttributeName }; + let d = CFDictionary::::from_slices(&[k], &[copied.as_ref()]); + + self.cf_release_pool.push(pooled(desc)); + self.cf_release_pool.push(pooled(copied)); + // SAFETY: used as an untyped CFDictionary. + CfBox::new(unsafe { CFRetained::cast_unchecked::(d) }) + } + + /// Get an attr dict for a font from a specific index. + /// These items are cached. + fn get_font( + &mut self, + grid: &SharedGrid, + index: crate::collection::Index, + ) -> Result, ShapeRunError> { + // If this grid doesn't match the one we've cached fonts for, + // then we reset the cache list since it's no longer valid. + // We use an integer rather than keeping the pointer because we + // don't want anyone to inadvertently use the pointer. + let grid_id = ptr::from_ref(grid) as usize; + if grid_id != self.cached_font_grid { + if self.cached_font_grid > 0 { + // Put all the currently cached fonts in to + // the release pool before clearing the list. + for ft in self.cached_fonts.drain(..).flatten() { + self.cf_release_pool.push(pooled(ft.0)); + } + } + + self.cached_font_grid = grid_id; + } + + let index_int = index.int() as usize; + + // The cached fonts are indexed directly by the font index, since + // this number is usually low. Therefore, we set any index we haven't + // seen to null. + if self.cached_fonts.len() <= index_int { + self.cached_fonts.resize_with(index_int + 1, || None); + } + + // If we have it, return the cached attr dict. + if let Some(cached) = &self.cached_fonts[index_int] { + return Ok(cached.clone()); + } + + // The CoreText shaper relies on CoreText and CoreText claims + // that CTFonts are threadsafe. See: + // https://developer.apple.com/documentation/coretext/ + // + // Quote: + // All individual functions in Core Text are thread-safe. Font + // objects (CTFont, CTFontDescriptor, and associated objects) can + // be used simultaneously by multiple operations, work queues, or + // threads. However, the layout objects (CTTypesetter, CTFramesetter, + // CTRun, CTLine, CTFrame, and associated objects) should be used in + // a single operation, work queue, or thread. + // + // Because of this, we only hold the grid lock to grab the face and + // set it up, then release it. + let attr_dict = { + let mut guard = grid.write(); + let face = guard.resolver.collection.get_face(index)?; + self.make_attr_dict(face) + }; + + self.cached_fonts[index_int] = Some(attr_dict.clone()); + Ok(attr_dict) + } +} + +impl Drop for Shaper { + fn drop(&mut self) { + // Release whatever is left in the pool on the release thread + // (it is joined when the thread handle drops). + let items = std::mem::take(&mut self.cf_release_pool); + if !items.is_empty() { + log::debug!("releasing {} remaining CF objects", items.len()); + } + let cached = std::mem::take(&mut self.cached_fonts); + let mut all = items; + all.extend(cached.into_iter().flatten().map(|d| pooled(d.0))); + if let Some(t) = &self.cf_release_thread { + t.release(all); + } + } +} + +/// Copy a CTRun's glyphs, advances, positions and string indices +/// (`getGlyphsPtr() orelse getGlyphs(alloc)` etc.). +fn read_run(ctrun: &CTRun) -> CtRun { + // SAFETY: plain CoreText getters on a valid run. + let (status, n) = unsafe { (ctrun.status(), ctrun.glyph_count() as usize) }; + let non_ltr = status.intersects(CTRunStatus::NonMonotonic | CTRunStatus::RightToLeft); + if n == 0 { + return CtRun { + non_ltr, + glyphs: Vec::new(), + }; + } + + let full = CFRange { + location: 0, + length: 0, + }; + let mut glyphs = vec![0u16; n]; + let mut advances = vec![CGSize::new(0.0, 0.0); n]; + let mut positions = vec![CGPoint::new(0.0, 0.0); n]; + let mut indices = vec![0isize; n]; + // SAFETY: each buffer holds `n` elements, which is the glyph count of the + // run; a zero-length range copies everything. + unsafe { + ctrun.glyphs(full, NonNull::new_unchecked(glyphs.as_mut_ptr())); + ctrun.advances(full, NonNull::new_unchecked(advances.as_mut_ptr())); + ctrun.positions(full, NonNull::new_unchecked(positions.as_mut_ptr())); + ctrun.string_indices(full, NonNull::new_unchecked(indices.as_mut_ptr())); + } + + CtRun { + non_ltr, + glyphs: (0..n) + .map(|i| RunGlyph { + glyph: glyphs[i], + advance_width: advances[i].width, + x: positions[i].x, + y: positions[i].y, + index: indices[i] as usize, + }) + .collect(), + } +} diff --git a/crates/ghostty-font/src/shaper/coretext_cells.rs b/crates/ghostty-font/src/shaper/coretext_cells.rs new file mode 100644 index 0000000..3fb011d --- /dev/null +++ b/crates/ghostty-font/src/shaper/coretext_cells.rs @@ -0,0 +1,400 @@ +//! Platform-independent part of the CoreText shaper +//! (`font/shaper/coretext.zig`): building the UTF-16 string / codepoint +//! table for a run, and converting CoreText glyph runs to shaped cells. +//! +//! The CoreText calls themselves live in `shaper/coretext.rs` (Apple +//! targets); this module takes plain data so the (subtle) cluster / +//! ligature heuristics can be unit tested on every platform. + +use crate::shape::Cell; + +/// One entry of the codepoint table. Surrogate pairs occupy two entries so +/// that indices match the UTF-16 string handed to CoreText; the second one +/// has `codepoint == 0`. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct Codepoint { + pub codepoint: u32, + pub cluster: u32, +} + +/// The state of the current run (`RunState`). +#[derive(Debug, Default)] +pub struct RunState { + pub codepoints: Vec, + pub unichars: Vec, +} + +impl RunState { + pub fn reset(&mut self) { + self.codepoints.clear(); + self.unichars.clear(); + } + + /// `RunIteratorHook.addCodepoint`. + pub fn add_codepoint(&mut self, cp: u32, cluster: u32) { + // Build our UTF-16 string for CoreText + let (units, len) = crate::face::coretext_math::utf16_units(cp); + self.unichars.extend_from_slice(&units[..len]); + + // Build our reverse lookup table for codepoints to clusters + self.codepoints.push(Codepoint { + codepoint: cp, + cluster, + }); + + // If the UTF-16 codepoint is a pair then we need to insert + // a dummy entry so that the CTRunGetStringIndices() function + // maps correctly. + if len == 2 { + self.codepoints.push(Codepoint { + codepoint: 0, + cluster, + }); + } + } +} + +/// Special fonts aren't shaped and their codepoint == glyph so we can just +/// return the codepoints as-is. +pub fn special_cells(codepoints: &[Codepoint], out: &mut Vec) { + out.clear(); + for entry in codepoints { + // We use null codepoints to pad out our list so indices match + // the UTF-16 string we constructed for CoreText. We don't want + // to emit these if this is a special font, since they're not + // part of the original run. + if entry.codepoint == 0 { + continue; + } + + out.push(Cell { + x: entry.cluster as u16, + glyph_index: entry.codepoint, + ..Cell::default() + }); + } +} + +/// One glyph of a CoreText run (the zipped glyphs / advances / positions / +/// string indices arrays). +#[derive(Debug, Clone, Copy, PartialEq)] +pub struct RunGlyph { + pub glyph: u16, + pub advance_width: f64, + pub x: f64, + pub y: f64, + /// Index into the UTF-16 string (and therefore the codepoint table). + pub index: usize, +} + +/// One CoreText glyph run of the line. +#[derive(Debug, Clone, Default)] +pub struct CtRun { + /// True if the run status has `non_monotonic` or `right_to_left`. + pub non_ltr: bool, + pub glyphs: Vec, +} + +#[derive(Debug, Clone, Copy, Default)] +struct Offset { + cluster: u32, + x: f64, +} + +/// Convert CoreText runs to cells (the loop at the end of `Shaper.shape`). +pub fn cells_from_runs(codepoints: &[Codepoint], runs: &[CtRun], out: &mut Vec) { + // This keeps track of the current x offset (sum of advance.width) and + // the furthest cluster we've seen so far (max). + let mut run_offset = Offset::default(); + + // This keeps track of the cell starting x and cluster. + let mut cell_offset = Offset::default(); + + out.clear(); + + // CoreText, despite our insistence with an enforced embedding level, + // may sometimes output runs that are non-monotonic. In order to fix + // this, we check the run status for each run and if any aren't ltr + // we set this to true, which indicates that we must sort our buffer. + let mut non_ltr = false; + + // CoreText may generate multiple runs even though our input to + // CoreText is already split into runs by our own run iterator. + // The runs as far as I can tell are always sequential to each + // other so we can iterate over them and just append to our + // cell buffer. + for run in runs { + if run.non_ltr { + non_ltr = true; + } + + for g in &run.glyphs { + let index = g.index; + + // Our cluster is also our cell X position. If the cluster changes + // then we need to reset our current cell offsets. + let cluster = codepoints[index].cluster; + if cell_offset.cluster != cluster { + // We previously asserted that the new cluster is greater + // than cell_offset.cluster, but this isn't always true. + // See e.g. the "shape Chakma vowel sign with ligature + // (vowel sign renders first)" test. + + let is_after_glyph_from_current_or_next_clusters = cluster <= run_offset.cluster; + + let is_first_codepoint_in_cluster = { + let mut i = index; + let mut result = true; + while i > 0 { + i -= 1; + let codepoint = codepoints[i]; + + // Skip surrogate pair padding + if codepoint.codepoint == 0 { + continue; + } + result = codepoint.cluster != cluster; + break; + } + result + }; + + // We need to reset the `cell_offset` at the start of a new + // cluster, but we do that conditionally if the codepoint + // `is_first_codepoint_in_cluster` and the cluster is not + // `is_after_glyph_from_current_or_next_clusters`, which is + // a heuristic to detect ligatures and avoid positioning + // glyphs that mark ligatures incorrectly. The idea is that + // if the first codepoint in a cluster doesn't appear in + // the stream, it's very likely that it combined with + // codepoints from a previous cluster into a ligature. + // Then, the subsequent codepoints are very likely marking + // glyphs that are placed relative to that ligature, so if + // we were to reset the `cell_offset` to align it with the + // grid, the positions would be off. The + // `!is_after_glyph_from_current_or_next_clusters` check is + // needed in case these marking glyphs come from a later + // cluster but are rendered first (see the Chakma and + // Bengali tests). In that case when we get to the + // codepoint that `is_first_codepoint_in_cluster`, but in a + // cluster that + // `is_after_glyph_from_current_or_next_clusters`, we don't + // want to reset to the grid and cause the positions to be + // off. (Note that we could go back and align the cells to + // the grid starting from the one from the cluster that + // rendered out of order, but that is more complicated so + // we don't do that for now. Also, it's TBD if there are + // exceptions to this heuristic for detecting ligatures, + // but using the logging below seems to show it works + // well.) + if is_first_codepoint_in_cluster && !is_after_glyph_from_current_or_next_clusters { + cell_offset = Offset { + cluster, + x: run_offset.x, + }; + } + } + + let x_offset = g.x - cell_offset.x; + + out.push(Cell { + x: cell_offset.cluster as u16, + x_offset: x_offset.round() as i16, + y_offset: g.y.round() as i16, + glyph_index: u32::from(g.glyph), + }); + + // Add our advances to keep track of our run offsets. + // Advances apply to the NEXT cell. + run_offset.x += g.advance_width; + run_offset.cluster = run_offset.cluster.max(cluster); + } + } + + // If our buffer contains some non-ltr sections we need to sort it :/ + if non_ltr { + // This is EXCEPTIONALLY rare. Only happens for languages with + // complex shaping which we don't even really support properly + // right now, so are very unlikely to be used heavily by users + // of Ghostty. + // + // Zig's `std.mem.sort` is not stable but Rust's `sort_by_key` is; + // equal keys keep their relative order which is a valid result. + out.sort_by_key(|c| c.x); + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn state(cps: &[(u32, u32)]) -> RunState { + let mut s = RunState::default(); + for &(cp, cl) in cps { + s.add_codepoint(cp, cl); + } + s + } + + fn glyph(glyph: u16, adv: f64, x: f64, y: f64, index: usize) -> RunGlyph { + RunGlyph { + glyph, + advance_width: adv, + x, + y, + index, + } + } + + #[test] + fn add_codepoint_bmp() { + let s = state(&[(0x41, 0), (0x42, 1)]); + assert_eq!(s.unichars, vec![0x41, 0x42]); + assert_eq!(s.codepoints.len(), 2); + } + + #[test] + fn add_codepoint_surrogate_pair_pads_table() { + let s = state(&[(0x1F978, 0), (0x41, 2)]); + assert_eq!(s.unichars, vec![0xD83E, 0xDD78, 0x41]); + assert_eq!( + s.codepoints, + vec![ + Codepoint { + codepoint: 0x1F978, + cluster: 0 + }, + Codepoint { + codepoint: 0, + cluster: 0 + }, + Codepoint { + codepoint: 0x41, + cluster: 2 + }, + ] + ); + } + + #[test] + fn special_skips_padding() { + let s = state(&[(0x1F978, 3), (0x41, 5)]); + let mut out = Vec::new(); + special_cells(&s.codepoints, &mut out); + assert_eq!(out.len(), 2); + assert_eq!(out[0].x, 3); + assert_eq!(out[0].glyph_index, 0x1F978); + assert_eq!(out[1].x, 5); + assert_eq!(out[1].glyph_index, 0x41); + } + + #[test] + fn simple_run_one_glyph_per_cluster() { + let s = state(&[(0x41, 0), (0x42, 1), (0x43, 2)]); + let run = CtRun { + non_ltr: false, + glyphs: vec![ + glyph(10, 8.0, 0.0, 0.0, 0), + glyph(11, 8.0, 8.0, 0.0, 1), + glyph(12, 8.0, 16.0, 0.0, 2), + ], + }; + let mut out = Vec::new(); + cells_from_runs(&s.codepoints, &[run], &mut out); + assert_eq!(out.len(), 3); + for (i, c) in out.iter().enumerate() { + assert_eq!(c.x as usize, i); + assert_eq!(c.x_offset, 0); + assert_eq!(c.y_offset, 0); + assert_eq!(c.glyph_index, 10 + i as u32); + } + } + + #[test] + fn ligature_glyph_spans_clusters() { + // "fi" shaped to one glyph (index 0) -> a single cell at cluster 0; + // the following char starts a new cell at cluster 2. + let s = state(&[(0x66, 0), (0x69, 1), (0x41, 2)]); + let run = CtRun { + non_ltr: false, + glyphs: vec![glyph(50, 16.0, 0.0, 0.0, 0), glyph(11, 8.0, 16.0, 0.0, 2)], + }; + let mut out = Vec::new(); + cells_from_runs(&s.codepoints, &[run], &mut out); + assert_eq!(out.len(), 2); + assert_eq!(out[0].x, 0); + assert_eq!(out[1].x, 2); + assert_eq!(out[1].x_offset, 0); + } + + #[test] + fn mark_after_ligature_keeps_ligature_cell() { + // Clusters 0,1 ligate; a mark in cluster 2 positioned relative to + // the ligature: first codepoint of cluster 2 isn't "first" in the + // stream (it's index 2, preceded by cluster 1) so it IS first in + // cluster; cluster 2 > run_offset.cluster(0) so offset resets. + let s = state(&[(0x66, 0), (0x69, 1), (0x301, 2)]); + let run = CtRun { + non_ltr: false, + glyphs: vec![glyph(50, 16.0, 0.0, 0.0, 0), glyph(99, 0.0, 14.0, 3.0, 2)], + }; + let mut out = Vec::new(); + cells_from_runs(&s.codepoints, &[run], &mut out); + assert_eq!(out.len(), 2); + assert_eq!(out[1].x, 2); + // cell offset reset to run_offset.x = 16 => x_offset = 14 - 16 + assert_eq!(out[1].x_offset, -2); + assert_eq!(out[1].y_offset, 3); + } + + #[test] + fn glyph_from_earlier_cluster_does_not_reset_offset() { + // Glyph for cluster 1 comes after glyph for cluster 2 (rendered out + // of order): cluster 1 <= run_offset.cluster (2) => no reset, the + // cell stays at the cluster 2 offset. + let s = state(&[(0x41, 0), (0x42, 1), (0x43, 2)]); + let run = CtRun { + non_ltr: false, + glyphs: vec![ + glyph(1, 8.0, 0.0, 0.0, 0), + glyph(3, 8.0, 8.0, 0.0, 2), + glyph(2, 8.0, 16.0, 0.0, 1), + ], + }; + let mut out = Vec::new(); + cells_from_runs(&s.codepoints, &[run], &mut out); + assert_eq!(out.len(), 3); + // cluster 2's glyph: reset (2 > 0): offset x = 8 + assert_eq!(out[1].x, 2); + // cluster 1 glyph after cluster 2: 1 <= 2 so no reset, stays at 2 + assert_eq!(out[2].x, 2); + assert_eq!(out[2].x_offset, 8); + } + + #[test] + fn non_ltr_sorts_by_x() { + let s = state(&[(0x41, 0), (0x42, 1)]); + let run = CtRun { + non_ltr: true, + glyphs: vec![glyph(2, 8.0, 0.0, 0.0, 1), glyph(1, 8.0, 8.0, 0.0, 0)], + }; + let mut out = Vec::new(); + cells_from_runs(&s.codepoints, &[run], &mut out); + assert!(out.windows(2).all(|w| w[0].x <= w[1].x)); + } + + #[test] + fn surrogate_padding_skipped_when_looking_back() { + // Emoji (pair) at cluster 0 followed by 'A' at cluster 1. The 'A' + // glyph looks back past the padding entry to the real codepoint. + let s = state(&[(0x1F978, 0), (0x41, 1)]); + let run = CtRun { + non_ltr: false, + glyphs: vec![glyph(7, 16.0, 0.0, 0.0, 0), glyph(8, 8.0, 16.0, 0.0, 2)], + }; + let mut out = Vec::new(); + cells_from_runs(&s.codepoints, &[run], &mut out); + assert_eq!(out[1].x, 1); + assert_eq!(out[1].x_offset, 0); + } +} diff --git a/crates/ghostty-font/src/shaper/harfbuzz.rs b/crates/ghostty-font/src/shaper/harfbuzz.rs index e6fe935..8d2ff31 100644 --- a/crates/ghostty-font/src/shaper/harfbuzz.rs +++ b/crates/ghostty-font/src/shaper/harfbuzz.rs @@ -12,7 +12,7 @@ use super::hb; use crate::{ - face::freetype::Face, + face::Face, shape::{self, Cell, DEFAULT_FEATURES, FeatureList, TextRun}, shared_grid::SharedGrid, }; diff --git a/crates/ghostty-font/src/shaper/hb.rs b/crates/ghostty-font/src/shaper/hb.rs index 14e8f3d..0f6d82c 100644 --- a/crates/ghostty-font/src/shaper/hb.rs +++ b/crates/ghostty-font/src/shaper/hb.rs @@ -6,7 +6,7 @@ use std::ptr::NonNull; use harfbuzz_sys as sys; -use crate::face::freetype::HbFont; +use crate::face::HbFont; /// A HarfBuzz feature (`hb_feature_t`) applied globally. pub type Feature = sys::hb_feature_t; diff --git a/crates/ghostty-font/src/shaper/mod.rs b/crates/ghostty-font/src/shaper/mod.rs index 165b15e..d9096a8 100644 --- a/crates/ghostty-font/src/shaper/mod.rs +++ b/crates/ghostty-font/src/shaper/mod.rs @@ -2,10 +2,15 @@ #[cfg(not(target_arch = "wasm32"))] pub mod cache; +#[cfg(font_coretext_shaper)] +mod cf_release; +#[cfg(font_coretext_shaper)] +pub mod coretext; +pub mod coretext_cells; pub mod feature; -#[cfg(not(target_arch = "wasm32"))] +#[cfg(font_harfbuzz)] pub mod harfbuzz; -#[cfg(not(target_arch = "wasm32"))] +#[cfg(font_harfbuzz)] mod hb; #[cfg(not(target_arch = "wasm32"))] pub mod noop; @@ -14,5 +19,5 @@ pub mod run; pub mod web_canvas; -#[cfg(all(test, not(target_arch = "wasm32")))] +#[cfg(all(test, font_harfbuzz, font_fontconfig))] pub(crate) mod test_util; diff --git a/crates/ghostty-font/src/shaper/noop.rs b/crates/ghostty-font/src/shaper/noop.rs index be2f9ed..8698f37 100644 --- a/crates/ghostty-font/src/shaper/noop.rs +++ b/crates/ghostty-font/src/shaper/noop.rs @@ -3,10 +3,25 @@ //! font's glyph index. use crate::{ - face::freetype::Face, - shape::{self, Cell}, + face::Face, + shape::{self, Cell, TextRun}, + shared_grid::SharedGrid, }; +/// Never produced (the no-op shaper can't fail to initialize); exists so the +/// API matches the other shapers (`shape::ShapeError`). +#[derive(Debug, thiserror::Error)] +pub enum ShapeError {} + +/// 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 { codepoint: u32, @@ -24,8 +39,9 @@ pub struct Shaper { } impl Shaper { - pub fn new(_opts: &shape::Options) -> Shaper { - Shaper::default() + #[allow(clippy::unnecessary_wraps)] + pub fn new(_opts: &shape::Options) -> Result { + Ok(Shaper::default()) } pub fn end_frame(&self) {} @@ -45,6 +61,20 @@ impl Shaper { pub fn finalize(&mut self) {} + /// Shape the given text run (looks the face up in `grid`). + pub fn shape_run( + &mut self, + grid: &SharedGrid, + run: &TextRun, + ) -> Result<&[Cell], ShapeRunError> { + 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. `face` is `None` for special fonts (sprite), /// whose codepoint == glyph index. /// @@ -76,7 +106,7 @@ mod tests { #[test] fn noop_special_passthrough() { - let mut s = Shaper::new(&shape::Options::default()); + let mut s = Shaper::new(&shape::Options::default()).unwrap(); s.prepare(); s.add_codepoint(0x41, 0); s.add_codepoint(0x42, 2); diff --git a/crates/ghostty-font/src/shaper/run.rs b/crates/ghostty-font/src/shaper/run.rs index 639c6b8..0f86670 100644 --- a/crates/ghostty-font/src/shaper/run.rs +++ b/crates/ghostty-font/src/shaper/run.rs @@ -160,6 +160,7 @@ pub trait RunIteratorHook { fn finalize(&mut self); } +#[cfg(font_harfbuzz)] impl RunIteratorHook for crate::shaper::harfbuzz::Shaper { fn prepare(&mut self) { Self::prepare(self); @@ -172,6 +173,19 @@ impl RunIteratorHook for crate::shaper::harfbuzz::Shaper { } } +#[cfg(font_coretext_shaper)] +impl RunIteratorHook for crate::shaper::coretext::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); diff --git a/crates/ghostty-font/src/shared_grid_set.rs b/crates/ghostty-font/src/shared_grid_set.rs index 6b9a845..58cd23f 100644 --- a/crates/ghostty-font/src/shared_grid_set.rs +++ b/crates/ghostty-font/src/shared_grid_set.rs @@ -33,7 +33,7 @@ use crate::{ codepoint_map::CodepointMap, codepoint_resolver::{CodepointResolver, StyleStatus}, collection::{self, AddOptions, Collection, LoadOptions, SizeAdjustment}, - discovery::{Descriptor, Discover, Fontconfig}, + discovery::{Descriptor, Discover}, embedded, face::{self, DesiredSize, FontData}, library::{Library, LibraryError}, @@ -47,7 +47,7 @@ pub enum RefError { #[error(transparent)] Discover(#[from] crate::discovery::DiscoverError), #[error("font discovery failed: {0}")] - DiscoverNext(#[from] crate::fontconfig::Error), + DiscoverNext(#[from] crate::discovery::NextError), #[error(transparent)] Add(#[from] collection::AddError), #[error(transparent)] @@ -57,7 +57,7 @@ pub enum RefError { #[error(transparent)] GetFace(#[from] collection::GetFaceError), #[error(transparent)] - Face(#[from] face::freetype::FaceError), + Face(#[from] face::FaceError), #[error(transparent)] Grid(#[from] shared_grid::InitError), #[error(transparent)] @@ -321,20 +321,49 @@ impl SharedGridSet { 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), - )?; + // On macOS, always search for and add the Apple Emoji font + // as our preferred emoji font for fallback. We do this in case + // people add other emoji fonts to their system, we always want to + // prefer the official one. Users can override this by explicitly + // specifying a font-family for emoji. + #[cfg(font_coretext)] + { + let disco = Self::discover(inner); + let mut disco_it = disco.discover(&Descriptor { + family: Some("Apple Color Emoji".into()), + ..Default::default() + })?; + if let Some(face) = disco_it.next()? { + c.add_deferred( + face, + AddOptions { + style: Style::Regular, + fallback: true, + // No size adjustment for emojis. + size_adjustment: SizeAdjustment::None, + }, + )?; + } + } + + // Emoji fallback. We don't include this on Mac since Mac is expected + // to always have the Apple Emoji available on the system. + #[cfg(not(font_coretext))] + { + 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) } @@ -361,8 +390,8 @@ impl SharedGridSet { /// 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) + fn discover(inner: &mut Inner) -> &Discover { + inner.font_discover.get_or_insert_with(Discover::init) } } diff --git a/crates/ghostty-input/Cargo.toml b/crates/ghostty-input/Cargo.toml index 9b09bbf..19d71dd 100644 --- a/crates/ghostty-input/Cargo.toml +++ b/crates/ghostty-input/Cargo.toml @@ -10,5 +10,37 @@ ghostty-unicode.workspace = true indexmap = "2.14.2" thiserror = "2.0.21" -[lints] -workspace = true +# Same as `[workspace.lints]` except `unsafe_code` is denied instead of +# forbidden: the macOS Carbon keymap (`keymap_darwin.rs`) needs `unsafe` FFI. +[lints.rust] +unsafe_code = "deny" + +[lints.clippy] +pedantic = { level = "warn", priority = -1 } +todo = "warn" +unwrap_used = "warn" +cast_possible_truncation = "allow" +cast_possible_wrap = "allow" +cast_sign_loss = "allow" +cast_precision_loss = "allow" +cast_lossless = "allow" +module_name_repetitions = "allow" +too_many_lines = "allow" +missing_errors_doc = "allow" +missing_panics_doc = "allow" +similar_names = "allow" +struct_excessive_bools = "allow" +fn_params_excessive_bools = "allow" +match_same_arms = "allow" +unreadable_literal = "allow" +many_single_char_names = "allow" +doc_markdown = "allow" +must_use_candidate = "allow" +return_self_not_must_use = "allow" +used_underscore_binding = "allow" +large_stack_arrays = "allow" +inline_always = "allow" + +[target.'cfg(target_vendor = "apple")'.dependencies] +log = "0.4.34" +objc2-core-foundation = "0.3.2" diff --git a/crates/ghostty-input/src/key.rs b/crates/ghostty-input/src/key.rs index 1b4b76f..f644ed7 100644 --- a/crates/ghostty-input/src/key.rs +++ b/crates/ghostty-input/src/key.rs @@ -538,7 +538,9 @@ impl Key { /// true if this key is one of the left or right versions of super (MacOS) /// or ctrl. pub fn ctrl_or_super(self) -> bool { - // Non-darwin target. + if crate::platform::is_darwin() { + return self == Key::MetaLeft || self == Key::MetaRight; + } self == Key::ControlLeft || self == Key::ControlRight } @@ -558,9 +560,18 @@ impl Key { /// Mac, we default to super (i.e. super+c for copy) and on /// non-Mac we default to ctrl (i.e. ctrl+c for copy). pub fn ctrl_or_super(mods: Mods) -> Mods { + ctrl_or_super_for(mods, crate::platform::is_darwin()) +} + +/// [`ctrl_or_super`] for an explicit platform (`darwin == true` sets super). +pub fn ctrl_or_super_for(mods: Mods, darwin: bool) -> Mods { let mut copy = mods; - // Non-darwin target. - copy.ctrl = true; + if darwin { + copy.super_ = true; + } else { + copy.ctrl = true; + } + copy } @@ -604,6 +615,23 @@ mod tests { assert!(Key::Unidentified.should_be_remappable()); } + #[test] + fn key_ctrl_or_super_follows_platform() { + crate::platform::with_darwin(true, || { + assert!(Key::MetaLeft.ctrl_or_super()); + assert!(Key::MetaRight.ctrl_or_super()); + assert!(!Key::ControlLeft.ctrl_or_super()); + assert!(ctrl_or_super(Mods::default()).super_); + assert!(!ctrl_or_super(Mods::default()).ctrl); + }); + crate::platform::with_darwin(false, || { + assert!(Key::ControlLeft.ctrl_or_super()); + assert!(!Key::MetaLeft.ctrl_or_super()); + assert!(ctrl_or_super(Mods::default()).ctrl); + assert!(!ctrl_or_super(Mods::default()).super_); + }); + } + #[test] fn ctrl_or_super_test() { let m = ctrl_or_super(Mods::default()); diff --git a/crates/ghostty-input/src/key_encode.rs b/crates/ghostty-input/src/key_encode.rs index 5922293..29fd428 100644 --- a/crates/ghostty-input/src/key_encode.rs +++ b/crates/ghostty-input/src/key_encode.rs @@ -9,6 +9,7 @@ use crate::{ config::OptionAsAlt, function_keys::{self, CursorMode, KeypadMode, ModifyKeys}, key::{self, Key, KeyEvent}, + key_mods::ModSide, kitty::{self as kitty_table, Entry as KittyEntry}, }; @@ -266,8 +267,16 @@ fn kitty(writer: &mut W, event: &KeyEvent, opts: &Options) -> io::Resu // Determine if the Alt modifier should be treated as an actual // modifier (in which case it prevents associated text) or as // the macOS Option key, which does not prevent associated text. - // (Non-macOS: always true.) - let alt_prevents_text = true; + let alt_prevents_text = if crate::platform::is_macos() { + match opts.macos_option_as_alt { + OptionAsAlt::Left => all_mods.sides.alt == ModSide::Left, + OptionAsAlt::Right => all_mods.sides.alt == ModSide::Right, + OptionAsAlt::True => true, + OptionAsAlt::False => false, + } + } else { + true + }; if !seq.mods.prevents_text(alt_prevents_text) { seq.text = event.utf8; @@ -373,7 +382,21 @@ fn legacy(writer: &mut W, event: &KeyEvent, opts: &Options) -> io::Res if let (Some(codepoint), None) = (it.next().map(u32::from), it.clone().next()) { // The mods we encode for this are just the binding mods (shift, ctrl, // super, alt unless it is actually option). - let mods = event.mods.binding(); + let mods = { + let mut mods_binding = event.mods.binding(); + if crate::platform::is_darwin() { + let keep_alt = match opts.macos_option_as_alt { + OptionAsAlt::False => false, + OptionAsAlt::True => true, + OptionAsAlt::Left => event.mods.sides.alt == ModSide::Left, + OptionAsAlt::Right => event.mods.sides.alt == ModSide::Right, + }; + if !keep_alt { + mods_binding.alt = false; + } + } + mods_binding + }; // This copies xterm's `ModifyOtherKeys` function that returns // whether modify other keys should be encoded for the given @@ -465,8 +488,18 @@ fn legacy(writer: &mut W, event: &KeyEvent, opts: &Options) -> io::Res return writer.write_all(&[0x1B, byte]); } - // (On macOS, command+keys do not encode text. On Linux we continue to - // encode text because it is typical.) + // If we are on macOS, command+keys do not encode text. It isn't + // typical for command+keys on macOS to ever encode text. They + // don't in native text inputs (i.e. TextEdit) and they also don't + // in other native terminals (Terminal.app officially but also + // iTerm2). + // + // For Linux, we continue to encode text because it is typical. + // For example on Gnome Console Super+b will encode a "b" character + // with legacy encoding. + if crate::platform::is_macos() && all_mods.super_ { + return Ok(()); + } writer.write_all(utf8.as_bytes()) } @@ -474,7 +507,7 @@ fn legacy(writer: &mut W, event: &KeyEvent, opts: &Options) -> io::Res fn legacy_alt_prefix( event: &KeyEvent, binding_mods: key::Mods, - _mods: key::Mods, + mods: key::Mods, opts: &Options, ) -> Option { // This only takes effect with alt pressed @@ -482,7 +515,25 @@ fn legacy_alt_prefix( return None; } - // (macOS option-as-alt handling does not apply on Linux.) + // On macOS, we only handle option like alt in certain + // circumstances. Otherwise, macOS does a unicode translation + // and we allow that to happen. + if crate::platform::is_macos() { + match opts.macos_option_as_alt { + OptionAsAlt::False => return None, + OptionAsAlt::Left => { + if mods.sides.alt == ModSide::Right { + return None; + } + } + OptionAsAlt::Right => { + if mods.sides.alt == ModSide::Left { + return None; + } + } + OptionAsAlt::True => {} + } + } // Otherwise, we require utf8 to already have the byte represented. let utf8 = event.utf8.as_bytes(); @@ -3159,3 +3210,216 @@ mod tests { ); } } + +/// The Zig tests that only run on Darwin (`isDarwin()` / `os.tag == .macos` +/// skip otherwise). They run on every host here by overriding the platform +/// predicates (see [`crate::platform`]). +#[cfg(test)] +#[allow(clippy::unwrap_used)] +mod macos_tests { + use super::*; + use crate::{Mods, platform::with_darwin}; + + fn enc_legacy(ev: &KeyEvent, opts: &Options) -> Vec { + let mut w = Vec::new(); + legacy(&mut w, ev, opts).unwrap(); + w + } + + fn enc_kitty(ev: &KeyEvent, opts: &Options) -> Vec { + let mut w = Vec::new(); + kitty(&mut w, ev, opts).unwrap(); + w + } + + fn kitty_opts(option_as_alt: OptionAsAlt) -> Options { + Options { + kitty_flags: KittyFlags { + disambiguate: true, + report_all: true, + report_alternates: true, + report_associated: true, + ..KittyFlags::DISABLED + }, + macos_option_as_alt: option_as_alt, + ..Options::default() + } + } + + #[test] + fn kitty_report_associated_with_alt_text_on_macos_with_option() { + with_darwin(true, || { + let ev = KeyEvent { + key: Key::KeyW, + mods: Mods { + alt: true, + ..Mods::default() + }, + utf8: "โˆ‘", + unshifted_codepoint: 119, + ..KeyEvent::default() + }; + assert_eq!( + enc_kitty(&ev, &kitty_opts(OptionAsAlt::False)), + "\x1b[119;3;8721u".as_bytes() + ); + }); + } + + #[test] + fn kitty_report_associated_with_alt_text_on_macos_with_alt() { + with_darwin(true, || { + // With Alt modifier + let ev = KeyEvent { + key: Key::KeyW, + mods: Mods { + alt: true, + ..Mods::default() + }, + utf8: "โˆ‘", + unshifted_codepoint: 119, + ..KeyEvent::default() + }; + assert_eq!( + enc_kitty(&ev, &kitty_opts(OptionAsAlt::True)), + b"\x1b[119;3u" + ); + + // Without Alt modifier + let ev = KeyEvent { + key: Key::KeyW, + utf8: "โˆ‘", + unshifted_codepoint: 119, + ..KeyEvent::default() + }; + assert_eq!( + enc_kitty(&ev, &kitty_opts(OptionAsAlt::True)), + "\x1b[119;;8721u".as_bytes() + ); + }); + } + + #[test] + fn legacy_alt_x_macos() { + with_darwin(true, || { + let ev = KeyEvent { + key: Key::KeyC, + utf8: "โ‰ˆ", + unshifted_codepoint: u32::from('c'), + mods: Mods { + alt: true, + ..Mods::default() + }, + ..KeyEvent::default() + }; + let opts = Options { + alt_esc_prefix: true, + macos_option_as_alt: OptionAsAlt::True, + ..Options::default() + }; + assert_eq!(enc_legacy(&ev, &opts), b"\x1bc"); + + // With option-as-alt off, macOS keeps the unicode translation. + let opts = Options { + macos_option_as_alt: OptionAsAlt::False, + ..opts + }; + assert_eq!(enc_legacy(&ev, &opts), "โ‰ˆ".as_bytes()); + }); + } + + #[test] + fn legacy_shift_alt_period_macos() { + with_darwin(true, || { + let ev = KeyEvent { + key: Key::Period, + utf8: ">", + unshifted_codepoint: u32::from('.'), + mods: Mods { + alt: true, + shift: true, + ..Mods::default() + }, + ..KeyEvent::default() + }; + let opts = Options { + alt_esc_prefix: true, + macos_option_as_alt: OptionAsAlt::True, + ..Options::default() + }; + assert_eq!(enc_legacy(&ev, &opts), b"\x1b>"); + }); + } + + #[test] + fn legacy_alt_digit_with_modify_other_state_2_and_macos_option_as_alt_false() { + with_darwin(true, || { + let ev = KeyEvent { + key: Key::Digit8, + mods: Mods { + alt: true, + ..Mods::default() + }, + consumed_mods: Mods { + alt: true, + ..Mods::default() + }, + // common translation of option+8 with European keyboard layouts + utf8: "[", + ..KeyEvent::default() + }; + let opts = Options { + modify_other_keys_state_2: true, + macos_option_as_alt: OptionAsAlt::False, + ..Options::default() + }; + assert_eq!(enc_legacy(&ev, &opts), b"["); + }); + } + + #[test] + fn legacy_super_only_on_macos_with_text() { + with_darwin(true, || { + let ev = KeyEvent { + key: Key::KeyB, + utf8: "b", + mods: Mods { + super_: true, + ..Mods::default() + }, + ..KeyEvent::default() + }; + assert_eq!(enc_legacy(&ev, &Options::default()), b""); + }); + // On other platforms the text is still encoded. + with_darwin(false, || { + let ev = KeyEvent { + key: Key::KeyB, + utf8: "b", + mods: Mods { + super_: true, + ..Mods::default() + }, + ..KeyEvent::default() + }; + assert_eq!(enc_legacy(&ev, &Options::default()), b"b"); + }); + } + + #[test] + fn legacy_super_and_other_mods_on_macos_with_text() { + with_darwin(true, || { + let ev = KeyEvent { + key: Key::KeyB, + utf8: "B", + mods: Mods { + super_: true, + shift: true, + ..Mods::default() + }, + ..KeyEvent::default() + }; + assert_eq!(enc_legacy(&ev, &Options::default()), b""); + }); + } +} diff --git a/crates/ghostty-input/src/key_mods.rs b/crates/ghostty-input/src/key_mods.rs index e024e80..995a652 100644 --- a/crates/ghostty-input/src/key_mods.rs +++ b/crates/ghostty-input/src/key_mods.rs @@ -213,16 +213,33 @@ impl Mods { /// Return the mods to use for key translation. This handles settings /// like macos-option-as-alt. The translation mods should be used for /// translation but never sent back in for the key callback. - /// - /// Only macOS changes anything here; this port targets Linux so the - /// mods are returned unchanged. - pub fn translation(self, _option_as_alt: OptionAsAlt) -> Mods { - self + pub fn translation(self, option_as_alt: OptionAsAlt) -> Mods { + let mut result = self; + + // macos-option-as-alt for darwin + if crate::platform::is_darwin() { + // Alt has to be set only on the correct side + let applies = match option_as_alt { + OptionAsAlt::False => false, + OptionAsAlt::True => true, + OptionAsAlt::Left => self.sides.alt != ModSide::Right, + OptionAsAlt::Right => self.sides.alt != ModSide::Left, + }; + + // Unset alt + if applies { + result.alt = false; + } + } + + result } /// Checks to see if super is on (MacOS) or ctrl. pub fn ctrl_or_super(self) -> bool { - // Non-darwin target. + if crate::platform::is_darwin() { + return self.super_; + } self.ctrl } } @@ -577,12 +594,70 @@ mod tests { #[test] fn translation_macos_option_as_alt() { - // Darwin only upstream; on Linux it is the identity. - let mods = Mods { - alt: true, - ..Default::default() - }; - assert_eq!(mods.translation(OptionAsAlt::True), mods); + crate::platform::with_darwin(true, || { + // Unset + { + let mods = Mods::default(); + assert_eq!(mods.translation(OptionAsAlt::True), mods); + } + + // Set + { + let mods = m(|x| x.alt = true); + assert_eq!(Mods::default(), mods.translation(OptionAsAlt::True)); + } + + // Set but disabled + { + let mods = m(|x| x.alt = true); + assert_eq!(mods.translation(OptionAsAlt::False), mods); + } + + // Set wrong side + { + let mods = m(|x| { + x.alt = true; + x.sides.alt = ModSide::Right; + }); + assert_eq!(mods.translation(OptionAsAlt::Left), mods); + } + { + let mods = m(|x| { + x.alt = true; + x.sides.alt = ModSide::Left; + }); + assert_eq!(mods.translation(OptionAsAlt::Right), mods); + } + + // Set with other mods + { + let mods = m(|x| { + x.alt = true; + x.shift = true; + }); + assert_eq!(m(|x| x.shift = true), mods.translation(OptionAsAlt::True)); + } + }); + + // Not darwin: identity. + crate::platform::with_darwin(false, || { + let mods = m(|x| x.alt = true); + assert_eq!(mods.translation(OptionAsAlt::True), mods); + }); + } + + #[test] + fn ctrl_or_super_follows_platform() { + let ctrl = m(|x| x.ctrl = true); + let sup = m(|x| x.super_ = true); + crate::platform::with_darwin(true, || { + assert!(sup.ctrl_or_super()); + assert!(!ctrl.ctrl_or_super()); + }); + crate::platform::with_darwin(false, || { + assert!(ctrl.ctrl_or_super()); + assert!(!sup.ctrl_or_super()); + }); } fn left(k: &str) -> Mods { diff --git a/crates/ghostty-input/src/keycodes.rs b/crates/ghostty-input/src/keycodes.rs index 7749f91..4cb81e7 100644 --- a/crates/ghostty-input/src/keycodes.rs +++ b/crates/ghostty-input/src/keycodes.rs @@ -27,19 +27,60 @@ pub struct Entry { pub fn entries() -> &'static [Entry] { static ENTRIES: OnceLock> = OnceLock::new(); ENTRIES.get_or_init(|| { - // Linux/FreeBSD use the xkb column (index 2). + // Linux/FreeBSD use the xkb column (index 2), Windows the win column (3). RAW_ENTRIES .iter() .map(|raw| Entry { key: code_to_key(raw.5).unwrap_or(Key::Unidentified), usb: raw.0, code: raw.5, - native: raw.2, + native: native_code(raw), }) .collect() }) } +/// The native keycode column for the current platform (`native_idx` in Zig: +/// xkb = 2 on Linux/BSD, win = 3 on Windows, mac = 4 on macOS/iOS). +#[cfg(windows)] +fn native_code(raw: &RawEntry) -> u32 { + raw.3 +} + +/// macOS / iOS use the mac column. +#[cfg(target_vendor = "apple")] +fn native_code(raw: &RawEntry) -> u32 { + raw.4 +} + +/// See the Windows variant. +#[cfg(not(any(windows, target_vendor = "apple")))] +fn native_code(raw: &RawEntry) -> u32 { + raw.2 +} + +/// The columns of [`RAW_ENTRIES`] holding native keycodes. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum NativeColumn { + Xkb, + Win, + Mac, +} + +/// The native keycode of the entry with the given W3C DOM `code` in a +/// specific platform column (not only the current one). The Darwin keymap +/// uses this to find the keycode of "Space". +pub fn native_for_code(code: &str, column: NativeColumn) -> Option { + RAW_ENTRIES + .iter() + .find(|raw| raw.5 == code) + .map(|raw| match column { + NativeColumn::Xkb => raw.2, + NativeColumn::Win => raw.3, + NativeColumn::Mac => raw.4, + }) +} + /// A map from code to key. This isn't meant to be used at runtime /// (though it could), so it isn't exported. It it used to build the /// key value for Entry. @@ -685,7 +726,22 @@ mod tests { let a = e.iter().find(|e| e.code == "KeyA").unwrap(); assert_eq!(a.key, Key::KeyA); assert_eq!(a.usb, 0x070004); + #[cfg(not(any(windows, target_vendor = "apple")))] assert_eq!(a.native, 0x0026); + #[cfg(windows)] + assert_eq!(a.native, 0x001e); + #[cfg(target_vendor = "apple")] + assert_eq!(a.native, 0x0000); + } + + #[test] + fn mac_native_column() { + // kVK_ANSI_A = 0x00, kVK_Space = 0x31, kVK_Return = 0x24. + assert_eq!(native_for_code("KeyA", NativeColumn::Mac), Some(0x00)); + assert_eq!(native_for_code("Space", NativeColumn::Mac), Some(0x31)); + assert_eq!(native_for_code("Enter", NativeColumn::Mac), Some(0x24)); + assert_eq!(native_for_code("KeyA", NativeColumn::Xkb), Some(0x26)); + assert_eq!(native_for_code("Nope", NativeColumn::Mac), None); } #[test] diff --git a/crates/ghostty-input/src/keymap_darwin.rs b/crates/ghostty-input/src/keymap_darwin.rs new file mode 100644 index 0000000..8911b80 --- /dev/null +++ b/crates/ghostty-input/src/keymap_darwin.rs @@ -0,0 +1,422 @@ +//! Keymap is responsible for translating keyboard inputs into localized +//! chars. Port of `input/KeymapDarwin.zig`. +//! +//! For example, the physical key "S" on a US-layout keyboard might mean "O" +//! in Dvorak. On international keyboard layouts, it may require multiple +//! keystrokes to produce a single character that is otherwise a single +//! keystroke on a US-layout keyboard. +//! +//! This information is critical to know for many reasons. For keybindings, +//! if a user configures "ctrl+o" to do something, it should work with the +//! physical "ctrl+S" key on a Dvorak keyboard and so on. +//! +//! The Carbon / HIToolbox FFI only exists on macOS (`target_os = "macos"`); +//! the platform-independent pieces (Carbon modifier mapping, UTF-16 +//! decoding) compile everywhere so they are unit-testable. + +use crate::Mods; + +#[derive(Debug, thiserror::Error, PartialEq, Eq)] +pub enum Error { + #[error("get input source failed")] + GetInputSourceFailed, + #[error("translate failed")] + TranslateFailed, + #[error("invalid utf-16 in translation")] + InvalidUtf16, +} + +/// The state that has to be passed in with each call to translate. +/// The contents of this are meant to mostly be opaque and can change +/// for platform-specific reasons. +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)] +pub struct State { + pub dead_key: u32, +} + +/// The result of a translation. The result of a translation can be multiple +/// states. For example, if the user types a dead key, the result will be +/// "composing" since they're still in the process of composing a full +/// character. +#[derive(Debug, Clone, Default, PartialEq, Eq)] +pub struct Translation { + /// The translation result. If this is a dead key state, then this will + /// be pre-edit text that can be displayed but will ultimately be + /// replaced. + pub text: String, + + /// Whether the text is still composing, i.e. this is a dead key state. + pub composing: bool, + + /// The mods that were consumed to produce this translation + pub mods: Mods, +} + +/// Map to the modifiers format used by the UCKeyTranslate function. +/// We use a u32 here because our bit arithmetic is all u32 anyways. +/// +/// Layout (`packed struct(u32)` upstream): 8 padding bits, then `meta`, +/// `shift`, `caps_lock`, `alt`, `ctrl`. +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)] +pub struct CarbonMods { + pub meta: bool, + pub shift: bool, + pub caps_lock: bool, + pub alt: bool, + pub ctrl: bool, +} + +impl CarbonMods { + /// The packed u32 representation (`@bitCast` upstream). + pub fn to_bits(self) -> u32 { + u32::from(self.meta) << 8 + | u32::from(self.shift) << 9 + | u32::from(self.caps_lock) << 10 + | u32::from(self.alt) << 11 + | u32::from(self.ctrl) << 12 + } + + /// Translate NSEventModifierFlags into the format used by + /// UCKeyTranslate. + pub fn uc_key_translate(self) -> u32 { + (self.to_bits() >> 8) & 0xFF + } +} + +/// Convert our mods to the Carbon API format. On macOS we strip ctrl because +/// UCKeyTranslate converts to the masked values (i.e. ctrl+c becomes 3) and +/// we don't want that behavior in Ghostty ever. This makes this file not a +/// general-purpose keymap implementation. +pub fn carbon_modifier_state(input_mods: Mods) -> (Mods, u32) { + let mut mods = input_mods; + mods.ctrl = false; + + let state = CarbonMods { + alt: mods.alt, + ctrl: mods.ctrl, + meta: mods.super_, + shift: mods.shift, + caps_lock: mods.caps_lock, + } + .uc_key_translate(); + (mods, state) +} + +/// Convert the UTF-16 produced by UCKeyTranslate to UTF-8 +/// (`std.unicode.utf16LeToUtf8`). +pub fn utf16_to_utf8(units: &[u16]) -> Result { + String::from_utf16(units).map_err(|_| Error::InvalidUtf16) +} + +#[cfg(target_os = "macos")] +pub use darwin::Keymap; + +#[cfg(target_os = "macos")] +#[allow(unsafe_code, non_snake_case, non_upper_case_globals)] +mod darwin { + use std::{ffi::c_void, ptr::NonNull}; + + use objc2_core_foundation::{CFData, CFRetained, CFString, CFType}; + + use super::{Error, State, Translation, carbon_modifier_state, utf16_to_utf8}; + use crate::{ + Mods, + keycodes::{NativeColumn, native_for_code}, + }; + + // The documentation for all of these types and functions is in the macOS + // SDK: Carbon.framework/Versions/A/Frameworks/HIToolbox.framework/ + // Versions/A/Headers/TextInputSources.h + #[link(name = "Carbon", kind = "framework")] + unsafe extern "C" { + fn TISCopyCurrentKeyboardLayoutInputSource() -> *mut c_void; + fn TISGetInputSourceProperty(source: *mut c_void, key: &CFString) -> *const c_void; + fn LMGetKbdType() -> u8; + fn UCKeyTranslate( + key_layout: *const c_void, + virtual_key_code: u16, + key_action: u16, + modifier_key_state: u32, + keyboard_type: u32, + key_translate_options: u32, + dead_key_state: *mut u32, + max_string_length: std::ffi::c_ulong, + actual_string_length: *mut std::ffi::c_ulong, + unicode_string: *mut u16, + ) -> i32; + + static kTISPropertyUnicodeKeyLayoutData: &'static CFString; + static kTISPropertyInputSourceID: &'static CFString; + } + + const K_UC_KEY_ACTION_DOWN: u16 = 0; + const K_UC_KEY_TRANSLATE_NO_DEAD_KEYS_BIT: u32 = 0; + const K_UC_KEY_TRANSLATE_NO_DEAD_KEYS_MASK: u32 = 1 << K_UC_KEY_TRANSLATE_NO_DEAD_KEYS_BIT; + + /// The keyboard layout translation state of the current input source. + pub struct Keymap { + /// The current input source that is selected for the keyboard. This + /// can and does change whenever the user selects a new keyboard + /// layout. This change doesn't happen automatically; the user of + /// this struct has to detect it and then call `reload` to update + /// the keymap. + source: CFRetained, + + /// The keyboard layout (`UCKeyboardLayout *`) for the current input + /// source. This doesn't need to be freed because its owned by the + /// input source. + unicode_layout: *const c_void, + } + + // SAFETY: the input source is a thread-safe CF object and the layout + // data is immutable and owned by it. + unsafe impl Send for Keymap {} + + impl Keymap { + pub fn new() -> Result { + let (source, unicode_layout) = Self::load()?; + let keymap = Keymap { + source, + unicode_layout, + }; + if cfg!(debug_assertions) + && let Some(id) = keymap.source_id() + { + log::debug!("keyboard layout={id}"); + } + Ok(keymap) + } + + /// Reload the keymap. This must be called if the user changes their + /// keyboard layout. + pub fn reload(&mut self) -> Result<(), Error> { + let (source, unicode_layout) = Self::load()?; + self.source = source; + self.unicode_layout = unicode_layout; + Ok(()) + } + + fn load() -> Result<(CFRetained, *const c_void), Error> { + // SAFETY: plain Carbon call; returns a +1 retained input source + // or null. + let raw = unsafe { TISCopyCurrentKeyboardLayoutInputSource() }; + let raw = NonNull::new(raw.cast::()).ok_or(Error::GetInputSourceFailed)?; + // SAFETY: we own the +1 retain count from the Copy function. + let source = unsafe { CFRetained::from_raw(raw) }; + + // This returns a CFDataRef. + // SAFETY: `source` is a valid TISInputSource and the key is a + // valid constant. + let data_raw = unsafe { + TISGetInputSourceProperty(raw.as_ptr().cast(), kTISPropertyUnicodeKeyLayoutData) + }; + if data_raw.is_null() { + return Err(Error::GetInputSourceFailed); + } + // SAFETY: the property is documented to be a CFDataRef owned by + // the input source (alive as long as `source`). + let data = unsafe { &*data_raw.cast::() }; + + // The CFDataRef contains a UCKeyboardLayout pointer. + Ok((source, data.byte_ptr().cast())) + } + + /// Get the input source ID for the current keyboard layout. The + /// input source ID is a unique identifier for the keyboard layout + /// which is uniquely defined by Apple. + /// + /// This is macOS-only. Other platforms don't have an equivalent of + /// this so this isn't expected to be generally implemented. + pub fn source_id(&self) -> Option { + // SAFETY: valid source and constant key; the result is a + // CFStringRef owned by the source. + let id_raw = unsafe { + TISGetInputSourceProperty( + std::ptr::from_ref::(&self.source).cast_mut().cast(), + kTISPropertyInputSourceID, + ) + }; + if id_raw.is_null() { + return None; + } + // SAFETY: documented to be a CFStringRef. + let id = unsafe { &*id_raw.cast::() }; + Some(id.to_string()) + } + + /// Translate a single key input into a utf8 sequence. + pub fn translate( + &self, + state: &mut State, + code: u16, + input_mods: Mods, + ) -> Result { + // On macOS we strip ctrl because UCKeyTranslate converts to the + // masked values (see `carbon_modifier_state`). + let (mods, modifier_state) = carbon_modifier_state(input_mods); + + // Get the keycode for the space key. + let code_space = + native_for_code("Space", NativeColumn::Mac).expect("space code not found") as u16; + + // We use 4 here because the Chromium source code uses 4 and + // Chrome works pretty well. They have a todo to look into + // longer sequences but given how mature that software is I think + // this is fine. + // + // From Chromium: Per Apple docs, the buffer length can be up to + // 255 but is rarely more than 4. + // https://developer.apple.com/documentation/coreservices/1390584-uckeytranslate + let mut char_buf = [0u16; 4]; + let mut char_count: std::ffi::c_ulong = 0; + // SAFETY: the layout pointer is valid while `self.source` lives; + // all out pointers reference live locals with matching sizes. + let rc = unsafe { + UCKeyTranslate( + self.unicode_layout, + code, + K_UC_KEY_ACTION_DOWN, + modifier_state, + u32::from(LMGetKbdType()), + K_UC_KEY_TRANSLATE_NO_DEAD_KEYS_BIT, + &raw mut state.dead_key, + char_buf.len() as std::ffi::c_ulong, + &raw mut char_count, + char_buf.as_mut_ptr(), + ) + }; + if rc != 0 { + return Err(Error::TranslateFailed); + } + + // If we got a dead key, then we translate again with "space" in + // order to get the pre-edit text. + let composing = if state.dead_key != 0 && char_count == 0 { + // We need to copy our dead key state so that it isn't + // modified. + let mut dead_key_ignore: u32 = state.dead_key; + // SAFETY: as above. + let rc = unsafe { + UCKeyTranslate( + self.unicode_layout, + code_space, + K_UC_KEY_ACTION_DOWN, + modifier_state, + u32::from(LMGetKbdType()), + K_UC_KEY_TRANSLATE_NO_DEAD_KEYS_MASK, + &raw mut dead_key_ignore, + char_buf.len() as std::ffi::c_ulong, + &raw mut char_count, + char_buf.as_mut_ptr(), + ) + }; + if rc != 0 { + return Err(Error::TranslateFailed); + } + true + } else { + false + }; + + // Convert the utf16 to utf8 + let text = utf16_to_utf8(&char_buf[..(char_count as usize).min(char_buf.len())])?; + Ok(Translation { + text, + composing, + mods, + }) + } + } +} + +#[cfg(test)] +#[allow(clippy::unwrap_used)] +mod tests { + use super::*; + + #[test] + fn carbon_mods_expected_values() { + // We got this from dumping the values out of another program since + // I can't find the header for this anywhere. + assert_eq!( + CarbonMods { + meta: true, + ..Default::default() + } + .to_bits(), + 0x100 + ); + assert_eq!( + CarbonMods { + shift: true, + ..Default::default() + } + .to_bits(), + 0x200 + ); + assert_eq!( + CarbonMods { + caps_lock: true, + ..Default::default() + } + .to_bits(), + 0x400 + ); + assert_eq!( + CarbonMods { + alt: true, + ..Default::default() + } + .to_bits(), + 0x800 + ); + assert_eq!( + CarbonMods { + ctrl: true, + ..Default::default() + } + .to_bits(), + 0x1000 + ); + } + + #[test] + fn uc_key_translate_shifts_down_eight_bits() { + let m = CarbonMods { + meta: true, + shift: true, + ..Default::default() + }; + assert_eq!(m.uc_key_translate(), 0x03); + let m = CarbonMods { + alt: true, + ctrl: true, + ..Default::default() + }; + assert_eq!(m.uc_key_translate(), 0x18); + } + + #[test] + fn modifier_state_strips_ctrl_and_maps_super_to_meta() { + let (mods, state) = carbon_modifier_state(Mods { + ctrl: true, + super_: true, + shift: true, + caps_lock: true, + alt: true, + ..Mods::default() + }); + assert!(!mods.ctrl); + assert!(mods.super_ && mods.shift && mods.alt && mods.caps_lock); + // meta(1) | shift(2) | caps(4) | alt(8); ctrl removed. + assert_eq!(state, 0x0F); + } + + #[test] + fn utf16_decoding() { + assert_eq!(utf16_to_utf8(&[0x00E1]).unwrap(), "รก"); + assert_eq!(utf16_to_utf8(&[0xD83D, 0xDE00]).unwrap(), "๐Ÿ˜€"); + assert_eq!(utf16_to_utf8(&[]).unwrap(), ""); + assert_eq!(utf16_to_utf8(&[0xD83D]), Err(Error::InvalidUtf16)); + } +} diff --git a/crates/ghostty-input/src/keymap_noop.rs b/crates/ghostty-input/src/keymap_noop.rs new file mode 100644 index 0000000..c6e935d --- /dev/null +++ b/crates/ghostty-input/src/keymap_noop.rs @@ -0,0 +1,53 @@ +//! A noop implementation of the keymap interface so that the embedded +//! library can compile on non-macOS platforms. Port of +//! `input/KeymapNoop.zig`. + +use crate::Mods; + +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)] +pub struct State; + +#[derive(Debug, Clone, Default, PartialEq, Eq)] +pub struct Translation { + pub text: String, + pub composing: bool, + pub mods: Mods, +} + +#[derive(Debug, Clone, Copy, Default)] +pub struct Keymap; + +impl Keymap { + #[allow(clippy::unnecessary_wraps)] + pub fn new() -> Result { + Ok(Keymap) + } + + #[allow(clippy::unnecessary_wraps, clippy::unused_self)] + pub fn reload(&mut self) -> Result<(), std::convert::Infallible> { + Ok(()) + } + + #[allow(clippy::unnecessary_wraps, clippy::unused_self)] + pub fn translate( + &self, + _state: &mut State, + _code: u16, + _mods: Mods, + ) -> Result { + Ok(Translation::default()) + } +} + +#[cfg(test)] +#[allow(clippy::unwrap_used)] +mod tests { + use super::*; + + #[test] + fn noop_translate_is_empty() { + let km = Keymap::new().unwrap(); + let t = km.translate(&mut State, 0, Mods::default()).unwrap(); + assert_eq!(t, Translation::default()); + } +} diff --git a/crates/ghostty-input/src/lib.rs b/crates/ghostty-input/src/lib.rs index 248d012..22c8f7b 100644 --- a/crates/ghostty-input/src/lib.rs +++ b/crates/ghostty-input/src/lib.rs @@ -10,15 +10,24 @@ pub mod key_encode; pub mod key_mods; pub mod keyboard; pub mod keycodes; +pub mod keymap_darwin; +pub mod keymap_noop; pub mod kitty; pub mod link; pub mod mouse; pub mod paste; +pub mod platform; pub use config::OptionAsAlt; pub use key::{Action, Key, KeyEvent, ctrl_or_super}; pub use key_mods::{Mods, RemapSet as KeyRemapSet}; pub use keyboard::Layout as KeyboardLayout; +/// The platform keymap: Carbon `UCKeyTranslate` on macOS, a no-op elsewhere +/// (Zig: `input.Keymap`). +#[cfg(target_os = "macos")] +pub use keymap_darwin::{Keymap, State as KeymapState, Translation as KeymapTranslation}; +#[cfg(not(target_os = "macos"))] +pub use keymap_noop::{Keymap, State as KeymapState, Translation as KeymapTranslation}; pub use link::Link; pub use mouse::{ Button as MouseButton, diff --git a/crates/ghostty-input/src/platform.rs b/crates/ghostty-input/src/platform.rs new file mode 100644 index 0000000..341d077 --- /dev/null +++ b/crates/ghostty-input/src/platform.rs @@ -0,0 +1,51 @@ +//! Compile-time platform predicates used by the input code +//! (`builtin.target.os.tag.isDarwin()` / `builtin.os.tag == .macos` in Zig). +//! +//! In test builds the predicates can be overridden per thread with +//! [`with_darwin`] so the macOS-only branches (option-as-alt, super-instead- +//! of-ctrl, keycode table column) are exercised on every host. + +/// `isDarwin()`: macOS and iOS. +const DARWIN: bool = cfg!(target_vendor = "apple"); + +/// `os.tag == .macos`. +const MACOS: bool = cfg!(target_os = "macos"); + +#[cfg(test)] +thread_local! { + static OVERRIDE: std::cell::Cell> = + const { std::cell::Cell::new(None) }; +} + +/// True when targeting Darwin (macOS or iOS). +#[inline] +pub fn is_darwin() -> bool { + #[cfg(test)] + if let Some((darwin, _)) = OVERRIDE.with(std::cell::Cell::get) { + return darwin; + } + DARWIN +} + +/// True when targeting macOS proper. +#[inline] +pub fn is_macos() -> bool { + #[cfg(test)] + if let Some((_, macos)) = OVERRIDE.with(std::cell::Cell::get) { + return macos; + } + MACOS +} + +/// Run `f` pretending to be macOS (`darwin == macos == true`) or not. +#[cfg(test)] +pub fn with_darwin(on: bool, f: impl FnOnce() -> R) -> R { + struct Reset(Option<(bool, bool)>); + impl Drop for Reset { + fn drop(&mut self) { + OVERRIDE.with(|o| o.set(self.0)); + } + } + let _reset = Reset(OVERRIDE.with(|o| o.replace(Some((on, on))))); + f() +} diff --git a/crates/ghostty-os/Cargo.toml b/crates/ghostty-os/Cargo.toml index a14d939..7ed9cc2 100644 --- a/crates/ghostty-os/Cargo.toml +++ b/crates/ghostty-os/Cargo.toml @@ -11,12 +11,36 @@ thiserror = "2.0.21" # Zig's wasm build only compiles `os/wasm.zig`; the POSIX / D-Bus / gettext # dependencies are unavailable on wasm32. -[target.'cfg(not(target_arch = "wasm32"))'.dependencies] +[target.'cfg(not(any(target_arch = "wasm32", windows)))'.dependencies] gettext-rs = { version = "0.8.0", features = ["gettext-system"] } nix = { version = "0.31.3", features = ["user"] } rustix = { version = "1.1.5", features = ["fs", "process", "system", "pipe", "net", "time", "rand", "pty"] } zbus = "5.19.0" +# Windows: ConPTY / pipes / process APIs (`os/windows.zig`). +[target.'cfg(windows)'.dependencies] +windows-sys = { version = "0.61", features = [ + "Win32_Foundation", + "Win32_Security", + "Win32_Security_Cryptography", + "Win32_Storage_FileSystem", + "Win32_System_Console", + "Win32_System_IO", + "Win32_System_Pipes", + "Win32_System_WindowsProgramming", + "Win32_System_Threading", +] } + +[target.'cfg(target_vendor = "apple")'.dependencies] +libc = "0.2.190" +objc2 = "0.6.5" +objc2-app-kit = "0.3.2" +objc2-core-foundation = "0.3.2" +objc2-foundation = "0.3.2" + +[target.'cfg(not(target_arch = "wasm32"))'.dependencies] +crossbeam-channel = "0.5.17" + [features] # Mirrors Zig's `wasm_shared` build option (SharedArrayBuffer-backed memory). wasm-shared = [] diff --git a/crates/ghostty-os/src/args.rs b/crates/ghostty-os/src/args.rs index 10abfe5..2e9c029 100644 --- a/crates/ghostty-os/src/args.rs +++ b/crates/ghostty-os/src/args.rs @@ -5,10 +5,28 @@ //! don't panic). /// Returns an iterator over the command line arguments. +#[cfg(not(target_os = "macos"))] pub fn iterator() -> std::env::ArgsOs { std::env::args_os() } +/// Returns an iterator over the command line arguments. +/// +/// On macOS this reads `NSProcessInfo.arguments` instead of libc +/// argc/argv, which are not valid for `NSApplicationMain`-based +/// applications (Zig: `IteratorMacOS`). +#[cfg(target_os = "macos")] +pub fn iterator() -> std::vec::IntoIter { + use objc2_foundation::NSProcessInfo; + + NSProcessInfo::processInfo() + .arguments() + .iter() + .map(|s| std::ffi::OsString::from(s.to_string())) + .collect::>() + .into_iter() +} + /// Collect the process arguments as `String`s without ever panicking on /// non-UTF-8 bytes (invalid sequences become U+FFFD). The CLI/config layers /// are string based; `+action` dispatch only depends on the ASCII prefix. @@ -30,6 +48,7 @@ mod tests { assert!(!iter.next().unwrap().is_empty()); } + #[cfg(unix)] #[test] fn lossy_does_not_panic_on_non_utf8() { use std::os::unix::ffi::OsStringExt; diff --git a/crates/ghostty-os/src/cf_release_thread.rs b/crates/ghostty-os/src/cf_release_thread.rs new file mode 100644 index 0000000..1dbc754 --- /dev/null +++ b/crates/ghostty-os/src/cf_release_thread.rs @@ -0,0 +1,193 @@ +//! The CFRelease thread. Port of `os/cf_release_thread.zig`. +//! +//! Pools of `CFTypeRef`s are sent to this thread to be released, so that +//! their release callback logic doesn't block the execution of a high +//! throughput thread like the renderer thread. +//! +//! The libxev loop of the Zig version (wakeup + stop async handles around a +//! blocking mailbox) is replaced by a bounded channel (capacity 64, like +//! `BlockingQueue(Message, 64)`) plus a stop channel; a `select!` loop +//! drains the mailbox. The release function is injectable so the thread +//! logic is testable on every platform; on Darwin the default is +//! `CFRelease`. + +use std::{ffi::c_void, ptr::NonNull, thread::JoinHandle}; + +use crossbeam_channel::{Receiver, Sender, bounded, select}; + +/// Mailbox capacity (Zig: `BlockingQueue(Message, 64)`). +pub const MAILBOX_CAPACITY: usize = 64; + +/// A raw `CFTypeRef` owned by the sender (+1 retain to be balanced by one +/// release on this thread). +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct CfRef(pub NonNull); + +// SAFETY: a CFTypeRef is a thread-safe, reference-counted handle; ownership +// of one retain count is transferred with the message. +#[allow(unsafe_code)] +unsafe impl Send for CfRef {} + +#[derive(Debug)] +pub enum Message { + /// Release a list of CFTypeRefs. + Release(Vec), +} + +/// Function used to release one ref. +pub type ReleaseFn = fn(CfRef); + +/// Darwin `CFRelease`. +#[cfg(target_vendor = "apple")] +pub fn cf_release(r: CfRef) { + use objc2_core_foundation::{CFRetained, CFType}; + + // SAFETY: the sender transferred one retain count to us, and `r` is a + // valid non-null CFTypeRef; dropping the CFRetained releases it. + #[allow(unsafe_code)] + unsafe { + drop(CFRetained::from_raw(r.0.cast::())); + } +} + +/// Handle used to talk to a running [`Thread`]. +#[derive(Clone)] +pub struct Handle { + mailbox: Sender, + stop: Sender<()>, +} + +impl Handle { + /// Push a message, blocking if the mailbox is full (`.forever`). + /// Returns false if the thread is gone. + pub fn send(&self, msg: Message) -> bool { + self.mailbox.send(msg).is_ok() + } + + /// Ask the thread to stop on the next loop iteration. + pub fn stop(&self) { + let _ = self.stop.try_send(()); + } +} + +/// The CFRelease thread (not yet started). +pub struct Thread { + mailbox_rx: Receiver, + stop_rx: Receiver<()>, + handle: Handle, + release: ReleaseFn, +} + +pub struct Running { + pub handle: Handle, + join: JoinHandle<()>, +} + +impl Running { + /// Stop the thread and wait for it to exit. + pub fn stop_and_join(self) { + self.handle.stop(); + let _ = self.join.join(); + } +} + +impl Thread { + /// Initialize the thread with the default (`CFRelease`) release function. + #[cfg(target_vendor = "apple")] + pub fn init() -> Thread { + Thread::with_release(cf_release) + } + + /// Initialize the thread with a custom release function. + pub fn with_release(release: ReleaseFn) -> Thread { + let (tx, mailbox_rx) = bounded(MAILBOX_CAPACITY); + let (stop_tx, stop_rx) = bounded(1); + Thread { + mailbox_rx, + stop_rx, + handle: Handle { + mailbox: tx, + stop: stop_tx, + }, + release, + } + } + + pub fn handle(&self) -> Handle { + self.handle.clone() + } + + /// Spawn the thread (named "cf_release" like the Zig version). + pub fn spawn(self) -> std::io::Result { + let handle = self.handle.clone(); + let join = std::thread::Builder::new() + .name("cf_release".into()) + .spawn(move || self.thread_main())?; + Ok(Running { handle, join }) + } + + /// The main entrypoint for the thread. + pub fn thread_main(self) { + // Right now, on Darwin, `std::thread::Builder::name` already names + // the thread through pthread_setname_np for the current thread, but + // keep the explicit call for parity with Zig. + #[cfg(target_vendor = "apple")] + crate::macos::pthread_setname_np(c"cf_release"); + + log::debug!("starting cf release thread"); + loop { + select! { + recv(self.mailbox_rx) -> msg => match msg { + Ok(msg) => self.handle_message(msg), + // All senders gone: nothing more can arrive. + Err(_) => break, + }, + recv(self.stop_rx) -> _ => break, + } + } + + // Messages published before the stop request are still released so + // we never leak refs. + while let Ok(msg) = self.mailbox_rx.try_recv() { + self.handle_message(msg); + } + log::debug!("cf release thread exited"); + } + + fn handle_message(&self, msg: Message) { + match msg { + Message::Release(refs) => { + for r in refs { + (self.release)(r); + } + } + } + } +} + +#[cfg(test)] +mod tests { + use std::sync::atomic::{AtomicUsize, Ordering}; + + use super::*; + + static RELEASED: AtomicUsize = AtomicUsize::new(0); + + fn count_release(_: CfRef) { + RELEASED.fetch_add(1, Ordering::SeqCst); + } + + #[test] + fn releases_all_refs_before_exit() { + let t = Thread::with_release(count_release); + let running = t.spawn().unwrap(); + for _ in 0..3 { + let refs = (1..=5usize) + .map(|i| CfRef(NonNull::new(i as *mut c_void).unwrap())) + .collect(); + assert!(running.handle.send(Message::Release(refs))); + } + running.stop_and_join(); + assert_eq!(RELEASED.load(Ordering::SeqCst), 15); + } +} diff --git a/crates/ghostty-os/src/desktop.rs b/crates/ghostty-os/src/desktop.rs index 21e1ed1..6542a18 100644 --- a/crates/ghostty-os/src/desktop.rs +++ b/crates/ghostty-os/src/desktop.rs @@ -3,6 +3,23 @@ /// On Linux GTK, this returns true if the program was launched using the /// desktop file. This also includes when `gtk-launch` is used because I /// can't find a way to distinguish the two scenarios. +#[cfg(target_os = "macos")] +pub fn launched_from_desktop() -> bool { + // macOS apps launched from finder or `open` always have the init + // process as their parent. + macos_launched_from_desktop(false, std::env::var_os(MAC_LAUNCH_SOURCE_ENV), || { + rustix::process::getppid().map_or(0, rustix::process::Pid::as_raw_pid) + }) +} + +/// iPhone/iPad is always launched from the "desktop". +#[cfg(target_os = "ios")] +pub fn launched_from_desktop() -> bool { + true +} + +/// See the other variants. +#[cfg(not(any(target_os = "macos", target_os = "ios")))] pub fn launched_from_desktop() -> bool { // On Linux and BSD, GTK sets GIO_LAUNCHED_DESKTOP_FILE and // GIO_LAUNCHED_DESKTOP_FILE_PID. We only check the latter to see if @@ -14,14 +31,49 @@ pub fn launched_from_desktop() -> bool { return false; }; - let pid = rustix::process::getpid().as_raw_nonzero().get(); - let Ok(gio_pid) = gio_pid_str.to_string_lossy().parse::() else { + // (`std::process::id` is `getpid` / `GetCurrentProcessId`.) + let pid = std::process::id(); + let Ok(gio_pid) = gio_pid_str.to_string_lossy().parse::() else { return false; }; gio_pid == pid } +/// Environment variable set by the macOS app to tell libghostty how it was +/// launched ("app", "cli" or "zig_run"). +pub const MAC_LAUNCH_SOURCE_ENV: &str = "GHOSTTY_MAC_LAUNCH_SOURCE"; + +/// `launched_from_desktop` on macOS. `artifact_is_lib` mirrors +/// `build_config.artifact == .lib`: when embedded as a library, the app +/// bundle may tell us via [`MAC_LAUNCH_SOURCE_ENV`] that it launched us even +/// though our parent isn't `launchd`. +#[cfg_attr(not(any(target_os = "macos", test)), allow(dead_code))] +fn macos_launched_from_desktop( + artifact_is_lib: bool, + source: Option, + getppid: impl FnOnce() -> i32, +) -> bool { + if artifact_is_lib && let Some(source) = source { + // Source can be "app", "cli", or "zig_run". We assume its the + // desktop only if its "app". + if source == "app" { + return true; + } + } + + getppid() == 1 +} + +/// The libghostty variant of [`launched_from_desktop`] on macOS (honours the +/// `GHOSTTY_MAC_LAUNCH_SOURCE` environment variable). +#[cfg(target_os = "macos")] +pub fn launched_from_desktop_lib() -> bool { + macos_launched_from_desktop(true, std::env::var_os(MAC_LAUNCH_SOURCE_ENV), || { + rustix::process::getppid().map_or(0, rustix::process::Pid::as_raw_pid) + }) +} + /// The list of desktop environments that we detect. New Linux desktop /// environments should only be added to this list if there's a specific reason /// to differentiate between `gnome` and `other`. @@ -37,6 +89,13 @@ pub enum DesktopEnvironment { /// on Linux and BSD to enable or disable certain features but there may be more uses in /// the future. pub fn desktop_environment() -> DesktopEnvironment { + if cfg!(windows) { + return DesktopEnvironment::Windows; + } + if cfg!(target_os = "macos") { + return DesktopEnvironment::Macos; + } + // Use $XDG_SESSION_DESKTOP to determine what DE we are using on Linux // https://www.freedesktop.org/software/systemd/man/latest/pam_systemd.html#desktop= if let Some(sd) = std::env::var_os("XDG_SESSION_DESKTOP") { @@ -69,6 +128,8 @@ pub fn desktop_environment() -> DesktopEnvironment { mod tests { use super::*; + #[cfg(unix)] + #[allow(dead_code)] fn de(session: Option<&str>, current: Option<&str>) -> DesktopEnvironment { temp_env::with_vars( [ @@ -79,6 +140,8 @@ mod tests { ) } + #[cfg(unix)] + #[cfg(not(target_os = "macos"))] #[test] fn desktop_environment_detection() { use DesktopEnvironment::{Gnome, Other}; @@ -93,6 +156,24 @@ mod tests { assert_eq!(de(Some("foobar"), Some("GNOME")), Gnome); } + #[test] + fn macos_launch_detection() { + use std::ffi::OsString; + // Parent is launchd. + assert!(macos_launched_from_desktop(false, None, || 1)); + assert!(!macos_launched_from_desktop(false, None, || 4242)); + // The env var only counts for the lib artifact and only for "app". + let app = Some(OsString::from("app")); + assert!(macos_launched_from_desktop(true, app.clone(), || 4242)); + assert!(!macos_launched_from_desktop(false, app, || 4242)); + assert!(!macos_launched_from_desktop( + true, + Some(OsString::from("cli")), + || 4242 + )); + } + + #[cfg(not(target_os = "macos"))] #[test] fn launched_from_desktop_pid_match() { let pid = std::process::id().to_string(); diff --git a/crates/ghostty-os/src/env.rs b/crates/ghostty-os/src/env.rs index 903b451..c4c8d0b 100644 --- a/crates/ghostty-os/src/env.rs +++ b/crates/ghostty-os/src/env.rs @@ -1,11 +1,15 @@ use std::{ + borrow::Cow, collections::HashMap, ffi::{OsStr, OsString}, }; use crate::flatpak::is_flatpak; -/// PATH-style delimiter (`std.fs.path.delimiter` on POSIX). +/// PATH-style delimiter (`std.fs.path.delimiter`). +#[cfg(windows)] +const DELIMITER: char = ';'; +#[cfg(not(windows))] const DELIMITER: char = ':'; /// A byte-preserving environment map (Zig `std.process.EnvMap`): keys and @@ -18,21 +22,43 @@ impl EnvMap { Self::default() } - pub fn insert(&mut self, key: impl AsRef, value: impl AsRef) -> Option { + /// On Windows environment variable names are case-insensitive (Zig's + /// `EnvMap` uses a case-insensitive hash context there), so lookups find + /// the existing key regardless of ASCII case. Elsewhere this is the key + /// itself. + #[cfg(windows)] + fn canonical_key<'a>(&'a self, key: &'a OsStr) -> Cow<'a, OsStr> { + let want = key.to_string_lossy().to_ascii_uppercase(); self.0 - .insert(key.as_ref().to_owned(), value.as_ref().to_owned()) + .keys() + .find(|k| k.to_string_lossy().to_ascii_uppercase() == want) + .map_or(Cow::Borrowed(key), |k| Cow::Borrowed(k.as_os_str())) + } + + #[cfg(not(windows))] + #[allow(clippy::unused_self)] + fn canonical_key<'a>(&'a self, key: &'a OsStr) -> Cow<'a, OsStr> { + Cow::Borrowed(key) + } + + pub fn insert(&mut self, key: impl AsRef, value: impl AsRef) -> Option { + let key = self.canonical_key(key.as_ref()).into_owned(); + self.0.insert(key, value.as_ref().to_owned()) } pub fn get(&self, key: impl AsRef) -> Option<&OsStr> { - self.0.get(key.as_ref()).map(OsString::as_os_str) + self.0 + .get(&*self.canonical_key(key.as_ref())) + .map(OsString::as_os_str) } pub fn remove(&mut self, key: impl AsRef) -> Option { - self.0.remove(key.as_ref()) + let key = self.canonical_key(key.as_ref()).into_owned(); + self.0.remove(&key) } pub fn contains_key(&self, key: impl AsRef) -> bool { - self.0.contains_key(key.as_ref()) + self.0.contains_key(&*self.canonical_key(key.as_ref())) } pub fn len(&self) -> usize { @@ -89,6 +115,13 @@ pub fn get_env_map() -> EnvMap { /// drop `:`-separated components that start with one of the snap /// directories. Variables we set ourselves (`TERMINFO`, `GHOSTTY*`) are /// left alone. +#[cfg(windows)] +pub fn filter_snap_paths(_env: &mut EnvMap) { + // Snap only exists on Linux. +} + +/// See the Windows no-op above. +#[cfg(unix)] pub fn filter_snap_paths(env: &mut EnvMap) { use std::os::unix::ffi::{OsStrExt, OsStringExt}; @@ -216,10 +249,12 @@ pub fn getenv_not_empty(key: &str) -> Option { #[cfg(test)] mod tests { + #[cfg(unix)] use std::os::unix::ffi::OsStringExt; use super::*; + #[cfg(unix)] #[test] fn env_map_preserves_non_utf8() { let mut env = EnvMap::new(); @@ -228,6 +263,7 @@ mod tests { assert_eq!(env.get("K"), Some(v.as_os_str())); } + #[cfg(unix)] #[test] fn filter_snap_paths_filters() { let mut env = EnvMap::new(); @@ -255,9 +291,10 @@ mod tests { #[test] fn append_env_existing() { - assert_eq!(append_env("a:b", "foo"), "a:b:foo"); + assert_eq!(append_env("a:b", "foo"), format!("a:b{DELIMITER}foo")); } + #[cfg(unix)] #[test] fn os_variants_preserve_bytes() { let cur = OsString::from_vec(vec![0xff, b'a']); @@ -275,13 +312,13 @@ mod tests { #[test] fn prepend_env_existing() { - assert_eq!(prepend_env("a:b", "foo"), "foo:a:b"); + assert_eq!(prepend_env("a:b", "foo"), format!("foo{DELIMITER}a:b")); } #[test] fn append_env_always_empty_value() { - assert_eq!(append_env_always("a", ""), "a:"); - assert_eq!(append_env_always("", "x"), ":x"); + assert_eq!(append_env_always("a", ""), format!("a{DELIMITER}")); + assert_eq!(append_env_always("", "x"), format!("{DELIMITER}x")); } #[test] diff --git a/crates/ghostty-os/src/file.rs b/crates/ghostty-os/src/file.rs index 40e854b..2edba9c 100644 --- a/crates/ghostty-os/src/file.rs +++ b/crates/ghostty-os/src/file.rs @@ -1,17 +1,23 @@ +#[cfg(unix)] use rustix::process::{Resource, Rlimit as RustixRlimit, getrlimit, setrlimit}; /// Resource limit (`rlimit` in Zig). `None` means infinity. +#[cfg(unix)] pub type Rlimit = RustixRlimit; /// rlim_t value for infinity in the Zig implementation. +#[cfg(unix)] const INFINITY: u64 = u64::MAX; +#[cfg(unix)] fn cur(l: &Rlimit) -> u64 { l.current.unwrap_or(INFINITY) } +#[cfg(unix)] fn max(l: &Rlimit) -> u64 { l.maximum.unwrap_or(INFINITY) } +#[cfg(unix)] fn mk(cur: u64, max: Option) -> Rlimit { Rlimit { current: if cur == INFINITY { None } else { Some(cur) }, @@ -22,6 +28,7 @@ fn mk(cur: u64, max: Option) -> Rlimit { /// This maximizes the number of file descriptors we can have open. We /// need to do this because each window consumes at least a handful of fds. /// This is extracted from the Zig compiler source code. +#[cfg(unix)] pub fn fix_max_files() -> Option { let old = getrlimit(Resource::Nofile); @@ -58,12 +65,17 @@ pub fn fix_max_files() -> Option { Some(old) } +#[cfg(unix)] pub fn restore_max_files(lim: Rlimit) { let _ = setrlimit(Resource::Nofile, lim); } /// Return the recommended path for temporary files. pub fn alloc_tmp_dir() -> Option { + if cfg!(windows) { + // TODO: what is a good fallback path on windows? (same as Zig) + return std::env::var_os("TMP").map(|v| v.to_string_lossy().into_owned()); + } if let Some(v) = std::env::var_os("TMPDIR") { return Some(v.to_string_lossy().into_owned()); } @@ -73,10 +85,11 @@ pub fn alloc_tmp_dir() -> Option { Some("/tmp".to_owned()) } -#[cfg(test)] +#[cfg(all(test, unix))] mod tests { use super::*; + #[cfg(unix)] #[test] fn tmp_dir_env_precedence() { temp_env::with_vars([("TMPDIR", Some("/a")), ("TMP", Some("/b"))], || { @@ -90,6 +103,7 @@ mod tests { }); } + #[cfg(unix)] #[test] fn fix_and_restore_max_files() { let old = fix_max_files().unwrap(); diff --git a/crates/ghostty-os/src/homedir.rs b/crates/ghostty-os/src/homedir.rs index a0024b6..bb4884a 100644 --- a/crates/ghostty-os/src/homedir.rs +++ b/crates/ghostty-os/src/homedir.rs @@ -1,5 +1,7 @@ +#[cfg(unix)] use std::process::{Command, Stdio}; +#[cfg(unix)] use crate::passwd; #[derive(Debug, thiserror::Error, PartialEq, Eq)] @@ -28,6 +30,7 @@ pub fn home() -> Result, Error> { /// Like [`home`] but emulates the fixed output buffer of the Zig version: /// errors with `BufferTooSmall` if the result is longer than `cap` bytes. +#[cfg(unix)] pub fn home_with_capacity(cap: usize) -> Result, Error> { let check = |s: String| { if cap < s.len() { @@ -42,6 +45,18 @@ pub fn home_with_capacity(cap: usize) -> Result, Error> { return check(result.to_string_lossy().into_owned()); } + // On macOS: [NSFileManager defaultManager].homeDirectoryForCurrentUser.path + #[cfg(target_os = "macos")] + { + use objc2_foundation::NSFileManager; + + let manager = NSFileManager::defaultManager(); + let home_url = manager.homeDirectoryForCurrentUser(); + if let Some(path) = home_url.path() { + return check(path.to_string()); + } + } + // We try passwd. This doesn't work on multi-user mac but we try it anyways. if let Ok(pw) = passwd::get() && let Some(result) = pw.home @@ -65,6 +80,34 @@ pub fn home_with_capacity(cap: usize) -> Result, Error> { Ok(None) } +/// Windows: `%HOMEDRIVE%%HOMEPATH%` (`homeWindows`). Returns `None` if +/// either variable is missing. +#[cfg(windows)] +pub fn home_with_capacity(cap: usize) -> Result, Error> { + home_windows( + std::env::var("HOMEDRIVE").ok().as_deref(), + std::env::var("HOMEPATH").ok().as_deref(), + cap, + ) +} + +/// Pure part of [`home_with_capacity`] on Windows. +#[cfg(any(windows, test))] +fn home_windows( + drive: Option<&str>, + path: Option<&str>, + cap: usize, +) -> Result, Error> { + let (Some(drive), Some(path)) = (drive, path) else { + return Ok(None); + }; + if cap < drive.len() + path.len() { + return Err(Error::BufferTooSmall); + } + Ok(Some(format!("{drive}{path}"))) +} + +#[cfg(unix)] fn trim_space(input: &str) -> &str { input.trim_matches([' ', '\n', '\t']) } @@ -80,6 +123,14 @@ pub fn expand_home(path: &str) -> Result { /// Like [`expand_home`] with an explicit output capacity (emulating the /// caller-provided buffer of the Zig version). +#[cfg(windows)] +pub fn expand_home_with_capacity(path: &str, _cap: usize) -> Result { + // `~/` is not an idiom generally used on Windows + Ok(path.to_owned()) +} + +/// See the Windows variant above. +#[cfg(unix)] pub fn expand_home_with_capacity(path: &str, cap: usize) -> Result { if !path.starts_with("~/") { return Ok(path.to_owned()); @@ -100,6 +151,25 @@ pub fn expand_home_with_capacity(path: &str, cap: usize) -> Result bool { true } +/// The current machine's hostname (`GetComputerNameA`). +#[cfg(windows)] +fn our_hostname() -> Option { + crate::windows::computer_name() +} + /// The current machine's hostname (`gethostname`). -fn our_hostname() -> String { - rustix::system::uname() - .nodename() - .to_string_lossy() - .into_owned() +#[cfg(unix)] +#[allow(clippy::unnecessary_wraps)] +fn our_hostname() -> Option { + Some( + rustix::system::uname() + .nodename() + .to_string_lossy() + .into_owned(), + ) } /// Checks if a hostname is local to the current machine. This matches @@ -102,7 +112,7 @@ pub fn is_local(hostname: &str) -> Result { } // If hostname is not "localhost" it must match our hostname. - Ok(hostname == our_hostname()) + Ok(our_hostname().is_some_and(|ours| hostname == ours)) } #[cfg(test)] @@ -147,7 +157,7 @@ mod tests { #[test] fn is_local_returns_true_when_hostname_is_local() { - let local = our_hostname(); + let local = our_hostname().unwrap(); assert!(is_local(&local).unwrap()); } diff --git a/crates/ghostty-os/src/i18n.rs b/crates/ghostty-os/src/i18n.rs index 5e499bd..0a26d63 100644 --- a/crates/ghostty-os/src/i18n.rs +++ b/crates/ghostty-os/src/i18n.rs @@ -1,3 +1,4 @@ +#[cfg(not(windows))] use std::path::Path; use crate::i18n_locales::LOCALES; @@ -27,6 +28,15 @@ pub enum InitError { /// of translations. This does NOT call `textdomain` as we don't /// want to set the domain for the entire application since this is also /// used by libghostty. +#[cfg(windows)] +#[allow(clippy::unnecessary_wraps)] +pub fn init(_resources_dir: &str) -> Result<(), InitError> { + // i18n is unsupported on Windows + Ok(()) +} + +/// See the Windows variant above. +#[cfg(not(windows))] pub fn init(resources_dir: &str) -> Result<(), InitError> { // Our resources dir is always nested below the share dir that // is standard for translations. @@ -52,12 +62,16 @@ pub fn init(resources_dir: &str) -> Result<(), InitError> { /// This should only be called for apprts that are fully owning the /// Ghostty application. This should not be called for libghostty users. pub fn init_global_domain() -> Result<(), InitError> { + #[cfg(not(windows))] gettextrs::textdomain(BUNDLE_ID).map_err(|_| InitError::OutOfMemory)?; Ok(()) } /// Translate a message for the Ghostty domain (Zig: `_`). pub fn tr(msgid: &str) -> String { + #[cfg(windows)] + return msgid.to_owned(); + #[cfg(not(windows))] gettextrs::dgettext(BUNDLE_ID, msgid) } diff --git a/crates/ghostty-os/src/lib.rs b/crates/ghostty-os/src/lib.rs index f8c1aa1..51fd3df 100644 --- a/crates/ghostty-os/src/lib.rs +++ b/crates/ghostty-os/src/lib.rs @@ -16,6 +16,8 @@ pub mod args; #[cfg(not(target_arch = "wasm32"))] +pub mod cf_release_thread; +#[cfg(not(target_arch = "wasm32"))] pub mod cgroup; #[cfg(not(target_arch = "wasm32"))] pub mod dbus; @@ -26,8 +28,17 @@ pub mod env; #[cfg(not(target_arch = "wasm32"))] pub mod file; pub mod file_type; -#[cfg(not(target_arch = "wasm32"))] +#[cfg(unix)] pub mod flatpak; +#[cfg(windows)] +pub mod flatpak { + //! Flatpak does not exist on Windows. + + /// Always false on Windows. + pub fn is_flatpak() -> bool { + false + } +} #[cfg(not(target_arch = "wasm32"))] pub mod homedir; #[cfg(not(target_arch = "wasm32"))] @@ -39,11 +50,34 @@ pub mod i18n_locales; pub mod kernel_info; #[cfg(not(target_arch = "wasm32"))] pub mod locale; +#[cfg(unix)] +pub mod mach; +#[cfg(unix)] +pub mod macos; pub mod mouse; #[cfg(not(target_arch = "wasm32"))] pub mod open; -#[cfg(not(target_arch = "wasm32"))] +#[cfg(unix)] pub mod passwd; +#[cfg(windows)] +pub mod passwd { + //! `passwd.zig` does not exist on Windows (it `@compileError`s). This + //! stand-in reports an empty entry, which is what Zig callers' fallback + //! paths expect. + + /// Entry retrieved from the passwd API (same shape as the Unix one). + #[derive(Debug, Clone, Default, PartialEq, Eq)] + pub struct Entry { + pub shell: Option, + pub home: Option, + pub name: Option, + } + + /// No passwd database on Windows: always an empty entry. + pub fn get() -> std::io::Result { + Ok(Entry::default()) + } +} #[cfg(not(target_arch = "wasm32"))] pub mod path; #[cfg(not(target_arch = "wasm32"))] @@ -52,13 +86,15 @@ pub mod pipe; pub mod resourcesdir; pub mod shell; pub mod string_encoding; -#[cfg(not(target_arch = "wasm32"))] +#[cfg(unix)] pub mod systemd; #[cfg(not(target_arch = "wasm32"))] pub mod temp_dir; pub mod uri; #[cfg(target_arch = "wasm32")] pub mod wasm; +#[cfg(windows)] +pub mod windows; #[cfg(target_arch = "wasm32")] mod unsupported; @@ -91,9 +127,13 @@ pub use env::{ prepend_env_os, }; #[cfg(not(target_arch = "wasm32"))] -pub use file::{Rlimit, alloc_tmp_dir, fix_max_files, restore_max_files}; +pub use file::alloc_tmp_dir; +#[cfg(unix)] +pub use file::{Rlimit, fix_max_files, restore_max_files}; +#[cfg(unix)] +pub use flatpak::FlatpakHostCommand; #[cfg(not(target_arch = "wasm32"))] -pub use flatpak::{FlatpakHostCommand, is_flatpak}; +pub use flatpak::is_flatpak; #[cfg(not(target_arch = "wasm32"))] pub use homedir::{expand_home, home}; #[cfg(not(target_arch = "wasm32"))] @@ -108,15 +148,32 @@ pub use pipe::pipe; #[cfg(not(target_arch = "wasm32"))] pub use resourcesdir::{ResourcesDir, resources_dir}; pub use shell::ShellEscapeWriter; -#[cfg(not(target_arch = "wasm32"))] +#[cfg(unix)] pub use systemd::launched_by_systemd; #[cfg(not(target_arch = "wasm32"))] pub use temp_dir::TempDir; -/// Join path components like `std.fs.path.join` (POSIX): empty components -/// are skipped and exactly one separator is placed between components. +/// The native path separator (`std.fs.path.sep`). +#[cfg(windows)] +const SEP: char = '\\'; +#[cfg(not(any(windows, target_arch = "wasm32")))] +const SEP: char = '/'; + +/// Join path components like `std.fs.path.join` for the host platform: +/// empty components are skipped and exactly one separator is placed +/// between components. #[cfg(not(target_arch = "wasm32"))] pub(crate) fn path_join(parts: &[&str]) -> String { + path_join_with(SEP, parts) +} + +/// [`path_join`] with an explicit separator (so both flavours are testable +/// on any host). +#[cfg(not(target_arch = "wasm32"))] +pub(crate) fn path_join_with(sep: char, parts: &[&str]) -> String { + // Windows accepts `/` as a separator too; Zig treats both as separators + // when joining. + let is_sep = |c: char| c == sep || (sep == '\\' && c == '/'); let mut out = String::new(); for p in parts { if p.is_empty() { @@ -126,12 +183,12 @@ pub(crate) fn path_join(parts: &[&str]) -> String { out.push_str(p); continue; } - let prev_sep = out.ends_with('/'); - let this_sep = p.starts_with('/'); + let prev_sep = out.ends_with(is_sep); + let this_sep = p.starts_with(is_sep); match (prev_sep, this_sep) { (true, true) => out.push_str(&p[1..]), (false, false) => { - out.push('/'); + out.push(sep); out.push_str(p); } _ => out.push_str(p), @@ -142,13 +199,23 @@ pub(crate) fn path_join(parts: &[&str]) -> String { #[cfg(test)] mod tests { - use super::path_join; + use super::path_join_with; #[test] fn path_join_basics() { - assert_eq!(path_join(&["/a", ".cache", ""]), "/a/.cache"); - assert_eq!(path_join(&["/a/", "/b"]), "/a/b"); - assert_eq!(path_join(&["", "b"]), "b"); - assert_eq!(path_join(&["/a", "b", "c"]), "/a/b/c"); + let j = |p: &[&str]| path_join_with('/', p); + assert_eq!(j(&["/a", ".cache", ""]), "/a/.cache"); + assert_eq!(j(&["/a/", "/b"]), "/a/b"); + assert_eq!(j(&["", "b"]), "b"); + assert_eq!(j(&["/a", "b", "c"]), "/a/b/c"); + } + + #[test] + fn path_join_windows_flavour() { + let j = |p: &[&str]| path_join_with('\\', p); + assert_eq!(j(&["C:\\a", ".cache", ""]), "C:\\a\\.cache"); + assert_eq!(j(&["C:\\a\\", "\\b"]), "C:\\a\\b"); + assert_eq!(j(&["C:\\a", "/b"]), "C:\\a/b"); + assert_eq!(j(&["", "b"]), "b"); } } diff --git a/crates/ghostty-os/src/locale.rs b/crates/ghostty-os/src/locale.rs index 0324108..c79e742 100644 --- a/crates/ghostty-os/src/locale.rs +++ b/crates/ghostty-os/src/locale.rs @@ -16,7 +16,10 @@ //! (try `setlocale(LC_ALL, "")`, then with `LANG` unset, then //! `en_US.UTF-8` and export `LANG=en_US.UTF-8`). //! -//! The macOS-specific `LANG` population from Cocoa is not applicable. +//! On Darwin (`target_vendor = "apple"`) the full Zig behaviour is +//! implemented in [`darwin`] (it needs `unsafe` for `setlocale`/`setenv`): +//! `LANG`/`LANGUAGE` are pre-populated from Cocoa when unset and +//! `setlocale(LC_ALL, "")` is called with the Zig fallback chain. /// The locale Ghostty falls back to (and exports as `LANG`) when the /// environment's locale can't be set. @@ -27,15 +30,179 @@ pub const FALLBACK_LOCALE: &str = "en_US.UTF-8"; /// DEFERRED (see module docs): this is currently a no-op that only logs. /// Returns `Some(value)` when `LANG` should be set to `value` for child /// processes; always `None` for now. +#[cfg(not(target_vendor = "apple"))] pub fn ensure_locale() -> Option { log::debug!("ensure_locale: setlocale is not available from safe Rust; relying on the toolkit"); None } +/// Ensure that the locale is set (Darwin: full port of `ensureLocale`). +/// Returns `Some("en_US.UTF-8")` if we had to fall back to it (and exported +/// it as `LANG`), otherwise `None`. +#[cfg(target_vendor = "apple")] +pub fn ensure_locale() -> Option { + darwin::ensure_locale() +} + +/// Format the system locale as `_.UTF-8` (the value Zig's +/// `setLangFromCocoa` exports as `LANG`). +pub fn format_lang(lang: &str, country: &str) -> String { + format!("{lang}_{country}.UTF-8") +} + +/// Build the `LANGUAGE` value from Apple's preferred languages +/// (`preferredLanguageFromCocoa`): each BCP-47 tag is canonicalized to the +/// POSIX format, gets `.UTF-8` appended and they are joined with `:`. +/// Returns `None` if there are no preferred languages. +/// +/// gettext uses `LANGUAGE` to override only translations, with a priority +/// order given by colon-separated entries (e.g. `en:fr`). +pub fn preferred_language_value>(preferred: &[S]) -> Option { + let mut out = String::new(); + for lang in preferred { + if !out.is_empty() { + out.push(':'); + } + out.push_str(&crate::i18n::canonicalize_locale(lang.as_ref())); + // The canonicalized locale never contains the encoding and all of + // our translations require UTF-8 so we add that. + out.push_str(".UTF-8"); + } + (!out.is_empty()).then_some(out) +} + +#[cfg(target_vendor = "apple")] +#[allow(unsafe_code)] +mod darwin { + use std::ffi::{CStr, CString}; + + use super::{FALLBACK_LOCALE, format_lang, preferred_language_value}; + + fn setlocale_all(locale: &str) -> Option { + let c = CString::new(locale).ok()?; + // SAFETY: valid NUL-terminated string; setlocale returns a pointer to + // a static string (or null) which we copy immediately. + unsafe { + let r = libc::setlocale(libc::LC_ALL, c.as_ptr()); + (!r.is_null()).then(|| CStr::from_ptr(r).to_string_lossy().into_owned()) + } + } + + fn set_env(key: &str, value: &str) { + // SAFETY: called during early process startup (locale setup) before + // other threads read the environment, mirroring the Zig code. + unsafe { std::env::set_var(key, value) }; + } + + fn unset_env(key: &str) { + // SAFETY: see `set_env`. + unsafe { std::env::remove_var(key) }; + } + + /// Sets LANG (and LANGUAGE) from the system preferences. + fn set_lang_from_cocoa() { + use objc2_foundation::NSLocale; + + let locale = NSLocale::currentLocale(); + let lang = locale.languageCode().to_string(); + // `countryCode` is deprecated by Apple but is what the Zig code uses. + #[allow(deprecated)] + let country = locale.countryCode(); + let Some(country) = country.map(|c| c.to_string()) else { + log::warn!("languageCode or countryCode not found. Locale may be incorrect."); + return; + }; + + let env_value = format_lang(&lang, &country); + log::info!("detected system locale={env_value}"); + set_env("LANG", &env_value); + + // Get our preferred languages and set that to the LANGUAGE env var + // in case our language differs from our locale. + let preferred: Vec = NSLocale::preferredLanguages() + .iter() + .map(|s| s.to_string()) + .collect(); + if let Some(pref) = preferred_language_value(&preferred) { + log::debug!("setting LANGUAGE from preferred languages value={pref}"); + set_env("LANGUAGE", &pref); + } + } + + pub(super) fn ensure_locale() -> Option { + // On macOS, pre-populate the LANG env var with system preferences. + // When launching the .app, LANG is not set so we must query it from + // the OS. When launching from the CLI, LANG is usually set by the + // parent process. + let lang = std::env::var_os("LANG"); + if lang.as_ref().is_none_or(|v| v.is_empty()) { + set_lang_from_cocoa(); + } + + // Set the locale to whatever is set in env vars. + if let Some(v) = setlocale_all("") { + log::info!("setlocale from env result={v}"); + return None; + } + + // setlocale failed. This is probably because the LANG env var is + // invalid. Try to set it without the LANG var set to use the system + // default. + if std::env::var_os("LANG").is_some_and(|v| !v.is_empty()) { + // We don't need to do both of these things but we do them both + // to be sure that lang is either empty or unset completely. + set_env("LANG", ""); + unset_env("LANG"); + + if let Some(v) = setlocale_all("") { + log::info!("setlocale after unset lang result={v}"); + + // If we try to setlocale to an unsupported locale it'll + // return "C" as the POSIX/C fallback, if that's the case we + // want to not use it and move to our fallback. + if v != "C" { + return None; + } + } + } + + // Failure again... fallback to en_US.UTF-8 + log::warn!( + "setlocale failed with LANG and system default. Falling back to {FALLBACK_LOCALE}" + ); + if let Some(v) = setlocale_all(FALLBACK_LOCALE) { + set_env("LANG", FALLBACK_LOCALE); + log::info!("setlocale default result={v}"); + return Some(FALLBACK_LOCALE.to_owned()); + } + log::warn!("setlocale failed even with the fallback, uncertain results"); + None + } +} + #[cfg(test)] mod tests { use super::*; + #[test] + fn format_lang_value() { + assert_eq!(format_lang("en", "US"), "en_US.UTF-8"); + } + + #[test] + fn preferred_languages_to_language_var() { + assert_eq!(preferred_language_value::<&str>(&[]), None); + assert_eq!( + preferred_language_value(&["en-US"]).as_deref(), + Some("en_US.UTF-8") + ); + assert_eq!( + preferred_language_value(&["zh-Hans-CN", "en", "fr-CA"]).as_deref(), + Some("zh_CN.UTF-8:en.UTF-8:fr_CA.UTF-8") + ); + } + + #[cfg(not(target_vendor = "apple"))] #[test] fn ensure_locale_is_noop() { assert_eq!(ensure_locale(), None); diff --git a/crates/ghostty-os/src/mach.rs b/crates/ghostty-os/src/mach.rs new file mode 100644 index 0000000..bcfb05e --- /dev/null +++ b/crates/ghostty-os/src/mach.rs @@ -0,0 +1,176 @@ +//! macOS virtual memory tags and a VM-tagged page allocator. Port of +//! `os/mach.zig`. + +/// macOS virtual memory tags for use with `mach_vm_map`/`mmap`. These +/// identify memory regions in tools like vmmap and Instruments. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct VmTag(pub u8); + +impl VmTag { + pub const APPLICATION_SPECIFIC_1: VmTag = VmTag(240); + pub const APPLICATION_SPECIFIC_2: VmTag = VmTag(241); + pub const APPLICATION_SPECIFIC_3: VmTag = VmTag(242); + pub const APPLICATION_SPECIFIC_4: VmTag = VmTag(243); + pub const APPLICATION_SPECIFIC_5: VmTag = VmTag(244); + pub const APPLICATION_SPECIFIC_6: VmTag = VmTag(245); + pub const APPLICATION_SPECIFIC_7: VmTag = VmTag(246); + pub const APPLICATION_SPECIFIC_8: VmTag = VmTag(247); + pub const APPLICATION_SPECIFIC_9: VmTag = VmTag(248); + pub const APPLICATION_SPECIFIC_10: VmTag = VmTag(249); + pub const APPLICATION_SPECIFIC_11: VmTag = VmTag(250); + pub const APPLICATION_SPECIFIC_12: VmTag = VmTag(251); + pub const APPLICATION_SPECIFIC_13: VmTag = VmTag(252); + pub const APPLICATION_SPECIFIC_14: VmTag = VmTag(253); + pub const APPLICATION_SPECIFIC_15: VmTag = VmTag(254); + pub const APPLICATION_SPECIFIC_16: VmTag = VmTag(255); + + /// Converts the tag to the format expected by `mach_vm_map`/`mmap`'s fd + /// argument. Equivalent to the C macro `VM_MAKE_TAG(tag)`. + pub fn make(self) -> i32 { + (u32::from(self.0) << 24) as i32 + } +} + +/// A page allocator that tags all allocated memory with a [`VmTag`] +/// (Zig: `taggedPageAllocator`, based on the Zig 0.15.2 PageAllocator +/// POSIX implementation). +#[cfg(target_vendor = "apple")] +#[allow(unsafe_code)] +pub mod tagged { + use super::VmTag; + + #[derive(Debug, Clone, Copy)] + pub struct TaggedPageAllocator { + pub tag: VmTag, + } + + fn page_size() -> usize { + // SAFETY: sysconf has no preconditions. + unsafe { libc::sysconf(libc::_SC_PAGESIZE) as usize } + } + + fn align_forward(n: usize, align: usize) -> usize { + (n + align - 1) & !(align - 1) + } + + impl TaggedPageAllocator { + pub fn new(tag: VmTag) -> Self { + Self { tag } + } + + /// Map `n` bytes aligned to `alignment` (a power of two). Returns + /// null on failure. + /// + /// # Safety + /// `n` must be non-zero; release the result with [`Self::unmap`] + /// using the same `n`. + pub unsafe fn map(&self, n: usize, alignment: usize) -> *mut u8 { + let page = page_size(); + if n >= usize::MAX - page { + return std::ptr::null_mut(); + } + let aligned_len = align_forward(n, page); + let max_drop_len = alignment - alignment.min(page); + let overalloc_len = if max_drop_len <= aligned_len - n { + aligned_len + } else { + align_forward(aligned_len + max_drop_len, page) + }; + // SAFETY: anonymous private mapping; the tag is smuggled via the + // fd argument as `mach_vm_map` expects. + let ptr = unsafe { + libc::mmap( + std::ptr::null_mut(), + overalloc_len, + libc::PROT_READ | libc::PROT_WRITE, + libc::MAP_PRIVATE | libc::MAP_ANON, + self.tag.make(), + 0, + ) + }; + if ptr == libc::MAP_FAILED { + return std::ptr::null_mut(); + } + let base = ptr.cast::(); + let result = align_forward(base as usize, alignment) as *mut u8; + // Unmap the extra bytes that were only requested in order to + // guarantee alignment; they could be at the beginning, end or both. + let drop_len = result as usize - base as usize; + if drop_len != 0 { + // SAFETY: [base, base+drop_len) lies inside our mapping. + unsafe { libc::munmap(base.cast(), drop_len) }; + } + let remaining_len = overalloc_len - drop_len; + if remaining_len > aligned_len { + // SAFETY: the tail lies inside our mapping. + unsafe { + libc::munmap(result.add(aligned_len).cast(), remaining_len - aligned_len); + } + } + result + } + + /// # Safety + /// `ptr`/`len` must come from [`Self::map`] with the same length. + pub unsafe fn unmap(&self, ptr: *mut u8, len: usize) { + let aligned = align_forward(len, page_size()); + // SAFETY: caller contract. + unsafe { libc::munmap(ptr.cast(), aligned) }; + } + + /// Try to resize in place (Zig `realloc` with no `mremap` on + /// Darwin): only shrinking within the mapping or staying on the same + /// page count succeeds. Returns the (unchanged) pointer or null. + /// + /// # Safety + /// `ptr`/`old_len` must come from [`Self::map`]. + pub unsafe fn realloc(&self, ptr: *mut u8, old_len: usize, new_len: usize) -> *mut u8 { + let page = page_size(); + let new_aligned = align_forward(new_len, page); + let old_aligned = align_forward(old_len, page); + if new_aligned == old_aligned { + return ptr; + } + if new_aligned < old_aligned { + // SAFETY: the released tail lies inside the mapping. + unsafe { + libc::munmap(ptr.add(new_aligned).cast(), old_aligned - new_aligned); + } + return ptr; + } + std::ptr::null_mut() + } + } + + #[cfg(test)] + mod tests { + use super::*; + + #[test] + fn map_unmap_roundtrip() { + let a = TaggedPageAllocator::new(VmTag::APPLICATION_SPECIFIC_1); + // SAFETY: test-only use following the documented contract. + unsafe { + let p = a.map(10_000, 16384); + assert!(!p.is_null()); + assert_eq!(p as usize % 16384, 0); + p.write(1); + p.add(9_999).write(2); + let q = a.realloc(p, 10_000, 4096); + assert_eq!(q, p); + a.unmap(p, 4096); + } + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn vm_tag_make() { + assert_eq!(VmTag::APPLICATION_SPECIFIC_1.make(), (240u32 << 24) as i32); + assert_eq!(VmTag::APPLICATION_SPECIFIC_16.make(), (255u32 << 24) as i32); + } +} diff --git a/crates/ghostty-os/src/macos.rs b/crates/ghostty-os/src/macos.rs new file mode 100644 index 0000000..8423cf9 --- /dev/null +++ b/crates/ghostty-os/src/macos.rs @@ -0,0 +1,191 @@ +//! macOS-specific helpers. Port of `os/macos.zig`. +//! +//! The platform-independent items ([`QosClass`], [`join_dir`]) compile +//! everywhere so they can be unit tested on any host; everything that talks +//! to Foundation / libpthread is gated on `target_vendor = "apple"`. + +use std::path::{Path, PathBuf}; + +/// Quality-of-service classes (`pthread_set_qos_class_self_np`). +/// +/// +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +#[repr(u32)] +pub enum QosClass { + UserInteractive = 0x21, + UserInitiated = 0x19, + Default = 0x15, + Utility = 0x11, + Background = 0x09, + Unspecified = 0x00, +} + +#[derive(Debug, thiserror::Error, PartialEq, Eq)] +pub enum SetQosClassError { + /// The thread can't have its QoS class changed usually because a + /// different pthread API was called that makes it an invalid target. + #[error("thread incompatible")] + ThreadIncompatible, +} + +#[derive(Debug, thiserror::Error, PartialEq, Eq)] +pub enum DirError { + #[error("apple api failed")] + AppleApiFailed, +} + +/// `NSSearchPathDirectory` values we use. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +#[repr(u64)] +pub enum SearchPathDirectory { + CachesDirectory = 13, + ApplicationSupportDirectory = 14, +} + +/// Join `base_dir` with the given sub paths like `std.fs.path.join` +/// (`commonDir`'s tail): empty components are skipped. +pub fn join_dir(base_dir: &str, sub_paths: &[&str]) -> PathBuf { + let mut parts = vec![base_dir]; + parts.extend_from_slice(sub_paths); + PathBuf::from(crate::path_join(&parts)) +} + +/// Return the path to the application support directory for Ghostty +/// with the given sub path joined. +#[cfg(target_vendor = "apple")] +pub fn app_support_dir(sub_path: &str) -> Result { + common_dir( + SearchPathDirectory::ApplicationSupportDirectory, + &[crate::i18n::BUNDLE_ID, sub_path], + ) +} + +/// Return the path to the system cache directory with the given sub path +/// joined. +#[cfg(target_vendor = "apple")] +pub fn cache_dir(sub_path: &str) -> Result { + common_dir( + SearchPathDirectory::CachesDirectory, + &[crate::i18n::BUNDLE_ID, sub_path], + ) +} + +#[cfg(target_vendor = "apple")] +fn common_dir(directory: SearchPathDirectory, sub_paths: &[&str]) -> Result { + use objc2_foundation::{NSFileManager, NSSearchPathDirectory, NSSearchPathDomainMask}; + + let manager = NSFileManager::defaultManager(); + let url = manager + .URLForDirectory_inDomain_appropriateForURL_create_error( + NSSearchPathDirectory(directory as usize), + NSSearchPathDomainMask::UserDomainMask, + None, + true, + ) + .map_err(|_| DirError::AppleApiFailed)?; + let path = url.path().ok_or(DirError::AppleApiFailed)?; + Ok(join_dir(&path.to_string(), sub_paths)) +} + +/// Verifies that the running macOS system version is at least the given +/// version. +#[cfg(target_vendor = "apple")] +pub fn is_at_least_version(major: isize, minor: isize, patch: isize) -> bool { + use objc2_foundation::{NSOperatingSystemVersion, NSProcessInfo}; + + NSProcessInfo::processInfo().isOperatingSystemAtLeastVersion(NSOperatingSystemVersion { + majorVersion: major, + minorVersion: minor, + patchVersion: patch, + }) +} + +/// Set the QoS class of the running thread. +/// +/// +#[cfg(target_vendor = "apple")] +#[allow(unsafe_code)] +pub fn set_qos_class(class: QosClass) -> Result<(), SetQosClassError> { + // SAFETY: plain libpthread call with a valid QoS value and no pointers. + let rc = unsafe { libc::pthread_set_qos_class_self_np(class.to_libc(), 0) }; + match rc { + 0 => Ok(()), + libc::EPERM => Err(SetQosClassError::ThreadIncompatible), + // EPERM is the only known error that can happen based on the man + // pages for pthread_set_qos_class_self_np. + e => panic!("unexpected pthread_set_qos_class_self_np error: {e}"), + } +} + +#[cfg(target_vendor = "apple")] +impl QosClass { + fn to_libc(self) -> libc::qos_class_t { + match self { + QosClass::UserInteractive => libc::qos_class_t::QOS_CLASS_USER_INTERACTIVE, + QosClass::UserInitiated => libc::qos_class_t::QOS_CLASS_USER_INITIATED, + QosClass::Default => libc::qos_class_t::QOS_CLASS_DEFAULT, + QosClass::Utility => libc::qos_class_t::QOS_CLASS_UTILITY, + QosClass::Background => libc::qos_class_t::QOS_CLASS_BACKGROUND, + QosClass::Unspecified => libc::qos_class_t::QOS_CLASS_UNSPECIFIED, + } + } +} + +/// Name the *current* thread (`pthread_setname_np` on Darwin can only name +/// the calling thread). +#[cfg(target_vendor = "apple")] +#[allow(unsafe_code)] +pub fn pthread_setname_np(name: &std::ffi::CStr) { + // SAFETY: `name` is a valid NUL-terminated string for the duration of + // the call; Darwin copies it. + unsafe { + libc::pthread_setname_np(name.as_ptr()); + } +} + +/// Convenience for non-Darwin callers/tests: is `p` below a `Caches` +/// directory. +pub fn is_cache_path(p: &Path) -> bool { + p.components().any(|c| c.as_os_str() == "Caches") +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn qos_class_values() { + assert_eq!(QosClass::UserInteractive as u32, 0x21); + assert_eq!(QosClass::UserInitiated as u32, 0x19); + assert_eq!(QosClass::Default as u32, 0x15); + assert_eq!(QosClass::Utility as u32, 0x11); + assert_eq!(QosClass::Background as u32, 0x09); + assert_eq!(QosClass::Unspecified as u32, 0); + } + + #[test] + fn search_path_values() { + assert_eq!(SearchPathDirectory::CachesDirectory as u64, 13); + assert_eq!(SearchPathDirectory::ApplicationSupportDirectory as u64, 14); + } + + #[test] + fn cache_dir_paths() { + let id = crate::i18n::BUNDLE_ID; + // Test base path. + let p = join_dir("/Users/x/Library/Caches", &[id, ""]); + assert!(is_cache_path(&p)); + assert!(p.to_string_lossy().contains(id)); + // Test with subdir. + let p = join_dir("/Users/x/Library/Caches", &[id, "test"]); + assert!(is_cache_path(&p)); + assert!(p.to_string_lossy().contains(&format!("{id}/test"))); + } + + #[cfg(target_vendor = "apple")] + #[test] + fn cache_dir_real() { + let p = cache_dir("test").unwrap(); + assert!(is_cache_path(&p)); + } +} diff --git a/crates/ghostty-os/src/mouse.rs b/crates/ghostty-os/src/mouse.rs index 43d01e0..fdf3459 100644 --- a/crates/ghostty-os/src/mouse.rs +++ b/crates/ghostty-os/src/mouse.rs @@ -1,6 +1,33 @@ /// The system-configured double-click interval if its available. /// -/// Only macOS can report this; on Linux this is always `None`. +/// Only macOS can report this; elsewhere this is always `None`. +#[cfg(not(target_os = "macos"))] pub fn click_interval() -> Option { None } + +/// Convert an `NSEvent.doubleClickInterval` (seconds) to milliseconds, +/// rounding up like Zig's `@ceil(interval * 1000)`. +pub fn click_interval_ms(seconds: f64) -> u32 { + (seconds * 1000.0).ceil() as u32 +} + +/// The system-configured double-click interval (`NSEvent.doubleClickInterval`). +#[cfg(target_os = "macos")] +pub fn click_interval() -> Option { + Some(click_interval_ms( + objc2_app_kit::NSEvent::doubleClickInterval(), + )) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn click_interval_rounds_up() { + assert_eq!(click_interval_ms(0.5), 500); + assert_eq!(click_interval_ms(0.2501), 251); + assert_eq!(click_interval_ms(0.0), 0); + } +} diff --git a/crates/ghostty-os/src/open.rs b/crates/ghostty-os/src/open.rs index 6535440..7c1c12b 100644 --- a/crates/ghostty-os/src/open.rs +++ b/crates/ghostty-os/src/open.rs @@ -25,9 +25,7 @@ pub enum OpenUrlKind { /// apprt for Ghostty, you should consider doing something special-cased /// for your platform. pub fn open(kind: OpenUrlKind, url: &str) -> io::Result<()> { - let _ = kind; - let mut cmd = Command::new("xdg-open"); - cmd.arg(url); + let mut cmd = open_command(kind, url)?; // Pipe stdout/stderr so we can collect output from the command. cmd.stdin(Stdio::null()) @@ -57,6 +55,52 @@ pub fn open(kind: OpenUrlKind, url: &str) -> io::Result<()> { Ok(()) } +/// Build the platform's "open URL" command (Zig `open`'s switch over +/// `builtin.os.tag`). +#[cfg(windows)] +#[allow(clippy::unnecessary_wraps)] +fn open_command(kind: OpenUrlKind, url: &str) -> io::Result { + let _ = kind; + let mut cmd = Command::new("rundll32"); + cmd.args(["url.dll,FileProtocolHandler", url]); + Ok(cmd) +} + +/// Linux and the BSDs use `xdg-open`. +#[cfg(not(any(windows, target_os = "macos", target_os = "ios")))] +#[allow(clippy::unnecessary_wraps)] +fn open_command(kind: OpenUrlKind, url: &str) -> io::Result { + let _ = kind; + let mut cmd = Command::new("xdg-open"); + cmd.arg(url); + Ok(cmd) +} + +/// macOS uses `open` (`open -t` for text files). +#[cfg(target_os = "macos")] +#[allow(clippy::unnecessary_wraps)] +fn open_command(kind: OpenUrlKind, url: &str) -> io::Result { + let mut cmd = Command::new("open"); + cmd.args(macos_open_args(kind, url)); + Ok(cmd) +} + +/// Arguments of the macOS `open` command for a URL kind. +#[cfg_attr(not(any(target_os = "macos", all(test, unix))), allow(dead_code))] +fn macos_open_args(kind: OpenUrlKind, url: &str) -> Vec<&str> { + match kind { + OpenUrlKind::Text => vec!["-t", url], + OpenUrlKind::Html | OpenUrlKind::Unknown => vec![url], + } +} + +/// iOS cannot spawn processes (`error.Unimplemented`). +#[cfg(target_os = "ios")] +#[allow(clippy::unnecessary_wraps)] +fn open_command(_kind: OpenUrlKind, _url: &str) -> io::Result { + Err(io::Error::new(io::ErrorKind::Unsupported, "unimplemented")) +} + fn open_thread(child: std::process::Child) { // 50 KiB is the default value used by std.process.Child.run and should // be enough to get the output we care about. @@ -78,10 +122,18 @@ fn open_thread(child: std::process::Child) { } } -#[cfg(test)] +#[cfg(all(test, unix))] mod tests { use super::*; + #[test] + fn macos_open_command_args() { + assert_eq!(macos_open_args(OpenUrlKind::Text, "/a"), ["-t", "/a"]); + assert_eq!(macos_open_args(OpenUrlKind::Html, "u"), ["u"]); + assert_eq!(macos_open_args(OpenUrlKind::Unknown, "u"), ["u"]); + } + + #[cfg(not(target_vendor = "apple"))] #[test] fn open_reports_spawn_failure_quickly() { // With an empty PATH, xdg-open can't be found; Zig detects this diff --git a/crates/ghostty-os/src/passwd.rs b/crates/ghostty-os/src/passwd.rs index 857c8d7..6f28a99 100644 --- a/crates/ghostty-os/src/passwd.rs +++ b/crates/ghostty-os/src/passwd.rs @@ -1,3 +1,4 @@ +#[cfg(any(target_os = "linux", target_os = "android"))] use crate::flatpak::is_flatpak; /// Entry that is retrieved from the passwd API. This only contains the fields @@ -28,6 +29,7 @@ pub fn get() -> std::io::Result { // If we're in flatpak then our entry is always empty so we grab it // by shelling out to the host. note that we do HAVE an entry in the // sandbox but only the username is correct. + #[cfg(any(target_os = "linux", target_os = "android"))] if is_flatpak() { log::info!("flatpak detected, will use host command to get our entry"); return host_entry(&user.name); @@ -46,6 +48,7 @@ pub fn get() -> std::io::Result { /// Dependency note: Zig uses `pty.zig`; `ghostty-termio` (which owns the Rust /// `Pty`) depends on this crate, so a minimal openpt/ptsname pair is opened /// here with rustix instead. Like Zig, only `shell` and `home` are filled in. +#[cfg(any(target_os = "linux", target_os = "android"))] fn host_entry(name: &str) -> std::io::Result { use std::io::Read; @@ -112,7 +115,9 @@ fn host_entry(name: &str) -> std::io::Result { Ok(parse_getent(&String::from_utf8_lossy(&output))) } +#[cfg(any(target_os = "linux", target_os = "android"))] /// Shell and home are the last two `:`-separated fields of the output. +#[cfg(any(target_os = "linux", target_os = "android"))] fn parse_getent(output: &str) -> Entry { let mut it = output.trim_end_matches([' ', '\r', '\n']).rsplit(':'); let shell = it.next().map(str::to_owned); @@ -128,6 +133,7 @@ fn parse_getent(output: &str) -> Entry { mod tests { use super::*; + #[cfg(any(target_os = "linux", target_os = "android"))] #[test] fn parse_getent_output() { let e = parse_getent("mitchell:x:1000:1000:Mitchell:/home/mitchell:/bin/zsh\r\n"); diff --git a/crates/ghostty-os/src/path.rs b/crates/ghostty-os/src/path.rs index 1146c5a..7eeaad3 100644 --- a/crates/ghostty-os/src/path.rs +++ b/crates/ghostty-os/src/path.rs @@ -1,4 +1,16 @@ -use std::{io, os::unix::fs::PermissionsExt}; +use std::io; +#[cfg(unix)] +use std::os::unix::fs::PermissionsExt; + +/// `std.fs.path.delimiter` / `sep`. +#[cfg(windows)] +const DELIMITER: char = ';'; +#[cfg(not(windows))] +const DELIMITER: char = ':'; +#[cfg(windows)] +const SEP: char = '\\'; +#[cfg(not(windows))] +const SEP: char = '/'; /// Equivalent of `std.fs.max_path_bytes` on Linux. const MAX_PATH_BYTES: usize = 4096; @@ -27,14 +39,14 @@ pub fn expand(cmd: &str) -> Result, Error> { let path_var = path_var.to_string_lossy(); let mut seen_eacces = false; - for search_path in path_var.split(':').filter(|s| !s.is_empty()) { + for search_path in path_var.split(DELIMITER).filter(|s| !s.is_empty()) { // We need enough space in our path buffer to store this let path_len = search_path.len() + cmd.len() + 1; if MAX_PATH_BYTES < path_len { return Err(Error::PathTooLong); } - let full_path = format!("{search_path}/{cmd}"); + let full_path = format!("{search_path}{SEP}{cmd}"); // Stat it let f = match std::fs::File::open(&full_path) { @@ -51,7 +63,7 @@ pub fn expand(cmd: &str) -> Result, Error> { }, }; let meta = f.metadata()?; - if !meta.is_dir() && is_executable(meta.permissions().mode()) { + if !meta.is_dir() && is_executable(&meta) { return Ok(Some(full_path)); } } @@ -63,8 +75,14 @@ pub fn expand(cmd: &str) -> Result, Error> { Ok(None) } -fn is_executable(mode: u32) -> bool { - mode & 0o0111 != 0 +#[cfg(unix)] +fn is_executable(meta: &std::fs::Metadata) -> bool { + meta.permissions().mode() & 0o0111 != 0 +} + +#[cfg(windows)] +fn is_executable(_meta: &std::fs::Metadata) -> bool { + true } #[cfg(test)] @@ -74,8 +92,13 @@ mod tests { // `uname -n` is the *nix equivalent of `hostname.exe` on Windows #[test] fn expand_hostname() { - let path = crate::testutil::with_env_lock(|| expand("uname").unwrap().unwrap()); - assert!(path.len() > "uname".len()); + let executable = if cfg!(windows) { + "hostname.exe" + } else { + "uname" + }; + let path = crate::testutil::with_env_lock(|| expand(executable).unwrap().unwrap()); + assert!(path.len() > executable.len()); } #[test] @@ -91,6 +114,7 @@ mod tests { assert_eq!(path.len(), 7); } + #[cfg(unix)] #[test] fn expand_skips_non_executable_and_dirs() { let dir = crate::TempDir::init().unwrap(); diff --git a/crates/ghostty-os/src/pipe.rs b/crates/ghostty-os/src/pipe.rs index 8319f97..fdc6ec4 100644 --- a/crates/ghostty-os/src/pipe.rs +++ b/crates/ghostty-os/src/pipe.rs @@ -1,12 +1,53 @@ +#[cfg(unix)] use std::{io, os::fd::OwnedFd}; /// pipe() for POSIX. Sets CLOEXEC on the file descriptors. /// Returns `(read, write)`. +#[cfg(all(unix, not(target_vendor = "apple")))] pub fn pipe() -> io::Result<(OwnedFd, OwnedFd)> { rustix::pipe::pipe_with(rustix::pipe::PipeFlags::CLOEXEC).map_err(io::Error::from) } -#[cfg(test)] +/// pipe() for Darwin, which has no `pipe2`: like Zig's `posix.pipe2` +/// fallback, create the pipe and then set CLOEXEC on both ends. +#[cfg(target_vendor = "apple")] +pub fn pipe() -> io::Result<(OwnedFd, OwnedFd)> { + let (r, w) = rustix::pipe::pipe().map_err(io::Error::from)?; + for fd in [&r, &w] { + rustix::io::fcntl_setfd(fd, rustix::io::FdFlags::CLOEXEC).map_err(io::Error::from)?; + } + Ok((r, w)) +} + +/// pipe() for Windows (`CreatePipe` with default security attributes, so the +/// handles are not inheritable). Returns `(read, write)`. +#[cfg(windows)] +#[allow(unsafe_code)] +pub fn pipe() -> std::io::Result<( + std::os::windows::io::OwnedHandle, + std::os::windows::io::OwnedHandle, +)> { + use std::os::windows::io::{FromRawHandle, OwnedHandle}; + + use windows_sys::Win32::{Foundation::HANDLE, System::Pipes::CreatePipe}; + + let mut read: HANDLE = std::ptr::null_mut(); + let mut write: HANDLE = std::ptr::null_mut(); + // SAFETY: both out pointers are valid for writes; null security + // attributes and size 0 request the defaults. + if unsafe { CreatePipe(&raw mut read, &raw mut write, std::ptr::null(), 0) } == 0 { + return Err(std::io::Error::last_os_error()); + } + // SAFETY: CreatePipe succeeded so both handles are valid and owned by us. + Ok(unsafe { + ( + OwnedHandle::from_raw_handle(read.cast()), + OwnedHandle::from_raw_handle(write.cast()), + ) + }) +} + +#[cfg(all(test, unix))] mod tests { use std::io::{Read, Write}; @@ -26,3 +67,22 @@ mod tests { assert_eq!(s, "hi"); } } + +#[cfg(all(test, windows))] +mod tests_windows { + use std::io::{Read, Write}; + + use super::*; + + #[test] + fn pipe_roundtrip() { + let (r, w) = pipe().unwrap(); + let mut w = std::fs::File::from(w); + let mut r = std::fs::File::from(r); + w.write_all(b"hi").unwrap(); + drop(w); + let mut s = String::new(); + r.read_to_string(&mut s).unwrap(); + assert_eq!(s, "hi"); + } +} diff --git a/crates/ghostty-os/src/resourcesdir.rs b/crates/ghostty-os/src/resourcesdir.rs index 59cfbd9..d98d278 100644 --- a/crates/ghostty-os/src/resourcesdir.rs +++ b/crates/ghostty-os/src/resourcesdir.rs @@ -29,8 +29,21 @@ impl ResourcesDir { /// Sentinels we look for in the path to know we've found the resources /// directory (Linux). +#[cfg(not(any(windows, target_os = "macos")))] const SENTINELS: [&str; 2] = ["terminfo/g/ghostty", "terminfo/x/xterm-ghostty"]; +/// Sentinel on macOS (terminfo directories are named by hex there). +#[cfg(target_os = "macos")] +const SENTINELS: [&str; 1] = ["terminfo/78/xterm-ghostty"]; + +/// Sentinel on Windows (a single terminfo source file). +#[cfg(windows)] +const SENTINELS: [&str; 1] = ["terminfo/ghostty.terminfo"]; + +/// The in-repo `resources/` tree always carries the Linux-style terminfo +/// layout (also on macOS, where the installed layout uses hex dirs). +const DEV_SENTINELS: [&str; 1] = ["terminfo/x/xterm-ghostty"]; + /// Gets the directory to the bundled resources directory, if it /// exists (not all platforms or packages have it). /// @@ -71,6 +84,17 @@ fn resources_dir_impl(debug: bool, exe: Option<&Path>, env: Option) -> R exe = dir; let dir_str = dir.to_string_lossy(); + // On MacOS, we look for the app bundle path. + #[cfg(target_vendor = "apple")] + for sentinel in SENTINELS { + if let Some(v) = maybe_dir(&dir_str, "Contents/Resources", sentinel) { + return ResourcesDir { + app_path: Some(path_join(&[&v, "ghostty"])), + host_path: None, + }; + } + } + // We look for a /usr/share style path. for sentinel in SENTINELS { if let Some(v) = maybe_dir(&dir_str, "share", sentinel) { @@ -122,6 +146,7 @@ fn dev_resources_dir(exe: &Path) -> Option { let res = root.join("resources"); SENTINELS .iter() + .chain(DEV_SENTINELS.iter()) .any(|s| res.join(s).exists()) .then(|| res.to_string_lossy().into_owned()) } @@ -142,7 +167,7 @@ pub fn maybe_dir(base: &str, sub: &str, suffix: &str) -> Option { None } -#[cfg(test)] +#[cfg(all(test, unix))] mod tests { use super::*; diff --git a/crates/ghostty-os/src/systemd.rs b/crates/ghostty-os/src/systemd.rs index 0d71c2c..11fa787 100644 --- a/crates/ghostty-os/src/systemd.rs +++ b/crates/ghostty-os/src/systemd.rs @@ -1,12 +1,15 @@ +#[cfg(any(target_os = "linux", target_os = "android"))] use std::io::Read; /// Kernel `TASK_COMM_LEN`. +#[cfg(any(target_os = "linux", target_os = "android"))] const TASK_COMM_LEN: u64 = 16; /// Returns true if the program was launched as a systemd service. /// /// On Linux, this returns true if the program was launched as a systemd /// service. It will return false if Ghostty was launched any other way. +#[cfg(any(target_os = "linux", target_os = "android"))] pub fn launched_by_systemd() -> bool { // On Linux, systemd sets the `INVOCATION_ID` (v232+) and the // `JOURNAL_STREAM` (v231+) environment variables. If these @@ -58,6 +61,12 @@ pub fn launched_by_systemd() -> bool { &comm_data[..end] == b"systemd" } +/// For other platforms (macOS, ...), this returns false. +#[cfg(not(any(target_os = "linux", target_os = "android")))] +pub fn launched_by_systemd() -> bool { + false +} + /// systemd notifications. Used by Ghostty to inform systemd of the state of the /// process. Currently only used to notify systemd that we are ready and that /// configuration reloading has started. @@ -68,6 +77,7 @@ pub fn launched_by_systemd() -> bool { /// avoid another external dependency. Linux systems that do not use /// `systemd` should never start Ghostty with the `NOTIFY_SOCKET` environment /// variable set and these functions essentially become a no-op. +#[cfg(any(target_os = "linux", target_os = "android"))] pub mod notify { use rustix::net::{ AddressFamily, @@ -172,8 +182,17 @@ pub mod notify { } } +/// systemd notifications are a no-op on platforms without systemd. +#[cfg(not(any(target_os = "linux", target_os = "android")))] +pub mod notify { + pub fn ready() {} + + pub fn reloading() {} +} + #[cfg(test)] mod tests { + #[cfg(any(target_os = "linux", target_os = "android"))] use std::os::unix::net::UnixDatagram; use super::*; @@ -190,6 +209,7 @@ mod tests { ); } + #[cfg(any(target_os = "linux", target_os = "android"))] fn recv_with_socket(f: impl FnOnce()) -> String { let dir = crate::TempDir::init().unwrap(); let path = dir.path().join("notify.sock"); @@ -202,11 +222,13 @@ mod tests { String::from_utf8(buf[..n].to_vec()).unwrap() } + #[cfg(any(target_os = "linux", target_os = "android"))] #[test] fn notify_ready_sends_message() { assert_eq!(recv_with_socket(notify::ready), "READY=1"); } + #[cfg(any(target_os = "linux", target_os = "android"))] #[test] fn notify_reloading_sends_message() { let msg = recv_with_socket(notify::reloading); diff --git a/crates/ghostty-os/src/temp_dir.rs b/crates/ghostty-os/src/temp_dir.rs index 20585fd..db27290 100644 --- a/crates/ghostty-os/src/temp_dir.rs +++ b/crates/ghostty-os/src/temp_dir.rs @@ -1,9 +1,10 @@ //! Creates a temporary directory at runtime that can be safely used to //! store temporary data and is destroyed on drop. +#[cfg(unix)] +use std::os::unix::fs::DirBuilderExt; use std::{ io, - os::unix::fs::DirBuilderExt, path::{Path, PathBuf}, }; @@ -43,6 +44,48 @@ pub struct TempDir { name: String, } +/// Fill `buf` with random bytes (Zig: `std.crypto.random.bytes`). +#[cfg(any(target_os = "linux", target_os = "android"))] +fn fill_random(buf: &mut [u8]) -> io::Result<()> { + rustix::rand::getrandom(buf, rustix::rand::GetRandomFlags::empty())?; + Ok(()) +} + +/// Darwin has no `getrandom` syscall (Zig uses `getentropy`); read the +/// kernel CSPRNG device instead. +#[cfg(all(unix, not(any(target_os = "linux", target_os = "android"))))] +fn fill_random(buf: &mut [u8]) -> io::Result<()> { + use std::io::Read; + std::fs::File::open("/dev/urandom")?.read_exact(buf) +} + +/// Windows: the system preferred RNG (`BCryptGenRandom`). +#[cfg(windows)] +#[allow(unsafe_code)] +fn fill_random(buf: &mut [u8]) -> io::Result<()> { + use windows_sys::Win32::Security::Cryptography::{ + BCRYPT_USE_SYSTEM_PREFERRED_RNG, + BCryptGenRandom, + }; + + // SAFETY: `buf` is valid for `len` bytes; a null algorithm handle is + // allowed together with BCRYPT_USE_SYSTEM_PREFERRED_RNG. + let status = unsafe { + BCryptGenRandom( + std::ptr::null_mut(), + buf.as_mut_ptr(), + buf.len() as u32, + BCRYPT_USE_SYSTEM_PREFERRED_RNG, + ) + }; + if status < 0 { + return Err(io::Error::other(format!( + "BCryptGenRandom failed: {status:#x}" + ))); + } + Ok(()) +} + impl TempDir { /// Create the temporary directory. pub fn init() -> io::Result { @@ -56,13 +99,14 @@ impl TempDir { // We now loop forever until we can find a directory that we can create. loop { let mut rand_buf = [0u8; RANDOM_BYTES]; - rustix::rand::getrandom(&mut rand_buf, rustix::rand::GetRandomFlags::empty())?; + fill_random(&mut rand_buf)?; let name = b64_encode(&rand_buf); - match std::fs::DirBuilder::new() - .mode(0o755) - .create(parent.join(&name)) - { + #[allow(unused_mut)] + let mut builder = std::fs::DirBuilder::new(); + #[cfg(unix)] + builder.mode(0o755); + match builder.create(parent.join(&name)) { Ok(()) => return Ok(TempDir { parent, name }), Err(e) if e.kind() == io::ErrorKind::AlreadyExists => {} Err(e) => return Err(e), diff --git a/crates/ghostty-os/src/xdg.rs b/crates/ghostty-os/src/xdg.rs index 8f1af85..66c2b4a 100644 --- a/crates/ghostty-os/src/xdg.rs +++ b/crates/ghostty-os/src/xdg.rs @@ -21,6 +21,10 @@ pub struct Options<'a> { pub enum Error { #[error("no home directory could be determined")] NoHomeDir, + /// A macOS Foundation API (e.g. `NSFileManager`) failed (Zig: + /// `error.AppleAPIFailed`). + #[error("apple api failed")] + AppleApiFailed, } /// Get the XDG user config directory. @@ -49,8 +53,12 @@ fn dir(opts: &Options<'_>, env: &str, default_subdir: &str) -> Result(argv: &'a [&'a CStr]) -> Option<&'a [&'a CStr]> { Some(&argv[start..]) } -#[cfg(test)] +#[cfg(all(test, unix))] mod tests { use super::*; diff --git a/crates/ghostty-renderer/Cargo.toml b/crates/ghostty-renderer/Cargo.toml index 7ba2617..75110ce 100644 --- a/crates/ghostty-renderer/Cargo.toml +++ b/crates/ghostty-renderer/Cargo.toml @@ -8,6 +8,7 @@ license.workspace = true bytemuck = { version = "1.25.2", features = ["derive"] } crossbeam-channel = "0.5.17" ghostty-config.workspace = true +ghostty-crash.workspace = true ghostty-datastruct.workspace = true ghostty-font.workspace = true ghostty-input.workspace = true @@ -18,7 +19,7 @@ glow = "0.18.0" jpeg-decoder = { version = "0.3.2", default-features = false } khronos-egl = { version = "6.0.0", features = ["dynamic"] } log = "0.4.34" -naga = { version = "30.0.1", features = ["glsl-in", "glsl-out", "wgsl-in"] } +naga = { version = "30.0.1", features = ["glsl-in", "glsl-out", "msl-out", "wgsl-in"] } onig = "6.5.3" png = "0.18.1" thiserror = "2.0.21" @@ -54,3 +55,21 @@ return_self_not_must_use = "allow" used_underscore_binding = "allow" large_stack_arrays = "allow" inline_always = "allow" + +# The Metal graphics API (`renderer/Metal.zig`) and the CoreVideo display +# link; compiled on macOS and iOS only. +[target.'cfg(target_vendor = "apple")'.dependencies] +block2 = "0.6.2" +dispatch2 = "0.3.1" +ghostty-os.workspace = true +objc2 = "0.6.5" +objc2-core-foundation = "0.3.2" +objc2-core-graphics = "0.3.2" +objc2-foundation = "0.3.2" +objc2-io-surface = "0.3.2" +objc2-metal = { version = "0.3.2", features = ["block2", "objc2-io-surface"] } +objc2-quartz-core = "0.3.2" + +# CVDisplayLink does not exist on iOS. +[target.'cfg(target_os = "macos")'.dependencies] +objc2-core-video = "0.3.2" diff --git a/crates/ghostty-renderer/shaders/shaders.metal b/crates/ghostty-renderer/shaders/shaders.metal new file mode 100644 index 0000000..4e02b63 --- /dev/null +++ b/crates/ghostty-renderer/shaders/shaders.metal @@ -0,0 +1,853 @@ +#include + +using namespace metal; + +enum Padding : uint8_t { + EXTEND_LEFT = 1u, + EXTEND_RIGHT = 2u, + EXTEND_UP = 4u, + EXTEND_DOWN = 8u, +}; + +struct Uniforms { + float4x4 projection_matrix; + float2 screen_size; + float2 cell_size; + ushort2 grid_size; + float4 grid_padding; + uint8_t padding_extend; + float min_contrast; + ushort2 cursor_pos; + uchar4 cursor_color; + uchar4 bg_color; + bool cursor_wide; + bool use_display_p3; + bool use_linear_blending; + bool use_linear_correction; +}; + +//------------------------------------------------------------------- +// Color Functions +//------------------------------------------------------------------- +#pragma mark - Colors + +// D50-adapted sRGB to XYZ conversion matrix. +// http://www.brucelindbloom.com/Eqn_RGB_XYZ_Matrix.html +constant float3x3 sRGB_XYZ = transpose(float3x3( + 0.4360747, 0.3850649, 0.1430804, + 0.2225045, 0.7168786, 0.0606169, + 0.0139322, 0.0971045, 0.7141733 +)); +// XYZ to Display P3 conversion matrix. +// http://endavid.com/index.php?entry=79 +constant float3x3 XYZ_DP3 = transpose(float3x3( + 2.40414768,-0.99010704,-0.39759019, + -0.84239098, 1.79905954, 0.01597023, + 0.04838763,-0.09752546, 1.27393636 +)); +// By composing the two above matrices we get +// our sRGB to Display P3 conversion matrix. +constant float3x3 sRGB_DP3 = XYZ_DP3 * sRGB_XYZ; + +// Converts a color in linear sRGB to linear Display P3 +// +// TODO: The color matrix should probably be computed +// dynamically and passed as a uniform, rather +// than being hard coded above. +float3 srgb_to_display_p3(float3 srgb) { + return sRGB_DP3 * srgb; +} + +// Converts a color from sRGB gamma encoding to linear. +float4 linearize(float4 srgb) { + bool3 cutoff = srgb.rgb <= 0.04045; + float3 lower = srgb.rgb / 12.92; + float3 higher = pow((srgb.rgb + 0.055) / 1.055, 2.4); + srgb.rgb = mix(higher, lower, float3(cutoff)); + + return srgb; +} +float linearize(float v) { + return v <= 0.04045 ? v / 12.92 : pow((v + 0.055) / 1.055, 2.4); +} + +// Converts a color from linear to sRGB gamma encoding. +float4 unlinearize(float4 linear) { + bool3 cutoff = linear.rgb <= 0.0031308; + float3 lower = linear.rgb * 12.92; + float3 higher = pow(linear.rgb, 1.0 / 2.4) * 1.055 - 0.055; + linear.rgb = mix(higher, lower, float3(cutoff)); + + return linear; +} +float unlinearize(float v) { + return v <= 0.0031308 ? v * 12.92 : pow(v, 1.0 / 2.4) * 1.055 - 0.055; +} + +// Compute the luminance of the provided color. +// +// Takes colors in linear RGB space. If your colors are gamma +// encoded, linearize them before using them with this function. +float luminance(float3 color) { + return dot(color, float3(0.2126f, 0.7152f, 0.0722f)); +} + +// https://www.w3.org/TR/2008/REC-WCAG20-20081211/#contrast-ratiodef +// +// Takes colors in linear RGB space. If your colors are gamma +// encoded, linearize them before using them with this function. +float contrast_ratio(float3 color1, float3 color2) { + float l1 = luminance(color1); + float l2 = luminance(color2); + return (max(l1, l2) + 0.05f) / (min(l1, l2) + 0.05f); +} + +// Return the fg if the contrast ratio is greater than min, otherwise +// return a color that satisfies the contrast ratio. Currently, the color +// is always white or black, whichever has the highest contrast ratio. +// +// Takes colors in linear RGB space. If your colors are gamma +// encoded, linearize them before using them with this function. +float4 contrasted_color(float min, float4 fg, float4 bg) { + float ratio = contrast_ratio(fg.rgb, bg.rgb); + if (ratio < min) { + float white_ratio = contrast_ratio(float3(1.0f), bg.rgb); + float black_ratio = contrast_ratio(float3(0.0f), bg.rgb); + if (white_ratio > black_ratio) { + return float4(1.0f); + } else { + return float4(0.0f, 0.0f, 0.0f, 1.0f); + } + } + + return fg; +} + +// Load a 4 byte RGBA non-premultiplied color and linearize +// and convert it as necessary depending on the provided info. +// +// Returns a color in the Display P3 color space. +// +// If `display_p3` is true, then the provided color is assumed to +// already be in the Display P3 color space, otherwise it's treated +// as an sRGB color and is appropriately converted to Display P3. +// +// `linear` controls whether the returned color is linear or gamma encoded. +float4 load_color( + uchar4 in_color, + bool display_p3, + bool linear +) { + // 0 .. 255 -> 0.0 .. 1.0 + float4 color = float4(in_color) / 255.0f; + + // If our color is already in Display P3 and + // we aren't doing linear blending, then we + // already have the correct color here and + // can premultiply and return it. + if (display_p3 && !linear) { + color.rgb *= color.a; + return color; + } + + // The color is in either the sRGB or Display P3 color space, + // so in either case, it's a color space which uses the sRGB + // transfer function, so we can use one function in order to + // linearize it in either case. + // + // Even if we aren't doing linear blending, the color + // needs to be in linear space to convert color spaces. + color = linearize(color); + + // If we're *NOT* using display P3 colors, then we're dealing + // with an sRGB color, in which case we need to convert it in + // to the Display P3 color space, since our output is always + // Display P3. + if (!display_p3) { + color.rgb = srgb_to_display_p3(color.rgb); + } + + // If we're not doing linear blending, then we need to + // unlinearize after doing the color space conversion. + if (!linear) { + color = unlinearize(color); + } + + // Premultiply our color by its alpha. + color.rgb *= color.a; + + return color; +} + +//------------------------------------------------------------------- +// Full Screen Vertex Shader +//------------------------------------------------------------------- +#pragma mark - Full Screen Vertex Shader + +struct FullScreenVertexOut { + float4 position [[position]]; +}; + +vertex FullScreenVertexOut full_screen_vertex( + uint vid [[vertex_id]] +) { + FullScreenVertexOut out; + + float4 position; + position.x = (vid == 2) ? 3.0 : -1.0; + position.y = (vid == 0) ? -3.0 : 1.0; + position.zw = 1.0; + + // Single triangle is clipped to viewport. + // + // X <- vid == 0: (-1, -3) + // |\ + // | \ + // | \ + // |###\ + // |#+# \ `+` is (0, 0). `#`s are viewport area. + // |### \ + // X------X <- vid == 2: (3, 1) + // ^ + // vid == 1: (-1, 1) + + out.position = position; + + return out; +} + +//------------------------------------------------------------------- +// Background Color Shader +//------------------------------------------------------------------- +#pragma mark - BG Color Shader + +fragment float4 bg_color_fragment( + FullScreenVertexOut in [[stage_in]], + constant Uniforms& uniforms [[buffer(1)]] +) { + return load_color( + uniforms.bg_color, + uniforms.use_display_p3, + uniforms.use_linear_blending + ); +} + +//------------------------------------------------------------------- +// Background Image Shader +//------------------------------------------------------------------- +#pragma mark - BG Image Shader + +struct BgImageVertexIn { + float opacity [[attribute(0)]]; + uint8_t info [[attribute(1)]]; +}; + +enum BgImagePosition : uint8_t { + // 4 bits of info. + BG_IMAGE_POSITION = 15u, + + BG_IMAGE_TL = 0u, + BG_IMAGE_TC = 1u, + BG_IMAGE_TR = 2u, + BG_IMAGE_ML = 3u, + BG_IMAGE_MC = 4u, + BG_IMAGE_MR = 5u, + BG_IMAGE_BL = 6u, + BG_IMAGE_BC = 7u, + BG_IMAGE_BR = 8u, +}; + +enum BgImageFit : uint8_t { + // 2 bits of info shifted 4. + BG_IMAGE_FIT = 3u << 4, + + BG_IMAGE_CONTAIN = 0u << 4, + BG_IMAGE_COVER = 1u << 4, + BG_IMAGE_STRETCH = 2u << 4, + BG_IMAGE_NO_FIT = 3u << 4, +}; + +enum BgImageRepeat : uint8_t { + // 1 bit of info shifted 6. + BG_IMAGE_REPEAT = 1u << 6, +}; + +struct BgImageVertexOut { + float4 position [[position]]; + float4 bg_color [[flat]]; + float2 offset [[flat]]; + float2 scale [[flat]]; + float opacity [[flat]]; + bool repeat [[flat]]; +}; + +vertex BgImageVertexOut bg_image_vertex( + uint vid [[vertex_id]], + BgImageVertexIn in [[stage_in]], + texture2d image [[texture(0)]], + constant Uniforms& uniforms [[buffer(1)]] +) { + BgImageVertexOut out; + + float4 position; + position.x = (vid == 2) ? 3.0 : -1.0; + position.y = (vid == 0) ? -3.0 : 1.0; + position.zw = 1.0; + + // Single triangle is clipped to viewport. + // + // X <- vid == 0: (-1, -3) + // |\ + // | \ + // | \ + // |###\ + // |#+# \ `+` is (0, 0). `#`s are viewport area. + // |### \ + // X------X <- vid == 2: (3, 1) + // ^ + // vid == 1: (-1, 1) + + out.position = position; + + out.opacity = in.opacity; + + out.repeat = (in.info & BG_IMAGE_REPEAT) == BG_IMAGE_REPEAT; + + float2 screen_size = uniforms.screen_size; + float2 tex_size = float2(image.get_width(), image.get_height()); + + float2 dest_size = tex_size; + switch (in.info & BG_IMAGE_FIT) { + // For `contain` we scale by a factor that makes the image + // width match the screen width or makes the image height + // match the screen height, whichever is smaller. + case BG_IMAGE_CONTAIN: { + float scale = min(screen_size.x / tex_size.x, screen_size.y / tex_size.y); + dest_size = tex_size * scale; + } break; + + // For `cover` we scale by a factor that makes the image + // width match the screen width or makes the image height + // match the screen height, whichever is larger. + case BG_IMAGE_COVER: { + float scale = max(screen_size.x / tex_size.x, screen_size.y / tex_size.y); + dest_size = tex_size * scale; + } break; + + // For `stretch` we stretch the image to the size of + // the screen without worrying about aspect ratio. + case BG_IMAGE_STRETCH: { + dest_size = screen_size; + } break; + + // For `none` we just use the original texture size. + case BG_IMAGE_NO_FIT: { + dest_size = tex_size; + } break; + } + + float2 start = float2(0.0); + float2 mid = (screen_size - dest_size) / 2; + float2 end = screen_size - dest_size; + + float2 dest_offset = mid; + switch (in.info & BG_IMAGE_POSITION) { + case BG_IMAGE_TL: { + dest_offset = float2(start.x, start.y); + } break; + case BG_IMAGE_TC: { + dest_offset = float2(mid.x, start.y); + } break; + case BG_IMAGE_TR: { + dest_offset = float2(end.x, start.y); + } break; + case BG_IMAGE_ML: { + dest_offset = float2(start.x, mid.y); + } break; + case BG_IMAGE_MC: { + dest_offset = float2(mid.x, mid.y); + } break; + case BG_IMAGE_MR: { + dest_offset = float2(end.x, mid.y); + } break; + case BG_IMAGE_BL: { + dest_offset = float2(start.x, end.y); + } break; + case BG_IMAGE_BC: { + dest_offset = float2(mid.x, end.y); + } break; + case BG_IMAGE_BR: { + dest_offset = float2(end.x, end.y); + } break; + } + + out.offset = dest_offset; + out.scale = tex_size / dest_size; + + // We load a fully opaque version of the bg color and combine it with + // the alpha separately, because we need these as separate values in + // the framgment shader. + out.bg_color = float4(load_color( + uchar4(uniforms.bg_color.rgb, 255), + uniforms.use_display_p3, + uniforms.use_linear_blending + ).rgb, float(uniforms.bg_color.a) / 255.0); + + return out; +} + +fragment float4 bg_image_fragment( + BgImageVertexOut in [[stage_in]], + texture2d image [[texture(0)]], + constant Uniforms& uniforms [[buffer(1)]] +) { + constexpr sampler textureSampler( + coord::pixel, + address::clamp_to_zero, + filter::linear + ); + + // Our texture coordinate is based on the screen position, offset by the + // dest rect origin, and scaled by the ratio between the dest rect size + // and the original texture size, which effectively scales the original + // size of the texture to the dest rect size. + float2 tex_coord = (in.position.xy - in.offset) * in.scale; + + // If we need to repeat the texture, wrap the coordinates. + if (in.repeat) { + float2 tex_size = float2(image.get_width(), image.get_height()); + + tex_coord = fmod(fmod(tex_coord, tex_size) + tex_size, tex_size); + } + + float4 rgba = image.sample(textureSampler, tex_coord); + + if (!uniforms.use_linear_blending) { + rgba = unlinearize(rgba); + } + + // Premultiply the bg image. + rgba.rgb *= rgba.a; + + // Multiply it by the configured opacity, but cap it at + // the value that will make it fully opaque relative to + // the background color alpha, so it isn't overexposed. + rgba *= min(in.opacity, 1.0 / in.bg_color.a); + + // Blend it on to a fully opaque version of the background color. + rgba += max(float4(0.0), float4(in.bg_color.rgb, 1.0) * (1.0 - rgba.a)); + + // Multiply everything by the background color alpha. + rgba *= in.bg_color.a; + + return rgba; +} + +//------------------------------------------------------------------- +// Cell Background Shader +//------------------------------------------------------------------- +#pragma mark - Cell BG Shader + +fragment float4 cell_bg_fragment( + FullScreenVertexOut in [[stage_in]], + constant Uniforms& uniforms [[buffer(1)]], + constant uchar4 *cells [[buffer(2)]] +) { + int2 grid_pos = int2(floor((in.position.xy - uniforms.grid_padding.wx) / uniforms.cell_size)); + + float4 bg = float4(0.0); + + // Clamp x position, extends edge bg colors in to padding on sides. + if (grid_pos.x < 0) { + if (uniforms.padding_extend & EXTEND_LEFT) { + grid_pos.x = 0; + } else { + return bg; + } + } else if (grid_pos.x > uniforms.grid_size.x - 1) { + if (uniforms.padding_extend & EXTEND_RIGHT) { + grid_pos.x = uniforms.grid_size.x - 1; + } else { + return bg; + } + } + + // Clamp y position if we should extend, otherwise discard if out of bounds. + if (grid_pos.y < 0) { + if (uniforms.padding_extend & EXTEND_UP) { + grid_pos.y = 0; + } else { + return bg; + } + } else if (grid_pos.y > uniforms.grid_size.y - 1) { + if (uniforms.padding_extend & EXTEND_DOWN) { + grid_pos.y = uniforms.grid_size.y - 1; + } else { + return bg; + } + } + + // Load the color for the cell. + uchar4 cell_color = cells[grid_pos.y * uniforms.grid_size.x + grid_pos.x]; + + // Convert the color and return it. + // + // TODO: It might be a good idea to do a pass before this + // to convert all of the bg colors, so we don't waste + // a bunch of work converting the cell color in every + // fragment of each cell. It's not the most epxensive + // operation, but it is still wasted work. + return load_color( + cell_color, + uniforms.use_display_p3, + uniforms.use_linear_blending + ); +} + +//------------------------------------------------------------------- +// Cell Text Shader +//------------------------------------------------------------------- +#pragma mark - Cell Text Shader + +enum CellTextAtlas : uint8_t { + ATLAS_GRAYSCALE = 0u, + ATLAS_COLOR = 1u, +}; + +// We use a packed struct of bools for misc properties of the glyph. +enum CellTextBools : uint8_t { + // Don't apply min contrast to this glyph. + NO_MIN_CONTRAST = 1u, + // This is the cursor glyph. + IS_CURSOR_GLYPH = 2u, +}; + +struct CellTextVertexIn { + // The position of the glyph in the texture (x, y) + uint2 glyph_pos [[attribute(0)]]; + + // The size of the glyph in the texture (w, h) + uint2 glyph_size [[attribute(1)]]; + + // The left and top bearings for the glyph (x, y) + int2 bearings [[attribute(2)]]; + + // The grid coordinates (x, y) where x < columns and y < rows + ushort2 grid_pos [[attribute(3)]]; + + // The color of the rendered text glyph. + uchar4 color [[attribute(4)]]; + + // Which atlas to sample for our glyph. + uint8_t atlas [[attribute(5)]]; + + // Misc properties of the glyph. + uint8_t bools [[attribute(6)]]; +}; + +struct CellTextVertexOut { + float4 position [[position]]; + uint8_t atlas [[flat]]; + float4 color [[flat]]; + float4 bg_color [[flat]]; + float2 tex_coord; +}; + +vertex CellTextVertexOut cell_text_vertex( + uint vid [[vertex_id]], + CellTextVertexIn in [[stage_in]], + constant Uniforms& uniforms [[buffer(1)]], + constant uchar4 *bg_colors [[buffer(2)]] +) { + // Convert the grid x, y into world space x, y by accounting for cell size + float2 cell_pos = uniforms.cell_size * float2(in.grid_pos); + + // We use a triangle strip with 4 vertices to render quads, + // so we determine which corner of the cell this vertex is in + // based on the vertex ID. + // + // 0 --> 1 + // | .'| + // | / | + // | L | + // 2 --> 3 + // + // 0 = top-left (0, 0) + // 1 = top-right (1, 0) + // 2 = bot-left (0, 1) + // 3 = bot-right (1, 1) + float2 corner; + corner.x = float(vid == 1 || vid == 3); + corner.y = float(vid == 2 || vid == 3); + + CellTextVertexOut out; + out.atlas = in.atlas; + + // === Grid Cell === + // +X + // 0,0--...-> + // | + // . offset.x = bearings.x + // +Y. .|. + // . | | + // | cell_pos -> +-------+ _. + // v ._| |_. _|- offset.y = cell_size.y - bearings.y + // | | .###. | | + // | | #...# | | + // glyph_size.y -+ | ##### | | + // | | #.... | +- bearings.y + // |_| .#### | | + // | |_| + // +-------+ + // |_._| + // | + // glyph_size.x + // + // In order to get the top left of the glyph, we compute an offset based on + // the bearings. The Y bearing is the distance from the bottom of the cell + // to the top of the glyph, so we subtract it from the cell height to get + // the y offset. The X bearing is the distance from the left of the cell + // to the left of the glyph, so it works as the x offset directly. + + float2 size = float2(in.glyph_size); + float2 offset = float2(in.bearings); + + offset.y = uniforms.cell_size.y - offset.y; + + // Calculate the final position of the cell which uses our glyph size + // and glyph offset to create the correct bounding box for the glyph. + cell_pos = cell_pos + size * corner + offset; + out.position = + uniforms.projection_matrix * float4(cell_pos.x, cell_pos.y, 0.0f, 1.0f); + + // Calculate the texture coordinate in pixels. This is NOT normalized + // (between 0.0 and 1.0), and does not need to be, since the texture will + // be sampled with pixel coordinate mode. + out.tex_coord = float2(in.glyph_pos) + float2(in.glyph_size) * corner; + + // Get our color. We always fetch a linearized version to + // make it easier to handle minimum contrast calculations. + out.color = load_color( + in.color, + uniforms.use_display_p3, + true + ); + + // Get the BG color + out.bg_color = load_color( + bg_colors[in.grid_pos.y * uniforms.grid_size.x + in.grid_pos.x], + uniforms.use_display_p3, + true + ); + // Blend it with the global bg color + float4 global_bg = load_color( + uniforms.bg_color, + uniforms.use_display_p3, + true + ); + out.bg_color += global_bg * (1.0 - out.bg_color.a); + + // If we have a minimum contrast, we need to check if we need to + // change the color of the text to ensure it has enough contrast + // with the background. + if (uniforms.min_contrast > 1.0f && (in.bools & NO_MIN_CONTRAST) == 0) { + // Ensure our minimum contrast + out.color = contrasted_color(uniforms.min_contrast, out.color, out.bg_color); + } + + // Check if current position is under cursor (including wide cursor) + bool is_cursor_pos = ( + in.grid_pos.x == uniforms.cursor_pos.x || + uniforms.cursor_wide && + in.grid_pos.x == uniforms.cursor_pos.x + 1 + ) && in.grid_pos.y == uniforms.cursor_pos.y; + + // If this cell is the cursor cell, but we're not processing + // the cursor glyph itself, then we need to change the color. + if ((in.bools & IS_CURSOR_GLYPH) == 0 && is_cursor_pos) { + out.color = load_color( + uniforms.cursor_color, + uniforms.use_display_p3, + true + ); + } + + return out; +} + +fragment float4 cell_text_fragment( + CellTextVertexOut in [[stage_in]], + texture2d textureGrayscale [[texture(0)]], + texture2d textureColor [[texture(1)]], + constant Uniforms& uniforms [[buffer(1)]] +) { + constexpr sampler textureSampler( + coord::pixel, + address::clamp_to_edge, + filter::nearest + ); + + switch (in.atlas) { + default: + case ATLAS_GRAYSCALE: { + // Our input color is always linear. + float4 color = in.color; + + // If we're not doing linear blending, then we need to + // re-apply the gamma encoding to our color manually. + // + // Since the alpha is premultiplied, we need to divide + // it out before unlinearizing and re-multiply it after. + if (!uniforms.use_linear_blending) { + color.rgb /= color.a; + color = unlinearize(color); + color.rgb *= color.a; + } + + // Fetch our alpha mask for this pixel. + float a = textureGrayscale.sample(textureSampler, in.tex_coord).r; + + // Linear blending weight correction corrects the alpha value to + // produce blending results which match gamma-incorrect blending. + if (uniforms.use_linear_correction) { + // Short explanation of how this works: + // + // We get the luminances of the foreground and background colors, + // and then unlinearize them and perform blending on them. This + // gives us our desired luminance, which we derive our new alpha + // value from by mapping the range [bg_l, fg_l] to [0, 1], since + // our final blend will be a linear interpolation from bg to fg. + // + // This yields virtually identical results for grayscale blending, + // and very similar but non-identical results for color blending. + float4 bg = in.bg_color; + float fg_l = luminance(color.rgb); + float bg_l = luminance(bg.rgb); + // To avoid numbers going haywire, we don't apply correction + // when the bg and fg luminances are within 0.001 of each other. + if (abs(fg_l - bg_l) > 0.001) { + float blend_l = linearize(unlinearize(fg_l) * a + unlinearize(bg_l) * (1.0 - a)); + a = clamp((blend_l - bg_l) / (fg_l - bg_l), 0.0, 1.0); + } + } + + // Multiply our whole color by the alpha mask. + // Since we use premultiplied alpha, this is + // the correct way to apply the mask. + color *= a; + + return color; + } + + case ATLAS_COLOR: { + // For now, we assume that color glyphs + // are already premultiplied linear colors. + float4 color = textureColor.sample(textureSampler, in.tex_coord); + + // If we're doing linear blending, we can return this right away. + if (uniforms.use_linear_blending) { + return color; + } + + // Otherwise we need to unlinearize the color. Since the alpha is + // premultiplied, we need to divide it out before unlinearizing. + color.rgb /= color.a; + color = unlinearize(color); + color.rgb *= color.a; + + return color; + } + } +} +//------------------------------------------------------------------- +// Image Shader +//------------------------------------------------------------------- +#pragma mark - Image Shader + +struct ImageVertexIn { + // The grid coordinates (x, y) where x < columns and y < rows where + // the image will be rendered. It will be rendered from the top left. + float2 grid_pos [[attribute(0)]]; + + // Offset in pixels from the top-left of the cell to make the top-left + // corner of the image. + float2 cell_offset [[attribute(1)]]; + + // The source rectangle of the texture to sample from. + float4 source_rect [[attribute(2)]]; + + // The final width/height of the image in pixels. + float2 dest_size [[attribute(3)]]; +}; + +struct ImageVertexOut { + float4 position [[position]]; + float2 tex_coord; +}; + +vertex ImageVertexOut image_vertex( + uint vid [[vertex_id]], + ImageVertexIn in [[stage_in]], + texture2d image [[texture(0)]], + constant Uniforms& uniforms [[buffer(1)]] +) { + // We use a triangle strip with 4 vertices to render quads, + // so we determine which corner of the cell this vertex is in + // based on the vertex ID. + // + // 0 --> 1 + // | .'| + // | / | + // | L | + // 2 --> 3 + // + // 0 = top-left (0, 0) + // 1 = top-right (1, 0) + // 2 = bot-left (0, 1) + // 3 = bot-right (1, 1) + float2 corner; + corner.x = float(vid == 1 || vid == 3); + corner.y = float(vid == 2 || vid == 3); + + // The texture coordinates start at our source x/y + // and add the width/height depending on the corner. + // + // We don't need to normalize because we use pixel addressing for our sampler. + float2 tex_coord = in.source_rect.xy; + tex_coord += in.source_rect.zw * corner; + + ImageVertexOut out; + + // The position of our image starts at the top-left of the grid cell and + // adds the source rect width/height components. + float2 image_pos = (uniforms.cell_size * in.grid_pos) + in.cell_offset; + image_pos += in.dest_size * corner; + + out.position = + uniforms.projection_matrix * float4(image_pos.x, image_pos.y, 0.0f, 1.0f); + out.tex_coord = tex_coord; + return out; +} + +fragment float4 image_fragment( + ImageVertexOut in [[stage_in]], + texture2d image [[texture(0)]], + constant Uniforms& uniforms [[buffer(1)]] +) { + constexpr sampler textureSampler( + coord::pixel, + address::clamp_to_edge, + filter::linear + ); + + float4 rgba = image.sample(textureSampler, in.tex_coord); + + if (!uniforms.use_linear_blending) { + rgba = unlinearize(rgba); + } + + rgba.rgb *= rgba.a; + + return rgba; +} + diff --git a/crates/ghostty-renderer/src/backend.rs b/crates/ghostty-renderer/src/backend.rs index e474595..5baea25 100644 --- a/crates/ghostty-renderer/src/backend.rs +++ b/crates/ghostty-renderer/src/backend.rs @@ -9,8 +9,10 @@ pub enum Backend { } impl Backend { - /// The default backend for the target. Metal/WebGL are out of scope for - /// the Rust port (Linux only), so this is always OpenGL on non-Darwin. + /// The default backend for the target (Metal on Apple platforms, OpenGL + /// otherwise). WebGL is only a name here: the wasm build has no GL renderer + /// in this crate. This is what [`crate::Api`] / [`crate::Renderer`] are + /// instantiated with. pub fn default_for_target() -> Backend { if cfg!(target_vendor = "apple") { Backend::Metal @@ -27,3 +29,25 @@ pub enum Health { Healthy, Unhealthy, } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn default_backend_matches_selected_api() { + // `renderer.zig` picks the graphics API from the backend. + if cfg!(target_vendor = "apple") { + assert_eq!(Backend::default_for_target(), Backend::Metal); + } else { + assert_eq!(Backend::default_for_target(), Backend::Opengl); + } + } + + #[test] + fn health_values_match_ghostty_h() { + // `GHOSTTY_RENDERER_HEALTH_{OK,ERROR}`. + assert_eq!(Health::Healthy as i32, 0); + assert_eq!(Health::Unhealthy as i32, 1); + } +} diff --git a/crates/ghostty-renderer/src/display_link.rs b/crates/ghostty-renderer/src/display_link.rs new file mode 100644 index 0000000..140a228 --- /dev/null +++ b/crates/ghostty-renderer/src/display_link.rs @@ -0,0 +1,197 @@ +//! The CVDisplayLink used to drive the rendering loop in sync with the +//! display (`pkg/macos/video/display_link.zig`, used by `generic.zig`). +//! +//! This is only functional on macOS. On every other platform +//! [`DisplayLink`] is an uninhabited type, so a `None` is all the generic +//! renderer ever has (Zig: `DisplayLink = void`). + +use std::sync::Arc; + +/// The error of display link operations (`DisplayLink.Error`). +#[derive(Debug, Clone, Copy, PartialEq, Eq, thiserror::Error)] +pub enum Error { + #[error("invalid operation")] + InvalidOperation, +} + +/// Called on every vsync from the display link's thread. +pub type Callback = Arc; + +#[cfg(target_os = "macos")] +pub use self::macos::DisplayLink; +#[cfg(not(target_os = "macos"))] +pub use self::unsupported::DisplayLink; + +#[cfg(not(target_os = "macos"))] +mod unsupported { + use std::convert::Infallible; + + use super::{Callback, Error}; + + /// There is no display link on this platform. + pub struct DisplayLink(Infallible); + + impl DisplayLink { + pub fn create_with_active_cg_displays() -> Option { + None + } + + pub fn start(&self) -> Result<(), Error> { + match self.0 {} + } + + pub fn stop(&self) -> Result<(), Error> { + match self.0 {} + } + + pub fn is_running(&self) -> bool { + match self.0 {} + } + + pub fn set_current_cg_display(&self, _display_id: u32) -> Result<(), Error> { + match self.0 {} + } + + pub fn set_output_callback(&self, _callback: Callback) -> Result<(), Error> { + match self.0 {} + } + } +} + +#[cfg(target_os = "macos")] +#[allow(unsafe_code, deprecated)] +mod macos { + use std::{ + ffi::c_void, + ptr::NonNull, + sync::{Mutex, PoisonError}, + }; + + use objc2_core_foundation::CFRetained; + use objc2_core_video::{CVDisplayLink, CVOptionFlags, CVReturn, CVTimeStamp, kCVReturnSuccess}; + + use super::{Callback, Error}; + + /// A CVDisplayLink. + pub struct DisplayLink { + link: CFRetained, + + /// The callback handed to CoreVideo as `user_info` (a leaked + /// `Box`), freed when the link is dropped or the callback is + /// replaced. + callback: Mutex>>, + } + + // SAFETY: CVDisplayLink is a thread-safe CoreVideo object; the callback + // pointer is only freed with the lock held, after the link is stopped. + unsafe impl Send for DisplayLink {} + // SAFETY: see above. + unsafe impl Sync for DisplayLink {} + + fn check(r: CVReturn) -> Result<(), Error> { + if r == kCVReturnSuccess { + Ok(()) + } else { + Err(Error::InvalidOperation) + } + } + + impl DisplayLink { + /// `createWithActiveCGDisplays`. + pub fn create_with_active_cg_displays() -> Option { + let mut out: *mut CVDisplayLink = std::ptr::null_mut(); + // SAFETY: `out` is a valid out pointer. + let r = + unsafe { CVDisplayLink::create_with_active_cg_displays(NonNull::from(&mut out)) }; + if r != kCVReturnSuccess { + return None; + } + let ptr = NonNull::new(out)?; + // SAFETY: the function returns a +1 retained link. + let link = unsafe { CFRetained::from_raw(ptr) }; + Some(DisplayLink { + link, + callback: Mutex::new(None), + }) + } + + pub fn start(&self) -> Result<(), Error> { + check(self.link.start()) + } + + pub fn stop(&self) -> Result<(), Error> { + check(self.link.stop()) + } + + pub fn is_running(&self) -> bool { + self.link.is_running() + } + + pub fn set_current_cg_display(&self, display_id: u32) -> Result<(), Error> { + check(self.link.set_current_cg_display(display_id)) + } + + /// Set the callback called on every vsync. + /// + /// Note: this purposely throws away a ton of arguments we don't need. + pub fn set_output_callback(&self, callback: Callback) -> Result<(), Error> { + let boxed = NonNull::from(Box::leak(Box::new(callback))); + // SAFETY: `trampoline` matches the callback signature and + // `user_info` stays valid until the link is dropped. + let r = unsafe { + self.link + .set_output_callback(Some(trampoline), boxed.as_ptr().cast::()) + }; + if let Err(e) = check(r) { + // SAFETY: we just leaked this box and CoreVideo rejected it. + drop(unsafe { Box::from_raw(boxed.as_ptr()) }); + return Err(e); + } + + let old = self + .callback + .lock() + .unwrap_or_else(PoisonError::into_inner) + .replace(boxed); + if let Some(old) = old { + // Make sure the old callback can't be running anymore. + let _ = self.link.stop(); + // SAFETY: the pointer came from `Box::leak` above and the + // link no longer references it (replaced + stopped). + drop(unsafe { Box::from_raw(old.as_ptr()) }); + } + Ok(()) + } + } + + impl Drop for DisplayLink { + fn drop(&mut self) { + let _ = self.link.stop(); + if let Some(cb) = self + .callback + .get_mut() + .unwrap_or_else(PoisonError::into_inner) + .take() + { + // SAFETY: the link is stopped and released right after. + drop(unsafe { Box::from_raw(cb.as_ptr()) }); + } + } + } + + unsafe extern "C-unwind" fn trampoline( + _display_link: NonNull, + _in_now: NonNull, + _in_output_time: NonNull, + _flags_in: CVOptionFlags, + _flags_out: NonNull, + user_info: *mut c_void, + ) -> CVReturn { + // SAFETY: `user_info` is the leaked `Box` set in + // `set_output_callback`, alive until the link is stopped. + if let Some(cb) = unsafe { user_info.cast::().as_ref() } { + cb(); + } + kCVReturnSuccess + } +} diff --git a/crates/ghostty-renderer/src/generic.rs b/crates/ghostty-renderer/src/generic.rs index c097a74..59f332a 100644 --- a/crates/ghostty-renderer/src/generic.rs +++ b/crates/ghostty-renderer/src/generic.rs @@ -44,10 +44,11 @@ use std::{ collections::HashSet, sync::{ Arc, + Condvar, Mutex, MutexGuard, PoisonError, - atomic::{AtomicBool, AtomicU8, Ordering}, + atomic::{AtomicBool, AtomicU8, AtomicUsize, Ordering}, }, time::Instant, }; @@ -92,56 +93,58 @@ use crate::{ backend::Health, cell::{self, Contents, Key as CellKey}, cursor::{self, Style as CursorStyle}, + display_link::DisplayLink, + graphics_api::{ + ApiError, + Attachment, + AttachmentTarget, + Draw, + FrameCompletion, + GpuBuffer, + GpuFrame, + GpuRenderPass, + GpuShaders, + GpuTarget, + GpuTexture, + GraphicsApi, + Primitive, + Step, + TextureKind, + }, image::{self, Gpu}, link, math, - opengl::{ - self, - OpenGL, - Sampler, - Target, - Texture, - buffer::Buffer, - gl::{self, Gl, Primitive}, - render_pass::{Attachment, AttachmentTarget, Draw, Step}, - shaders::{ - BgImage, - BgImageFit, - BgImageInfo, - BgImagePosition, - Bools, - CellBg, - CellText, - CellTextAtlas, - PaddingExtend, - Shaders, - Uniforms, - }, - }, + opengl::OpenGL, options::Options, overlay::{self, Overlay}, row::never_extend_bg, + shader_types::{ + BgImage, + BgImageFit, + BgImageInfo, + BgImagePosition, + Bools, + CellBg, + CellText, + CellTextAtlas, + PaddingExtend, + Uniforms, + }, shadertoy::{self, PathItem}, size::{CellSize, GridSize, Padding, Size}, state::{Preedit, SharedState}, }; /// The graphics API used by the renderer on Linux. -pub type GraphicsApi = OpenGL; - /// Errors surfaced by the generic renderer. #[derive(Debug, thiserror::Error)] pub enum Error { #[error(transparent)] - Api(#[from] opengl::Error), + Api(#[from] ApiError), #[error(transparent)] - Gl(#[from] gl::Error), + Shaper(#[from] ghostty_font::shape::ShapeError), #[error(transparent)] - Texture(#[from] opengl::texture::Error), - #[error(transparent)] - Shaper(#[from] ghostty_font::shaper::harfbuzz::ShapeError), - #[error(transparent)] - ShapeRun(#[from] ghostty_font::shaper::harfbuzz::ShapeRunError), + ShapeRun(#[from] ghostty_font::shape::ShapeRunError), #[error(transparent)] Grid(#[from] ghostty_font::shared_grid::Error), #[error(transparent)] @@ -317,47 +320,48 @@ impl std::fmt::Debug for DerivedConfig { /// With OpenGL `swap_chain_count == 1` and frames complete synchronously /// (`glFinish`), so the semaphore of the Zig swap chain is not needed: the /// draw mutex already serializes `draw_frame`. -struct FrameState { - uniforms: Buffer, - cells: Buffer, - cells_bg: Buffer, +struct FrameState { + uniforms: G::Buffer, + cells: G::Buffer, + cells_bg: G::Buffer, - grayscale: Texture, + grayscale: G::Texture, grayscale_modified: usize, - color: Texture, + color: G::Texture, color_modified: usize, - target: Arc, + target: Arc, /// See property of same name on Renderer for explanation. target_config_modified: usize, /// Buffer with the vertex data for our background image. - bg_image_buffer: Buffer, + bg_image_buffer: G::Buffer, /// See property of same name on Renderer for explanation. bg_image_buffer_modified: usize, /// Custom shader state, this is None if we have no custom shaders. - custom_shader_state: Option, + custom_shader_state: Option>, + + /// The swap chain's atlas epoch this frame state last synced with. + atlas_epoch: usize, } -impl FrameState { - fn new(api: &OpenGL, custom_shaders: bool) -> Result { - let gl = api.gl().ok_or(opengl::Error::NoContext)?; - +impl FrameState { + fn new(api: &G, custom_shaders: bool) -> Result, Error> { // Uniform buffer contains exactly 1 uniform struct. The // uniform data will be zeroed so this must be set before // a frame is drawn. - let uniforms = Buffer::::init(gl, api.uniform_buffer_options(), 1)?; + let uniforms = api.init_buffer::(1)?; // Create GPU buffers for our cells. // // We start them off with a size of 1, which will of course be // too small, but they will be resized as needed. - let cells = Buffer::::init(gl, api.fg_buffer_options(), 1)?; - let cells_bg = Buffer::::init(gl, api.bg_buffer_options(), 1)?; + let cells = api.init_buffer::(1)?; + let cells_bg = api.init_buffer::(1)?; // Create a GPU buffer for our background image info. - let bg_image_buffer = Buffer::::init(gl, api.bg_image_buffer_options(), 1)?; + let bg_image_buffer = api.init_buffer::(1)?; // Initialize our textures for our font atlas. // @@ -393,10 +397,11 @@ impl FrameState { bg_image_buffer, bg_image_buffer_modified: 0, custom_shader_state, + atlas_epoch: 0, }) } - fn resize(&mut self, api: &OpenGL, width: usize, height: usize) -> Result<(), Error> { + fn resize(&mut self, api: &G, width: usize, height: usize) -> Result<(), Error> { if let Some(state) = &mut self.custom_shader_state { state.resize(api, width, height)?; } @@ -406,35 +411,32 @@ impl FrameState { } /// State relevant to our custom shaders if we have any. -struct CustomShaderState { +struct CustomShaderState { /// When we have a custom shader state, we maintain a front /// and back texture which we use as a swap chain to render /// between when multiple custom shaders are defined. - front_texture: Texture, - back_texture: Texture, + front_texture: G::Texture, + back_texture: G::Texture, /// Shadertoy uses a sampler for accessing the various channel textures. /// Samplers are immutable and describe sampling properties so we can /// share the sampler across front/back textures. - sampler: Sampler, + sampler: G::Sampler, - uniforms: Buffer, + uniforms: G::Buffer, } -impl CustomShaderState { - fn new(api: &OpenGL) -> Result { - let gl = api.gl().ok_or(opengl::Error::NoContext)?; - +impl CustomShaderState { + fn new(api: &G) -> Result, Error> { // Create a GPU buffer to hold our uniforms. - let uniforms = Buffer::::init(gl, api.uniform_buffer_options(), 1)?; + let uniforms = api.init_buffer::(1)?; // Initialize the front and back textures at 1x1 px, this // is slightly wasteful but it's only done once so whatever. - let front_texture = Texture::init(gl, api.texture_options(), 1, 1, None)?; - let back_texture = Texture::init(gl, api.texture_options(), 1, 1, None)?; + let front_texture = api.init_texture(TextureKind::CustomShader, 1, 1, None)?; + let back_texture = api.init_texture(TextureKind::CustomShader, 1, 1, None)?; - let sampler = Sampler::init(gl, api.sampler_options()) - .map_err(|_| gl::Error::CreateFailed("sampler".into()))?; + let sampler = api.init_sampler()?; Ok(CustomShaderState { front_texture, @@ -449,18 +451,35 @@ impl CustomShaderState { std::mem::swap(&mut self.front_texture, &mut self.back_texture); } - fn resize(&mut self, api: &OpenGL, width: usize, height: usize) -> Result<(), Error> { - let gl = api.gl().ok_or(opengl::Error::NoContext)?; - self.front_texture = Texture::init(gl, api.texture_options(), width, height, None)?; - self.back_texture = Texture::init(gl, api.texture_options(), width, height, None)?; + fn resize(&mut self, api: &G, width: usize, height: usize) -> Result<(), Error> { + self.front_texture = api.init_texture(TextureKind::CustomShader, width, height, None)?; + self.back_texture = api.init_texture(TextureKind::CustomShader, width, height, None)?; Ok(()) } } +/// The frame slots of a [`SwapChain`], shared with the frame completion +/// callbacks (which may run on another thread and hand the frame state back). +struct SwapChainInner { + /// `None` while the slot's frame is in flight (or being drawn). + slots: Mutex>>>, + /// Signalled when a frame is returned (Zig: `frame_sema.post`). + returned: Condvar, + /// Bumped to force every frame's atlas textures to re-sync, including + /// the ones that are in flight right now (see `FrameState::atlas_epoch`). + atlas_epoch: AtomicUsize, +} + /// Swap chain which maintains multiple copies of the state needed to render -/// a frame (always one for OpenGL, see [`FrameState`]). -struct SwapChain { - frames: Vec>, +/// a frame, so that we can start building the next frame while the previous +/// frame is still being processed on the GPU (always one for OpenGL, see +/// [`FrameState`]; three for Metal). +/// +/// Zig guards the frames with a semaphore; here taking a frame waits (on a +/// condition variable) until its slot has been handed back by the frame's +/// completion callback. +struct SwapChain { + inner: Arc>, /// Index of the most recently used frame state struct. frame_index: usize, /// Set to true when deinited, so that a defunct swap chain is ignored @@ -468,14 +487,18 @@ struct SwapChain { defunct: bool, } -impl SwapChain { - fn new(api: &OpenGL, custom_shaders: bool) -> Result { - let mut frames = Vec::with_capacity(opengl::SWAP_CHAIN_COUNT); - for _ in 0..opengl::SWAP_CHAIN_COUNT { +impl SwapChain { + fn new(api: &G, custom_shaders: bool) -> Result, Error> { + let mut frames = Vec::with_capacity(G::SWAP_CHAIN_COUNT); + for _ in 0..G::SWAP_CHAIN_COUNT { frames.push(Some(FrameState::new(api, custom_shaders)?)); } Ok(SwapChain { - frames, + inner: Arc::new(SwapChainInner { + slots: Mutex::new(frames), + returned: Condvar::new(), + atlas_epoch: AtomicUsize::new(0), + }), frame_index: 0, defunct: false, }) @@ -486,39 +509,91 @@ impl SwapChain { return; } self.defunct = true; - self.frames.clear(); + + // Wait for all of our inflight draws to complete + // so that we can cleanly deinit our GPU state. + let mut slots = lock(&self.inner.slots); + while slots.iter().any(Option::is_none) { + slots = self + .inner + .returned + .wait(slots) + .unwrap_or_else(PoisonError::into_inner); + } + slots.clear(); } - /// Get the next frame state to draw to. - fn next_frame(&mut self) -> Result<(usize, FrameState), Error> { - if self.defunct || self.frames.is_empty() { + /// Get the next frame state to draw to. This waits until the frame is + /// available. It must always be paired with a [`SwapChain::release_frame`] + /// (directly, or through the frame completion callback). + fn next_frame(&mut self) -> Result<(usize, FrameState), Error> { + if self.defunct { return Err(Error::Defunct); } - self.frame_index = (self.frame_index + 1) % self.frames.len(); - let frame = self.frames[self.frame_index].take().ok_or(Error::Defunct)?; - Ok((self.frame_index, frame)) + let mut slots = lock(&self.inner.slots); + if slots.is_empty() { + return Err(Error::Defunct); + } + self.frame_index = (self.frame_index + 1) % slots.len(); + let idx = self.frame_index; + let mut frame = loop { + if let Some(frame) = slots[idx].take() { + break frame; + } + slots = self + .inner + .returned + .wait(slots) + .unwrap_or_else(PoisonError::into_inner); + }; + drop(slots); + + // Update all our textures so that they sync on the next frame, if + // the font grid changed since this frame state was last used. + let epoch = self.inner.atlas_epoch.load(Ordering::SeqCst); + if frame.atlas_epoch != epoch { + frame.atlas_epoch = epoch; + frame.grayscale_modified = 0; + frame.color_modified = 0; + } + Ok((idx, frame)) } - fn release_frame(&mut self, idx: usize, frame: FrameState) { - if let Some(slot) = self.frames.get_mut(idx) { + /// A handle that hands a frame state back to its slot. + fn releaser(&self) -> Arc> { + Arc::clone(&self.inner) + } + + fn release_frame(&self, idx: usize, frame: FrameState) { + SwapChainInner::release(&self.inner, idx, frame); + } + + /// Make every frame re-upload its atlas textures on its next use. + fn invalidate_atlases(&self) { + self.inner.atlas_epoch.fetch_add(1, Ordering::SeqCst); + } +} + +impl SwapChainInner { + /// Hand a frame state back to its slot (Zig: `releaseFrame`). + fn release(this: &Arc, idx: usize, frame: FrameState) { + let mut slots = lock(&this.slots); + if let Some(slot) = slots.get_mut(idx) { *slot = Some(frame); } - } - - /// Mutable access to all frames (for invalidating atlas textures). - fn frames_mut(&mut self) -> impl Iterator { - self.frames.iter_mut().flatten() + drop(slots); + this.returned.notify_all(); } } /// The background image state (`bg_image: ?imagepkg.Image`). -type BgImageState = Option; +type BgImageState = Option>; // --------------------------------------------------------------------------- // Renderer state /// State protected by the draw mutex: everything `draw_frame` reads. -struct DrawState { +struct DrawState { /// The configuration we need derived from the main config. config: DerivedConfig, @@ -566,13 +641,13 @@ struct DrawState { font_grid: Arc, /// The images that we may render. - images: image::State, + images: image::State, /// True when a debug overlay image is currently present (Zig: `overlay != null`). has_overlay: bool, /// Background image, if we have one. - bg_image: BgImageState, + bg_image: BgImageState, /// Background image vertex buffer. bg_image_buffer: BgImage, /// This value is used to force-update the swap chain copy @@ -580,10 +655,10 @@ struct DrawState { bg_image_buffer_modified: usize, /// Graphics API state. - api: OpenGL, + api: G, /// Our swap chain (multiple buffering) - swap_chain: SwapChain, + swap_chain: SwapChain, /// This value is used to force-update swap chain targets in the /// event of a config change that requires it (such as blending mode). @@ -597,7 +672,7 @@ struct DrawState { has_custom_shaders: bool, /// Our shader pipelines. None while defunct (display unrealized). - shaders: Option, + shaders: Option, } /// State only touched by the renderer thread. @@ -642,17 +717,22 @@ struct ThreadState { } /// The generic renderer. See the module docs for the threading model. -pub struct Renderer { - draw: Mutex, +pub struct Renderer { + draw: Mutex>, thread: Mutex, /// Health of the most recently completed frame. - health: AtomicU8, + health: Arc, /// Mirrors `DrawState::has_custom_shaders` so the renderer thread can /// read it without the draw lock (`hasAnimations`). has_animations: AtomicBool, + /// The CVDisplayLink used to drive the rendering loop in sync with the + /// display. Always `None` on platforms that don't support a display link + /// (everything but macOS). + display_link: Mutex>, + /// The surface/apprt callbacks. host: Arc, } @@ -670,8 +750,8 @@ struct PreeditRange { /// Load the custom shaders (`initShaders`). Errors are logged and result in /// no custom shaders. -fn load_custom_shaders(config: &DerivedConfig) -> Vec { - match shadertoy::load_from_files(&config.custom_shaders, opengl::CUSTOM_SHADER_TARGET) { +fn load_custom_shaders(config: &DerivedConfig) -> Vec { + match shadertoy::load_from_files(&config.custom_shaders, G::CUSTOM_SHADER_TARGET) { Ok(v) => v, Err(err) => { log::warn!("error loading custom shaders err={err}"); @@ -680,11 +760,11 @@ fn load_custom_shaders(config: &DerivedConfig) -> Vec { } } -impl DrawState { +impl DrawState { /// Rebuild the shader pipelines (`initShaders`). fn init_shaders(&mut self) -> Result<(), Error> { // Load our custom shaders - let custom_shaders = load_custom_shaders(&self.config); + let custom_shaders = load_custom_shaders::(&self.config); let has_custom_shaders = !custom_shaders.is_empty(); let shaders = self.api.init_shaders(&custom_shaders)?; @@ -778,7 +858,7 @@ impl DrawState { fn prep_background_image(&mut self) { // Then we try to load the background image if we have a path. if let Some(p) = &self.config.bg_image - && let Some(image) = load_background_image(p.path()) + && let Some(image) = load_background_image(p.path()).map(image::Image::Pending) { // If we have an existing background image, replace it. // Otherwise, set this as our background image directly. @@ -795,21 +875,6 @@ impl DrawState { } } - fn upload_background_image(&mut self, gpu: &Gpu<'_>) { - // Make sure our bg image is uploaded if it needs to be. - if let Some(bg) = &mut self.bg_image { - if bg.is_unloading() { - self.bg_image = None; - return; - } - if bg.is_pending() - && let Err(err) = bg.upload(gpu) - { - log::warn!("error uploading background image err={err}"); - } - } - } - /// Update custom shader uniforms that depend on terminal state. /// /// This should be called in `update_frame` when terminal state changes. @@ -909,7 +974,7 @@ impl DrawState { let cell = self.size.cell; let cursor_glyph = self.cells.get_cursor_glyph(); let focused = self.focused; - let y_is_down = opengl::CUSTOM_SHADER_Y_IS_DOWN; + let y_is_down = G::CUSTOM_SHADER_Y_IS_DOWN; let uniforms = &mut self.custom_shader_uniforms; @@ -1001,8 +1066,8 @@ impl DrawState { /// Sync the atlas data to the given texture. This copies the bytes /// associated with the atlas to the given texture. If the atlas no /// longer fits into the texture, the texture will be resized. - fn sync_atlas_texture(api: &OpenGL, atlas: &Atlas, texture: &mut Texture) -> Result<(), Error> { - if atlas.size as usize > texture.width { + fn sync_atlas_texture(api: &G, atlas: &Atlas, texture: &mut G::Texture) -> Result<(), Error> { + if atlas.size as usize > texture.width() { // Reallocate (the old texture is dropped/deleted) *texture = api.init_atlas_texture(atlas)?; } @@ -1012,9 +1077,24 @@ impl DrawState { } } +/// Make sure our bg image is uploaded if it needs to be. +fn upload_background_image(bg_image: &mut BgImageState, gpu: &Gpu<'_, G>) { + if let Some(bg) = bg_image { + if bg.is_unloading() { + *bg_image = None; + return; + } + if bg.is_pending() + && let Err(err) = bg.upload(gpu) + { + log::warn!("error uploading background image err={err}"); + } + } +} + /// Decode a background image file (`prepBackgroundImage`). Failures are /// logged and result in no image, as in Zig. -fn load_background_image(path: &str) -> Option { +fn load_background_image(path: &str) -> Option { // Read it (Max size of 4 GiB, for now.) let contents = match std::fs::read(path) { Ok(c) if u32::try_from(c.len()).is_ok() => c, @@ -1060,12 +1140,12 @@ fn load_background_image(path: &str) -> Option { } }; - Some(image::Image::Pending(image::Pending { + Some(image::Pending { width, height, pixel_format: image::PixelFormat::Rgba, data, - })) + }) } /// `file_type.zig` (the subset needed here). @@ -1239,14 +1319,14 @@ fn shaped_cells_for_run( Ok(()) } -impl Renderer { +impl Renderer { /// Create the renderer (`Renderer.init`). /// - /// `api` must already have its GL context prepared - /// ([`OpenGL::prepare_context`], i.e. Zig `Renderer.surfaceInit`), and the - /// context must be current on the calling thread: swap chain buffers, + /// `api` must already be prepared (OpenGL: [`OpenGL::prepare_context`], + /// i.e. Zig `Renderer.surfaceInit`, with the context current on the + /// calling thread; Metal: attached to its view): swap chain buffers, /// textures and shaders are created here. - pub fn new(opts: Options, api: OpenGL) -> Result { + pub fn new(opts: Options, api: G) -> Result, Error> { let config = opts.config; let has_custom_shaders = !config.custom_shaders.is_empty(); @@ -1342,28 +1422,36 @@ impl Renderer { }; let has_animations = AtomicBool::new(draw.has_custom_shaders); + + // The display link is created here but only started in `loop_enter`. + let display_link = if cfg!(target_os = "macos") && draw.config.vsync { + DisplayLink::create_with_active_cg_displays() + } else { + None + }; + Ok(Renderer { + display_link: Mutex::new(display_link), draw: Mutex::new(draw), thread: Mutex::new(thread), - health: AtomicU8::new(Health::Healthy as u8), + health: Arc::new(AtomicU8::new(Health::Healthy as u8)), has_animations, host: opts.host, }) } - /// This is called by the GTK apprt after the surface is reinitialized + /// This is called by the apprt after the surface is reinitialized /// due to any of the events mentioned in the doc comment of - /// [`Renderer::display_unrealized`]. The GL context must be current. - pub fn display_realized(&self, get_proc_address: F) -> Result<(), Error> - where - F: FnMut(&str) -> *const std::ffi::c_void, - { + /// [`Renderer::display_unrealized`] (Zig `displayRealized`). `realize` is + /// the API's own hook ("if our API has to do things on realize, let it": + /// OpenGL reloads its context). The GPU context must be current. + pub fn display_realized_with(&self, realize: impl FnOnce(&mut G)) -> Result<(), Error> { // Lock the draw mutex so that we can // safely reinitialize our GPU resources. let mut d = lock(&self.draw); // If our API has to do things on realize, let it. - d.api.display_realized(get_proc_address); + realize(&mut d.api); // We assume that the swap chain was deinited in // `display_unrealized`, in which case it should be @@ -1398,6 +1486,7 @@ impl Renderer { // // This will mark them as defunct so that they // can't be double-freed or used in draw calls. + d.api.display_unrealized(); d.swap_chain.deinit(); d.shaders = None; d.api.clear_last_target(); @@ -1423,10 +1512,79 @@ impl Renderer { /// True if our renderer is using vsync. If true, the renderer or apprt /// is responsible for triggering draw_now calls to the render thread. - /// That is the only way to trigger a `draw_frame`. (Always false for - /// OpenGL on Linux; there is no display link.) + /// That is the only way to trigger a `draw_frame`. (Only true on macOS + /// with a running display link.) pub fn has_vsync(&self) -> bool { - false + lock(&self.display_link) + .as_ref() + .is_some_and(DisplayLink::is_running) + } + + /// Called by the renderer thread when it begins (`threadEnter`). + pub fn thread_enter(&self) { + lock(&self.draw).api.thread_enter(); + } + + /// Called by the renderer thread when it exits (`threadExit`). + pub fn thread_exit(&self) { + lock(&self.draw).api.thread_exit(); + } + + /// Called by the renderer thread when it starts the main loop + /// (`loopEnter`). `draw_now` forces an immediate draw on the renderer + /// thread; the display link calls it on every vsync. + pub fn loop_enter(self: &Arc, draw_now: Arc) { + // If our API has to do things on loop enter, let it. (Metal draws + // synchronously from the layer's display callback.) + let weak = Arc::downgrade(self); + lock(&self.draw).api.loop_enter(Arc::new(move || { + if let Some(renderer) = weak.upgrade() + && let Err(err) = renderer.draw_frame(true) + { + log::warn!("Error drawing frame in display callback, err={err}"); + } + })); + + // If we don't support a display link we have no work to do. + let guard = lock(&self.display_link); + let Some(link) = guard.as_ref() else { + return; + }; + + // To setup the display link we set our callback and we can start it + // immediately. + if let Err(err) = link.set_output_callback(draw_now) { + log::warn!("error setting display link callback err={err}"); + return; + } + let _ = link.start(); + } + + /// Called by the renderer thread when it exits the main loop + /// (`loopExit`). + pub fn loop_exit(&self) { + // If our API has to do things on loop exit, let it. + lock(&self.draw).api.loop_exit(); + + // Stop our display link. If this fails its okay it just means + // that we either never started it or the view its attached to + // is gone which is fine. + if let Some(link) = lock(&self.display_link).as_ref() { + let _ = link.stop(); + } + } + + /// Called when we get an updated display ID for our display link + /// (`setMacOSDisplayID`). + pub fn set_macos_display_id(&self, id: u32) { + let guard = lock(&self.display_link); + let Some(link) = guard.as_ref() else { + return; + }; + log::info!("updating display link display id={id}"); + if let Err(err) = link.set_current_cg_display(id) { + log::warn!("error setting display link display id err={err}"); + } } /// The health of the most recently completed frame. @@ -1454,13 +1612,31 @@ impl Renderer { // Flag that we need to update our custom shaders d.custom_shader_focused_changed = true; + + // If we're not focused, then we want to stop the display link + // because it is a waste of resources and we can move to pure + // change-driven updates. + if let Some(link) = lock(&self.display_link).as_ref() { + let _ = if focus { link.start() } else { link.stop() }; + } } /// Callback when the window is visible or occluded. /// - /// Must be called on the render thread. (There is no display link on - /// Linux, so there is nothing to do.) - pub fn set_visible(&self, _visible: bool) {} + /// Must be called on the render thread. + pub fn set_visible(&self, visible: bool) { + // If we're not visible, then we want to stop the display link + // because it is a waste of resources and we can move to pure + // change-driven updates. + let focused = lock(&self.draw).focused; + if let Some(link) = lock(&self.display_link).as_ref() { + let _ = if visible && focused { + link.start() + } else { + link.stop() + }; + } + } /// Set the new font grid. /// @@ -1477,10 +1653,7 @@ impl Renderer { d.font_grid = grid; // Update all our textures so that they sync on the next frame. - for frame in d.swap_chain.frames_mut() { - frame.grayscale_modified = 0; - frame.color_modified = 0; - } + d.swap_chain.invalidate_atlases(); d.grid_metrics = metrics; @@ -1561,7 +1734,8 @@ impl Renderer { if blending_changed { // We update our API's blending mode. - d.api.blending = d.config.blending; + let blending = d.config.blending; + d.api.set_blending(blending); // And indicate that we need to reinitialize our shaders. d.reinitialize_shaders = true; // And indicate that our swap chain targets need to @@ -1602,31 +1776,55 @@ impl Renderer { t.search_matches_dirty = true; } + /// The callback the graphics API calls when a frame is completed + /// (`renderer.frameCompleted`). Metal calls it from the command buffer's + /// completion handler, so it owns handles instead of borrowing `self`. + fn frame_completion(&self) -> FrameCompletion { + let health = Arc::clone(&self.health); + let host = Arc::clone(&self.host); + Arc::new(move |h| frame_completed(&health, host.as_ref(), h)) + } +} + +/// Record the health of a completed frame and notify the surface on change. +fn frame_completed(current: &AtomicU8, host: &dyn Host, health: Health) { + // If our health value hasn't changed, then we do nothing. We don't + // do a cmpxchg here because strict atomicity isn't important. + if current.load(Ordering::SeqCst) != health as u8 { + current.store(health as u8, Ordering::SeqCst); + + // Our health value changed, so we notify the surface so that it + // can do something about it. + host.renderer_health(health); + } +} + +impl Renderer { + /// This is called by the GTK apprt after the surface is reinitialized + /// due to any of the events mentioned in the doc comment of + /// [`Renderer::display_unrealized`]. The GL context must be current. + pub fn display_realized(&self, get_proc_address: F) -> Result<(), Error> + where + F: FnMut(&str) -> *const std::ffi::c_void, + { + self.display_realized_with(|api| api.display_realized(get_proc_address)) + } + /// Read back the pixels (RGBA8, bottom-up like `glReadPixels`) of the /// most recently presented frame. Intended for tests and screenshots; /// requires the GL context to be current. pub fn last_frame_pixels(&self) -> Option<(usize, usize, Vec)> { let d = lock(&self.draw); - let target = Arc::clone(d.api.last_target()?); + let target = d.api.last_target()?; let gl = d.api.gl()?.clone(); - let bind = gl.bind_framebuffer(target.framebuffer(), gl::FramebufferTarget::Read); + let bind = gl.bind_framebuffer( + target.framebuffer(), + crate::opengl::gl::FramebufferTarget::Read, + ); let px = gl.read_pixels_rgba8(0, 0, target.width as i32, target.height as i32); gl.unbind_framebuffer(bind); Some((target.width, target.height, px)) } - - // Callback from the graphics API when a frame is completed. - fn frame_completed(&self, health: Health) { - // If our health value hasn't changed, then we do nothing. We don't - // do a cmpxchg here because strict atomicity isn't important. - if self.health.load(Ordering::SeqCst) != health as u8 { - self.health.store(health as u8, Ordering::SeqCst); - - // Our health value changed, so we notify the surface so that it - // can do something about it. - self.host.renderer_health(health); - } - } } // --------------------------------------------------------------------------- @@ -1646,7 +1844,7 @@ struct Critical { /// (~12 minutes at 120Hz). const MAX_TERMINAL_STATE_FRAME_COUNT: usize = 100_000; -impl Renderer { +impl Renderer { /// Update the frame data. /// /// This is the renderer thread half of the renderer: it locks @@ -1849,8 +2047,16 @@ impl Renderer { (t.config.background_opacity * 255.0).round() as u8, ]; - // (On macOS glass styles the background opacity is removed; that - // is out of scope on Linux.) + // If we're on macOS and have glass styles, we remove + // the background opacity because the glass effect handles + // it. + #[cfg(target_os = "macos")] + if matches!( + t.config.background_blur, + BackgroundBlur::MacosGlassRegular | BackgroundBlur::MacosGlassClear + ) { + d.uniforms.bg_color[3] = 0; + } // Prepare our overlay image for upload (or unload). d.has_overlay = t.overlay.is_some(); @@ -1922,8 +2128,8 @@ impl Renderer { /// Update the kitty image state from the terminal (the `kittyUpdate` call in /// `updateFrame`). -fn update_kitty( - images: &mut image::State, +fn update_kitty( + images: &mut image::State, terminal: &mut ghostty_terminal::terminal::Terminal, cell_size: CellSize, ) { @@ -1955,7 +2161,7 @@ enum Selected { SearchSelected, } -impl DrawState { +impl DrawState { /// Convert the terminal state to GPU cells stored in CPU memory. These /// are then synced to the GPU in the next frame. This only updates CPU /// memory and doesn't touch the GPU. @@ -2246,7 +2452,7 @@ impl DrawState { } } -impl DrawState { +impl DrawState { #[allow(clippy::too_many_arguments)] fn rebuild_row( &mut self, @@ -2968,7 +3174,7 @@ impl DrawState { // --------------------------------------------------------------------------- // draw_frame (GL thread) -impl Renderer { +impl Renderer { /// Draw the frame to the screen. /// /// If `sync` is true, this will synchronously block until @@ -3000,7 +3206,7 @@ impl Renderer { result } - fn draw_frame_locked(&self, d: &mut DrawState, sync: bool) -> Result<(), Error> { + fn draw_frame_locked(&self, d: &mut DrawState, sync: bool) -> Result<(), Error> { d.api.draw_frame_start(); // Retrieve the most up-to-date surface size from the Graphics API @@ -3030,30 +3236,45 @@ impl Renderer { // Get a frame state to draw to. let (idx, mut frame) = d.swap_chain.next_frame()?; - let result = d.draw_with_frame( - &mut frame, - sync, - (surface_width, surface_height), - size_changed, - ); - d.swap_chain.release_frame(idx, frame); - - let health = result?; + let result = d.draw_with_frame(&mut frame, (surface_width, surface_height), size_changed); + let frame_ctx = match result { + Ok(frame_ctx) => frame_ctx, + Err(err) => { + // Nothing was submitted: give the frame state back. + d.swap_chain.release_frame(idx, frame); + return Err(err); + } + }; self.has_animations .store(d.has_custom_shaders, Ordering::Relaxed); - self.frame_completed(health); + + // Complete the frame. The frame state is handed back to the swap + // chain, and the health reported, when the frame completes + // (`frameCompleted`): inline for synchronous frames, from the + // graphics API's completion thread otherwise. + let report = self.frame_completion(); + let swap_chain = d.swap_chain.releaser(); + let pending = Mutex::new(Some(frame)); + let completion: FrameCompletion = Arc::new(move |health| { + report(health); + if let Some(frame) = lock(&pending).take() { + SwapChainInner::release(&swap_chain, idx, frame); + } + }); + frame_ctx.complete(sync, completion); Ok(()) } } -impl DrawState { +impl DrawState { + /// Encode a frame into `frame`'s buffers and the swap chain's targets. + /// The returned graphics API frame has not been completed yet. fn draw_with_frame( &mut self, - frame: &mut FrameState, - sync: bool, + frame: &mut FrameState, surface_size: (u32, u32), size_changed: bool, - ) -> Result { + ) -> Result { let (surface_width, surface_height) = surface_size; // If we need to reinitialize our shaders, do so. @@ -3073,7 +3294,7 @@ impl DrawState { // if we have a state but don't need it we remove it. if self.has_custom_shaders { if frame.custom_shader_state.is_none() { - let mut state = CustomShaderState::new(&self.api)?; + let mut state = CustomShaderState::::new(&self.api)?; state.resize(&self.api, surface_width as usize, surface_height as usize)?; frame.custom_shader_state = Some(state); } @@ -3094,8 +3315,8 @@ impl DrawState { // If this frame's target isn't the correct size, or the target // config has changed (such as when the blending mode changes), // remove it and replace it with a new one with the right values. - if frame.target.width != self.size.screen.width as usize - || frame.target.height != self.size.screen.height as usize + if frame.target.width() != self.size.screen.width as usize + || frame.target.height() != self.size.screen.height as usize || frame.target_config_modified != self.target_config_modified { frame.resize( @@ -3108,26 +3329,21 @@ impl DrawState { self.api.clear_last_target(); } - let gl: Gl = self.api.gl().ok_or(opengl::Error::NoContext)?.clone(); - let gpu = Gpu { - gl: &gl, - texture_options: self - .api - .image_texture_options(opengl::ImageTextureFormat::Rgba, true), - buffer_options: self.api.image_buffer_options(), - }; + { + let gpu = Gpu { api: &self.api }; - // Upload images to the GPU as necessary. - let _ = self.images.upload(&gpu); + // Upload images to the GPU as necessary. + let _ = self.images.upload(&gpu); - // Upload the background image to the GPU as necessary. - self.upload_background_image(&gpu); + // Upload the background image to the GPU as necessary. + upload_background_image(&mut self.bg_image, &gpu); + } // Update per-frame custom shader uniforms. self.update_custom_shader_uniforms_for_frame(); // Setup our frame data - frame.uniforms.sync(&[self.uniforms])?; + frame.uniforms.sync(&[G::gpu_uniforms(&self.uniforms)])?; frame.cells_bg.sync(&self.cells.bg_cells)?; let fg_lists: Vec<&[CellText]> = self.cells.fg_rows.iter().map(Vec::as_slice).collect(); let fg_count = frame.cells.sync_from_slices(&fg_lists)?; @@ -3174,17 +3390,18 @@ impl DrawState { bg_image, has_overlay, .. - } = self; + } = &*self; let Some(shaders) = shaders.as_ref() else { return Err(Error::Defunct); }; + let gpu = Gpu { api }; let frame_ctx = api.begin_frame(Arc::clone(&frame.target)); { let attachments = [Attachment { target: match &frame.custom_shader_state { Some(state) => AttachmentTarget::Texture(&state.back_texture), - None => AttachmentTarget::Target(&frame.target), + None => AttachmentTarget::Target(&*frame.target), }, clear_color: Some([0.0, 0.0, 0.0, 0.0]), }]; @@ -3204,7 +3421,7 @@ impl DrawState { Some(img) => { if let image::Image::Ready(texture) = img { pass.step(&Step { - pipeline: &shaders.bg_image, + pipeline: shaders.bg_image(), uniforms: Some(frame.uniforms.raw()), buffers: &[Some(frame.bg_image_buffer.raw())], textures: &[Some(texture)], @@ -3215,7 +3432,7 @@ impl DrawState { } None => { pass.step(&Step { - pipeline: &shaders.bg_color, + pipeline: shaders.bg_color(), uniforms: Some(frame.uniforms.raw()), buffers: &[None, Some(frame.cells_bg.raw())], textures: &[], @@ -3229,14 +3446,14 @@ impl DrawState { // to be behind text AND cell backgrounds. images.draw( &gpu, - &shaders.image, + shaders.image(), &mut pass, image::DrawPlacements::KittyBelowBg, ); // Then we draw any opaque cell backgrounds. pass.step(&Step { - pipeline: &shaders.cell_bg, + pipeline: shaders.cell_bg(), uniforms: Some(frame.uniforms.raw()), buffers: &[None, Some(frame.cells_bg.raw())], textures: &[], @@ -3247,14 +3464,14 @@ impl DrawState { // Kitty images between cell backgrounds and text. images.draw( &gpu, - &shaders.image, + shaders.image(), &mut pass, image::DrawPlacements::KittyBelowText, ); // Text. pass.step(&Step { - pipeline: &shaders.cell_text, + pipeline: shaders.cell_text(), uniforms: Some(frame.uniforms.raw()), buffers: &[Some(frame.cells.raw()), Some(frame.cells_bg.raw())], textures: &[Some(&frame.grayscale), Some(&frame.color)], @@ -3269,7 +3486,7 @@ impl DrawState { // Kitty images in front of text. images.draw( &gpu, - &shaders.image, + shaders.image(), &mut pass, image::DrawPlacements::KittyAboveText, ); @@ -3279,7 +3496,7 @@ impl DrawState { if *has_overlay { images.draw( &gpu, - &shaders.image, + shaders.image(), &mut pass, image::DrawPlacements::Overlay, ); @@ -3290,14 +3507,14 @@ impl DrawState { // If we have custom shaders, then we render them. if let Some(state) = &mut frame.custom_shader_state { - let post_count = shaders.post_pipelines.len(); - for (i, pipeline) in shaders.post_pipelines.iter().enumerate() { + let post_count = shaders.post_pipelines().len(); + for (i, pipeline) in shaders.post_pipelines().iter().enumerate() { { let attachments = [Attachment { target: if i < post_count - 1 { AttachmentTarget::Texture(&state.front_texture) } else { - AttachmentTarget::Target(&frame.target) + AttachmentTarget::Target(&*frame.target) }, clear_color: Some([0.0, 0.0, 0.0, 0.0]), }]; @@ -3317,8 +3534,8 @@ impl DrawState { } } - // Complete the frame: glFinish, error check, present. - Ok(frame_ctx.complete(sync)) + // The caller completes the frame (glFinish, error check, present). + Ok(frame_ctx) } } @@ -3433,13 +3650,8 @@ mod tests { std::fs::write(&path, &bytes).unwrap(); let img = load_background_image(path.to_str().unwrap()).expect("decoded"); - match img { - image::Image::Pending(p) => { - assert_eq!((p.width, p.height), (2, 1)); - assert_eq!(p.data, [1, 2, 3, 255, 4, 5, 6, 128]); - } - _ => panic!("expected a pending image"), - } + assert_eq!((img.width, img.height), (2, 1)); + assert_eq!(img.data, [1, 2, 3, 255, 4, 5, 6, 128]); // Missing / undecodable files are not an error, just no image. assert!(load_background_image(dir.join("missing.png").to_str().unwrap()).is_none()); diff --git a/crates/ghostty-renderer/src/graphics_api.rs b/crates/ghostty-renderer/src/graphics_api.rs new file mode 100644 index 0000000..a49a2a4 --- /dev/null +++ b/crates/ghostty-renderer/src/graphics_api.rs @@ -0,0 +1,334 @@ +//! The graphics API abstraction used by the generic renderer. +//! +//! Zig's `GenericRenderer(comptime GraphicsAPI: type)` duck-types its graphics +//! API (`Metal`, `OpenGL`): it reads associated declarations such as +//! `GraphicsAPI.Target`, `GraphicsAPI.Buffer`, `GraphicsAPI.shaders`, +//! `GraphicsAPI.swap_chain_count`, `custom_shader_target`, +//! `custom_shader_y_is_down`, and calls methods like `initTarget`, +//! `beginFrame`, `drawFrameStart`. [`GraphicsApi`] is the Rust spelling of that +//! implicit interface: associated types for the GPU objects, associated +//! consts for the per-API constants, and methods for the operations. +//! +//! `renderer.zig` picks the implementation by `build_config.renderer` +//! (`.metal` => `GenericRenderer(Metal)`, `.opengl` => `GenericRenderer(OpenGL)`); +//! `lib.rs` does the same with `cfg(target_vendor = "apple")`. + +use std::{error::Error as StdError, fmt, sync::Arc}; + +use bytemuck::Pod; +use ghostty_config::types_b::AlphaBlending; +use ghostty_font::atlas::Atlas; + +use crate::{backend::Health, shader_types::Uniforms, shadertoy}; + +/// A graphics API failure (OpenGL error, Metal failure, ...). The generic +/// renderer only reports these, so the concrete error is boxed. +#[derive(Debug)] +pub struct ApiError(Box); + +impl ApiError { + /// Wrap a concrete error. + pub fn new(err: E) -> Self { + ApiError(Box::new(err)) + } + + /// An error that is only a message. + pub fn msg(msg: impl Into) -> Self { + ApiError(msg.into().into()) + } +} + +impl fmt::Display for ApiError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + fmt::Display::fmt(&self.0, f) + } +} + +impl StdError for ApiError { + fn source(&self) -> Option<&(dyn StdError + 'static)> { + self.0.source() + } +} + +/// Called with the health of a frame once it has completed. For synchronous +/// APIs (OpenGL, or Metal with `sync = true`) this happens inside +/// [`GpuFrame::complete`]; for asynchronous Metal frames it happens on the +/// command buffer's completion thread (Zig: `renderer.frameCompleted`). +pub type FrameCompletion = Arc; + +/// Primitive types we support drawing (same subset in OpenGL and Metal). +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum Primitive { + Point, + Line, + LineStrip, + Triangle, + TriangleStrip, +} + +/// Pixel format for image texture options. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum ImageTextureFormat { + /// 1 byte per pixel grayscale. + Gray, + /// 4 bytes per pixel RGBA. + Rgba, + /// 4 bytes per pixel BGRA. + Bgra, +} + +/// Which texture options (`textureOptions` / `imageTextureOptions`) to create +/// a texture with. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum TextureKind { + /// `api.textureOptions()`: the front/back textures of custom shaders + /// (shader read + render target). + CustomShader, + /// `api.imageTextureOptions(format, srgb)`: shader-read-only images. + Image { + format: ImageTextureFormat, + srgb: bool, + }, +} + +/// What a color attachment renders into. +pub enum AttachmentTarget<'a, G: GraphicsApi> { + Texture(&'a G::Texture), + Target(&'a G::Target), +} + +/// Describes a color attachment. +pub struct Attachment<'a, G: GraphicsApi> { + pub target: AttachmentTarget<'a, G>, + pub clear_color: Option<[f32; 4]>, +} + +/// Describes the draw call for a step. +#[derive(Debug, Clone, Copy)] +pub struct Draw { + pub ty: Primitive, + pub vertex_count: usize, + pub instance_count: usize, +} + +impl Draw { + pub fn new(ty: Primitive, vertex_count: usize) -> Self { + Draw { + ty, + vertex_count, + instance_count: 1, + } + } +} + +/// Describes a step in a render pass. +/// +/// Buffer slot conventions are shared by both APIs: `uniforms` is bound at +/// index 1, `buffers[0]` is the vertex buffer, the rest are storage buffers +/// bound at their slice index; `textures` / `samplers` bind at their slice +/// index. +pub struct Step<'a, G: GraphicsApi> { + pub pipeline: &'a G::Pipeline, + pub uniforms: Option, + pub buffers: &'a [Option], + pub textures: &'a [Option<&'a G::Texture>], + pub samplers: &'a [Option<&'a G::Sampler>], + pub draw: Draw, +} + +/// A GPU buffer holding `T`s (`GraphicsAPI.Buffer(T)`). +pub trait GpuBuffer { + /// The raw handle passed to render pass steps. + fn raw(&self) -> G::BufferRef; + + /// Sync new contents to the buffer. The data is expected to be the + /// complete contents of the buffer. If the amount of data is larger + /// than the buffer length, the buffer will be reallocated. + fn sync(&mut self, data: &[T]) -> Result<(), ApiError>; + + /// Like `sync` but takes data from several slices. Returns the number + /// of items synced. + fn sync_from_slices(&mut self, lists: &[&[T]]) -> Result; +} + +/// A GPU texture (`GraphicsAPI.Texture`). +pub trait GpuTexture { + fn width(&self) -> usize; + fn height(&self) -> usize; + + /// Replace a region of the texture with the provided data. Does NOT + /// check the dimensions of the data to ensure correctness. + fn replace_region( + &self, + x: usize, + y: usize, + width: usize, + height: usize, + data: &[u8], + ) -> Result<(), ApiError>; +} + +/// A render target that can be presented (`GraphicsAPI.Target`). +pub trait GpuTarget { + fn width(&self) -> usize; + fn height(&self) -> usize; +} + +/// The collection of shader pipelines (`GraphicsAPI.shaders.Shaders`). +pub trait GpuShaders { + fn bg_color(&self) -> &G::Pipeline; + fn cell_bg(&self) -> &G::Pipeline; + fn cell_text(&self) -> &G::Pipeline; + fn image(&self) -> &G::Pipeline; + fn bg_image(&self) -> &G::Pipeline; + + /// Custom shaders to run against the final drawable texture, each one + /// against the output of the previous. + fn post_pipelines(&self) -> &[G::Pipeline]; +} + +/// A frame being encoded (`GraphicsAPI.Frame`). +pub trait GpuFrame { + /// Add a render pass to this frame with the provided attachments. + fn render_pass<'a>(&'a self, attachments: &'a [Attachment<'a, G>]) -> G::RenderPass<'a> + where + G: 'a; + + /// Complete this frame and present the target. If `sync` is true, this + /// blocks until the frame is presented. The frame's health is reported + /// through `completion` (before this returns if the frame completes + /// synchronously). + fn complete(self, sync: bool, completion: FrameCompletion); +} + +/// A render pass being encoded (`GraphicsAPI.RenderPass`). +pub trait GpuRenderPass<'a, G: GraphicsApi> { + /// Add a step to this render pass. + fn step(&mut self, step: &Step<'_, G>); + + /// Complete this render pass. It can no longer be used after this. + fn complete(self); +} + +/// The graphics API the generic renderer is instantiated with. +pub trait GraphicsApi: Sized + Send + 'static { + /// `custom_shader_target`: Metal => MSL, OpenGL => GLSL. + const CUSTOM_SHADER_TARGET: shadertoy::Target; + /// `custom_shader_y_is_down`: the fragCoord for Metal shaders is +Y = down. + const CUSTOM_SHADER_Y_IS_DOWN: bool; + /// `swap_chain_count`: Metal triple buffers, OpenGL completes frames + /// synchronously and needs no multi-buffering. + const SWAP_CHAIN_COUNT: usize; + + type Target: GpuTarget + Send + Sync + 'static; + type Texture: GpuTexture + Send + 'static; + type Sampler: Send + 'static; + type Buffer: GpuBuffer + Send + 'static; + /// The raw buffer handle bound by render pass steps. + type BufferRef: Clone; + type Pipeline; + type Shaders: GpuShaders + Send + 'static; + /// The GPU-side layout of the uniform block (`shaders.Uniforms`). + type GpuUniforms: Pod; + type Frame: GpuFrame; + type RenderPass<'a>: GpuRenderPass<'a, Self> + where + Self: 'a; + + /// Convert the renderer's CPU-side uniforms to the GPU layout. + fn gpu_uniforms(uniforms: &Uniforms) -> Self::GpuUniforms; + + /// The current alpha blending mode. + fn blending(&self) -> AlphaBlending; + + /// Change the alpha blending mode (`api.blending = ..`). Targets and + /// textures must be recreated by the caller afterwards. + fn set_blending(&mut self, blending: AlphaBlending); + + /// Build the shader pipelines, including the post-processing pipelines + /// for `custom_shaders` (in [`Self::CUSTOM_SHADER_TARGET`] format). + fn init_shaders(&self, custom_shaders: &[String]) -> Result; + + /// Get the current size of the runtime surface. + fn surface_size(&self) -> Result<(u32, u32), ApiError>; + + /// Initialize a new render target which can be presented by this API. + fn init_target(&self, width: usize, height: usize) -> Result; + + /// `Buffer.init` with the API's buffer options. + fn init_buffer(&self, len: usize) -> Result, ApiError>; + + /// `Buffer.initFill` with the API's buffer options. + fn init_buffer_fill(&self, data: &[T]) -> Result, ApiError>; + + /// Initialize a texture with the options of `kind`. + fn init_texture( + &self, + kind: TextureKind, + width: usize, + height: usize, + data: Option<&[u8]>, + ) -> Result; + + /// Initializes a Texture suitable for the provided font atlas. + fn init_atlas_texture(&self, atlas: &Atlas) -> Result; + + /// Initialize the sampler used by custom shaders (`samplerOptions`). + fn init_sampler(&self) -> Result; + + /// Begin a frame. The target is presented once the frame is completed + /// ([`GpuFrame::complete`]). + fn begin_frame(&self, target: Arc) -> Self::Frame; + + /// Present the last presented target again (a no-op for Metal). + fn present_last_target(&self) -> Result<(), ApiError>; + + /// Forget the remembered last-presented target (it is being replaced). + fn clear_last_target(&self); + + /// Actions taken before doing anything in `drawFrame` (Metal starts an + /// autorelease pool). + fn draw_frame_start(&mut self) {} + + /// Actions taken after `drawFrame` is done. + fn draw_frame_end(&mut self) {} + + /// Called by the renderer thread when it begins (`threadEnter`). + fn thread_enter(&self) {} + + /// Called by the renderer thread when it exits (`threadExit`). + fn thread_exit(&self) {} + + /// Called when the renderer thread starts its main loop (`loopEnter`). + /// `draw` draws a frame synchronously; Metal installs it as the layer's + /// display callback. + fn loop_enter(&mut self, draw: Arc) { + let _ = draw; + } + + /// Called when the renderer thread leaves its main loop (`loopExit`). + fn loop_exit(&mut self) {} + + /// Called when the display is unrealized (`displayUnrealized`). + fn display_unrealized(&mut self) {} +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn api_error_display() { + let e = ApiError::msg("boom"); + assert_eq!(e.to_string(), "boom"); + let e = ApiError::new(std::io::Error::other("io")); + assert_eq!(e.to_string(), "io"); + } + + #[test] + fn draw_defaults_to_one_instance() { + let d = Draw::new(Primitive::Triangle, 3); + assert_eq!(d.instance_count, 1); + assert_eq!(d.vertex_count, 3); + } +} diff --git a/crates/ghostty-renderer/src/image.rs b/crates/ghostty-renderer/src/image.rs index 6dfe401..a2f5b1d 100644 --- a/crates/ghostty-renderer/src/image.rs +++ b/crates/ghostty-renderer/src/image.rs @@ -21,39 +21,34 @@ use ghostty_terminal::{ }; use crate::{ - opengl::{ - Buffer, - Texture, - buffer, - gl::{Gl, Primitive}, - pipeline::Pipeline, - render_pass::{Draw, RenderPass, Step}, - shaders::Image as ImageVertex, - texture, + graphics_api::{ + Draw, + GpuBuffer, + GpuRenderPass, + GraphicsApi, + ImageTextureFormat, + Primitive, + Step, + TextureKind, }, overlay::Overlay, + shader_types::Image as ImageVertex, size::CellSize, }; /// The GPU objects needed to upload and draw images. Built per frame from -/// the graphics API (the API itself is mutably borrowed by the frame while -/// drawing). -pub struct Gpu<'a> { - pub gl: &'a Gl, - /// `api.imageTextureOptions(.rgba, true)`. - pub texture_options: texture::Options, - /// `api.imageBufferOptions()`. - pub buffer_options: buffer::Options, +/// the graphics API. +pub struct Gpu<'a, G: GraphicsApi> { + pub api: &'a G, } /// Generic image rendering state for the renderer. This stores all /// images and their placements and exposes only a limited public API /// for adding images and placements and drawing them. -#[derive(Default)] -pub struct State { +pub struct State { /// The full image state for the renderer that specifies what images /// need to be uploaded, pruned, etc. - pub images: HashMap, + pub images: HashMap>, /// The placements for the Kitty image protocol. pub kitty_placements: Vec, @@ -77,8 +72,8 @@ pub struct State { } /// An image in the map plus the time it was transmitted. -pub struct Entry { - pub image: Image, +pub struct Entry { + pub image: Image, pub transmit_time: Instant, } @@ -96,8 +91,22 @@ pub enum PrepImageError { ImageConversionError, } -impl State { - pub fn new() -> State { +impl Default for State { + fn default() -> Self { + State { + images: HashMap::new(), + kitty_placements: Vec::new(), + kitty_bg_end: 0, + kitty_text_end: 0, + kitty_virtual: false, + overlay_placements: Vec::new(), + force_kitty_update: false, + } + } +} + +impl State { + pub fn new() -> State { State::default() } @@ -107,7 +116,7 @@ impl State { /// If any uploads fail, they are ignored. The return value /// can be used to detect if upload was a total success (true) /// or not (false). - pub fn upload(&mut self, gpu: &Gpu<'_>) -> bool { + pub fn upload(&mut self, gpu: &Gpu<'_, G>) -> bool { let mut success = true; self.images.retain(|_, entry| { let img = &mut entry.image; @@ -131,11 +140,11 @@ impl State { /// /// Any placements that have non-uploaded images are ignored. Any /// graphics API errors during drawing are also ignored. - pub fn draw( + pub fn draw<'p, P: GpuRenderPass<'p, G>>( &self, - gpu: &Gpu<'_>, - pipeline: &Pipeline, - pass: &mut RenderPass<'_>, + gpu: &Gpu<'_, G>, + pipeline: &G::Pipeline, + pass: &mut P, placement_type: DrawPlacements, ) { let bg_end = self.kitty_bg_end as usize; @@ -162,22 +171,18 @@ impl State { // Create our vertex buffer, which is always exactly one item. // future(mitchellh): we can group rendering multiple instances of a single image - let buf = match Buffer::::init_fill( - gpu.gl, - gpu.buffer_options, - &[ImageVertex { - grid_pos: [p.x as f32, p.y as f32], - cell_offset: [p.cell_offset_x as f32, p.cell_offset_y as f32], - source_rect: [ - p.source_x as f32, - p.source_y as f32, - p.source_width as f32, - p.source_height as f32, - ], - dest_size: [p.width as f32, p.height as f32], - pad: [0.0; 2], - }], - ) { + let buf = match gpu.api.init_buffer_fill::(&[ImageVertex { + grid_pos: [p.x as f32, p.y as f32], + cell_offset: [p.cell_offset_x as f32, p.cell_offset_y as f32], + source_rect: [ + p.source_x as f32, + p.source_y as f32, + p.source_width as f32, + p.source_height as f32, + ], + dest_size: [p.width as f32, p.height as f32], + pad: [0.0; 2], + }]) { Ok(b) => b, Err(err) => { log::warn!("error creating image vertex buffer err={err}"); @@ -770,27 +775,33 @@ pub enum UploadError { } /// The state for a single image that is to be rendered. -pub enum Image { +pub enum Image { /// The image data is pending upload to the GPU. Pending(Pending), /// This is the same as the pending states but there is /// a texture already allocated that we want to replace. - Replace { texture: Texture, pending: Pending }, + Replace { + texture: G::Texture, + pending: Pending, + }, /// The image is uploaded and ready to be used. - Ready(Texture), + Ready(G::Texture), /// The image isn't uploaded yet but is scheduled to be unloaded. UnloadPending(Pending), /// The image is uploaded and is scheduled to be unloaded. - UnloadReady(Texture), + UnloadReady(G::Texture), /// The image is uploaded and scheduled to be replaced /// with new data, but it's also scheduled to be unloaded. - UnloadReplace { texture: Texture, pending: Pending }, + UnloadReplace { + texture: G::Texture, + pending: Pending, + }, } -impl Image { +impl Image { /// Mark this image for unload whatever state it is in. pub fn mark_for_unload(&mut self) { let old = std::mem::replace(self, Image::Pending(Pending::empty())); @@ -807,7 +818,7 @@ impl Image { /// Mark the current image to be replaced with a pending one. This will /// attempt to update the existing texture if we have one, otherwise it /// will act like a new upload. - pub fn mark_for_replace(&mut self, img: Image) { + pub fn mark_for_replace(&mut self, img: Image) { let Image::Pending(new_pending) = img else { debug_assert!(false, "mark_for_replace requires a pending image"); return; @@ -850,7 +861,7 @@ impl Image { } /// The texture if the image is ready or unload_ready (usable for drawing). - pub fn ready_texture(&self) -> Option<&Texture> { + pub fn ready_texture(&self) -> Option<&G::Texture> { match self { Image::Ready(t) | Image::UnloadReady(t) => Some(t), _ => None, @@ -873,7 +884,7 @@ impl Image { } } - fn texture(&self) -> Option<&Texture> { + fn texture(&self) -> Option<&G::Texture> { match self { Image::Ready(t) | Image::UnloadReady(t) => Some(t), Image::Replace { texture, .. } | Image::UnloadReplace { texture, .. } => Some(texture), @@ -894,7 +905,7 @@ impl Image { /// Upload the pending image to the GPU and change the state of this /// image to ready. - pub fn upload(&mut self, gpu: &Gpu<'_>) -> Result<(), UploadError> { + pub fn upload(&mut self, gpu: &Gpu<'_, G>) -> Result<(), UploadError> { // No error recovery is required after this call because it just // converts in place and is idempotent. self.prep_for_upload()?; @@ -905,14 +916,19 @@ impl Image { }; // Create our texture - let texture = Texture::init( - gpu.gl, - gpu.texture_options, - p.width as usize, - p.height as usize, - Some(&p.data), - ) - .map_err(|_| UploadError::UploadFailed)?; + // (`api.imageTextureOptions(.rgba, true)`) + let texture = gpu + .api + .init_texture( + TextureKind::Image { + format: ImageTextureFormat::Rgba, + srgb: true, + }, + p.width as usize, + p.height as usize, + Some(&p.data), + ) + .map_err(|_| UploadError::UploadFailed)?; // Uploaded. We can now clear our data and change our state. // @@ -938,6 +954,7 @@ impl Pending { #[cfg(test)] mod tests { use super::*; + use crate::opengl::OpenGL; fn pending(fmt: PixelFormat, data: Vec, w: u32, h: u32) -> Pending { Pending { @@ -1001,7 +1018,7 @@ mod tests { #[test] fn state_transitions_without_gpu() { - let mut img = Image::Pending(pending(PixelFormat::Rgb, vec![1, 2, 3], 1, 1)); + let mut img: Image = Image::Pending(pending(PixelFormat::Rgb, vec![1, 2, 3], 1, 1)); assert!(img.is_pending()); assert!(!img.is_unloading()); assert!(!img.has_texture()); @@ -1028,7 +1045,7 @@ mod tests { #[test] fn prep_image_same_transmit_time_is_noop() { - let mut s = State::new(); + let mut s: State = State::new(); let t = Instant::now(); s.prep_image( Id::Kitty(1), @@ -1118,7 +1135,7 @@ mod tests { .unwrap(); let info = TerminalInfo::from_terminal(&t); - let mut s = State::new(); + let mut s: State = State::new(); s.kitty_update( &storage, &t.screen().pages, diff --git a/crates/ghostty-renderer/src/lib.rs b/crates/ghostty-renderer/src/lib.rs index 577c72a..a11ed60 100644 --- a/crates/ghostty-renderer/src/lib.rs +++ b/crates/ghostty-renderer/src/lib.rs @@ -14,16 +14,23 @@ pub mod backend; pub mod cell; pub mod cursor; +pub mod display_link; pub mod generic; +pub mod graphics_api; pub mod headless; pub mod image; pub mod link; pub mod math; pub mod message; +#[cfg(target_vendor = "apple")] +#[allow(unsafe_code, clippy::struct_field_names)] +pub mod metal; +pub mod metal_layout; pub mod opengl; pub mod options; pub mod overlay; pub mod row; +pub mod shader_types; pub mod shadertoy; mod shadertoy_sampler; pub mod size; @@ -37,9 +44,19 @@ pub use opengl::OpenGL; pub use size::{CellSize, Coordinate, GridSize, Padding, ScreenSize, Size}; pub use state::State; -/// The renderer used on Linux: the generic renderer instantiated with the -/// OpenGL graphics API (`renderer.zig`: `pub const Renderer = GenericRenderer(OpenGL)`). -pub type Renderer = generic::Renderer; +/// The graphics API the renderer is built with (`renderer.zig`: `Renderer = +/// switch (build_config.renderer) { .metal => GenericRenderer(Metal), .opengl +/// => GenericRenderer(OpenGL) }`): Metal on Apple platforms, OpenGL elsewhere +/// (see [`Backend::default_for_target`]). +#[cfg(target_vendor = "apple")] +pub type Api = metal::Metal; +/// The graphics API the renderer is built with: OpenGL. +#[cfg(not(target_vendor = "apple"))] +pub type Api = opengl::OpenGL; + +/// The renderer: the generic renderer instantiated with the graphics API +/// selected for the target ([`Api`]). +pub type Renderer = generic::Renderer; /// The renderer's derived configuration (`Renderer.DerivedConfig`). pub type DerivedConfig = generic::DerivedConfig; diff --git a/crates/ghostty-renderer/src/metal/buffer.rs b/crates/ghostty-renderer/src/metal/buffer.rs new file mode 100644 index 0000000..8921c80 --- /dev/null +++ b/crates/ghostty-renderer/src/metal/buffer.rs @@ -0,0 +1,174 @@ +//! Metal data storage for a certain set of equal types (`metal/buffer.zig`). +use std::marker::PhantomData; + +use bytemuck::Pod; +use objc2::{rc::Retained, runtime::ProtocolObject}; +use objc2_foundation::NSRange; +use objc2_metal::{MTLBuffer, MTLDevice as _, MTLResourceOptions}; + +use super::{Device, Error}; +use crate::{ + graphics_api::{ApiError, GpuBuffer}, + metal_layout::{ResourceOptions, StorageMode}, +}; + +/// Options for initializing a buffer. +#[derive(Clone)] +pub struct Options { + /// MTLDevice + pub device: Device, + pub resource_options: ResourceOptions, +} + +impl Options { + fn mtl_options(&self) -> MTLResourceOptions { + MTLResourceOptions(self.resource_options.bits() as usize) + } +} + +/// Metal data storage for a certain set of equal types. This is usually +/// used for vertex buffers, etc. This helpful wrapper makes it easy to +/// prealloc, shrink, grow, sync, buffers with Metal. +pub struct Buffer { + /// The options this buffer was initialized with. + opts: Options, + + /// The underlying MTLBuffer object. + buffer: Retained>, + + /// The allocated length of the buffer. Note that this is the number + /// of `T`s not the size in bytes. + pub len: usize, + + _marker: PhantomData T>, +} + +// SAFETY: an MTLBuffer is safe to use from any thread; CPU writes through +// `contents` are serialized by `&mut self` and by the swap chain, which never +// hands a buffer to the CPU while the GPU is using it. +unsafe impl Send for Buffer {} +// SAFETY: see above; shared access only reads the handle. +unsafe impl Sync for Buffer {} + +impl Buffer { + /// The underlying MTLBuffer. + pub fn buffer(&self) -> &ProtocolObject { + &self.buffer + } + + /// A retained handle to the underlying MTLBuffer (what render pass steps + /// bind). + pub fn raw(&self) -> Retained> { + self.buffer.clone() + } + + /// Initialize a buffer with the given length pre-allocated. + pub fn init(opts: Options, len: usize) -> Result { + let buffer = opts + .device + .newBufferWithLength_options(len * size_of::(), opts.mtl_options()) + .ok_or(Error::MetalFailed)?; + Ok(Buffer { + buffer, + opts, + len, + _marker: PhantomData, + }) + } + + /// Init the buffer filled with the given data. + pub fn init_fill(opts: Options, data: &[T]) -> Result { + let bytes: &[u8] = bytemuck::cast_slice(data); + // newBufferWithBytes rejects a null pointer / zero length, so empty + // data allocates an (empty) buffer of length 0 via the length path. + let buffer = if bytes.is_empty() { + opts.device + .newBufferWithLength_options(0, opts.mtl_options()) + } else { + let ptr = std::ptr::NonNull::from(bytes).cast(); + // SAFETY: `ptr` points to `bytes.len()` readable bytes. + unsafe { + opts.device + .newBufferWithBytes_length_options(ptr, bytes.len(), opts.mtl_options()) + } + } + .ok_or(Error::MetalFailed)?; + Ok(Buffer { + buffer, + opts, + len: data.len(), + _marker: PhantomData, + }) + } + + /// Sync new contents to the buffer. The data is expected to be the + /// complete contents of the buffer. If the amount of data is larger + /// than the buffer length, the buffer will be reallocated. + /// + /// If the amount of data is smaller than the buffer length, the + /// remaining data in the buffer is left untouched. + pub fn sync(&mut self, data: &[T]) -> Result<(), Error> { + self.sync_from_slices(&[data]).map(|_| ()) + } + + /// Like `Buffer::sync` but takes data from an array of slices, rather + /// than a single slice. Returns the number of items synced. + pub fn sync_from_slices(&mut self, lists: &[&[T]]) -> Result { + let total_len: usize = lists.iter().map(|l| l.len()).sum(); + + // If we need more bytes than our buffer has, we need to reallocate. + let req_bytes = total_len * size_of::(); + let avail_bytes = self.buffer.length(); + if req_bytes > avail_bytes { + // Allocate a new buffer with enough to hold double what we + // require (the old buffer is released when it is replaced). + // + // NOTE: Zig multiplies this size by `@sizeOf(T)` a second time, + // over-allocating; we allocate exactly double. + let size = req_bytes * 2; + self.buffer = self + .opts + .device + .newBufferWithLength_options(size, self.opts.mtl_options()) + .ok_or(Error::MetalFailed)?; + self.len = total_len * 2; + } + + // We can fit within the buffer so we can just replace bytes. + let dst = self.buffer.contents().as_ptr().cast::(); + let mut offset = 0; + for list in lists { + let src: &[u8] = bytemuck::cast_slice(list); + // SAFETY: `dst` points to a live buffer of at least `req_bytes` + // bytes (checked/reallocated above) and `offset + src.len()` never + // exceeds `req_bytes`; the regions can't overlap because the + // buffer is GPU memory owned by us. + unsafe { std::ptr::copy_nonoverlapping(src.as_ptr(), dst.add(offset), src.len()) }; + offset += src.len(); + } + + // If we're using the managed resource storage mode, then + // we need to signal Metal to synchronize the buffer data. + // + // Ref: https://developer.apple.com/documentation/metal/synchronizing-a-managed-resource-in-macos?language=objc + if self.opts.resource_options.storage_mode == StorageMode::Managed { + self.buffer.didModifyRange(NSRange::new(0, req_bytes)); + } + + Ok(total_len) + } +} + +impl GpuBuffer for Buffer { + fn raw(&self) -> Retained> { + Buffer::raw(self) + } + + fn sync(&mut self, data: &[T]) -> Result<(), ApiError> { + Buffer::sync(self, data).map_err(ApiError::new) + } + + fn sync_from_slices(&mut self, lists: &[&[T]]) -> Result { + Buffer::sync_from_slices(self, lists).map_err(ApiError::new) + } +} diff --git a/crates/ghostty-renderer/src/metal/frame.rs b/crates/ghostty-renderer/src/metal/frame.rs new file mode 100644 index 0000000..e9d5580 --- /dev/null +++ b/crates/ghostty-renderer/src/metal/frame.rs @@ -0,0 +1,143 @@ +//! Wrapper for handling render frames (`metal/Frame.zig`). +use std::{ptr::NonNull, sync::Arc}; + +use block2::RcBlock; +use objc2::{rc::Retained, runtime::ProtocolObject}; +use objc2_metal::{MTLCommandBuffer, MTLCommandBufferStatus, MTLCommandQueue}; + +use super::{IOSurfaceLayer, Metal, Queue, render_pass::RenderPass, target::Target}; +use crate::{ + backend::Health, + graphics_api::{Attachment, FrameCompletion, GpuFrame}, +}; + +/// A frame being encoded: one MTLCommandBuffer. +pub struct Frame { + /// MTLCommandBuffer (`None` if the queue failed to create one, in which + /// case the frame is a no-op that completes unhealthy). + buffer: Option>>, + + /// The layer the target is presented on. + layer: IOSurfaceLayer, + + /// The target is presented via the layer when completed. + target: Arc, +} + +impl Frame { + /// Begin encoding a frame. + pub fn begin(queue: &Queue, layer: IOSurfaceLayer, target: Arc) -> Frame { + let buffer = queue.commandBuffer(); + if buffer.is_none() { + log::warn!("unable to create a Metal command buffer"); + } + Frame { + buffer, + layer, + target, + } + } + + /// Add a render pass to this frame with the provided attachments. + /// Returns a RenderPass which allows render steps to be added. + pub fn render_pass(&self, attachments: &[Attachment<'_, Metal>]) -> RenderPass { + RenderPass::begin(self.buffer.as_deref(), attachments) + } + + /// Complete this frame and present the target. + /// + /// If `sync` is true, this will block until the frame is presented. + pub fn complete(self, sync: bool, completion: FrameCompletion) { + let Frame { + buffer, + layer, + target, + } = self; + let Some(buffer) = buffer else { + completion(Health::Unhealthy); + return; + }; + + // What happens when the command buffer has finished (`bufferCompleted`). + let finish = move |buffer: &ProtocolObject, sync: bool| { + // Get our command buffer status to pass back to the generic renderer. + let health = health_for_status(buffer.status()); + + // If the frame is healthy, present it. + if health == Health::Healthy { + if sync { + layer.set_surface_sync(target.surface()); + } else { + layer.set_surface(target.surface()); + } + } + + completion(health); + }; + + if sync { + // If we need to complete synchronously, we wait until the buffer + // is completed and invoke the callback directly. + buffer.commit(); + buffer.waitUntilCompleted(); + finish(&buffer, true); + } else { + // Otherwise we add our block as a completion handler. It is + // copied when we add the handler, and then the copy is deallocated + // by the objc runtime on success. + let block = RcBlock::new( + move |buffer: NonNull>| { + // SAFETY: Metal passes the (live) command buffer. + finish(unsafe { buffer.as_ref() }, false); + }, + ); + // SAFETY: the block is a valid completion handler and is copied + // by `addCompletedHandler`. + unsafe { buffer.addCompletedHandler(RcBlock::as_ptr(&block)) }; + buffer.commit(); + } + } +} + +/// The health of a frame from its command buffer status. +pub fn health_for_status(status: MTLCommandBufferStatus) -> Health { + if status == MTLCommandBufferStatus::Error { + Health::Unhealthy + } else { + Health::Healthy + } +} + +impl GpuFrame for Frame { + fn render_pass<'a>(&'a self, attachments: &'a [Attachment<'a, Metal>]) -> RenderPass + where + Metal: 'a, + { + Frame::render_pass(self, attachments) + } + + fn complete(self, sync: bool, completion: FrameCompletion) { + Frame::complete(self, sync, completion); + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn error_status_is_unhealthy() { + assert_eq!( + health_for_status(MTLCommandBufferStatus::Error), + Health::Unhealthy + ); + assert_eq!( + health_for_status(MTLCommandBufferStatus::Completed), + Health::Healthy + ); + assert_eq!( + health_for_status(MTLCommandBufferStatus::NotEnqueued), + Health::Healthy + ); + } +} diff --git a/crates/ghostty-renderer/src/metal/io_surface_layer.rs b/crates/ghostty-renderer/src/metal/io_surface_layer.rs new file mode 100644 index 0000000..b9b113c --- /dev/null +++ b/crates/ghostty-renderer/src/metal/io_surface_layer.rs @@ -0,0 +1,193 @@ +//! A wrapper around a CALayer with a utility method for settings its +//! `contents` to an IOSurface (`metal/IOSurfaceLayer.zig`). +use std::sync::{Arc, Mutex, PoisonError}; + +use dispatch2::DispatchQueue; +use objc2::{ + AnyThread, + DefinedClass, + MainThreadMarker, + define_class, + msg_send, + rc::Retained, + runtime::AnyObject, +}; +use objc2_core_foundation::CFRetained; +use objc2_foundation::{NSNull, NSString}; +use objc2_io_surface::IOSurfaceRef; +use objc2_quartz_core::{CALayer, kCAGravityTopLeft}; + +use super::Error; + +/// The callback invoked when the layer needs to `display`. +pub type DisplayCallback = Arc; + +/// Instance variables of the layer subclass (`display_cb` / `display_ctx`). +#[derive(Default)] +pub struct Ivars { + display_cb: Mutex>, +} + +define_class!( + /// We subclass CALayer with a custom display handler. + // SAFETY: + // - The superclass CALayer has no subclassing requirements we violate. + // - The class does not implement `Drop`. + #[unsafe(super(CALayer))] + #[name = "IOSurfaceLayer"] + #[ivars = Ivars] + pub struct IOSurfaceLayerClass; + + impl IOSurfaceLayerClass { + #[unsafe(method(display))] + fn display(&self) { + // Clone the callback out so it isn't invoked with the lock held. + let cb = self + .ivars() + .display_cb + .lock() + .unwrap_or_else(PoisonError::into_inner) + .clone(); + if let Some(cb) = cb { + cb(); + } + } + + /// Disable all animations for this layer by returning null for all + /// actions. + #[unsafe(method_id(actionForKey:))] + fn action_for_key(&self, _key: &NSString) -> Option> { + Some(NSNull::null()) + } + } +); + +impl IOSurfaceLayerClass { + fn create() -> Retained { + let this = Self::alloc().set_ivars(Ivars::default()); + // SAFETY: `init` is the designated initializer of the superclass. + unsafe { msg_send![super(this), init] } + } +} + +/// Wrapper to move an Objective-C / CoreFoundation object to another thread. +struct SendWrapper(T); +// SAFETY: used only for the layer and IOSurface, which are reference counted +// thread-safe objects when messaged on the main thread (we only message the +// layer on the main queue). +unsafe impl Send for SendWrapper {} + +/// A CALayer which displays IOSurfaces. +#[derive(Clone)] +pub struct IOSurfaceLayer { + /// The underlying CALayer + layer: Retained, +} + +// SAFETY: the layer is only messaged from the render / main threads through +// thread-safe CALayer properties (`contents`, `bounds`, `contentsScale`), the +// same way Ghostty (Zig) shares it between the renderer and the main thread. +unsafe impl Send for IOSurfaceLayer {} +// SAFETY: see above. +unsafe impl Sync for IOSurfaceLayer {} + +impl IOSurfaceLayer { + pub fn new() -> Result { + let layer = IOSurfaceLayerClass::create(); + + // The layer gravity is set to top-left so that the contents aren't + // stretched during resize operations before a new frame has been drawn. + // + // SAFETY: a constant NSString exported by QuartzCore. + layer.setContentsGravity(unsafe { kCAGravityTopLeft }); + + Ok(IOSurfaceLayer { layer }) + } + + /// The underlying CALayer. + pub fn layer(&self) -> &CALayer { + &self.layer + } + + /// The layer's bounds size and contents scale. + pub fn bounds_and_scale(&self) -> (f64, f64, f64) { + let bounds = self.layer.bounds(); + ( + bounds.size.width, + bounds.size.height, + self.layer.contentsScale(), + ) + } + + /// Sets the layer's `contents` to the provided IOSurface. + /// + /// Makes sure to do so on the main thread to avoid visual artifacts. + pub fn set_surface(&self, surface: &CFRetained) { + // We retain the surface (and the layer) to make sure they're not + // released before we can set it as the contents of the layer; both + // are released when the closure is dropped. + let layer = SendWrapper(self.layer.clone()); + let surface = SendWrapper(surface.clone()); + + // We check if we're on the main thread and run the work directly if so. + if MainThreadMarker::new().is_some() { + set_surface_callback(&layer.0, &surface.0); + } else { + DispatchQueue::main().exec_async(move || { + let layer = layer; + let surface = surface; + set_surface_callback(&layer.0, &surface.0); + }); + } + } + + /// Sets the layer's `contents` to the provided IOSurface. + /// + /// Does not ensure this happens on the main thread. + pub fn set_surface_sync(&self, surface: &CFRetained) { + let contents: &AnyObject = surface_as_object(surface); + // SAFETY: an IOSurface is a valid value for `contents`. + unsafe { self.layer.setContents(Some(contents)) }; + } + + /// Set (or clear) the callback invoked from the layer's `display` method. + pub fn set_display_callback(&self, cb: Option) { + *self + .layer + .ivars() + .display_cb + .lock() + .unwrap_or_else(PoisonError::into_inner) = cb; + } +} + +/// View an IOSurface as an Objective-C object (they are toll-free bridged). +fn surface_as_object(surface: &CFRetained) -> &AnyObject { + let cf: &objc2_core_foundation::CFType = surface; + cf.as_ref() +} + +fn set_surface_callback(layer: &IOSurfaceLayerClass, surface: &CFRetained) { + // We check to see if the surface is the appropriate size for + // the layer, if it's not then we discard it. This is because + // asynchronously drawn frames can sometimes finish just after + // a synchronously drawn frame during a resize, and if we don't + // discard the improperly sized surface it creates jank. + let bounds = layer.bounds(); + let scale = layer.contentsScale(); + let width = (bounds.size.width * scale) as usize; + let height = (bounds.size.height * scale) as usize; + if width != surface.width() || height != surface.height() { + log::debug!( + "setSurfaceCallback(): surface is wrong size for layer, discarding. surface = {}x{}, layer = {}x{}", + surface.width(), + surface.height(), + width, + height, + ); + return; + } + + // SAFETY: an IOSurface is a valid value for `contents`. + unsafe { layer.setContents(Some(surface_as_object(surface))) }; +} diff --git a/crates/ghostty-renderer/src/metal/mod.rs b/crates/ghostty-renderer/src/metal/mod.rs new file mode 100644 index 0000000..66890f5 --- /dev/null +++ b/crates/ghostty-renderer/src/metal/mod.rs @@ -0,0 +1,522 @@ +//! Graphics API wrapper for Metal (`renderer/Metal.zig` and `renderer/metal/*`). +//! +//! The renderer draws into IOSurface-backed textures ([`target::Target`]) and +//! presents them by setting the `contents` of an [`IOSurfaceLayer`], which is +//! installed on the apprt's NSView (macOS: made "layer-hosting") or UIView +//! (iOS: added as a sublayer). +//! +//! The platform-independent parts (enum values, GPU data layouts, pixel +//! format selection, vertex attribute tables) live in +//! [`crate::metal_layout`] so they can be unit tested on any host. +//! +//! Safety: this module talks to Metal / QuartzCore through `objc2`, which is +//! mostly safe; `unsafe` is limited to encoder calls that take raw resources, +//! raw pointer copies and the autorelease pool, each with a SAFETY comment. + +use std::{ffi::c_void, sync::Arc}; + +use bytemuck::Pod; +use ghostty_config::types_b::AlphaBlending; +use ghostty_font::atlas::Atlas; +use objc2::{ + msg_send, + rc::Retained, + runtime::{AnyObject, ProtocolObject}, + sel, +}; +use objc2_foundation::NSError; +use objc2_metal::{ + MTLBuffer, + MTLCommandQueue, + MTLDevice, + MTLGPUFamily, + MTLSamplerAddressMode, + MTLSamplerMinMagFilter, +}; + +pub mod buffer; +pub mod frame; +pub mod io_surface_layer; +pub mod pipeline; +pub mod render_pass; +pub mod sampler; +pub mod shaders; +pub mod target; +pub mod texture; + +pub use self::{ + buffer::Buffer, + frame::Frame, + io_surface_layer::IOSurfaceLayer, + pipeline::Pipeline, + render_pass::RenderPass, + sampler::Sampler, + shaders::Shaders, + target::Target, + texture::Texture, +}; +use crate::{ + graphics_api::{ApiError, GraphicsApi, ImageTextureFormat, TextureKind}, + metal_layout::{ + MetalUniforms, + PixelFormat, + ResourceOptions, + StorageMode, + TextureUsage, + atlas_pixel_format, + color_pixel_format, + default_storage_mode, + image_pixel_format, + max_texture_size, + surface_size, + }, + shader_types::Uniforms, + shadertoy, +}; + +/// A `MTLDevice`. +pub type Device = Retained>; +/// A `MTLCommandQueue`. +pub type Queue = Retained>; + +/// Custom shaders are converted to MSL for Metal. +pub const CUSTOM_SHADER_TARGET: shadertoy::Target = shadertoy::Target::Msl; +/// The fragCoord for Metal shaders is +Y = down. +pub const CUSTOM_SHADER_Y_IS_DOWN: bool = true; +/// Triple buffering. +pub const SWAP_CHAIN_COUNT: usize = 3; + +#[derive(Debug, thiserror::Error)] +pub enum Error { + /// A Metal API call failed. + #[error("Metal API call failed")] + MetalFailed, + /// No usable Metal device exists. + #[error("no Metal device")] + NoMetalDevice, + /// An Objective-C runtime operation failed. + #[error("Objective-C runtime failure")] + ObjCFailed, + /// A shader function was not found in its library. + #[error("shader function `{0}` not found")] + MissingFunction(String), + /// Metal reported an error (shader compilation, pipeline creation, ...). + #[error("metal error: {0}")] + Message(String), +} + +impl Error { + /// Log and convert an `NSError` (`checkError`). + pub(crate) fn from_ns_error(err: &NSError) -> Error { + let desc = err.localizedDescription().to_string(); + log::error!("metal error={desc}"); + Error::Message(desc) + } +} + +/// The NSView (macOS) / UIView (iOS) the renderer draws in, plus its content +/// scale (Zig: `rt_surface.platform` and `rt_surface.content_scale.x`). +pub struct ViewInfo<'a> { + /// The NSView / UIView. + pub view: &'a AnyObject, + /// The scale factor of the window (backing scale factor). + pub scale_factor: f64, +} + +/// An autorelease pool token (`objc_autoreleasePoolPush`). +struct PoolToken(*mut c_void); + +// SAFETY: the token is only used from the thread that pushed it (draw_frame +// starts and ends the pool on one thread); `Send` is needed because the +// graphics API lives inside the renderer's mutex. +unsafe impl Send for PoolToken {} + +unsafe extern "C" { + fn objc_autoreleasePoolPush() -> *mut c_void; + fn objc_autoreleasePoolPop(pool: *mut c_void); +} + +/// The Metal graphics API. +pub struct Metal { + layer: IOSurfaceLayer, + + /// MTLDevice + device: Device, + /// MTLCommandQueue + queue: Queue, + + /// Alpha blending mode + blending: AlphaBlending, + + /// The default storage mode to use for resources created with our device. + /// + /// This is based on whether the device is a discrete GPU or not, since + /// discrete GPUs do not have unified memory and therefore do not support + /// the "shared" storage mode, instead we have to use the "managed" mode. + default_storage_mode: StorageMode, + + /// The maximum 2D texture width and height supported by the device. + max_texture_size: u32, + + /// We start an AutoreleasePool before `drawFrame` and end it afterwards. + autorelease_pool: Option, +} + +impl Metal { + /// Create the Metal backend and attach its layer to `view`. + /// + /// Must be called on the thread that owns the view (the main thread). + pub fn init(blending: AlphaBlending, info: &ViewInfo<'_>) -> Result { + // Choose our MTLDevice and create a MTLCommandQueue for that device. + let device = choose_device()?; + let queue = device.newCommandQueue().ok_or(Error::MetalFailed)?; + + // Grab metadata about the device. + let has_unified_memory = device.hasUnifiedMemory(); + let default_storage_mode = + default_storage_mode(cfg!(target_os = "ios"), has_unified_memory); + let max_texture_size = query_max_texture_size(&device); + log::debug!( + "device properties default_storage_mode={default_storage_mode:?} max_texture_size={max_texture_size}" + ); + + // Create an IOSurfaceLayer which we can assign to the view to make + // it in to a "layer-hosting view", so that we can manually control + // the layer contents. + let layer = IOSurfaceLayer::new()?; + let layer_object: &AnyObject = layer.layer().as_ref(); + let view = info.view; + + // Add our layer to the view. + // + // On macOS we do this by making the view "layer-hosting" + // by assigning it to the view's `layer` property BEFORE + // setting `wantsLayer` to `true`. + // + // On iOS, views are always layer-backed, and `layer` + // is readonly, so instead we add it as a sublayer. + // + // SAFETY: `view` is an NSView / UIView (a documented requirement of + // `ViewInfo`), which implements these selectors with these types. + unsafe { + #[cfg(target_os = "macos")] + { + let _: () = msg_send![view, setLayer: layer_object]; + let _: () = msg_send![view, setWantsLayer: true]; + } + #[cfg(not(target_os = "macos"))] + { + let view_layer: Option> = msg_send![view, layer]; + let view_layer = view_layer.ok_or(Error::ObjCFailed)?; + let _: () = msg_send![&*view_layer, addSublayer: layer_object]; + } + + // Ensure that if our layer is oversized it does not overflow the + // bounds of the view. (`clipsToBounds` is NSView API since macOS + // 14; older systems don't have the setter.) + let responds: bool = msg_send![view, respondsToSelector: sel!(setClipsToBounds:)]; + if responds { + let _: () = msg_send![view, setClipsToBounds: true]; + } + } + + // Ensure that our layer has a content scale set to + // match the scale factor of the window. This avoids + // magnification issues leading to blurry rendering. + layer.layer().setContentsScale(info.scale_factor); + + // This makes it so that our display callback will actually be called. + layer.layer().setNeedsDisplayOnBoundsChange(true); + + Ok(Metal { + layer, + device, + queue, + blending, + default_storage_mode, + max_texture_size, + autorelease_pool: None, + }) + } + + /// The layer the renderer presents on. + pub fn layer(&self) -> &IOSurfaceLayer { + &self.layer + } + + /// The device the renderer draws with. + pub fn device(&self) -> &Device { + &self.device + } + + /// The default storage mode of resources created with this device. + pub fn default_storage_mode(&self) -> StorageMode { + self.default_storage_mode + } + + /// The maximum 2D texture size of the device. + pub fn max_texture_size(&self) -> u32 { + self.max_texture_size + } + + /// The pixel format of targets, custom shader textures and pipelines. + fn color_format(&self) -> PixelFormat { + color_pixel_format(self.blending) + } + + /// Returns the options to use when constructing buffers. + pub fn buffer_options(&self) -> buffer::Options { + buffer::Options { + device: self.device.clone(), + // Indicate that the CPU writes to this resource but never reads it. + resource_options: ResourceOptions::write_combined(self.default_storage_mode), + } + } + + /// Returns the options to use when constructing textures. + pub fn texture_options(&self) -> texture::Options { + texture::Options { + device: self.device.clone(), + // Using an `*_srgb` pixel format makes Metal gamma encode the + // pixels written to it *after* blending, which means we get linear + // alpha blending rather than gamma-incorrect blending. + pixel_format: self.color_format(), + resource_options: ResourceOptions::write_combined(self.default_storage_mode), + // textureOptions is currently only used for custom shaders. + usage: TextureUsage::custom_shader(), + } + } + + /// Returns the options to use when constructing samplers. + pub fn sampler_options(&self) -> sampler::Options { + sampler::Options { + device: self.device.clone(), + + // These parameters match Shadertoy behaviors. + min_filter: MTLSamplerMinMagFilter::Linear, + mag_filter: MTLSamplerMinMagFilter::Linear, + s_address_mode: MTLSamplerAddressMode::ClampToEdge, + t_address_mode: MTLSamplerAddressMode::ClampToEdge, + } + } + + /// Returns the options to use when constructing textures for images. + pub fn image_texture_options( + &self, + format: ImageTextureFormat, + srgb: bool, + ) -> texture::Options { + texture::Options { + device: self.device.clone(), + pixel_format: image_pixel_format(format, srgb), + resource_options: ResourceOptions::write_combined(self.default_storage_mode), + // We only need to read from this texture from a shader. + usage: TextureUsage::shader_read_only(), + } + } + + /// Initialize a new render target which can be presented by this API. + pub fn init_target(&self, width: usize, height: usize) -> Result { + Target::init(&target::Options { + device: self.device.clone(), + // Using an `*_srgb` pixel format makes Metal gamma encode the + // pixels written to it *after* blending, which means we get linear + // alpha blending rather than gamma-incorrect blending. + pixel_format: self.color_format(), + storage_mode: self.default_storage_mode, + width, + height, + }) + } + + /// Initializes a Texture suitable for the provided font atlas. + pub fn init_atlas_texture(&self, atlas: &Atlas) -> Result { + let pixel_format = + atlas_pixel_format(atlas.format).expect("unsupported atlas format for Metal texture"); + + Texture::init( + &texture::Options { + device: self.device.clone(), + pixel_format, + resource_options: ResourceOptions::write_combined(self.default_storage_mode), + // We only need to read from this texture from a shader. + usage: TextureUsage::shader_read_only(), + }, + atlas.size as usize, + atlas.size as usize, + None, + ) + } + + /// Get the current size of the runtime surface. + pub fn surface_size(&self) -> (u32, u32) { + let (width, height, scale) = self.layer.bounds_and_scale(); + + // We need to clamp our runtime surface size to the maximum + // possible texture size since we can't create a screen buffer + // (texture) larger than that. + surface_size(width, height, scale, self.max_texture_size) + } +} + +impl Drop for Metal { + fn drop(&mut self) { + // Nothing may call back into a renderer that is going away. + self.layer.set_display_callback(None); + if let Some(pool) = self.autorelease_pool.take() { + // SAFETY: the pool was pushed by `draw_frame_start` on this thread. + unsafe { objc_autoreleasePoolPop(pool.0) }; + } + } +} + +/// Determines the maximum 2D texture size supported by the device. +/// We need to clamp our frame size to this if it's larger. +fn query_max_texture_size(device: &ProtocolObject) -> u32 { + // https://developer.apple.com/metal/Metal-Feature-Set-Tables.pdf + max_texture_size( + device.supportsFamily(MTLGPUFamily::Apple10), + device.supportsFamily(MTLGPUFamily::Apple3), + ) +} + +#[cfg(target_os = "macos")] +fn choose_device() -> Result { + let devices = objc2_metal::MTLCopyAllDevices(); + + let mut chosen_device: Option = None; + for device in &devices { + // We want a GPU thatโ€™s connected to a display. + if device.isHeadless() { + continue; + } + chosen_device = Some(device.clone()); + // If the user has an eGPU plugged in, they probably want + // to use it. Otherwise, integrated GPUs are better for + // battery life and thermals. + if device.isRemovable() || device.isLowPower() { + break; + } + } + + chosen_device.ok_or(Error::NoMetalDevice) +} + +#[cfg(not(target_os = "macos"))] +fn choose_device() -> Result { + objc2_metal::MTLCreateSystemDefaultDevice().ok_or(Error::NoMetalDevice) +} + +impl GraphicsApi for Metal { + const CUSTOM_SHADER_TARGET: shadertoy::Target = CUSTOM_SHADER_TARGET; + const CUSTOM_SHADER_Y_IS_DOWN: bool = CUSTOM_SHADER_Y_IS_DOWN; + const SWAP_CHAIN_COUNT: usize = SWAP_CHAIN_COUNT; + + type Target = Target; + type Texture = Texture; + type Sampler = Sampler; + type Buffer = Buffer; + type BufferRef = Retained>; + type Pipeline = Pipeline; + type Shaders = Shaders; + type GpuUniforms = MetalUniforms; + type Frame = Frame; + type RenderPass<'a> = RenderPass; + + fn gpu_uniforms(uniforms: &Uniforms) -> MetalUniforms { + MetalUniforms::from(uniforms) + } + + fn blending(&self) -> AlphaBlending { + self.blending + } + + fn set_blending(&mut self, blending: AlphaBlending) { + self.blending = blending; + } + + fn init_shaders(&self, custom_shaders: &[String]) -> Result { + Shaders::init( + &self.device, + custom_shaders, + // Using an `*_srgb` pixel format makes Metal gamma encode the + // pixels written to it *after* blending, which means we get linear + // alpha blending rather than gamma-incorrect blending. + self.color_format(), + ) + .map_err(ApiError::new) + } + + fn surface_size(&self) -> Result<(u32, u32), ApiError> { + Ok(Metal::surface_size(self)) + } + + fn init_target(&self, width: usize, height: usize) -> Result { + Metal::init_target(self, width, height).map_err(ApiError::new) + } + + fn init_buffer(&self, len: usize) -> Result, ApiError> { + Buffer::init(self.buffer_options(), len).map_err(ApiError::new) + } + + fn init_buffer_fill(&self, data: &[T]) -> Result, ApiError> { + Buffer::init_fill(self.buffer_options(), data).map_err(ApiError::new) + } + + fn init_texture( + &self, + kind: TextureKind, + width: usize, + height: usize, + data: Option<&[u8]>, + ) -> Result { + let opts = match kind { + TextureKind::CustomShader => self.texture_options(), + TextureKind::Image { format, srgb } => self.image_texture_options(format, srgb), + }; + Texture::init(&opts, width, height, data).map_err(ApiError::new) + } + + fn init_atlas_texture(&self, atlas: &Atlas) -> Result { + Metal::init_atlas_texture(self, atlas).map_err(ApiError::new) + } + + fn init_sampler(&self) -> Result { + Sampler::init(&self.sampler_options()).map_err(ApiError::new) + } + + fn begin_frame(&self, target: Arc) -> Frame { + Frame::begin(&self.queue, self.layer.clone(), target) + } + + /// Present the last presented target again. (noop for Metal) + fn present_last_target(&self) -> Result<(), ApiError> { + Ok(()) + } + + fn clear_last_target(&self) {} + + /// Actions taken before doing anything in `drawFrame`. + /// + /// Right now we use this to start an AutoreleasePool. + fn draw_frame_start(&mut self) { + debug_assert!(self.autorelease_pool.is_none()); + // SAFETY: balanced with the pop in `draw_frame_end` / `Drop`. + self.autorelease_pool = Some(PoolToken(unsafe { objc_autoreleasePoolPush() })); + } + + /// Actions taken after `drawFrame` is done. + /// + /// Right now we use this to end our AutoreleasePool. + fn draw_frame_end(&mut self) { + debug_assert!(self.autorelease_pool.is_some()); + if let Some(pool) = self.autorelease_pool.take() { + // SAFETY: the pool was pushed by `draw_frame_start` on this thread. + unsafe { objc_autoreleasePoolPop(pool.0) }; + } + } + + fn loop_enter(&mut self, draw: Arc) { + self.layer.set_display_callback(Some(draw)); + } +} diff --git a/crates/ghostty-renderer/src/metal/pipeline.rs b/crates/ghostty-renderer/src/metal/pipeline.rs new file mode 100644 index 0000000..cf40c5d --- /dev/null +++ b/crates/ghostty-renderer/src/metal/pipeline.rs @@ -0,0 +1,168 @@ +//! Wrapper for handling render pipelines (`metal/Pipeline.zig`). +use objc2::{rc::Retained, runtime::ProtocolObject}; +use objc2_foundation::NSString; +use objc2_metal::{ + MTLBlendFactor, + MTLBlendOperation, + MTLDevice as _, + MTLLibrary, + MTLPixelFormat, + MTLRenderPipelineDescriptor, + MTLRenderPipelineState, + MTLVertexDescriptor, + MTLVertexFormat, + MTLVertexStepFunction, +}; + +use super::{Device, Error}; +use crate::metal_layout::{ + MetalVertex, + PREMULTIPLIED_ALPHA, + PixelFormat, + VertexAttribute, + VertexStepFunction, +}; + +/// Describes a color attachment. +#[derive(Debug, Clone, Copy)] +pub struct Attachment { + pub pixel_format: PixelFormat, + pub blending_enabled: bool, +} + +/// Options for initializing a render pipeline. +pub struct Options<'a> { + /// MTLDevice + pub device: &'a Device, + + /// Name of the vertex function + pub vertex_fn: &'a str, + /// Name of the fragment function + pub fragment_fn: &'a str, + + /// MTLLibrary to get the vertex function from + pub vertex_library: &'a ProtocolObject, + /// MTLLibrary to get the fragment function from + pub fragment_library: &'a ProtocolObject, + + /// Vertex step function + pub step_fn: VertexStepFunction, + + /// Info about the color attachments used by this render pipeline. + pub attachments: &'a [Attachment], +} + +/// A render pipeline state. +pub struct Pipeline { + /// MTLRenderPipelineState + state: Retained>, +} + +// SAFETY: MTLRenderPipelineState objects are immutable and thread-safe. +unsafe impl Send for Pipeline {} +// SAFETY: see above. +unsafe impl Sync for Pipeline {} + +impl Pipeline { + /// The underlying MTLRenderPipelineState. + pub fn state(&self) -> &ProtocolObject { + &self.state + } + + /// Create a pipeline whose vertex attributes are those of `V`. + pub fn init(opts: &Options<'_>) -> Result { + Self::init_impl(Some((V::ATTRIBUTES, size_of::())), opts) + } + + /// Create a pipeline without a vertex descriptor. + pub fn init_no_attributes(opts: &Options<'_>) -> Result { + Self::init_impl(None, opts) + } + + fn init_impl( + vertex_attributes: Option<(&[VertexAttribute], usize)>, + opts: &Options<'_>, + ) -> Result { + // Create our descriptor + let desc = MTLRenderPipelineDescriptor::new(); + + // Get our vertex and fragment functions and add them to the descriptor. + { + let name = NSString::from_str(opts.vertex_fn); + let func_vert = opts + .vertex_library + .newFunctionWithName(&name) + .ok_or_else(|| Error::MissingFunction(opts.vertex_fn.to_owned()))?; + desc.setVertexFunction(Some(&func_vert)); + } + { + let name = NSString::from_str(opts.fragment_fn); + let func_frag = opts + .fragment_library + .newFunctionWithName(&name) + .ok_or_else(|| Error::MissingFunction(opts.fragment_fn.to_owned()))?; + desc.setFragmentFunction(Some(&func_frag)); + } + + // If we have vertex attributes, create and add a vertex descriptor. + if let Some((attributes, stride)) = vertex_attributes { + let vertex_desc = MTLVertexDescriptor::new(); + + // Our attributes are the fields of the input + let attrs = vertex_desc.attributes(); + for (i, a) in attributes.iter().enumerate() { + // SAFETY: indexing the attribute array with a valid index + // (the descriptor array has 31 slots); offsets and buffer + // index are plain numbers validated by pipeline creation. + unsafe { + let attr = attrs.objectAtIndexedSubscript(i); + attr.setFormat(MTLVertexFormat(a.format as usize)); + attr.setOffset(a.offset); + attr.setBufferIndex(0); + } + } + + // The layout describes how and when we fetch the next vertex input. + let layouts = vertex_desc.layouts(); + // SAFETY: buffer layout 0 exists; the stride is a plain number. + unsafe { + let layout = layouts.objectAtIndexedSubscript(0); + layout.setStepFunction(MTLVertexStepFunction(opts.step_fn as usize)); + layout.setStride(stride); + } + + desc.setVertexDescriptor(Some(&vertex_desc)); + } + + // Set our color attachment + let attachments = desc.colorAttachments(); + for (i, at) in opts.attachments.iter().enumerate() { + // SAFETY: color attachment indexes are validated by Metal; the + // pipelines use a single attachment. + let attachment = unsafe { attachments.objectAtIndexedSubscript(i) }; + + attachment.setPixelFormat(MTLPixelFormat(at.pixel_format as usize)); + + attachment.setBlendingEnabled(at.blending_enabled); + // We always use premultiplied alpha blending for now. + if at.blending_enabled { + let b = PREMULTIPLIED_ALPHA; + attachment.setRgbBlendOperation(MTLBlendOperation(b.rgb_operation as usize)); + attachment.setAlphaBlendOperation(MTLBlendOperation(b.alpha_operation as usize)); + attachment.setSourceRGBBlendFactor(MTLBlendFactor(b.source_rgb as usize)); + attachment.setSourceAlphaBlendFactor(MTLBlendFactor(b.source_alpha as usize)); + attachment.setDestinationRGBBlendFactor(MTLBlendFactor(b.destination_rgb as usize)); + attachment + .setDestinationAlphaBlendFactor(MTLBlendFactor(b.destination_alpha as usize)); + } + } + + // Make our state + let state = opts + .device + .newRenderPipelineStateWithDescriptor_error(&desc) + .map_err(|err| Error::from_ns_error(&err))?; + + Ok(Pipeline { state }) + } +} diff --git a/crates/ghostty-renderer/src/metal/render_pass.rs b/crates/ghostty-renderer/src/metal/render_pass.rs new file mode 100644 index 0000000..542ad00 --- /dev/null +++ b/crates/ghostty-renderer/src/metal/render_pass.rs @@ -0,0 +1,198 @@ +//! Wrapper for handling render passes (`metal/RenderPass.zig`). +use objc2::{rc::Retained, runtime::ProtocolObject}; +use objc2_metal::{ + MTLClearColor, + MTLCommandBuffer, + MTLCommandEncoder as _, + MTLLoadAction, + MTLPrimitiveType, + MTLRenderCommandEncoder, + MTLRenderPassDescriptor, + MTLStoreAction, +}; + +use super::Metal; +use crate::{ + graphics_api::{Attachment, AttachmentTarget, GpuRenderPass, Primitive, Step}, + metal_layout::{UNIFORMS_BUFFER_INDEX, buffer_index}, +}; + +/// The `MTLPrimitiveType` of a [`Primitive`]. +fn primitive_type(p: Primitive) -> MTLPrimitiveType { + match p { + Primitive::Point => MTLPrimitiveType::Point, + Primitive::Line => MTLPrimitiveType::Line, + Primitive::LineStrip => MTLPrimitiveType::LineStrip, + Primitive::Triangle => MTLPrimitiveType::Triangle, + Primitive::TriangleStrip => MTLPrimitiveType::TriangleStrip, + } +} + +/// A render pass being encoded. +pub struct RenderPass { + /// MTLRenderCommandEncoder (`None` if the encoder couldn't be created, in + /// which case every step is a no-op). + encoder: Option>>, +} + +impl RenderPass { + /// Begin a render pass. + pub fn begin( + command_buffer: Option<&ProtocolObject>, + attachments: &[Attachment<'_, Metal>], + ) -> RenderPass { + let Some(command_buffer) = command_buffer else { + return RenderPass { encoder: None }; + }; + // Create a pass descriptor + let desc = MTLRenderPassDescriptor::renderPassDescriptor(); + + // Set our color attachment to be our drawable surface. + let color_attachments = desc.colorAttachments(); + for (i, at) in attachments.iter().enumerate() { + // SAFETY: color attachment indexes are validated by Metal. + let attachment = unsafe { color_attachments.objectAtIndexedSubscript(i) }; + + attachment.setLoadAction(if at.clear_color.is_some() { + MTLLoadAction::Clear + } else { + MTLLoadAction::Load + }); + attachment.setStoreAction(MTLStoreAction::Store); + attachment.setTexture(Some(match &at.target { + AttachmentTarget::Texture(t) => t.texture(), + AttachmentTarget::Target(t) => t.texture(), + })); + if let Some(c) = at.clear_color { + attachment.setClearColor(MTLClearColor { + red: f64::from(c[0]), + green: f64::from(c[1]), + blue: f64::from(c[2]), + alpha: f64::from(c[3]), + }); + } + } + + // MTLRenderCommandEncoder + let encoder = command_buffer.renderCommandEncoderWithDescriptor(&desc); + if encoder.is_none() { + log::warn!("unable to create a Metal render command encoder"); + } + + RenderPass { encoder } + } + + /// Add a step to this render pass. + pub fn step(&self, s: &Step<'_, Metal>) { + let Some(encoder) = &self.encoder else { + return; + }; + if s.draw.instance_count == 0 { + return; + } + + // Set pipeline state + encoder.setRenderPipelineState(s.pipeline.state()); + + if !s.buffers.is_empty() { + // We set buffers for both stages, at index 0 for the vertex + // buffer (this isn't very flexible but it lines up with the API we + // have for OpenGL) and the rest starting at index 2, so that we + // can use index 1 for the uniforms if present. + // + // Also, we set buffers (and textures) for both stages. + // + // Again, not very flexible, but it's consistent and predictable, + // and we need to treat the uniforms as special because of OpenGL. + // + // TODO: Maybe in the future add info to the pipeline struct which + // allows it to define a mapping between provided buffers and + // what index they get set at for the vertex / fragment stage. + for (i, b) in s.buffers.iter().enumerate() { + if let Some(buf) = b { + // SAFETY: the buffer is a live MTLBuffer. + unsafe { + encoder.setVertexBuffer_offset_atIndex(Some(buf), 0, buffer_index(i)); + encoder.setFragmentBuffer_offset_atIndex(Some(buf), 0, buffer_index(i)); + } + } + } + } + + // Set the uniforms as buffer index 1 if present. + if let Some(buf) = &s.uniforms { + // SAFETY: the buffer is a live MTLBuffer. + unsafe { + encoder.setVertexBuffer_offset_atIndex(Some(buf), 0, UNIFORMS_BUFFER_INDEX); + encoder.setFragmentBuffer_offset_atIndex(Some(buf), 0, UNIFORMS_BUFFER_INDEX); + } + } + + // Set textures. + for (i, t) in s.textures.iter().enumerate() { + if let Some(tex) = t { + // SAFETY: the texture is a live MTLTexture. + unsafe { + encoder.setVertexTexture_atIndex(Some(tex.texture()), i); + encoder.setFragmentTexture_atIndex(Some(tex.texture()), i); + } + } + } + + // Set samplers. + for (i, samp) in s.samplers.iter().enumerate() { + if let Some(sampler) = samp { + // SAFETY: the sampler is a live MTLSamplerState. + unsafe { + encoder.setFragmentSamplerState_atIndex(Some(sampler.sampler()), i); + } + } + } + + // Draw! + // + // SAFETY: all resources the pipeline reads were bound above (the + // same contract as the Zig renderer; Metal validation reports misuse). + unsafe { + encoder.drawPrimitives_vertexStart_vertexCount_instanceCount( + primitive_type(s.draw.ty), + 0, + s.draw.vertex_count, + s.draw.instance_count, + ); + } + } + + /// Complete this render pass. + /// This struct can no longer be used after calling this. + pub fn complete(self) { + if let Some(encoder) = &self.encoder { + encoder.endEncoding(); + } + } +} + +impl GpuRenderPass<'_, Metal> for RenderPass { + fn step(&mut self, step: &Step<'_, Metal>) { + RenderPass::step(self, step); + } + + fn complete(self) { + RenderPass::complete(self); + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn primitive_mapping() { + // MTLPrimitiveType raw values (api.zig). + assert_eq!(primitive_type(Primitive::Point).0, 0); + assert_eq!(primitive_type(Primitive::Line).0, 1); + assert_eq!(primitive_type(Primitive::LineStrip).0, 2); + assert_eq!(primitive_type(Primitive::Triangle).0, 3); + assert_eq!(primitive_type(Primitive::TriangleStrip).0, 4); + } +} diff --git a/crates/ghostty-renderer/src/metal/sampler.rs b/crates/ghostty-renderer/src/metal/sampler.rs new file mode 100644 index 0000000..ee954e9 --- /dev/null +++ b/crates/ghostty-renderer/src/metal/sampler.rs @@ -0,0 +1,53 @@ +//! Wrapper for handling samplers (`metal/Sampler.zig`). +use objc2::{rc::Retained, runtime::ProtocolObject}; +use objc2_metal::{ + MTLDevice as _, + MTLSamplerAddressMode, + MTLSamplerDescriptor, + MTLSamplerMinMagFilter, + MTLSamplerState, +}; + +use super::{Device, Error}; + +/// Options for initializing a sampler. +#[derive(Clone)] +pub struct Options { + /// MTLDevice + pub device: Device, + pub min_filter: MTLSamplerMinMagFilter, + pub mag_filter: MTLSamplerMinMagFilter, + pub s_address_mode: MTLSamplerAddressMode, + pub t_address_mode: MTLSamplerAddressMode, +} + +/// A sampler state. +pub struct Sampler { + /// The underlying MTLSamplerState Object. + sampler: Retained>, +} + +impl Sampler { + /// The underlying MTLSamplerState. + pub fn sampler(&self) -> &ProtocolObject { + &self.sampler + } + + /// Initialize a sampler + pub fn init(opts: &Options) -> Result { + // Create our descriptor and set the properties + let desc = MTLSamplerDescriptor::new(); + desc.setMinFilter(opts.min_filter); + desc.setMagFilter(opts.mag_filter); + desc.setSAddressMode(opts.s_address_mode); + desc.setTAddressMode(opts.t_address_mode); + + // Create the sampler state + let sampler = opts + .device + .newSamplerStateWithDescriptor(&desc) + .ok_or(Error::MetalFailed)?; + + Ok(Sampler { sampler }) + } +} diff --git a/crates/ghostty-renderer/src/metal/shaders.rs b/crates/ghostty-renderer/src/metal/shaders.rs new file mode 100644 index 0000000..674a4e0 --- /dev/null +++ b/crates/ghostty-renderer/src/metal/shaders.rs @@ -0,0 +1,209 @@ +//! Shader pipelines and GPU data layouts for Metal (`metal/shaders.zig`). +//! +//! The CPU-side data layouts are shared with OpenGL +//! ([`crate::shader_types`]) except for the uniform block, see +//! [`crate::metal_layout::MetalUniforms`]. +use std::time::Instant; + +use objc2::{rc::Retained, runtime::ProtocolObject}; +use objc2_foundation::NSString; +use objc2_metal::{MTLDevice as _, MTLLibrary}; + +use super::{ + Device, + Error, + pipeline::{self, Attachment, Pipeline}, +}; +use crate::{ + graphics_api::GpuShaders, + metal_layout::{ + FULL_SCREEN_VERTEX_FN, + PIPELINE_DESCS, + POST_FRAGMENT_FN, + PipelineDesc, + PixelFormat, + VertexStepFunction, + }, + shader_types::{BgImage, CellText, Image as ImageVertex}, +}; + +/// The Metal shading language source of all built-in shaders +/// (`shaders/shaders.metal`). Ghostty compiles this to a `.metallib` at build +/// time with `xcrun metal`; we compile it with `newLibraryWithSource` when the +/// shaders are initialized. +pub const SHADERS_METAL: &str = include_str!("../../shaders/shaders.metal"); + +/// This contains the state for the shaders used by the Metal renderer. +pub struct Shaders { + library: Retained>, + + pub bg_color: Pipeline, + pub cell_bg: Pipeline, + pub cell_text: Pipeline, + pub image: Pipeline, + pub bg_image: Pipeline, + + /// Custom shaders to run against the final drawable texture. This + /// can be used to apply a lot of effects. Each shader is run in sequence + /// against the output of the previous shader. + pub post_pipelines: Vec, +} + +// SAFETY: MTLLibrary objects are thread-safe, and pipelines are immutable. +unsafe impl Send for Shaders {} +// SAFETY: see above. +unsafe impl Sync for Shaders {} + +impl Shaders { + /// Initialize our shader set. + /// + /// "post_shaders" is an optional list of postprocess shaders to run + /// against the final drawable texture. This is an array of shader source + /// code, not file paths. + pub fn init>( + device: &Device, + post_shaders: &[S], + pixel_format: PixelFormat, + ) -> Result { + let library = init_library(device)?; + + let make = |desc: &PipelineDesc| -> Result { + let opts = pipeline::Options { + device, + vertex_fn: desc.vertex_fn, + fragment_fn: desc.fragment_fn, + vertex_library: &library, + fragment_library: &library, + step_fn: desc.step_fn, + attachments: &[Attachment { + pixel_format, + blending_enabled: desc.blending_enabled, + }], + }; + match desc.name { + "cell_text" => Pipeline::init::(&opts), + "image" => Pipeline::init::(&opts), + "bg_image" => Pipeline::init::(&opts), + _ => Pipeline::init_no_attributes(&opts), + } + }; + let [bg_color, cell_bg, cell_text, image, bg_image] = &PIPELINE_DESCS; + + let bg_color = make(bg_color)?; + let cell_bg = make(cell_bg)?; + let cell_text = make(cell_text)?; + let image = make(image)?; + let bg_image = make(bg_image)?; + + let post_pipelines = init_post_pipelines(device, &library, post_shaders, pixel_format) + .unwrap_or_else(|err| { + // If an error happens while building postprocess shaders we + // want to just not use any postprocess shaders since we don't + // want to block Ghostty from working. + log::warn!("error initializing postprocess shaders err={err}"); + Vec::new() + }); + + Ok(Shaders { + library, + bg_color, + cell_bg, + cell_text, + image, + bg_image, + post_pipelines, + }) + } + + /// The compiled library of the built-in shaders. + pub fn library(&self) -> &ProtocolObject { + &self.library + } +} + +impl GpuShaders for Shaders { + fn bg_color(&self) -> &Pipeline { + &self.bg_color + } + + fn cell_bg(&self) -> &Pipeline { + &self.cell_bg + } + + fn cell_text(&self) -> &Pipeline { + &self.cell_text + } + + fn image(&self) -> &Pipeline { + &self.image + } + + fn bg_image(&self) -> &Pipeline { + &self.bg_image + } + + fn post_pipelines(&self) -> &[Pipeline] { + &self.post_pipelines + } +} + +/// Initialize the MTLLibrary. A MTLLibrary is a collection of shaders. +fn init_library(device: &Device) -> Result>, Error> { + let start = Instant::now(); + + let source = NSString::from_str(SHADERS_METAL); + let library = device + .newLibraryWithSource_options_error(&source, None) + .map_err(|err| Error::from_ns_error(&err))?; + + log::debug!( + "shader library loaded time={}us", + start.elapsed().as_micros() + ); + + Ok(library) +} + +/// Initialize our custom shader pipelines. +/// +/// The shaders argument is a set of shader source code, not file paths. +fn init_post_pipelines>( + device: &Device, + library: &ProtocolObject, + shaders: &[S], + pixel_format: PixelFormat, +) -> Result, Error> { + // If we have no shaders, do nothing. If any error happens the + // already-built pipelines are dropped (released). + shaders + .iter() + .map(|source| init_post_pipeline(device, library, source.as_ref(), pixel_format)) + .collect() +} + +/// Initialize a single custom shader pipeline from shader source. +fn init_post_pipeline( + device: &Device, + library: &ProtocolObject, + data: &str, + pixel_format: PixelFormat, +) -> Result { + // Create our library which has the shader source + let source = NSString::from_str(data); + let post_library = device + .newLibraryWithSource_options_error(&source, None) + .map_err(|err| Error::from_ns_error(&err))?; + + Pipeline::init_no_attributes(&pipeline::Options { + device, + vertex_fn: FULL_SCREEN_VERTEX_FN, + fragment_fn: POST_FRAGMENT_FN, + vertex_library: library, + fragment_library: &post_library, + step_fn: VertexStepFunction::PerVertex, + attachments: &[Attachment { + pixel_format, + blending_enabled: false, + }], + }) +} diff --git a/crates/ghostty-renderer/src/metal/target.rs b/crates/ghostty-renderer/src/metal/target.rs new file mode 100644 index 0000000..4d99f68 --- /dev/null +++ b/crates/ghostty-renderer/src/metal/target.rs @@ -0,0 +1,199 @@ +//! Represents a render target (`metal/Target.zig`). +//! +//! In this case, an IOSurface-backed MTLTexture. +use objc2::{rc::Retained, runtime::ProtocolObject}; +use objc2_core_foundation::{CFDictionary, CFNumber, CFRetained, CFString, CFType}; +use objc2_core_graphics::{CGColorSpace, kCGColorSpaceDisplayP3}; +use objc2_io_surface::{ + IOSurfaceRef, + kIOSurfaceBytesPerElement, + kIOSurfaceColorSpace, + kIOSurfaceHeight, + kIOSurfacePixelFormat, + kIOSurfaceWidth, +}; +use objc2_metal::{ + MTLDevice as _, + MTLPixelFormat, + MTLResourceOptions, + MTLTexture, + MTLTextureDescriptor, + MTLTextureUsage, +}; + +use super::{Device, Error}; +use crate::{ + graphics_api::GpuTarget, + metal_layout::{PixelFormat, ResourceOptions, StorageMode, TextureUsage}, +}; + +/// `kCVPixelFormatType_32BGRA` ('BGRA'): `video.PixelFormat.@"32BGRA"`. +pub const PIXEL_FORMAT_32BGRA: i32 = 0x4247_5241; + +/// `kIOSurfacePurgeableEmpty`. +const IOSURFACE_PURGEABLE_EMPTY: u32 = 2; + +/// Options for initializing a Target +pub struct Options { + /// MTLDevice + pub device: Device, + + /// Desired width + pub width: usize, + /// Desired height + pub height: usize, + + /// Pixel format for the MTLTexture + pub pixel_format: PixelFormat, + /// Storage mode for the MTLTexture + pub storage_mode: StorageMode, +} + +/// An IOSurface-backed render target. +pub struct Target { + /// The underlying IOSurface. + surface: CFRetained, + + /// The underlying MTLTexture. + texture: Retained>, + + /// Current width of this target. + pub width: usize, + /// Current height of this target. + pub height: usize, +} + +// SAFETY: IOSurfaces and MTLTextures are thread-safe reference counted +// objects; the target is only mutated by the GPU. +unsafe impl Send for Target {} +// SAFETY: see above. +unsafe impl Sync for Target {} + +impl Target { + /// The underlying IOSurface. + pub fn surface(&self) -> &CFRetained { + &self.surface + } + + /// The underlying MTLTexture. + pub fn texture(&self) -> &ProtocolObject { + &self.texture + } + + pub fn init(opts: &Options) -> Result { + // We set our surface's color space to Display P3. + // This allows us to have "Apple-style" alpha blending, + // since it seems to be the case that Apple apps like + // Terminal and TextEdit render text in the display's + // color space using converted colors, which reduces, + // but does not fully eliminate blending artifacts. + // + // SAFETY: a constant CFString exported by CoreGraphics. + let colorspace = CGColorSpace::with_name(Some(unsafe { kCGColorSpaceDisplayP3 })) + .ok_or(Error::MetalFailed)?; + + let surface = create_surface(opts.width, opts.height, &colorspace)?; + + // Create our descriptor and set our properties + let desc = MTLTextureDescriptor::new(); + // SAFETY: plain numbers, validated when the texture is created. + unsafe { + desc.setWidth(opts.width); + desc.setHeight(opts.height); + } + desc.setPixelFormat(MTLPixelFormat(opts.pixel_format as usize)); + desc.setUsage(MTLTextureUsage( + TextureUsage { + render_target: true, + ..TextureUsage::default() + } + .bits() as usize, + )); + desc.setResourceOptions(MTLResourceOptions( + // Indicate that the CPU writes to this resource but never reads it. + ResourceOptions::write_combined(opts.storage_mode).bits() as usize, + )); + + let texture = opts + .device + .newTextureWithDescriptor_iosurface_plane(&desc, &surface, 0) + .ok_or(Error::MetalFailed)?; + + Ok(Target { + surface, + texture, + width: opts.width, + height: opts.height, + }) + } +} + +impl Drop for Target { + fn drop(&mut self) { + // We mark it purgeable so that it is immediately unloaded, so that we + // don't have to wait for CoreFoundation garbage collection to trigger. + // + // SAFETY: a null `old_state` is allowed. + unsafe { + self.surface + .set_purgeable(IOSURFACE_PURGEABLE_EMPTY, std::ptr::null_mut()); + } + } +} + +impl GpuTarget for Target { + fn width(&self) -> usize { + self.width + } + + fn height(&self) -> usize { + self.height + } +} + +/// `IOSurface.init`: a BGRA IOSurface of the given size with a color space. +fn create_surface( + width: usize, + height: usize, + colorspace: &CGColorSpace, +) -> Result, Error> { + let w = CFNumber::new_i32(i32::try_from(width).map_err(|_| Error::MetalFailed)?); + let h = CFNumber::new_i32(i32::try_from(height).map_err(|_| Error::MetalFailed)?); + let pf = CFNumber::new_i32(PIXEL_FORMAT_32BGRA); + let bpe = CFNumber::new_i32(4); + + // SAFETY: constant CFStrings exported by IOSurface. + let keys: [&CFString; 4] = unsafe { + [ + kIOSurfaceWidth, + kIOSurfaceHeight, + kIOSurfacePixelFormat, + kIOSurfaceBytesPerElement, + ] + }; + let values: [&CFNumber; 4] = [&w, &h, &pf, &bpe]; + let properties = CFDictionary::::from_slices(&keys, &values); + + // SAFETY: the dictionary has the CFString keys / CFNumber values + // IOSurfaceCreate expects. + let surface = unsafe { IOSurfaceRef::new(properties.as_opaque()) }.ok_or(Error::MetalFailed)?; + + // setColorSpace: serialize the colorspace into the surface properties. + if let Some(serialized) = colorspace.property_list() { + let value: &CFType = serialized.as_ref(); + // SAFETY: the color space key takes a property list value. + unsafe { surface.set_value(kIOSurfaceColorSpace, value) }; + } + + Ok(surface) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn bgra_fourcc() { + assert_eq!(PIXEL_FORMAT_32BGRA, i32::from_be_bytes(*b"BGRA")); + } +} diff --git a/crates/ghostty-renderer/src/metal/texture.rs b/crates/ghostty-renderer/src/metal/texture.rs new file mode 100644 index 0000000..7a889ca --- /dev/null +++ b/crates/ghostty-renderer/src/metal/texture.rs @@ -0,0 +1,156 @@ +//! Wrapper for handling textures (`metal/Texture.zig`). +use std::{ffi::c_void, ptr::NonNull}; + +use objc2::{rc::Retained, runtime::ProtocolObject}; +use objc2_metal::{ + MTLDevice as _, + MTLOrigin, + MTLPixelFormat, + MTLRegion, + MTLResourceOptions, + MTLSize, + MTLTexture, + MTLTextureDescriptor, + MTLTextureUsage, +}; + +use super::{Device, Error}; +use crate::{ + graphics_api::{ApiError, GpuTexture}, + metal_layout::{PixelFormat, ResourceOptions, TextureUsage}, +}; + +/// Options for initializing a texture. +#[derive(Clone)] +pub struct Options { + /// MTLDevice + pub device: Device, + pub pixel_format: PixelFormat, + pub resource_options: ResourceOptions, + pub usage: TextureUsage, +} + +/// A 2D texture. +pub struct Texture { + /// The underlying MTLTexture Object. + texture: Retained>, + + /// The width of this texture. + pub width: usize, + /// The height of this texture. + pub height: usize, + + /// Bytes per pixel for this texture. + pub bpp: usize, +} + +// SAFETY: MTLTexture objects may be used from any thread (the CPU side only +// calls `replaceRegion`, which the renderer serializes through its draw +// mutex). +unsafe impl Send for Texture {} +// SAFETY: see above. +unsafe impl Sync for Texture {} + +impl Texture { + /// The underlying MTLTexture. + pub fn texture(&self) -> &ProtocolObject { + &self.texture + } + + /// Initialize a texture + pub fn init( + opts: &Options, + width: usize, + height: usize, + data: Option<&[u8]>, + ) -> Result { + // Create our descriptor and set our properties + let desc = MTLTextureDescriptor::new(); + desc.setPixelFormat(MTLPixelFormat(opts.pixel_format as usize)); + // SAFETY: plain numbers, validated when the texture is created. + unsafe { + desc.setWidth(width); + desc.setHeight(height); + } + desc.setResourceOptions(MTLResourceOptions(opts.resource_options.bits() as usize)); + desc.setUsage(MTLTextureUsage(opts.usage.bits() as usize)); + + // Initialize + let texture = opts + .device + .newTextureWithDescriptor(&desc) + .ok_or(Error::MetalFailed)?; + + let this = Texture { + texture, + width, + height, + bpp: opts.pixel_format.bytes_per_pixel(), + }; + + // If we have data, we set it here. + if let Some(d) = data { + debug_assert_eq!(d.len(), width * height * this.bpp); + this.replace_region(0, 0, width, height, d)?; + } + + Ok(this) + } + + /// Replace a region of the texture with the provided data. + /// + /// Zig does NOT check the dimensions of the data to ensure correctness; + /// since this is a safe function we reject data that is too short. + pub fn replace_region( + &self, + x: usize, + y: usize, + width: usize, + height: usize, + data: &[u8], + ) -> Result<(), Error> { + let bytes_per_row = self.bpp * width; + if data.len() < bytes_per_row * height { + return Err(Error::MetalFailed); + } + let Some(ptr) = NonNull::new(data.as_ptr().cast_mut().cast::()) else { + return Err(Error::MetalFailed); + }; + let region = MTLRegion { + origin: MTLOrigin { x, y, z: 0 }, + size: MTLSize { + width, + height, + depth: 1, + }, + }; + // SAFETY: `ptr` points to at least `bytes_per_row * height` readable + // bytes (checked above); Metal only reads from it. + unsafe { + self.texture + .replaceRegion_mipmapLevel_withBytes_bytesPerRow(region, 0, ptr, bytes_per_row); + } + Ok(()) + } +} + +impl GpuTexture for Texture { + fn width(&self) -> usize { + self.width + } + + fn height(&self) -> usize { + self.height + } + + fn replace_region( + &self, + x: usize, + y: usize, + width: usize, + height: usize, + data: &[u8], + ) -> Result<(), ApiError> { + Texture::replace_region(self, x, y, width, height, data).map_err(ApiError::new) + } +} diff --git a/crates/ghostty-renderer/src/metal_layout.rs b/crates/ghostty-renderer/src/metal_layout.rs new file mode 100644 index 0000000..beb10ae --- /dev/null +++ b/crates/ghostty-renderer/src/metal_layout.rs @@ -0,0 +1,778 @@ +//! Platform-independent parts of the Metal backend (`renderer/metal/api.zig`, +//! `metal/shaders.zig`, `metal/Texture.zig`, `Metal.zig`): raw enum values, +//! the Metal GPU data layouts, pixel format selection, vertex attribute +//! tables and the small pieces of math the backend does. Everything here is +//! plain Rust so it can be unit tested on any host; `crate::metal` converts +//! these values to `objc2-metal` types. + +use bytemuck::{Pod, Zeroable}; +use ghostty_config::types_b::AlphaBlending; +use ghostty_font::atlas::Format as AtlasFormat; + +use crate::{ + graphics_api::ImageTextureFormat, + math::Mat, + shader_types::{BgImage, CellText, Image as ImageVertex, Uniforms}, +}; + +// --------------------------------------------------------------------------- +// Resource options + +/// `MTLResourceOptions.CPUCacheMode`. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] +#[repr(u8)] +pub enum CpuCacheMode { + #[default] + Default = 0, + WriteCombined = 1, +} + +/// `MTLResourceOptions.StorageMode`. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +#[repr(u8)] +pub enum StorageMode { + Shared = 0, + Managed = 1, + Private = 2, + Memoryless = 3, +} + +/// `MTLResourceOptions.HazardTrackingMode`. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] +#[repr(u8)] +pub enum HazardTrackingMode { + #[default] + Default = 0, + Untracked = 1, + Tracked = 2, +} + +/// `MTLResourceOptions` (a `packed struct(c_ulong)` upstream: cache mode in +/// bits 0-3, storage mode in bits 4-7, hazard tracking in bits 8-9). +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct ResourceOptions { + pub cpu_cache_mode: CpuCacheMode, + pub storage_mode: StorageMode, + pub hazard_tracking_mode: HazardTrackingMode, +} + +impl ResourceOptions { + /// "Indicate that the CPU writes to this resource but never reads it." + pub fn write_combined(storage_mode: StorageMode) -> ResourceOptions { + ResourceOptions { + cpu_cache_mode: CpuCacheMode::WriteCombined, + storage_mode, + hazard_tracking_mode: HazardTrackingMode::Default, + } + } + + /// The raw `MTLResourceOptions` value. + pub fn bits(self) -> u64 { + u64::from(self.cpu_cache_mode as u8) + | u64::from(self.storage_mode as u8) << 4 + | u64::from(self.hazard_tracking_mode as u8) << 8 + } +} + +/// `MTLTextureUsage` (a `packed struct(c_ulong)` upstream). +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] +pub struct TextureUsage { + pub shader_read: bool, + pub shader_write: bool, + pub render_target: bool, + pub pixel_format_view: bool, + pub shader_atomic: bool, +} + +impl TextureUsage { + /// The raw `MTLTextureUsage` value. + pub fn bits(self) -> u64 { + u64::from(self.shader_read) + | u64::from(self.shader_write) << 1 + | u64::from(self.render_target) << 2 + // bit 3 is skipped by the enum + | u64::from(self.pixel_format_view) << 4 + | u64::from(self.shader_atomic) << 5 + } + + /// Textures only read from a shader (atlases, images). + pub fn shader_read_only() -> TextureUsage { + TextureUsage { + shader_read: true, + ..TextureUsage::default() + } + } + + /// Custom shader textures: "require both the shader read (for when + /// multiple shaders are chained) and render target (for the final output) + /// usage. Disabling either of these will lead to metal validation errors + /// in Xcode." + pub fn custom_shader() -> TextureUsage { + TextureUsage { + shader_read: true, + render_target: true, + ..TextureUsage::default() + } + } +} + +/// The default storage mode to use for resources created with a device. +/// +/// "This is based on whether the device is a discrete GPU or not, since +/// discrete GPUs do not have unified memory and therefore do not support the +/// 'shared' storage mode, instead we have to use the 'managed' mode." The +/// managed mode is not supported by iOS. +pub fn default_storage_mode(is_ios: bool, has_unified_memory: bool) -> StorageMode { + if is_ios || has_unified_memory { + StorageMode::Shared + } else { + StorageMode::Managed + } +} + +/// The maximum 2D texture size by GPU family +/// (). +pub fn max_texture_size(supports_apple10: bool, supports_apple3: bool) -> u32 { + if supports_apple10 { + 32768 + } else if supports_apple3 { + 16384 + } else { + 8192 + } +} + +/// Clamp a layer's pixel size (`bounds * contentsScale`) to the maximum +/// texture size ("we can't create a screen buffer (texture) larger than +/// that"). Truncates like Zig's `@intFromFloat`. +pub fn surface_size( + bounds_width: f64, + bounds_height: f64, + scale: f64, + max_texture_size: u32, +) -> (u32, u32) { + let w = (bounds_width * scale) as u32; + let h = (bounds_height * scale) as u32; + (w.min(max_texture_size), h.min(max_texture_size)) +} + +// --------------------------------------------------------------------------- +// Pixel formats + +/// The subset of `MTLPixelFormat` the renderer uses, with the raw values from +/// `api.zig`. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +#[repr(u64)] +pub enum PixelFormat { + R8Unorm = 10, + R8UnormSrgb = 11, + Rgba8Unorm = 70, + Rgba8UnormSrgb = 71, + Bgra8Unorm = 80, + Bgra8UnormSrgb = 81, +} + +impl PixelFormat { + /// Bytes per pixel (`Texture.bppOf`). + pub fn bytes_per_pixel(self) -> usize { + match self { + PixelFormat::R8Unorm | PixelFormat::R8UnormSrgb => 1, + PixelFormat::Rgba8Unorm + | PixelFormat::Rgba8UnormSrgb + | PixelFormat::Bgra8Unorm + | PixelFormat::Bgra8UnormSrgb => 4, + } + } +} + +/// The pixel format of render targets, custom shader textures and pipeline +/// color attachments. +/// +/// "Using an `*_srgb` pixel format makes Metal gamma encode the pixels +/// written to it *after* blending, which means we get linear alpha blending +/// rather than gamma-incorrect blending." +pub fn color_pixel_format(blending: AlphaBlending) -> PixelFormat { + if blending.is_linear() { + PixelFormat::Bgra8UnormSrgb + } else { + PixelFormat::Bgra8Unorm + } +} + +/// The pixel format of an image texture (`ImageTextureFormat.toPixelFormat`). +pub fn image_pixel_format(format: ImageTextureFormat, srgb: bool) -> PixelFormat { + match (format, srgb) { + (ImageTextureFormat::Gray, true) => PixelFormat::R8UnormSrgb, + (ImageTextureFormat::Gray, false) => PixelFormat::R8Unorm, + (ImageTextureFormat::Rgba, true) => PixelFormat::Rgba8UnormSrgb, + (ImageTextureFormat::Rgba, false) => PixelFormat::Rgba8Unorm, + (ImageTextureFormat::Bgra, true) => PixelFormat::Bgra8UnormSrgb, + (ImageTextureFormat::Bgra, false) => PixelFormat::Bgra8Unorm, + } +} + +/// The pixel format of a font atlas texture (`initAtlasTexture`). `None` for +/// formats Metal doesn't support (Zig panics). +pub fn atlas_pixel_format(format: AtlasFormat) -> Option { + match format { + AtlasFormat::Grayscale => Some(PixelFormat::R8Unorm), + AtlasFormat::Bgra => Some(PixelFormat::Bgra8UnormSrgb), + AtlasFormat::Bgr => None, + } +} + +// --------------------------------------------------------------------------- +// Vertex attributes + +/// The subset of `MTLVertexFormat` used by our vertex structs (raw values from +/// `api.zig`). +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +#[repr(u64)] +pub enum VertexFormat { + Uchar4 = 3, + Ushort2 = 13, + Short2 = 16, + Float = 28, + Float2 = 29, + Float4 = 31, + Uint2 = 37, + Uchar = 45, +} + +/// One vertex attribute (what `Pipeline.autoAttribute` derives by reflection). +/// The attribute index is the position in the table; all attributes read from +/// buffer 0. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct VertexAttribute { + pub format: VertexFormat, + pub offset: usize, +} + +/// A vertex struct usable with a Metal pipeline. +pub trait MetalVertex: Pod { + const ATTRIBUTES: &'static [VertexAttribute]; +} + +macro_rules! attr { + ($ty:ty, $field:ident, $format:expr) => { + VertexAttribute { + format: $format, + offset: core::mem::offset_of!($ty, $field), + } + }; +} + +impl MetalVertex for CellText { + const ATTRIBUTES: &'static [VertexAttribute] = &[ + attr!(CellText, glyph_pos, VertexFormat::Uint2), + attr!(CellText, glyph_size, VertexFormat::Uint2), + attr!(CellText, bearings, VertexFormat::Short2), + attr!(CellText, grid_pos, VertexFormat::Ushort2), + attr!(CellText, color, VertexFormat::Uchar4), + // `atlas` is an enum(u8): its tag type. + attr!(CellText, atlas, VertexFormat::Uchar), + // `bools` is a packed struct(u8): its backing integer. + attr!(CellText, bools, VertexFormat::Uchar), + ]; +} + +impl MetalVertex for ImageVertex { + const ATTRIBUTES: &'static [VertexAttribute] = &[ + attr!(ImageVertex, grid_pos, VertexFormat::Float2), + attr!(ImageVertex, cell_offset, VertexFormat::Float2), + attr!(ImageVertex, source_rect, VertexFormat::Float4), + attr!(ImageVertex, dest_size, VertexFormat::Float2), + ]; +} + +impl MetalVertex for BgImage { + const ATTRIBUTES: &'static [VertexAttribute] = &[ + attr!(BgImage, opacity, VertexFormat::Float), + // `info` is a packed struct(u8): its backing integer. + attr!(BgImage, info, VertexFormat::Uchar), + ]; +} + +/// `MTLVertexStepFunction`. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +#[repr(u64)] +pub enum VertexStepFunction { + Constant = 0, + PerVertex = 1, + PerInstance = 2, +} + +// --------------------------------------------------------------------------- +// Blending + +/// The color attachment blend state of a pipeline +/// ("We always use premultiplied alpha blending for now."). +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct BlendState { + pub rgb_operation: BlendOperation, + pub alpha_operation: BlendOperation, + pub source_rgb: BlendFactor, + pub source_alpha: BlendFactor, + pub destination_rgb: BlendFactor, + pub destination_alpha: BlendFactor, +} + +/// `MTLBlendOperation` (subset). +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +#[repr(u64)] +pub enum BlendOperation { + Add = 0, +} + +/// `MTLBlendFactor` (subset). +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +#[repr(u64)] +pub enum BlendFactor { + One = 1, + OneMinusSourceAlpha = 5, +} + +/// The blend state used by every blending pipeline. +pub const PREMULTIPLIED_ALPHA: BlendState = BlendState { + rgb_operation: BlendOperation::Add, + alpha_operation: BlendOperation::Add, + source_rgb: BlendFactor::One, + source_alpha: BlendFactor::One, + destination_rgb: BlendFactor::OneMinusSourceAlpha, + destination_alpha: BlendFactor::OneMinusSourceAlpha, +}; + +// --------------------------------------------------------------------------- +// Render pass buffer slots + +/// The Metal buffer index for entry `i` of `Step.buffers` +/// (`RenderPass.step`): index 0 is the vertex buffer, and the rest start at +/// index 2 "so that we can use index 1 for the uniforms if present". +pub fn buffer_index(i: usize) -> usize { + if i == 0 { 0 } else { i + 1 } +} + +/// The Metal buffer index of the uniforms. +pub const UNIFORMS_BUFFER_INDEX: usize = 1; + +// --------------------------------------------------------------------------- +// Uniforms + +/// The uniforms that are passed to our Metal shaders (`metal/shaders.zig` +/// `Uniforms`, an `extern struct` with the alignments of the MSL struct). +/// +/// This is the same layout as the OpenGL std140 struct except for `bools`, +/// which is four separate bytes instead of a bit mask, and the width of +/// `padding_extend`. +#[repr(C)] +#[derive(Debug, Clone, Copy, PartialEq, Default, Pod, Zeroable)] +pub struct MetalUniforms { + pub projection_matrix: Mat, + pub screen_size: [f32; 2], + pub cell_size: [f32; 2], + pub grid_size: [u16; 2], + pub pad0: [u8; 12], + pub grid_padding: [f32; 4], + /// Bit mask: left, right, up, down (LSB first). + pub padding_extend: u8, + pub pad1: [u8; 3], + pub min_contrast: f32, + pub cursor_pos: [u16; 2], + pub cursor_color: [u8; 4], + pub bg_color: [u8; 4], + pub cursor_wide: u8, + pub use_display_p3: u8, + pub use_linear_blending: u8, + pub use_linear_correction: u8, + pub pad2: [u8; 8], +} + +impl From<&Uniforms> for MetalUniforms { + fn from(u: &Uniforms) -> MetalUniforms { + use crate::shader_types::Bools; + MetalUniforms { + projection_matrix: u.projection_matrix, + screen_size: u.screen_size, + cell_size: u.cell_size, + grid_size: u.grid_size, + pad0: [0; 12], + grid_padding: u.grid_padding, + padding_extend: (u.padding_extend & 0xFF) as u8, + pad1: [0; 3], + min_contrast: u.min_contrast, + cursor_pos: u.cursor_pos, + cursor_color: u.cursor_color, + bg_color: u.bg_color, + cursor_wide: u8::from(u.bools & Bools::CURSOR_WIDE != 0), + use_display_p3: u8::from(u.bools & Bools::USE_DISPLAY_P3 != 0), + use_linear_blending: u8::from(u.bools & Bools::USE_LINEAR_BLENDING != 0), + use_linear_correction: u8::from(u.bools & Bools::USE_LINEAR_CORRECTION != 0), + pad2: [0; 8], + } + } +} + +// --------------------------------------------------------------------------- +// Custom shaders + +/// The entry point of the fragment function spirv-cross (and naga, see +/// `shadertoy::msl_from_compiled`) generates for custom shaders. +pub const POST_FRAGMENT_FN: &str = crate::shadertoy::MSL_ENTRY_POINT; + +/// The vertex function post-processing pipelines share with the built-in +/// shaders. +pub const FULL_SCREEN_VERTEX_FN: &str = "full_screen_vertex"; + +/// The built-in pipelines (`pipeline_descs` in `metal/shaders.zig`). +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct PipelineDesc { + pub name: &'static str, + pub vertex_fn: &'static str, + pub fragment_fn: &'static str, + pub step_fn: VertexStepFunction, + pub blending_enabled: bool, + /// Whether the pipeline has a vertex descriptor (vertex attributes). + pub has_vertex_attributes: bool, +} + +pub const PIPELINE_DESCS: [PipelineDesc; 5] = [ + PipelineDesc { + name: "bg_color", + vertex_fn: "full_screen_vertex", + fragment_fn: "bg_color_fragment", + step_fn: VertexStepFunction::PerVertex, + blending_enabled: false, + has_vertex_attributes: false, + }, + PipelineDesc { + name: "cell_bg", + vertex_fn: "full_screen_vertex", + fragment_fn: "cell_bg_fragment", + step_fn: VertexStepFunction::PerVertex, + blending_enabled: true, + has_vertex_attributes: false, + }, + PipelineDesc { + name: "cell_text", + vertex_fn: "cell_text_vertex", + fragment_fn: "cell_text_fragment", + step_fn: VertexStepFunction::PerInstance, + blending_enabled: true, + has_vertex_attributes: true, + }, + PipelineDesc { + name: "image", + vertex_fn: "image_vertex", + fragment_fn: "image_fragment", + step_fn: VertexStepFunction::PerInstance, + blending_enabled: true, + has_vertex_attributes: true, + }, + PipelineDesc { + name: "bg_image", + vertex_fn: "bg_image_vertex", + fragment_fn: "bg_image_fragment", + step_fn: VertexStepFunction::PerInstance, + blending_enabled: true, + has_vertex_attributes: true, + }, +]; + +#[cfg(test)] +mod tests { + use core::mem::{offset_of, size_of}; + + use super::*; + use crate::shader_types::{Bools, PaddingExtend}; + + #[test] + fn resource_options_bits() { + // MTLResourceCPUCacheModeWriteCombined | MTLResourceStorageModeShared + assert_eq!( + ResourceOptions::write_combined(StorageMode::Shared).bits(), + 1 + ); + // ... | MTLResourceStorageModeManaged (1 << 4) + assert_eq!( + ResourceOptions::write_combined(StorageMode::Managed).bits(), + 0x11 + ); + let private = ResourceOptions { + cpu_cache_mode: CpuCacheMode::Default, + storage_mode: StorageMode::Private, + hazard_tracking_mode: HazardTrackingMode::Tracked, + }; + assert_eq!(private.bits(), (2 << 4) | (2 << 8)); + } + + #[test] + fn texture_usage_bits() { + assert_eq!(TextureUsage::shader_read_only().bits(), 1); + assert_eq!(TextureUsage::custom_shader().bits(), 1 | 4); + let all = TextureUsage { + shader_read: true, + shader_write: true, + render_target: true, + pixel_format_view: true, + shader_atomic: true, + }; + // The enum skips from 4 to 16. + assert_eq!(all.bits(), 1 | 2 | 4 | 16 | 32); + } + + #[test] + fn storage_mode_selection() { + assert_eq!(default_storage_mode(false, true), StorageMode::Shared); + assert_eq!(default_storage_mode(false, false), StorageMode::Managed); + // Managed storage does not exist on iOS. + assert_eq!(default_storage_mode(true, false), StorageMode::Shared); + } + + #[test] + fn max_texture_size_by_family() { + assert_eq!(max_texture_size(true, true), 32768); + assert_eq!(max_texture_size(false, true), 16384); + assert_eq!(max_texture_size(false, false), 8192); + } + + #[test] + fn surface_size_clamps_and_truncates() { + assert_eq!(surface_size(800.0, 600.0, 2.0, 16384), (1600, 1200)); + assert_eq!(surface_size(800.5, 600.5, 1.5, 16384), (1200, 900)); + assert_eq!(surface_size(10000.0, 10.0, 2.0, 8192), (8192, 20)); + assert_eq!(surface_size(0.0, 0.0, 2.0, 8192), (0, 0)); + } + + #[test] + fn pixel_format_selection() { + assert_eq!( + color_pixel_format(AlphaBlending::Native), + PixelFormat::Bgra8Unorm + ); + assert_eq!( + color_pixel_format(AlphaBlending::Linear), + PixelFormat::Bgra8UnormSrgb + ); + assert_eq!( + color_pixel_format(AlphaBlending::LinearCorrected), + PixelFormat::Bgra8UnormSrgb + ); + assert_eq!( + image_pixel_format(ImageTextureFormat::Gray, true), + PixelFormat::R8UnormSrgb + ); + assert_eq!( + image_pixel_format(ImageTextureFormat::Rgba, false), + PixelFormat::Rgba8Unorm + ); + assert_eq!( + image_pixel_format(ImageTextureFormat::Bgra, true), + PixelFormat::Bgra8UnormSrgb + ); + assert_eq!( + atlas_pixel_format(AtlasFormat::Grayscale), + Some(PixelFormat::R8Unorm) + ); + assert_eq!( + atlas_pixel_format(AtlasFormat::Bgra), + Some(PixelFormat::Bgra8UnormSrgb) + ); + assert_eq!(atlas_pixel_format(AtlasFormat::Bgr), None); + } + + #[test] + fn pixel_format_raw_values_match_metal() { + // MTLPixelFormat values (api.zig). + assert_eq!(PixelFormat::R8Unorm as u64, 10); + assert_eq!(PixelFormat::Rgba8Unorm as u64, 70); + assert_eq!(PixelFormat::Bgra8Unorm as u64, 80); + assert_eq!(PixelFormat::Bgra8UnormSrgb as u64, 81); + assert_eq!(PixelFormat::Bgra8Unorm.bytes_per_pixel(), 4); + assert_eq!(PixelFormat::R8UnormSrgb.bytes_per_pixel(), 1); + } + + #[test] + fn vertex_attribute_tables() { + let cell_text: Vec<_> = CellText::ATTRIBUTES + .iter() + .map(|a| (a.format, a.offset)) + .collect(); + assert_eq!( + cell_text, + [ + (VertexFormat::Uint2, 0), + (VertexFormat::Uint2, 8), + (VertexFormat::Short2, 16), + (VertexFormat::Ushort2, 20), + (VertexFormat::Uchar4, 24), + (VertexFormat::Uchar, 28), + (VertexFormat::Uchar, 29), + ] + ); + let image: Vec<_> = ImageVertex::ATTRIBUTES + .iter() + .map(|a| (a.format, a.offset)) + .collect(); + assert_eq!( + image, + [ + (VertexFormat::Float2, 0), + (VertexFormat::Float2, 8), + (VertexFormat::Float4, 16), + (VertexFormat::Float2, 32), + ] + ); + let bg: Vec<_> = BgImage::ATTRIBUTES + .iter() + .map(|a| (a.format, a.offset)) + .collect(); + assert_eq!(bg, [(VertexFormat::Float, 0), (VertexFormat::Uchar, 4)]); + + // MTLVertexFormat raw values. + assert_eq!(VertexFormat::Uchar4 as u64, 3); + assert_eq!(VertexFormat::Ushort2 as u64, 13); + assert_eq!(VertexFormat::Short2 as u64, 16); + assert_eq!(VertexFormat::Float as u64, 28); + assert_eq!(VertexFormat::Float2 as u64, 29); + assert_eq!(VertexFormat::Float4 as u64, 31); + assert_eq!(VertexFormat::Uint2 as u64, 37); + assert_eq!(VertexFormat::Uchar as u64, 45); + } + + #[test] + fn buffer_slots() { + // Vertex buffer at 0, uniforms at 1, the rest start at 2. + assert_eq!(buffer_index(0), 0); + assert_eq!(buffer_index(1), 2); + assert_eq!(buffer_index(2), 3); + assert_eq!(UNIFORMS_BUFFER_INDEX, 1); + } + + #[test] + fn blend_state_is_premultiplied() { + assert_eq!(PREMULTIPLIED_ALPHA.source_rgb as u64, 1); + assert_eq!(PREMULTIPLIED_ALPHA.destination_rgb as u64, 5); + assert_eq!(PREMULTIPLIED_ALPHA.rgb_operation as u64, 0); + } + + #[test] + fn metal_uniforms_layout_matches_msl() { + // The MSL `Uniforms` struct in shaders/shaders.metal. + assert_eq!(offset_of!(MetalUniforms, projection_matrix), 0); + assert_eq!(offset_of!(MetalUniforms, screen_size), 64); + assert_eq!(offset_of!(MetalUniforms, cell_size), 72); + assert_eq!(offset_of!(MetalUniforms, grid_size), 80); + assert_eq!(offset_of!(MetalUniforms, grid_padding), 96); + assert_eq!(offset_of!(MetalUniforms, padding_extend), 112); + assert_eq!(offset_of!(MetalUniforms, min_contrast), 116); + assert_eq!(offset_of!(MetalUniforms, cursor_pos), 120); + assert_eq!(offset_of!(MetalUniforms, cursor_color), 124); + assert_eq!(offset_of!(MetalUniforms, bg_color), 128); + assert_eq!(offset_of!(MetalUniforms, cursor_wide), 132); + assert_eq!(offset_of!(MetalUniforms, use_linear_correction), 135); + assert_eq!(size_of::(), 144); + // Same size as the OpenGL std140 block. + assert_eq!(size_of::(), size_of::()); + } + + #[test] + fn metal_uniforms_from_common() { + let common = Uniforms { + screen_size: [800.0, 600.0], + padding_extend: PaddingExtend { + left: true, + right: false, + up: false, + down: true, + } + .to_bits(), + min_contrast: 3.0, + cursor_pos: [4, 5], + bg_color: [1, 2, 3, 4], + bools: Bools { + cursor_wide: true, + use_display_p3: false, + use_linear_blending: true, + use_linear_correction: false, + } + .to_bits(), + ..Uniforms::default() + }; + let m = MetalUniforms::from(&common); + assert_eq!(m.screen_size, [800.0, 600.0]); + assert_eq!(m.padding_extend, 0b1001); + assert!((m.min_contrast - 3.0).abs() < f32::EPSILON); + assert_eq!(m.cursor_pos, [4, 5]); + assert_eq!(m.bg_color, [1, 2, 3, 4]); + let raw: &[u8] = bytemuck::bytes_of(&m); + // cursor_wide, use_display_p3, use_linear_blending, use_linear_correction + assert_eq!(&raw[132..136], &[1, 0, 1, 0]); + } + + #[test] + fn pipeline_descs_match_shader_functions() { + let names: Vec<_> = PIPELINE_DESCS.iter().map(|d| d.name).collect(); + assert_eq!( + names, + ["bg_color", "cell_bg", "cell_text", "image", "bg_image"] + ); + // Every function exists in shaders.metal. + let src = include_str!("../shaders/shaders.metal"); + for d in &PIPELINE_DESCS { + assert!( + src.contains(&format!("{}(", d.vertex_fn)), + "{}", + d.vertex_fn + ); + assert!( + src.contains(&format!("{}(", d.fragment_fn)), + "{}", + d.fragment_fn + ); + } + assert!(src.contains(FULL_SCREEN_VERTEX_FN)); + // Instanced pipelines have vertex attributes. + for d in &PIPELINE_DESCS { + assert_eq!( + d.has_vertex_attributes, + d.step_fn == VertexStepFunction::PerInstance + ); + } + assert!(!PIPELINE_DESCS[0].blending_enabled); + } + + #[test] + fn msl_uniforms_struct_matches_rust() { + // Guard the shader <-> Rust contract: the MSL struct has these fields + // in this order. + let src = include_str!("../shaders/shaders.metal"); + let start = src.find("struct Uniforms {").unwrap(); + let end = start + src[start..].find("};").unwrap(); + let fields: Vec<&str> = src[start..end] + .lines() + .skip(1) + .map(|l| l.trim().trim_end_matches(';')) + .filter(|l| !l.is_empty()) + .map(|l| l.rsplit(' ').next().unwrap()) + .collect(); + assert_eq!( + fields, + [ + "projection_matrix", + "screen_size", + "cell_size", + "grid_size", + "grid_padding", + "padding_extend", + "min_contrast", + "cursor_pos", + "cursor_color", + "bg_color", + "cursor_wide", + "use_display_p3", + "use_linear_blending", + "use_linear_correction", + ] + ); + } +} diff --git a/crates/ghostty-renderer/src/opengl/api.rs b/crates/ghostty-renderer/src/opengl/api.rs new file mode 100644 index 0000000..e817e20 --- /dev/null +++ b/crates/ghostty-renderer/src/opengl/api.rs @@ -0,0 +1,249 @@ +//! `impl GraphicsApi for OpenGL`: adapts the OpenGL backend to the interface +//! the generic renderer is written against (see [`crate::graphics_api`]). +//! +//! The adapter delegates to the inherent OpenGL API (which keeps its richer, +//! GL-typed signatures for the apprt and the integration tests). Inherent +//! methods are always called with path syntax so they win over the trait +//! methods of the same name. +use std::sync::Arc; + +use bytemuck::Pod; +use ghostty_config::types_b::AlphaBlending; +use ghostty_font::atlas::Atlas; + +use super::{ + Buffer, + Frame, + OpenGL, + Pipeline, + RenderPass, + Sampler, + Target, + Texture, + gl::RawBuffer, + shaders::Shaders, +}; +use crate::{ + backend::Health, + graphics_api::{ + ApiError, + Attachment, + FrameCompletion, + GpuBuffer, + GpuFrame, + GpuRenderPass, + GpuShaders, + GpuTarget, + GpuTexture, + GraphicsApi, + Step, + TextureKind, + }, + shader_types::Uniforms, + shadertoy, +}; + +impl GraphicsApi for OpenGL { + /// Custom shaders are converted to GLSL for OpenGL. + const CUSTOM_SHADER_TARGET: shadertoy::Target = shadertoy::Target::Glsl; + /// The fragCoord for OpenGL shaders is +Y = up. + const CUSTOM_SHADER_Y_IS_DOWN: bool = false; + /// Because OpenGL's frame completion is always + /// sync, we have no need for multi-buffering. + const SWAP_CHAIN_COUNT: usize = 1; + + type Target = Target; + type Texture = Texture; + type Sampler = Sampler; + type Buffer = Buffer; + type BufferRef = RawBuffer; + type Pipeline = Pipeline; + type Shaders = Shaders; + type GpuUniforms = Uniforms; + type Frame = GlFrame; + type RenderPass<'a> = RenderPass<'a>; + + fn gpu_uniforms(uniforms: &Uniforms) -> Uniforms { + *uniforms + } + + fn blending(&self) -> AlphaBlending { + self.blending + } + + fn set_blending(&mut self, blending: AlphaBlending) { + self.blending = blending; + } + + fn init_shaders(&self, custom_shaders: &[String]) -> Result { + OpenGL::init_shaders(self, custom_shaders).map_err(ApiError::new) + } + + fn surface_size(&self) -> Result<(u32, u32), ApiError> { + OpenGL::surface_size(self).map_err(ApiError::new) + } + + fn init_target(&self, width: usize, height: usize) -> Result { + OpenGL::init_target(self, width, height).map_err(ApiError::new) + } + + fn init_buffer(&self, len: usize) -> Result, ApiError> { + let gl = self.gl_or_err().map_err(ApiError::new)?; + Buffer::init(gl, self.buffer_options(), len).map_err(ApiError::new) + } + + fn init_buffer_fill(&self, data: &[T]) -> Result, ApiError> { + let gl = self.gl_or_err().map_err(ApiError::new)?; + Buffer::init_fill(gl, self.buffer_options(), data).map_err(ApiError::new) + } + + fn init_texture( + &self, + kind: TextureKind, + width: usize, + height: usize, + data: Option<&[u8]>, + ) -> Result { + let gl = self.gl_or_err().map_err(ApiError::new)?; + let opts = match kind { + TextureKind::CustomShader => self.texture_options(), + TextureKind::Image { format, srgb } => self.image_texture_options(format, srgb), + }; + Texture::init(gl, opts, width, height, data).map_err(ApiError::new) + } + + fn init_atlas_texture(&self, atlas: &Atlas) -> Result { + OpenGL::init_atlas_texture(self, atlas).map_err(ApiError::new) + } + + fn init_sampler(&self) -> Result { + let gl = self.gl_or_err().map_err(ApiError::new)?; + Sampler::init(gl, self.sampler_options()).map_err(ApiError::new) + } + + fn begin_frame(&self, target: Arc) -> GlFrame { + GlFrame(OpenGL::begin_frame(self, target)) + } + + fn present_last_target(&self) -> Result<(), ApiError> { + OpenGL::present_last_target(self).map_err(ApiError::new) + } + + fn clear_last_target(&self) { + OpenGL::clear_last_target(self); + } + + fn draw_frame_start(&mut self) { + OpenGL::draw_frame_start(self); + } + + fn draw_frame_end(&mut self) { + OpenGL::draw_frame_end(self); + } + + fn thread_enter(&self) { + OpenGL::thread_enter(self); + } + + fn thread_exit(&self) { + OpenGL::thread_exit(self); + } +} + +impl GpuBuffer for Buffer { + fn raw(&self) -> RawBuffer { + Buffer::raw(self) + } + + fn sync(&mut self, data: &[T]) -> Result<(), ApiError> { + Buffer::sync(self, data).map_err(ApiError::new) + } + + fn sync_from_slices(&mut self, lists: &[&[T]]) -> Result { + Buffer::sync_from_slices(self, lists).map_err(ApiError::new) + } +} + +impl GpuTexture for Texture { + fn width(&self) -> usize { + self.width + } + + fn height(&self) -> usize { + self.height + } + + fn replace_region( + &self, + x: usize, + y: usize, + width: usize, + height: usize, + data: &[u8], + ) -> Result<(), ApiError> { + Texture::replace_region(self, x, y, width, height, data).map_err(ApiError::new) + } +} + +impl GpuTarget for Target { + fn width(&self) -> usize { + self.width + } + + fn height(&self) -> usize { + self.height + } +} + +impl GpuShaders for Shaders { + fn bg_color(&self) -> &Pipeline { + &self.bg_color + } + + fn cell_bg(&self) -> &Pipeline { + &self.cell_bg + } + + fn cell_text(&self) -> &Pipeline { + &self.cell_text + } + + fn image(&self) -> &Pipeline { + &self.image + } + + fn bg_image(&self) -> &Pipeline { + &self.bg_image + } + + fn post_pipelines(&self) -> &[Pipeline] { + &self.post_pipelines + } +} + +/// An OpenGL [`Frame`] as seen by the generic renderer. +pub struct GlFrame(Frame); + +impl GpuFrame for GlFrame { + fn render_pass<'a>(&'a self, attachments: &'a [Attachment<'a, OpenGL>]) -> RenderPass<'a> + where + OpenGL: 'a, + { + self.0.render_pass(attachments) + } + + fn complete(self, sync: bool, completion: FrameCompletion) { + let health: Health = self.0.complete(sync); + completion(health); + } +} + +impl<'a> GpuRenderPass<'a, OpenGL> for RenderPass<'a> { + fn step(&mut self, step: &Step<'_, OpenGL>) { + RenderPass::step(self, step); + } + + fn complete(self) { + RenderPass::complete(self); + } +} diff --git a/crates/ghostty-renderer/src/opengl/buffer.rs b/crates/ghostty-renderer/src/opengl/buffer.rs index 88df46b..4b8a08c 100644 --- a/crates/ghostty-renderer/src/opengl/buffer.rs +++ b/crates/ghostty-renderer/src/opengl/buffer.rs @@ -37,7 +37,7 @@ pub struct Buffer { /// Note this is the number of `T`s, not the size in bytes. pub len: usize, - _marker: PhantomData, + _marker: PhantomData T>, } impl Buffer { diff --git a/crates/ghostty-renderer/src/opengl/frame.rs b/crates/ghostty-renderer/src/opengl/frame.rs index c1e0771..7a5f4c2 100644 --- a/crates/ghostty-renderer/src/opengl/frame.rs +++ b/crates/ghostty-renderer/src/opengl/frame.rs @@ -2,7 +2,9 @@ use std::sync::Arc; use super::{ + LastTarget, OpenGL, + gl::Gl, render_pass::{Attachment, Options as RenderPassOptions, RenderPass}, target::Target, }; @@ -15,26 +17,31 @@ pub struct Options {} /// A frame being encoded. /// /// Upstream holds `*Renderer` and calls `renderer.frameCompleted(health)` -/// after presenting; here the frame borrows the graphics API and -/// [`Frame::complete`] returns the [`Health`] for the renderer to report. -pub struct Frame<'a> { - api: &'a mut OpenGL, +/// after presenting; here [`Frame::complete`] returns the [`Health`] for the +/// renderer to report. The frame holds its own handles (GL context and the +/// API's last-presented-target slot) rather than borrowing the API. +pub struct Frame { + gl: Gl, + last_target: LastTarget, target: Arc, } -impl<'a> Frame<'a> { +impl Frame { /// Begin encoding a frame. /// /// The target is presented via the provided API when completed. - pub fn begin(_opts: Options, api: &'a mut OpenGL, target: Arc) -> Frame<'a> { - Frame { api, target } + pub fn begin(_opts: Options, api: &OpenGL, target: Arc) -> Frame { + Frame { + gl: api.gl().expect("OpenGL context not initialized").clone(), + last_target: api.last_target_slot(), + target, + } } /// Add a render pass to this frame with the provided attachments. /// Returns a RenderPass which allows render steps to be added. pub fn render_pass<'b>(&self, attachments: &'b [Attachment<'b>]) -> RenderPass<'b> { - let gl = self.api.gl().expect("OpenGL context not initialized"); - RenderPass::begin(gl, RenderPassOptions { attachments }) + RenderPass::begin(&self.gl, RenderPassOptions { attachments }) } /// Complete this frame and present the target. @@ -43,13 +50,10 @@ impl<'a> Frame<'a> { /// /// NOTE: For OpenGL, `sync` is ignored and we always block. pub fn complete(self, _sync: bool) -> Health { - let Some(gl) = self.api.gl().cloned() else { - return Health::Unhealthy; - }; - gl.finish(); + self.gl.finish(); // If there are any GL errors, consider the frame unhealthy. - let health = if gl.get_error().is_ok() { + let health = if self.gl.get_error().is_ok() { Health::Healthy } else { Health::Unhealthy @@ -57,7 +61,7 @@ impl<'a> Frame<'a> { // If the frame is healthy, present it. if health == Health::Healthy - && let Err(err) = self.api.present(&self.target) + && let Err(err) = super::present_target(&self.gl, &self.last_target, &self.target) { log::error!("Failed to present render target: err={err}"); } diff --git a/crates/ghostty-renderer/src/opengl/gl.rs b/crates/ghostty-renderer/src/opengl/gl.rs index 3399118..68d2727 100644 --- a/crates/ghostty-renderer/src/opengl/gl.rs +++ b/crates/ghostty-renderer/src/opengl/gl.rs @@ -160,14 +160,17 @@ pub enum Wrap { } /// Primitive types we support drawing (same subset as Metal). -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -#[repr(u32)] -pub enum Primitive { - Point = glow::POINTS, - Line = glow::LINES, - LineStrip = glow::LINE_STRIP, - Triangle = glow::TRIANGLES, - TriangleStrip = glow::TRIANGLE_STRIP, +pub use crate::graphics_api::Primitive; + +/// The GL draw mode of a [`Primitive`]. +fn gl_mode(mode: Primitive) -> u32 { + match mode { + Primitive::Point => glow::POINTS, + Primitive::Line => glow::LINES, + Primitive::LineStrip => glow::LINE_STRIP, + Primitive::Triangle => glow::TRIANGLES, + Primitive::TriangleStrip => glow::TRIANGLE_STRIP, + } } /// Framebuffer binding targets. @@ -341,7 +344,7 @@ impl Gl { // SAFETY: see module docs; vertex data comes from bound GL buffers. unsafe { self.ctx - .draw_arrays_instanced(mode as u32, first, count, instances); + .draw_arrays_instanced(gl_mode(mode), first, count, instances); } self.get_error() } diff --git a/crates/ghostty-renderer/src/opengl/mod.rs b/crates/ghostty-renderer/src/opengl/mod.rs index f8bafd7..7bc18e5 100644 --- a/crates/ghostty-renderer/src/opengl/mod.rs +++ b/crates/ghostty-renderer/src/opengl/mod.rs @@ -5,6 +5,7 @@ //! closure (see [`OpenGL::prepare_context`]) by the apprt once the context is //! current, and everything afterwards goes through the [`Gl`] handle. +pub mod api; pub mod buffer; pub mod frame; #[allow(unsafe_code)] @@ -16,7 +17,10 @@ pub mod shaders; pub mod target; pub mod texture; -use std::{ffi::c_void, sync::Arc}; +use std::{ + ffi::c_void, + sync::{Arc, Mutex, PoisonError}, +}; use ghostty_font::atlas::{Atlas, Format as AtlasFormat}; @@ -32,6 +36,7 @@ use self::gl::{ Wrap, }; pub use self::{ + api::GlFrame, buffer::Buffer, frame::Frame, gl::Gl, @@ -43,8 +48,6 @@ pub use self::{ }; use crate::shadertoy; -pub type GraphicsApi = OpenGL; - /// Custom shaders are converted to GLSL for OpenGL. pub const CUSTOM_SHADER_TARGET: shadertoy::Target = shadertoy::Target::Glsl; /// The fragCoord for OpenGL shaders is +Y = up. @@ -85,27 +88,54 @@ pub struct OpenGL { pub blending: AlphaBlending, /// The most recently presented target, in case we need to present it again. - last_target: Option>, + /// Shared with in-flight [`Frame`]s, which record what they present. + last_target: LastTarget, } -/// Pixel format for image texture options. -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub enum ImageTextureFormat { - /// 1 byte per pixel grayscale. - Gray, - /// 4 bytes per pixel RGBA. - Rgba, - /// 4 bytes per pixel BGRA. - Bgra, +/// Slot holding the most recently presented target. +pub type LastTarget = Arc>>>; + +/// Present `target` by blitting it to the default framebuffer, remembering it +/// in `last_target` in case we need to repeat it. +fn present_target(gl: &Gl, last_target: &LastTarget, target: &Arc) -> Result<(), Error> { + // In order to present a target we blit it to the default framebuffer. + + // We disable GL_FRAMEBUFFER_SRGB while doing this blit, otherwise the + // values may be linearized as they're copied, but even though the draw + // framebuffer has a linear internal format, the values in it should be + // sRGB, not linear! + gl.disable(glow::FRAMEBUFFER_SRGB)?; + + // Bind the target for reading. + let fbobind = gl.bind_framebuffer(target.framebuffer(), FramebufferTarget::Read); + + // Blit + let (w, h) = (target.width as i32, target.height as i32); + gl.blit_framebuffer( + (0, 0, w, h), + (0, 0, w, h), + glow::COLOR_BUFFER_BIT, + glow::NEAREST, + ); + + gl.unbind_framebuffer(fbobind); + if let Err(err) = gl.enable(glow::FRAMEBUFFER_SRGB) { + log::error!("Error re-enabling GL_FRAMEBUFFER_SRGB, err={err}"); + } + + // Keep track of this target in case we need to repeat it. + *last_target.lock().unwrap_or_else(PoisonError::into_inner) = Some(Arc::clone(target)); + Ok(()) } -impl ImageTextureFormat { - fn to_pixel_format(self) -> PixelFormat { - match self { - ImageTextureFormat::Gray => PixelFormat::Red, - ImageTextureFormat::Rgba => PixelFormat::Rgba, - ImageTextureFormat::Bgra => PixelFormat::Bgra, - } +pub use crate::graphics_api::ImageTextureFormat; + +/// The GL pixel format of an [`ImageTextureFormat`]. +fn to_pixel_format(format: ImageTextureFormat) -> PixelFormat { + match format { + ImageTextureFormat::Gray => PixelFormat::Red, + ImageTextureFormat::Rgba => PixelFormat::Rgba, + ImageTextureFormat::Bgra => PixelFormat::Bgra, } } @@ -116,7 +146,7 @@ impl OpenGL { OpenGL { gl: None, blending, - last_target: None, + last_target: LastTarget::default(), } } @@ -126,7 +156,7 @@ impl OpenGL { OpenGL { gl: Some(gl), blending, - last_target: None, + last_target: LastTarget::default(), } } @@ -247,42 +277,18 @@ impl OpenGL { } /// Present the provided target. - pub fn present(&mut self, target: &Arc) -> Result<(), Error> { - let gl = self.gl_or_err()?.clone(); - - // In order to present a target we blit it to the default framebuffer. - - // We disable GL_FRAMEBUFFER_SRGB while doing this blit, otherwise the - // values may be linearized as they're copied, but even though the draw - // framebuffer has a linear internal format, the values in it should be - // sRGB, not linear! - gl.disable(glow::FRAMEBUFFER_SRGB)?; - - // Bind the target for reading. - let fbobind = gl.bind_framebuffer(target.framebuffer(), FramebufferTarget::Read); - - // Blit - let (w, h) = (target.width as i32, target.height as i32); - gl.blit_framebuffer( - (0, 0, w, h), - (0, 0, w, h), - glow::COLOR_BUFFER_BIT, - glow::NEAREST, - ); - - gl.unbind_framebuffer(fbobind); - if let Err(err) = gl.enable(glow::FRAMEBUFFER_SRGB) { - log::error!("Error re-enabling GL_FRAMEBUFFER_SRGB, err={err}"); - } - - // Keep track of this target in case we need to repeat it. - self.last_target = Some(Arc::clone(target)); - Ok(()) + pub fn present(&self, target: &Arc) -> Result<(), Error> { + present_target(self.gl_or_err()?, &self.last_target, target) } /// Present the last presented target again. - pub fn present_last_target(&mut self) -> Result<(), Error> { - if let Some(target) = self.last_target.clone() { + pub fn present_last_target(&self) -> Result<(), Error> { + let last = self + .last_target + .lock() + .unwrap_or_else(PoisonError::into_inner) + .clone(); + if let Some(target) = last { self.present(&target)?; } Ok(()) @@ -290,14 +296,25 @@ impl OpenGL { /// The most recently presented target, if any (also set when the blit to /// the default framebuffer is a no-op, e.g. on a surfaceless context). - pub fn last_target(&self) -> Option<&Arc> { - self.last_target.as_ref() + pub fn last_target(&self) -> Option> { + self.last_target + .lock() + .unwrap_or_else(PoisonError::into_inner) + .clone() } /// Drop the remembered last-presented target (call when the target it /// refers to is being replaced, e.g. on resize). - pub fn clear_last_target(&mut self) { - self.last_target = None; + pub fn clear_last_target(&self) { + *self + .last_target + .lock() + .unwrap_or_else(PoisonError::into_inner) = None; + } + + /// The slot [`Frame`]s record the target they present in. + pub(crate) fn last_target_slot(&self) -> LastTarget { + Arc::clone(&self.last_target) } /// Returns the options to use when constructing buffers. @@ -357,7 +374,7 @@ impl OpenGL { srgb: bool, ) -> texture::Options { texture::Options { - format: format.to_pixel_format(), + format: to_pixel_format(format), internal_format: if srgb { InternalFormat::Srgba } else { @@ -404,7 +421,7 @@ impl OpenGL { /// Begin a frame. The target is presented via this API when the frame is /// completed (see [`Frame::complete`]). - pub fn begin_frame(&mut self, target: Arc) -> Frame<'_> { + pub fn begin_frame(&self, target: Arc) -> Frame { Frame::begin(frame::Options::default(), self, target) } } diff --git a/crates/ghostty-renderer/src/opengl/render_pass.rs b/crates/ghostty-renderer/src/opengl/render_pass.rs index e99e415..a525501 100644 --- a/crates/ghostty-renderer/src/opengl/render_pass.rs +++ b/crates/ghostty-renderer/src/opengl/render_pass.rs @@ -1,23 +1,14 @@ //! Wrapper for handling render passes (`opengl/RenderPass.zig`). use super::{ - gl::{Attachment as GlAttachment, BufferTarget, FramebufferTarget, Gl, Primitive, RawBuffer}, - pipeline::Pipeline, - sampler::Sampler, - target::Target, - texture::Texture, + OpenGL, + gl::{Attachment as GlAttachment, BufferTarget, FramebufferTarget, Gl}, }; /// What a color attachment renders into. -pub enum AttachmentTarget<'a> { - Texture(&'a Texture), - Target(&'a Target), -} +pub type AttachmentTarget<'a> = crate::graphics_api::AttachmentTarget<'a, OpenGL>; /// Describes a color attachment. -pub struct Attachment<'a> { - pub target: AttachmentTarget<'a>, - pub clear_color: Option<[f32; 4]>, -} +pub type Attachment<'a> = crate::graphics_api::Attachment<'a, OpenGL>; /// Options for beginning a render pass. #[derive(Clone, Copy)] @@ -27,32 +18,10 @@ pub struct Options<'a> { } /// Describes the draw call for a step. -#[derive(Debug, Clone, Copy)] -pub struct Draw { - pub ty: Primitive, - pub vertex_count: usize, - pub instance_count: usize, -} - -impl Draw { - pub fn new(ty: Primitive, vertex_count: usize) -> Self { - Draw { - ty, - vertex_count, - instance_count: 1, - } - } -} +pub use crate::graphics_api::Draw; /// Describes a step in a render pass. -pub struct Step<'a> { - pub pipeline: &'a Pipeline, - pub uniforms: Option, - pub buffers: &'a [Option], - pub textures: &'a [Option<&'a Texture>], - pub samplers: &'a [Option<&'a Sampler>], - pub draw: Draw, -} +pub type Step<'a> = crate::graphics_api::Step<'a, OpenGL>; pub struct RenderPass<'a> { gl: Gl, diff --git a/crates/ghostty-renderer/src/opengl/shaders.rs b/crates/ghostty-renderer/src/opengl/shaders.rs index 42aa91e..3b2d69a 100644 --- a/crates/ghostty-renderer/src/opengl/shaders.rs +++ b/crates/ghostty-renderer/src/opengl/shaders.rs @@ -1,11 +1,8 @@ //! Shader pipelines and GPU data layouts for OpenGL (`opengl/shaders.zig`). -use bytemuck::{Pod, Zeroable}; - use super::{ gl::{self, AttribType, Gl}, pipeline::{Attribute, Options as PipelineOptions, Pipeline, StepFunction, Vertex}, }; -use crate::math::Mat; /// `shaders/glsl/common.glsl`, the only `#include`d file. const COMMON_GLSL: &str = include_str!("../../shaders/glsl/common.glsl"); @@ -160,167 +157,19 @@ pub fn process_includes(contents: &str) -> String { contents.to_owned() } -/// The uniforms that are passed to our shaders (std140 layout, 144 bytes). -#[repr(C)] -#[derive(Debug, Clone, Copy, PartialEq, Default, Pod, Zeroable)] -pub struct Uniforms { - /// The projection matrix for turning world coordinates to normalized. - /// This is calculated based on the size of the screen. - pub projection_matrix: Mat, - - /// Size of the screen (render target) in pixels. - pub screen_size: [f32; 2], - - /// Size of a single cell in pixels, unscaled. - pub cell_size: [f32; 2], - - /// Size of the grid in columns and rows. - pub grid_size: [u16; 2], - - // Explicit padding so that `grid_padding` is 16-byte aligned (std140). - pub pad0: [u8; 12], - - /// The padding around the terminal grid in pixels. In order: - /// top, right, bottom, left. - pub grid_padding: [f32; 4], - - /// Bit mask defining which directions to - /// extend cell colors in to the padding. - /// Order, LSB first: left, right, up, down. See [`PaddingExtend`]. - pub padding_extend: u32, - - /// The minimum contrast ratio for text. The contrast ratio is calculated - /// according to the WCAG 2.0 spec. - pub min_contrast: f32, - - /// The cursor position and color. - pub cursor_pos: [u16; 2], - pub cursor_color: [u8; 4], - - /// The background color for the whole surface. - pub bg_color: [u8; 4], - - /// Various booleans, in a packed bitfield for space efficiency. See [`Bools`]. - pub bools: u32, - - // Tail padding to the struct's 16-byte alignment (extern struct size). - pub pad1: [u8; 8], -} - -/// The `bools` bitfield of [`Uniforms`] (a `packed struct(u32)` upstream). -#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] -pub struct Bools { - /// Whether the cursor is 2 cells wide. - pub cursor_wide: bool, - - /// Indicates that colors provided to the shader are already in - /// the P3 color space, so they don't need to be converted from - /// sRGB. - pub use_display_p3: bool, - - /// Indicates that the color attachments for the shaders have - /// an `*_srgb` pixel format, which means the shaders need to - /// output linear RGB colors rather than gamma encoded colors, - /// since blending will be performed in linear space and then - /// the GPU itself will re-encode the colors for storage. - pub use_linear_blending: bool, - - /// Enables a weight correction step that makes text rendered - /// with linear alpha blending have a similar apparent weight - /// (thickness) to gamma-incorrect blending. - pub use_linear_correction: bool, -} - -impl Bools { - pub const CURSOR_WIDE: u32 = 1; - pub const USE_DISPLAY_P3: u32 = 2; - pub const USE_LINEAR_BLENDING: u32 = 4; - pub const USE_LINEAR_CORRECTION: u32 = 8; - - pub fn to_bits(self) -> u32 { - u32::from(self.cursor_wide) - | u32::from(self.use_display_p3) << 1 - | u32::from(self.use_linear_blending) << 2 - | u32::from(self.use_linear_correction) << 3 - } - - pub fn from_bits(bits: u32) -> Self { - Bools { - cursor_wide: bits & Self::CURSOR_WIDE != 0, - use_display_p3: bits & Self::USE_DISPLAY_P3 != 0, - use_linear_blending: bits & Self::USE_LINEAR_BLENDING != 0, - use_linear_correction: bits & Self::USE_LINEAR_CORRECTION != 0, - } - } -} - -/// The `padding_extend` bitfield of [`Uniforms`] (a `packed struct(u32)` upstream). -#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] -pub struct PaddingExtend { - pub left: bool, - pub right: bool, - pub up: bool, - pub down: bool, -} - -impl PaddingExtend { - pub fn to_bits(self) -> u32 { - u32::from(self.left) - | u32::from(self.right) << 1 - | u32::from(self.up) << 2 - | u32::from(self.down) << 3 - } - - pub fn from_bits(bits: u32) -> Self { - PaddingExtend { - left: bits & 1 != 0, - right: bits & 2 != 0, - up: bits & 4 != 0, - down: bits & 8 != 0, - } - } -} - -/// Which atlas a glyph lives in (`CellText.Atlas`). -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -#[repr(u8)] -pub enum CellTextAtlas { - Grayscale = 0, - Color = 1, -} - -/// This is a single parameter for the terminal cell shader (32 bytes). -#[repr(C)] -#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Pod, Zeroable)] -pub struct CellText { - pub glyph_pos: [u32; 2], - pub glyph_size: [u32; 2], - pub bearings: [i16; 2], - pub grid_pos: [u16; 2], - pub color: [u8; 4], - /// A [`CellTextAtlas`] value. - pub atlas: u8, - /// Bit 0: `no_min_contrast`, bit 1: `is_cursor_glyph`. See the consts below. - pub bools: u8, - pub pad: [u8; 2], -} - -impl CellText { - pub const NO_MIN_CONTRAST: u8 = 1; - pub const IS_CURSOR_GLYPH: u8 = 2; - - pub fn atlas(&self) -> CellTextAtlas { - if self.atlas == 0 { - CellTextAtlas::Grayscale - } else { - CellTextAtlas::Color - } - } - - pub fn set_atlas(&mut self, a: CellTextAtlas) { - self.atlas = a as u8; - } -} +pub use crate::shader_types::{ + BgImage, + BgImageFit, + BgImageInfo, + BgImagePosition, + Bools, + CellBg, + CellText, + CellTextAtlas, + Image, + PaddingExtend, + Uniforms, +}; impl Vertex for CellText { const ATTRIBUTES: &'static [Attribute] = &[ @@ -362,21 +211,6 @@ impl Vertex for CellText { ]; } -/// This is a single parameter for the cell bg shader. -pub type CellBg = [u8; 4]; - -/// Single parameter for the image shader. See shader for field details. -/// (48 bytes: the Zig extern struct is 16-byte aligned.) -#[repr(C)] -#[derive(Debug, Clone, Copy, PartialEq, Default, Pod, Zeroable)] -pub struct Image { - pub grid_pos: [f32; 2], - pub cell_offset: [f32; 2], - pub source_rect: [f32; 4], - pub dest_size: [f32; 2], - pub pad: [f32; 2], -} - impl Vertex for Image { const ATTRIBUTES: &'static [Attribute] = &[ Attribute { @@ -402,53 +236,6 @@ impl Vertex for Image { ]; } -/// Single parameter for the bg image shader (8 bytes). -#[repr(C)] -#[derive(Debug, Clone, Copy, PartialEq, Default, Pod, Zeroable)] -pub struct BgImage { - pub opacity: f32, - /// A packed [`BgImageInfo`] byte. - pub info: u8, - pub pad: [u8; 3], -} - -/// `BgImage.Info`, a `packed struct(u8)`: position:u4, fit:u2, repeat:u1, pad:u1. -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub struct BgImageInfo { - pub position: BgImagePosition, - pub fit: BgImageFit, - pub repeat: bool, -} - -impl BgImageInfo { - pub fn to_bits(self) -> u8 { - (self.position as u8) | (self.fit as u8) << 4 | u8::from(self.repeat) << 6 - } -} - -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -#[repr(u8)] -pub enum BgImagePosition { - Tl = 0, - Tc = 1, - Tr = 2, - Ml = 3, - Mc = 4, - Mr = 5, - Bl = 6, - Bc = 7, - Br = 8, -} - -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -#[repr(u8)] -pub enum BgImageFit { - Contain = 0, - Cover = 1, - Stretch = 2, - None = 3, -} - impl Vertex for BgImage { const ATTRIBUTES: &'static [Attribute] = &[ Attribute { diff --git a/crates/ghostty-renderer/src/shader_types.rs b/crates/ghostty-renderer/src/shader_types.rs new file mode 100644 index 0000000..5c201ad --- /dev/null +++ b/crates/ghostty-renderer/src/shader_types.rs @@ -0,0 +1,233 @@ +//! GPU data layouts shared by every graphics API (`opengl/shaders.zig` and +//! `metal/shaders.zig` define identical CPU-side structs; the only difference +//! is the Metal `Uniforms.bools` layout, see `crate::metal::shaders`). +//! +//! The CPU-side [`Uniforms`] is the std140 layout used by OpenGL; APIs with a +//! different GPU layout convert it with `GraphicsApi::gpu_uniforms`. +use bytemuck::{Pod, Zeroable}; + +use crate::math::Mat; + +/// The uniforms that are passed to our shaders (std140 layout, 144 bytes). +#[repr(C)] +#[derive(Debug, Clone, Copy, PartialEq, Default, Pod, Zeroable)] +pub struct Uniforms { + /// The projection matrix for turning world coordinates to normalized. + /// This is calculated based on the size of the screen. + pub projection_matrix: Mat, + + /// Size of the screen (render target) in pixels. + pub screen_size: [f32; 2], + + /// Size of a single cell in pixels, unscaled. + pub cell_size: [f32; 2], + + /// Size of the grid in columns and rows. + pub grid_size: [u16; 2], + + // Explicit padding so that `grid_padding` is 16-byte aligned (std140). + pub pad0: [u8; 12], + + /// The padding around the terminal grid in pixels. In order: + /// top, right, bottom, left. + pub grid_padding: [f32; 4], + + /// Bit mask defining which directions to + /// extend cell colors in to the padding. + /// Order, LSB first: left, right, up, down. See [`PaddingExtend`]. + pub padding_extend: u32, + + /// The minimum contrast ratio for text. The contrast ratio is calculated + /// according to the WCAG 2.0 spec. + pub min_contrast: f32, + + /// The cursor position and color. + pub cursor_pos: [u16; 2], + pub cursor_color: [u8; 4], + + /// The background color for the whole surface. + pub bg_color: [u8; 4], + + /// Various booleans, in a packed bitfield for space efficiency. See [`Bools`]. + pub bools: u32, + + // Tail padding to the struct's 16-byte alignment (extern struct size). + pub pad1: [u8; 8], +} + +/// The `bools` bitfield of [`Uniforms`] (a `packed struct(u32)` upstream). +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] +pub struct Bools { + /// Whether the cursor is 2 cells wide. + pub cursor_wide: bool, + + /// Indicates that colors provided to the shader are already in + /// the P3 color space, so they don't need to be converted from + /// sRGB. + pub use_display_p3: bool, + + /// Indicates that the color attachments for the shaders have + /// an `*_srgb` pixel format, which means the shaders need to + /// output linear RGB colors rather than gamma encoded colors, + /// since blending will be performed in linear space and then + /// the GPU itself will re-encode the colors for storage. + pub use_linear_blending: bool, + + /// Enables a weight correction step that makes text rendered + /// with linear alpha blending have a similar apparent weight + /// (thickness) to gamma-incorrect blending. + pub use_linear_correction: bool, +} + +impl Bools { + pub const CURSOR_WIDE: u32 = 1; + pub const USE_DISPLAY_P3: u32 = 2; + pub const USE_LINEAR_BLENDING: u32 = 4; + pub const USE_LINEAR_CORRECTION: u32 = 8; + + pub fn to_bits(self) -> u32 { + u32::from(self.cursor_wide) + | u32::from(self.use_display_p3) << 1 + | u32::from(self.use_linear_blending) << 2 + | u32::from(self.use_linear_correction) << 3 + } + + pub fn from_bits(bits: u32) -> Self { + Bools { + cursor_wide: bits & Self::CURSOR_WIDE != 0, + use_display_p3: bits & Self::USE_DISPLAY_P3 != 0, + use_linear_blending: bits & Self::USE_LINEAR_BLENDING != 0, + use_linear_correction: bits & Self::USE_LINEAR_CORRECTION != 0, + } + } +} + +/// The `padding_extend` bitfield of [`Uniforms`] (a `packed struct(u32)` upstream). +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] +pub struct PaddingExtend { + pub left: bool, + pub right: bool, + pub up: bool, + pub down: bool, +} + +impl PaddingExtend { + pub fn to_bits(self) -> u32 { + u32::from(self.left) + | u32::from(self.right) << 1 + | u32::from(self.up) << 2 + | u32::from(self.down) << 3 + } + + pub fn from_bits(bits: u32) -> Self { + PaddingExtend { + left: bits & 1 != 0, + right: bits & 2 != 0, + up: bits & 4 != 0, + down: bits & 8 != 0, + } + } +} + +/// Which atlas a glyph lives in (`CellText.Atlas`). +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +#[repr(u8)] +pub enum CellTextAtlas { + Grayscale = 0, + Color = 1, +} + +/// This is a single parameter for the terminal cell shader (32 bytes). +#[repr(C)] +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Pod, Zeroable)] +pub struct CellText { + pub glyph_pos: [u32; 2], + pub glyph_size: [u32; 2], + pub bearings: [i16; 2], + pub grid_pos: [u16; 2], + pub color: [u8; 4], + /// A [`CellTextAtlas`] value. + pub atlas: u8, + /// Bit 0: `no_min_contrast`, bit 1: `is_cursor_glyph`. See the consts below. + pub bools: u8, + pub pad: [u8; 2], +} + +impl CellText { + pub const NO_MIN_CONTRAST: u8 = 1; + pub const IS_CURSOR_GLYPH: u8 = 2; + + pub fn atlas(&self) -> CellTextAtlas { + if self.atlas == 0 { + CellTextAtlas::Grayscale + } else { + CellTextAtlas::Color + } + } + + pub fn set_atlas(&mut self, a: CellTextAtlas) { + self.atlas = a as u8; + } +} + +/// This is a single parameter for the cell bg shader. +pub type CellBg = [u8; 4]; + +/// Single parameter for the image shader. See shader for field details. +/// (48 bytes: the Zig extern struct is 16-byte aligned.) +#[repr(C)] +#[derive(Debug, Clone, Copy, PartialEq, Default, Pod, Zeroable)] +pub struct Image { + pub grid_pos: [f32; 2], + pub cell_offset: [f32; 2], + pub source_rect: [f32; 4], + pub dest_size: [f32; 2], + pub pad: [f32; 2], +} + +/// Single parameter for the bg image shader (8 bytes). +#[repr(C)] +#[derive(Debug, Clone, Copy, PartialEq, Default, Pod, Zeroable)] +pub struct BgImage { + pub opacity: f32, + /// A packed [`BgImageInfo`] byte. + pub info: u8, + pub pad: [u8; 3], +} + +/// `BgImage.Info`, a `packed struct(u8)`: position:u4, fit:u2, repeat:u1, pad:u1. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct BgImageInfo { + pub position: BgImagePosition, + pub fit: BgImageFit, + pub repeat: bool, +} + +impl BgImageInfo { + pub fn to_bits(self) -> u8 { + (self.position as u8) | (self.fit as u8) << 4 | u8::from(self.repeat) << 6 + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +#[repr(u8)] +pub enum BgImagePosition { + Tl = 0, + Tc = 1, + Tr = 2, + Ml = 3, + Mc = 4, + Mr = 5, + Bl = 6, + Bc = 7, + Br = 8, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +#[repr(u8)] +pub enum BgImageFit { + Contain = 0, + Cover = 1, + Stretch = 2, + None = 3, +} diff --git a/crates/ghostty-renderer/src/shadertoy.rs b/crates/ghostty-renderer/src/shadertoy.rs index cfa7712..897d879 100644 --- a/crates/ghostty-renderer/src/shadertoy.rs +++ b/crates/ghostty-renderer/src/shadertoy.rs @@ -12,7 +12,7 @@ use std::{io::Read, path::Path}; use bytemuck::{Pod, Zeroable}; use naga::{ - back::glsl as glsl_out, + back::{glsl as glsl_out, msl as msl_out}, front::glsl as glsl_in, valid::{Capabilities, ModuleInfo, ValidationFlags, Validator}, }; @@ -71,8 +71,7 @@ impl Default for Uniforms { #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum Target { Glsl, - /// Metal is out of scope for the Rust port; converting to it fails with - /// [`Error::Unsupported`]. + /// Metal Shading Language (naga's `msl-out` replaces spirv-cross). Msl, } @@ -101,7 +100,7 @@ pub enum Error { #[error("shader validation failed")] ValidationFailed, /// Writing the target GLSL failed (upstream: `error.SpvcFailed`). - #[error("GLSL generation failed: {0}")] + #[error("shader generation failed: {0}")] SpvcFailed(String), #[error("unsupported shader target")] Unsupported, @@ -171,7 +170,7 @@ pub fn load_from_file(path: &Path, target: Target) -> Result { match target { Target::Glsl => glsl_from_compiled(&compiled), - Target::Msl => Err(Error::Unsupported), + Target::Msl => msl_from_compiled(&compiled), } } @@ -295,6 +294,86 @@ fn adapt_for_naga(full: &str) -> String { s } +/// The name of the fragment entry point in generated MSL. spirv-cross calls +/// it `main0` because `main` is reserved in MSL; naga would turn `main0` into +/// `main0_` (it suffixes names ending in a digit), so we use a name it keeps +/// as is. The Metal renderer looks the function up by this name. +pub const MSL_ENTRY_POINT: &str = "ghostty_main"; + +/// Convert a compiled shader to MSL (`mslFromSpv`). +/// +/// The resource slots follow what spirv-cross produces with +/// `SPVC_COMPILER_OPTION_MSL_ENABLE_DECORATION_BINDING` for the shadertoy +/// prefix: the `Globals` uniform block at `[[buffer(1)]]` ("we need this to +/// properly locate the uniform block to index 1") and `iChannel0` as +/// `[[texture(0)]]` / `[[sampler(0)]]`, which is where the Metal render pass +/// binds them. +pub fn msl_from_compiled(compiled: &Compiled) -> Result { + let fail = |e: &dyn std::fmt::Display| { + log::warn!("spirv-cross error message={e}"); + Error::SpvcFailed(e.to_string()) + }; + + // Rename the entry point (info is indexed by entry point order, which + // doesn't change). + let mut module = compiled.module.clone(); + for ep in &mut module.entry_points { + if ep.name == "main" { + ep.name = MSL_ENTRY_POINT.into(); + } + } + + let mut resources = msl_out::EntryPointResources::default(); + let mut bind = |binding: u32, target: msl_out::BindTarget| { + resources + .resources + .insert(naga::ResourceBinding { group: 0, binding }, target); + }; + // iChannel0 texture (the sampler is split off at binding 2, see + // `adapt_for_naga`). + bind( + 0, + msl_out::BindTarget { + texture: Some(0), + ..Default::default() + }, + ); + bind( + 1, + msl_out::BindTarget { + buffer: Some(1), + ..Default::default() + }, + ); + bind( + 2, + msl_out::BindTarget { + sampler: Some(msl_out::BindSamplerTarget::Resource(0)), + ..Default::default() + }, + ); + + let mut options = msl_out::Options { + // Needed for `[[position]]` fragment inputs etc. on all supported + // macOS versions (the metallib is built for macOS 10.14 / MSL 2.1). + lang_version: (2, 1), + fake_missing_bindings: false, + ..Default::default() + }; + options + .per_entry_point_map + .insert(MSL_ENTRY_POINT.into(), resources); + + let pipeline = msl_out::PipelineOptions { + entry_point: Some((naga::ShaderStage::Fragment, MSL_ENTRY_POINT.into())), + ..Default::default() + }; + + let (msl, _info) = msl_out::write_string(&module, &compiled.info, &options, &pipeline) + .map_err(|e| fail(&e))?; + Ok(msl) +} + /// Convert a compiled shader to GLSL 430 core (`glslFromSpv`). pub fn glsl_from_compiled(compiled: &Compiled) -> Result { const GLSL_VERSION: u16 = 430; @@ -383,17 +462,38 @@ mod tests { } #[test] - fn shadertoy_to_msl_unsupported() { + fn shadertoy_to_msl() { + // `shadertoy to msl` let compiled = compile_shadertoy(TEST_CRT, None).unwrap(); assert_eq!(compiled.module.entry_points.len(), 1); + let msl = msl_from_compiled(&compiled).unwrap(); + assert!(msl.contains("#include ")); + // The Metal renderer looks the fragment function up by this name. + assert!( + msl.contains("fragment ghostty_mainOutput ghostty_main("), + "{msl}" + ); + // Uniform block at buffer 1, channel at texture 0 / sampler 0. + assert!(msl.contains("[[buffer(1)]]"), "{msl}"); + assert!(msl.contains("[[texture(0)]]"), "{msl}"); + assert!(msl.contains("[[sampler(0)]]"), "{msl}"); + } + + #[test] + fn shadertoy_focus_to_msl() { + let compiled = compile_shadertoy(TEST_FOCUS, None).unwrap(); + let msl = msl_from_compiled(&compiled).unwrap(); + assert!(msl.contains("ghostty_main(")); + } + + #[test] + fn load_from_file_msl() { let dir = std::env::temp_dir().join(format!("ghostty-shadertoy-{}", std::process::id())); std::fs::create_dir_all(&dir).unwrap(); let path = dir.join("crt.glsl"); std::fs::write(&path, TEST_CRT).unwrap(); - assert!(matches!( - load_from_file(&path, Target::Msl), - Err(Error::Unsupported) - )); + let msl = load_from_file(&path, Target::Msl).unwrap(); + assert!(msl.contains("ghostty_main(")); std::fs::remove_dir_all(&dir).ok(); } diff --git a/crates/ghostty-renderer/src/thread.rs b/crates/ghostty-renderer/src/thread.rs index c03b4f1..5ae9740 100644 --- a/crates/ghostty-renderer/src/thread.rs +++ b/crates/ghostty-renderer/src/thread.rs @@ -41,7 +41,9 @@ use ghostty_config::{config::Config, types_a::CustomShaderAnimation}; use ghostty_datastruct::blocking_queue::{BlockingQueue, Timeout}; use crate::{ + Api, generic::{Host, Renderer}, + graphics_api::GraphicsApi, message::Message, state::SharedState, }; @@ -161,12 +163,72 @@ impl Drop for Running { } } +/// Runs `Renderer::loop_exit` when the thread leaves its main loop. +struct LoopGuard(Arc>); + +impl Drop for LoopGuard { + fn drop(&mut self) { + self.0.loop_exit(); + } +} + +/// Runs `Renderer::thread_exit` when the thread exits. +struct ThreadGuard(Arc>); + +impl Drop for ThreadGuard { + fn drop(&mut self) { + self.0.thread_exit(); + } +} + +/// Run `f` inside an autorelease pool on Darwin ("This is effectively an +/// @autoreleasepool{} block, which we need in order to ensure that +/// autoreleased objects are properly released."); just calls `f` elsewhere. +fn with_autorelease_pool(f: impl FnOnce() -> R) -> R { + #[cfg(target_vendor = "apple")] + { + objc2::rc::autoreleasepool(|_| f()) + } + #[cfg(not(target_vendor = "apple"))] + { + f() + } +} + +/// The QoS class for the thread's visibility / focus state +/// (`setQosClass`): "If we aren't visible (our view is fully occluded) then we +/// always drop our rendering priority down because it's just mostly wasted +/// work. If we're not focused, but we're visible, then we set a higher than +/// default priority because framerates still matter but it isn't as important +/// as when we're focused. We are focused and visible, we are the definition of +/// user interactive." +#[cfg(any(target_vendor = "apple", test))] +fn qos_for(visible: bool, focused: bool) -> QosClass { + if !visible { + QosClass::Utility + } else if !focused { + QosClass::UserInitiated + } else { + QosClass::UserInteractive + } +} + +/// `ghostty_os::macos::QosClass` (re-declared so the policy can be tested on +/// every platform). +#[cfg(any(target_vendor = "apple", test))] +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum QosClass { + UserInteractive, + UserInitiated, + Utility, +} + /// The renderer thread state. Construct with [`Thread::new`] then start it /// with [`Thread::spawn`] (or run [`Thread::thread_main`] on a thread you /// manage). -pub struct Thread { +pub struct Thread { /// The underlying renderer implementation. - renderer: Arc, + renderer: Arc>, /// Pointer to the shared state that is used to generate the final render. state: SharedState, @@ -177,13 +239,16 @@ pub struct Thread { /// Configuration we need derived from the main config. config: DerivedConfig, - /// If true (the default, GTK), draws are requested from the apprt via + /// If true (the default, except on Apple platforms), draws are requested from the apprt via /// [`Host::redraw_surface`] instead of being performed on this thread /// (`must_draw_from_app_thread`). pub draw_on_app_thread: bool, flags: Flags, + /// The surface dimensions recorded in crash reports. + pub crash_dims: Option>, + handle: Handle, wakeup_rx: Receiver<()>, wakeup_tx: Sender<()>, @@ -198,16 +263,16 @@ pub struct Thread { draw_deadline: Option, } -impl Thread { +impl Thread { /// Initialize the thread. This does not START the thread. This only sets /// up all the internal state necessary prior to starting the thread. It /// is up to the caller to start the thread with [`Thread::spawn`]. pub fn new( config: &Config, - renderer: Arc, + renderer: Arc>, state: SharedState, host: Arc, - ) -> Thread { + ) -> Thread { // Wakeups, stop and draw-now coalesce (xev.Async semantics). let (wakeup_tx, wakeup_rx) = bounded(1); let (stop_tx, stop_rx) = bounded(1); @@ -225,13 +290,17 @@ impl Thread { state, host, config: DerivedConfig::new(config), - draw_on_app_thread: true, + // Only the GTK apprt must draw from the app thread + // (`apprt.App.must_draw_from_app_thread`); the embedded macOS / + // iOS apprt lets the renderer thread draw (Metal is thread-safe). + draw_on_app_thread: !cfg!(target_vendor = "apple"), flags: Flags { cursor_blink_visible: false, has_inspector: false, visible: true, focused: true, }, + crash_dims: None, handle, wakeup_rx, wakeup_tx, @@ -266,8 +335,34 @@ impl Thread { pub fn thread_main(mut self) { log::debug!("starting renderer thread"); - // Run our thread start/end callbacks. For OpenGL on GTK these are + // Right now, on Darwin, `std.Thread.setName` can only name the current + // thread, and we have no way to get the current thread from within it, + // so instead we use this code to name the thread instead. + #[cfg(target_vendor = "apple")] + ghostty_os::macos::pthread_setname_np(c"renderer"); + + // Setup our crash metadata + let _crash = ghostty_crash::ThreadGuard::new( + ghostty_crash::ThreadType::Renderer, + self.crash_dims.clone(), + ); + + // Setup our thread QoS + self.set_qos_class(); + + // Run our loop start/end callbacks if the renderer cares (Metal's + // display callback, the macOS display link). + let draw_now = self.handle.draw_now.clone(); + self.renderer.loop_enter(Arc::new(move || { + let _ = draw_now.try_send(()); + })); + let _loop_guard = LoopGuard(Arc::clone(&self.renderer)); + + // Run our thread start/end callbacks. This is important because some + // renderers have to do per-thread setup. For OpenGL on GTK these are // no-ops: all GL work is on the main thread. + self.renderer.thread_enter(); + let _thread_guard = ThreadGuard(Arc::clone(&self.renderer)); // Send an initial wakeup message so that we render right away. let _ = self.wakeup_tx.try_send(()); @@ -344,6 +439,31 @@ impl Thread { /// Drain the mailbox. fn drain_mailbox(&mut self) { + with_autorelease_pool(|| self.drain_mailbox_inner()); + } + + /// Set the QoS class of this thread. Thread QoS classes are only + /// relevant on macOS. + #[cfg_attr(not(target_vendor = "apple"), allow(clippy::unused_self))] + fn set_qos_class(&self) { + #[cfg(target_vendor = "apple")] + { + use ghostty_os::macos::{self, QosClass as OsQosClass}; + + let class = qos_for(self.flags.visible, self.flags.focused); + let os_class = match class { + QosClass::UserInteractive => OsQosClass::UserInteractive, + QosClass::UserInitiated => OsQosClass::UserInitiated, + QosClass::Utility => OsQosClass::Utility, + }; + match macos::set_qos_class(os_class) { + Ok(()) => log::debug!("thread QoS class set class={class:?}"), + Err(err) => log::warn!("error setting QoS class err={err}"), + } + } + } + + fn drain_mailbox_inner(&mut self) { while let Some(message) = self.handle.mailbox.pop() { match message { Message::Crash => panic!("crash request, crashing intentionally"), @@ -357,6 +477,9 @@ impl Thread { // Set our visible state self.flags.visible = v; + // Visibility affects our QoS class + self.set_qos_class(); + // If we became visible then we immediately trigger a draw. // We don't need to update frame data because that should // still be happening. @@ -384,6 +507,9 @@ impl Thread { // Set our state self.flags.focused = v; + // Focus affects our QoS class + self.set_qos_class(); + // Set it on the renderer self.renderer.set_focus(v); @@ -441,7 +567,7 @@ impl Thread { self.flags.has_inspector = v; } - Message::MacosDisplayId(_) => {} + Message::MacosDisplayId(id) => self.renderer.set_macos_display_id(id), } } } @@ -546,6 +672,14 @@ mod tests { assert!(!should_draw_timer_run(true, False, true)); } + #[test] + fn qos_class_policy() { + assert_eq!(qos_for(false, true), QosClass::Utility); + assert_eq!(qos_for(false, false), QosClass::Utility); + assert_eq!(qos_for(true, false), QosClass::UserInitiated); + assert_eq!(qos_for(true, true), QosClass::UserInteractive); + } + #[test] fn intervals_match_zig() { assert_eq!(DRAW_INTERVAL, Duration::from_millis(8)); diff --git a/crates/ghostty-renderer/tests/egl_headless.rs b/crates/ghostty-renderer/tests/egl_headless.rs index 309dcbc..51a6fb5 100644 --- a/crates/ghostty-renderer/tests/egl_headless.rs +++ b/crates/ghostty-renderer/tests/egl_headless.rs @@ -5,6 +5,8 @@ //! Skipped (returns early with a message) when no EGL display / GL 4.3 core //! context can be created, e.g. on CI without Mesa. +// These tests drive the OpenGL backend (Mesa); Apple platforms render with Metal. +#![cfg(not(target_vendor = "apple"))] // The EGL loader calls are `unsafe fn`s in khronos-egl (test-only usage). #![allow(unsafe_code, clippy::unwrap_used)] @@ -128,7 +130,7 @@ fn px(buf: &[u8], w: usize, x: usize, y: usize) -> [u8; 4] { #[test] fn headless_shaders_compile_and_render() { - let Some((_h, mut api)) = setup() else { + let Some((_h, api)) = setup() else { return; }; let gl: Gl = api.gl().unwrap().clone(); diff --git a/crates/ghostty-renderer/tests/mock_api.rs b/crates/ghostty-renderer/tests/mock_api.rs new file mode 100644 index 0000000..b4bec45 --- /dev/null +++ b/crates/ghostty-renderer/tests/mock_api.rs @@ -0,0 +1,398 @@ +//! The generic renderer over a mock graphics API: no GPU, no GL context. +//! +//! The mock completes frames on a worker thread (like Metal's command buffer +//! completion handlers) with a swap chain of three frames, which exercises the +//! swap chain hand-back, health reporting and the step/slot conventions the +//! real backends rely on. + +#![allow(clippy::unwrap_used)] + +use std::{ + sync::{ + Arc, + Mutex, + atomic::{AtomicUsize, Ordering}, + }, + time::Duration, +}; + +use bytemuck::Pod; +use ghostty_config::{config::Config, types_b::AlphaBlending}; +use ghostty_font::{ + Atlas, + face::DesiredSize, + shared_grid_set::{DerivedConfig as FontDerivedConfig, SharedGridSet}, +}; +use ghostty_renderer::{ + Health, + generic::{DerivedConfig, Host, Renderer}, + graphics_api::{ + ApiError, + Attachment, + FrameCompletion, + GpuBuffer, + GpuFrame, + GpuRenderPass, + GpuShaders, + GpuTarget, + GpuTexture, + GraphicsApi, + Step, + TextureKind, + }, + options::Options, + shader_types::Uniforms, + shadertoy, + size::{CellSize, Padding, ScreenSize, Size}, + state::State, +}; +use ghostty_terminal::{ + page_list::Scrollbar, + terminal::{Options as TermOptions, Terminal}, +}; + +/// What the mock saw: one entry per render pass step, as +/// `pipeline:instances`. +type Log = Arc>>; + +struct MockApi { + blending: AlphaBlending, + log: Log, + completed: Arc, + surface: (u32, u32), +} + +struct MockTarget(usize, usize); +impl GpuTarget for MockTarget { + fn width(&self) -> usize { + self.0 + } + fn height(&self) -> usize { + self.1 + } +} + +struct MockTexture { + w: usize, + h: usize, +} +impl GpuTexture for MockTexture { + fn width(&self) -> usize { + self.w + } + fn height(&self) -> usize { + self.h + } + fn replace_region( + &self, + _x: usize, + _y: usize, + _w: usize, + _h: usize, + _data: &[u8], + ) -> Result<(), ApiError> { + Ok(()) + } +} + +struct MockBuffer { + id: usize, + len: usize, + _marker: std::marker::PhantomData T>, +} +impl GpuBuffer for MockBuffer { + fn raw(&self) -> usize { + self.id + } + fn sync(&mut self, data: &[T]) -> Result<(), ApiError> { + self.len = data.len(); + Ok(()) + } + fn sync_from_slices(&mut self, lists: &[&[T]]) -> Result { + self.len = lists.iter().map(|l| l.len()).sum(); + Ok(self.len) + } +} + +struct MockPipeline(&'static str); + +struct MockShaders { + pipelines: [MockPipeline; 5], + post: Vec, +} +impl GpuShaders for MockShaders { + fn bg_color(&self) -> &MockPipeline { + &self.pipelines[0] + } + fn cell_bg(&self) -> &MockPipeline { + &self.pipelines[1] + } + fn cell_text(&self) -> &MockPipeline { + &self.pipelines[2] + } + fn image(&self) -> &MockPipeline { + &self.pipelines[3] + } + fn bg_image(&self) -> &MockPipeline { + &self.pipelines[4] + } + fn post_pipelines(&self) -> &[MockPipeline] { + &self.post + } +} + +struct MockFrame { + log: Log, + completed: Arc, +} +struct MockPass { + log: Log, +} +impl GpuRenderPass<'_, MockApi> for MockPass { + fn step(&mut self, step: &Step<'_, MockApi>) { + self.log + .lock() + .unwrap() + .push(format!("{}:{}", step.pipeline.0, step.draw.instance_count)); + } + fn complete(self) { + self.log.lock().unwrap().push("pass-end".into()); + } +} +impl GpuFrame for MockFrame { + fn render_pass<'a>(&'a self, _attachments: &'a [Attachment<'a, MockApi>]) -> MockPass + where + MockApi: 'a, + { + MockPass { + log: self.log.clone(), + } + } + fn complete(self, sync: bool, completion: FrameCompletion) { + let completed = self.completed; + let done = move || { + std::thread::sleep(Duration::from_millis(5)); + completion(Health::Healthy); + completed.fetch_add(1, Ordering::SeqCst); + }; + if sync { + done(); + } else { + std::thread::spawn(done); + } + } +} + +impl GraphicsApi for MockApi { + const CUSTOM_SHADER_TARGET: shadertoy::Target = shadertoy::Target::Msl; + const CUSTOM_SHADER_Y_IS_DOWN: bool = true; + const SWAP_CHAIN_COUNT: usize = 3; + + type Target = MockTarget; + type Texture = MockTexture; + type Sampler = (); + type Buffer = MockBuffer; + type BufferRef = usize; + type Pipeline = MockPipeline; + type Shaders = MockShaders; + type GpuUniforms = Uniforms; + type Frame = MockFrame; + type RenderPass<'a> = MockPass; + + fn gpu_uniforms(u: &Uniforms) -> Uniforms { + *u + } + fn blending(&self) -> AlphaBlending { + self.blending + } + fn set_blending(&mut self, b: AlphaBlending) { + self.blending = b; + } + fn init_shaders(&self, _custom: &[String]) -> Result { + Ok(MockShaders { + pipelines: [ + MockPipeline("bg_color"), + MockPipeline("cell_bg"), + MockPipeline("cell_text"), + MockPipeline("image"), + MockPipeline("bg_image"), + ], + post: Vec::new(), + }) + } + fn surface_size(&self) -> Result<(u32, u32), ApiError> { + Ok(self.surface) + } + fn init_target(&self, w: usize, h: usize) -> Result { + Ok(MockTarget(w, h)) + } + fn init_buffer(&self, len: usize) -> Result, ApiError> { + Ok(MockBuffer { + id: 1, + len, + _marker: std::marker::PhantomData, + }) + } + fn init_buffer_fill(&self, data: &[T]) -> Result, ApiError> { + self.init_buffer(data.len()) + } + fn init_texture( + &self, + _kind: TextureKind, + w: usize, + h: usize, + _data: Option<&[u8]>, + ) -> Result { + Ok(MockTexture { w, h }) + } + fn init_atlas_texture(&self, atlas: &Atlas) -> Result { + Ok(MockTexture { + w: atlas.size as usize, + h: atlas.size as usize, + }) + } + fn init_sampler(&self) -> Result<(), ApiError> { + Ok(()) + } + fn begin_frame(&self, _target: Arc) -> MockFrame { + MockFrame { + log: self.log.clone(), + completed: self.completed.clone(), + } + } + fn present_last_target(&self) -> Result<(), ApiError> { + Ok(()) + } + fn clear_last_target(&self) {} +} + +#[derive(Default)] +struct TestHost { + health: Mutex>, +} +impl Host for TestHost { + fn scrollbar(&self, _: Scrollbar) -> bool { + true + } + fn renderer_health(&self, h: Health) { + self.health.lock().unwrap().push(h); + } + fn redraw_surface(&self) -> bool { + true + } +} + +fn fixture() -> (Renderer, Arc>, Log, Arc) { + let config = Config::default(); + let set = Arc::new(SharedGridSet::new().unwrap()); + let (_key, grid) = set + .ref_( + &FontDerivedConfig::new(&config), + DesiredSize { + points: 12.0, + xdpi: 96, + ydpi: 96, + }, + ) + .unwrap(); + let gc = grid.cell_size(); + let cell = CellSize { + width: gc.width, + height: gc.height, + }; + let (w, h) = (cell.width * 20 + 20, cell.height * 5 + 20); + let size = Size { + screen: ScreenSize { + width: w, + height: h, + }, + cell, + padding: Padding { + top: 10, + bottom: 10, + left: 10, + right: 10, + }, + }; + + let mut term = Terminal::new(TermOptions::new(20, 5)).unwrap(); + term.vt_stream().next_slice(b"hello").unwrap(); + let state = State::new(term).into_shared(); + + let log = Log::default(); + let completed = Arc::new(AtomicUsize::new(0)); + let api = MockApi { + blending: AlphaBlending::Native, + log: log.clone(), + completed: completed.clone(), + surface: (w, h), + }; + let renderer = Renderer::::new( + Options { + config: DerivedConfig::new(&config).unwrap(), + font_grid: grid, + size, + host: Arc::new(TestHost::default()), + }, + api, + ) + .unwrap(); + (renderer, state, log, completed) +} + +#[test] +fn async_triple_buffered_frames_complete_and_release() { + let (renderer, state, log, completed) = fixture(); + + // More frames than the swap chain holds: `draw_frame` has to wait for + // earlier frames to be handed back by their completion callbacks. + for i in 0..10 { + renderer.update_frame(&state, true).unwrap(); + renderer + .draw_frame(true) + .unwrap_or_else(|_| panic!("draw {i}")); + } + // sync = true completes inline, so each draw was fully completed. + assert_eq!(completed.load(Ordering::SeqCst), 10); + + for _ in 0..10 { + renderer.mark_dirty(); + renderer.update_frame(&state, true).unwrap(); + // Asynchronous completion on worker threads. + renderer.draw_frame(false).unwrap(); + renderer.draw_frame(true).unwrap(); + } + // Wait for the async completions. + for _ in 0..200 { + if completed.load(Ordering::SeqCst) >= 30 { + break; + } + std::thread::sleep(Duration::from_millis(5)); + } + assert_eq!(completed.load(Ordering::SeqCst), 30); + assert_eq!(renderer.health(), Health::Healthy); + + // Every drawn frame ran bg_color, cell_bg and cell_text steps. + let log = log.lock().unwrap(); + let bg = log.iter().filter(|l| l.starts_with("bg_color:")).count(); + let cell_bg = log.iter().filter(|l| l.starts_with("cell_bg:")).count(); + let text: Vec<_> = log.iter().filter(|l| l.starts_with("cell_text:")).collect(); + assert_eq!(bg, 30); + assert_eq!(cell_bg, 30); + assert_eq!(text.len(), 30); + // "hello" is five glyph instances plus the cursor glyph. + assert!(text.iter().all(|l| l.as_str() == "cell_text:6"), "{text:?}"); +} + +#[test] +fn display_unrealize_waits_for_inflight_frames() { + let (renderer, state, _log, completed) = fixture(); + renderer.update_frame(&state, true).unwrap(); + renderer.draw_frame(false).unwrap(); + // Deinitializing the swap chain waits until in-flight frames are back. + renderer.display_unrealized(); + assert_eq!(completed.load(Ordering::SeqCst), 1); + renderer.display_realized_with(|_| {}).unwrap(); + renderer.draw_frame(true).unwrap(); + assert_eq!(completed.load(Ordering::SeqCst), 2); +} diff --git a/crates/ghostty-terminal/Cargo.toml b/crates/ghostty-terminal/Cargo.toml index 90f73cf..18371e9 100644 --- a/crates/ghostty-terminal/Cargo.toml +++ b/crates/ghostty-terminal/Cargo.toml @@ -20,8 +20,14 @@ thiserror = "2.0.21" [target.'cfg(not(target_arch = "wasm32"))'.dependencies] onig = "6.5.3" ghostty-onig.workspace = true + +# POSIX shm is not available on Windows (Zig returns `UnsupportedMedium`). +[target.'cfg(not(any(target_arch = "wasm32", windows)))'.dependencies] rustix = { version = "1.1.5", features = ["shm", "fs"] } +[target.'cfg(target_vendor = "apple")'.dependencies] +ghostty-os.workspace = true + [dev-dependencies] ghostty-config.workspace = true diff --git a/crates/ghostty-terminal/src/kitty/graphics_image.rs b/crates/ghostty-terminal/src/kitty/graphics_image.rs index b87a226..db63df6 100644 --- a/crates/ghostty-terminal/src/kitty/graphics_image.rs +++ b/crates/ghostty-terminal/src/kitty/graphics_image.rs @@ -174,7 +174,16 @@ impl LoadingImage { } /// Reads the data from a shared memory segment. - #[cfg(not(target_arch = "wasm32"))] + /// + /// Windows is currently unsupported (as in Zig). + #[cfg(windows)] + #[allow(clippy::unused_self)] + fn read_shared_memory(&mut self, _offset: u32, _size: u32, _path: &[u8]) -> Result<(), Error> { + Err(Error::UnsupportedMedium) + } + + /// Reads the data from a shared memory segment. + #[cfg(not(any(target_arch = "wasm32", windows)))] fn read_shared_memory(&mut self, offset: u32, size: u32, path: &[u8]) -> Result<(), Error> { use rustix::{fs::Mode, shm}; @@ -192,7 +201,7 @@ impl LoadingImage { result } - #[cfg(not(target_arch = "wasm32"))] + #[cfg(not(any(target_arch = "wasm32", windows)))] fn read_shm_fd( &mut self, fd: rustix::fd::OwnedFd, @@ -521,8 +530,7 @@ fn is_path_in_temp_dir(path: &[u8]) -> bool { if path.starts_with(b"/dev/shm") { return true; } - { - let dir = alloc_tmp_dir(); + if let Some(dir) = alloc_tmp_dir() { if path.starts_with(dir.as_bytes()) { return true; } @@ -539,13 +547,21 @@ fn is_path_in_temp_dir(path: &[u8]) -> bool { } /// `os/file.zig allocTmpDir` on POSIX (TMPDIR, TMP, then /tmp). -fn alloc_tmp_dir() -> String { +#[cfg(not(windows))] +#[allow(clippy::unnecessary_wraps)] +fn alloc_tmp_dir() -> Option { for key in ["TMPDIR", "TMP"] { if let Some(v) = std::env::var_os(key) { - return v.to_string_lossy().into_owned(); + return Some(v.to_string_lossy().into_owned()); } } - "/tmp".to_owned() + Some("/tmp".to_owned()) +} + +/// `os/file.zig allocTmpDir` on Windows (`TMP` only). +#[cfg(windows)] +fn alloc_tmp_dir() -> Option { + std::env::var_os("TMP").map(|v| v.to_string_lossy().into_owned()) } /// Image represents a single fully loaded image. @@ -931,7 +947,7 @@ mod tests { assert!(loading.complete().is_ok()); } - #[cfg(not(target_arch = "wasm32"))] + #[cfg(not(any(target_arch = "wasm32", windows)))] #[test] fn image_load_shared_memory() { use std::io::Write; diff --git a/crates/ghostty-terminal/src/search/thread.rs b/crates/ghostty-terminal/src/search/thread.rs index f0d7085..cd50ad3 100644 --- a/crates/ghostty-terminal/src/search/thread.rs +++ b/crates/ghostty-terminal/src/search/thread.rs @@ -234,6 +234,18 @@ impl Thread { pub fn thread_main(mut self) { log::debug!("starting search thread"); + // On Darwin we name the thread explicitly and run with a lower + // priority than other threads. + #[cfg(target_vendor = "apple")] + { + ghostty_os::macos::pthread_setname_np(c"search"); + let class = ghostty_os::macos::QosClass::Utility; + match ghostty_os::macos::set_qos_class(class) { + Ok(()) => log::debug!("thread QoS class set class={class:?}"), + Err(err) => log::warn!("error setting QoS class err={err}"), + } + } + // Drain the mailbox immediately (Zig sends an initial wakeup). self.drain_mailbox(); self.next_refresh = Instant::now() + REFRESH_INTERVAL; diff --git a/crates/ghostty-termio/Cargo.toml b/crates/ghostty-termio/Cargo.toml index 2ca013a..d2b9cce 100644 --- a/crates/ghostty-termio/Cargo.toml +++ b/crates/ghostty-termio/Cargo.toml @@ -8,16 +8,32 @@ license.workspace = true crossbeam-channel = "0.5.17" ghostty-apprt.workspace = true ghostty-config.workspace = true +ghostty-crash.workspace = true ghostty-datastruct.workspace = true ghostty-os.workspace = true ghostty-renderer.workspace = true ghostty-terminal.workspace = true ghostty-vt.workspace = true -libc = "0.2.190" log = "0.4.34" +thiserror = "2.0.21" + +[target.'cfg(unix)'.dependencies] +libc = "0.2.190" nix = { version = "0.31.3", features = ["signal"] } rustix = { version = "1.1.5", features = ["fs", "process", "pty", "termios", "stdio", "event", "pipe"] } -thiserror = "2.0.21" + +# ConPTY, pipes, process creation and waits (Zig: `pty.zig` WindowsPty, +# `Command.zig` startWindows, `termio/Exec.zig` Windows branches). +[target.'cfg(windows)'.dependencies] +windows-sys = { version = "0.61", features = [ + "Win32_Foundation", + "Win32_Security", + "Win32_Storage_FileSystem", + "Win32_System_Console", + "Win32_System_IO", + "Win32_System_Pipes", + "Win32_System_Threading", +] } # Termio forks/execs children and sets up ptys; unsafe is denied by default # and only allowed on minimal, SAFETY-commented items. @@ -55,3 +71,11 @@ inline_always = "allow" [dev-dependencies] temp-env = "0.3.6" + +[target.'cfg(unix)'.dev-dependencies] +zbus = "5.19.0" +rustix = { version = "1.1.5", features = ["process"] } + +[target.'cfg(target_vendor = "apple")'.dependencies] +objc2-core-foundation = "0.3.2" +objc2-foundation = "0.3.2" diff --git a/crates/ghostty-termio/src/command.rs b/crates/ghostty-termio/src/command.rs index 7d05f11..1d1983c 100644 --- a/crates/ghostty-termio/src/command.rs +++ b/crates/ghostty-termio/src/command.rs @@ -221,9 +221,37 @@ impl std::fmt::Debug for Command { /// Everything that must be allocated before forking. struct Prepared { + #[cfg_attr(target_vendor = "apple", allow(dead_code))] path: CString, argv: Vec, envp: Vec, + /// Darwin has no `execvpe`: the candidate executable paths (the PATH + /// search of Zig's `execvpeZ`, precomputed so the child doesn't allocate). + #[cfg(target_vendor = "apple")] + candidates: Vec, +} + +/// The default PATH used when `PATH` is unset (Zig `std.posix.execvpeZ`: +/// `/usr/local/bin:/bin/:/usr/bin`). +#[cfg(any(target_vendor = "apple", test))] +const DEFAULT_PATH: &str = "/usr/local/bin:/bin/:/usr/bin"; + +/// The paths `execvpe` tries for `file` given a PATH value: the file itself +/// if it contains a slash, otherwise `/` per PATH entry (an empty +/// entry means the current directory). +#[cfg(any(target_vendor = "apple", test))] +fn exec_candidates(file: &str, path_env: Option<&str>) -> Vec { + if file.contains('/') { + return vec![file.to_owned()]; + } + path_env + .unwrap_or(DEFAULT_PATH) + .split(':') + .map(|dir| { + let dir = if dir.is_empty() { "." } else { dir }; + format!("{dir}/{file}") + }) + .collect() } impl Command { @@ -266,7 +294,18 @@ impl Command { }) .collect::>()?, }; - Ok(Prepared { path, argv, envp }) + #[cfg(target_vendor = "apple")] + let candidates = exec_candidates(&self.path, std::env::var("PATH").ok().as_deref()) + .into_iter() + .map(CString::new) + .collect::, _>>()?; + Ok(Prepared { + path, + argv, + envp, + #[cfg(target_vendor = "apple")] + candidates, + }) } /// Start the subprocess. This returns immediately once the child is @@ -409,6 +448,16 @@ impl Command { fn setup_fd(dup: impl Fn(&OwnedFd) -> rustix::io::Result<()>, src: &OwnedFd) -> Result<(), Errno> { // dup2 (like Zig's dup3 with flags=0) leaves the target without // CLOEXEC: we do NOT want this file descriptor to be closed on exec. + // + // Mac doesn't support dup3 so Zig uses dup2 there, and purposely + // clears CLOEXEC on the source fd first. + #[cfg(target_vendor = "apple")] + { + let flags = rustix::io::fcntl_getfd(src)?; + if flags.contains(rustix::io::FdFlags::CLOEXEC) { + rustix::io::fcntl_setfd(src, flags - rustix::io::FdFlags::CLOEXEC)?; + } + } loop { match dup(src) { Err(Errno::INTR) => {} @@ -442,6 +491,7 @@ fn fork() -> Result { } } +#[cfg(not(target_vendor = "apple"))] #[allow(unsafe_code)] fn execvpe(prep: &Prepared, argv: &[*const c_char], envp: &[*const c_char]) -> Errno { debug_assert!(argv.last().is_some_and(|p| p.is_null())); @@ -455,12 +505,50 @@ fn execvpe(prep: &Prepared, argv: &[*const c_char], envp: &[*const c_char]) -> E Errno::from_raw_os_error(errno_now()) } +/// Darwin has no `execvpe`; do the PATH search by hand like Zig's +/// `execvpeZ` (EACCES is remembered, ENOENT/ENOTDIR continue the search, +/// anything else is returned immediately). +#[cfg(target_vendor = "apple")] +#[allow(unsafe_code)] +fn execvpe(prep: &Prepared, argv: &[*const c_char], envp: &[*const c_char]) -> Errno { + debug_assert!(argv.last().is_some_and(|p| p.is_null())); + debug_assert!(envp.last().is_some_and(|p| p.is_null())); + let mut seen_eacces = false; + for candidate in &prep.candidates { + // SAFETY: the candidate is a valid NUL-terminated string; argv/envp + // are NULL-terminated arrays of pointers into CStrings (owned by + // `prep`) that outlive the call. execve only returns on failure. + unsafe { + libc::execve(candidate.as_ptr(), argv.as_ptr(), envp.as_ptr()); + } + let err = errno_now(); + match err { + libc::EACCES => seen_eacces = true, + libc::ENOENT | libc::ENOTDIR => {} + _ => return Errno::from_raw_os_error(err), + } + } + Errno::from_raw_os_error(if seen_eacces { + libc::EACCES + } else { + libc::ENOENT + }) +} + +#[cfg(not(target_vendor = "apple"))] #[allow(unsafe_code)] fn errno_now() -> i32 { // SAFETY: __errno_location returns a valid thread-local pointer. unsafe { *libc::__errno_location() } } +#[cfg(target_vendor = "apple")] +#[allow(unsafe_code)] +fn errno_now() -> i32 { + // SAFETY: __error returns a valid thread-local pointer. + unsafe { *libc::__error() } +} + fn write_stderr(msg: &[u8]) { // Best effort, no allocation. let _ = rustix::io::write(borrow_stderr(), msg); @@ -910,4 +998,18 @@ mod tests { assert!(linux_cgroup_post_fork(&cmd, &hard, |_, _, _| None).is_err()); assert!(linux_cgroup_post_fork(&cmd, &hard, |_, _, _| Some(Err("x".into()))).is_err()); } + + #[test] + fn exec_candidates_search_path() { + assert_eq!(exec_candidates("/bin/sh", Some("/x")), ["/bin/sh"]); + assert_eq!(exec_candidates("a/b", None), ["a/b"]); + assert_eq!( + exec_candidates("sh", Some("/usr/bin::/bin")), + ["/usr/bin/sh", "./sh", "/bin/sh"] + ); + assert_eq!( + exec_candidates("sh", None), + ["/usr/local/bin/sh", "/bin//sh", "/usr/bin/sh"] + ); + } } diff --git a/crates/ghostty-termio/src/exec.rs b/crates/ghostty-termio/src/exec.rs index 6df8f53..14a3f3f 100644 --- a/crates/ghostty-termio/src/exec.rs +++ b/crates/ghostty-termio/src/exec.rs @@ -37,7 +37,7 @@ use ghostty_renderer::size::{GridSize, ScreenSize}; use ghostty_terminal::terminal::Terminal; use rustix::{ event::{PollFd, PollFlags}, - process::{Pid, PidfdFlags, Signal, WaitOptions}, + process::{Pid, Signal, WaitOptions}, }; use crate::{ @@ -254,9 +254,17 @@ impl Exec { set_nonblocking(&read_fd); // Start our read thread - let read_thread = std::thread::Builder::new() - .name("io-reader".into()) - .spawn(move || read_thread_main(&read_fd, &output, &quit_read))?; + // The read thread shares our crash context (surface dimensions). + let crash_dims = ghostty_crash::current_dims(); + let read_thread = + std::thread::Builder::new() + .name("io-reader".into()) + .spawn(move || { + // Setup our crash metadata + let _crash = + ghostty_crash::ThreadGuard::new(ghostty_crash::ThreadType::Io, crash_dims); + read_thread_main(&read_fd, &output, &quit_read); + })?; Ok(ThreadData { start, @@ -339,7 +347,9 @@ impl Exec { /// How we notice the child exiting. enum ProcessWatch { - /// A pidfd that becomes readable when the process exits. + /// A pidfd that becomes readable when the process exits (Linux only; no + /// equivalent is used on Darwin where libxev uses kqueue, so we poll). + #[cfg(any(target_os = "linux", target_os = "android"))] PidFd(OwnedFd, i32), /// Fallback: `waitpid(WNOHANG)` every [`PROCESS_POLL_MS`]. Poll(i32, Instant), @@ -354,13 +364,19 @@ impl ProcessWatch { let Some(p) = Pid::from_raw(pid) else { return ProcessWatch::Done; }; - match rustix::process::pidfd_open(p, PidfdFlags::empty()) { + #[cfg(any(target_os = "linux", target_os = "android"))] + match rustix::process::pidfd_open(p, rustix::process::PidfdFlags::empty()) { Ok(fd) => ProcessWatch::PidFd(fd, pid), Err(err) => { log::warn!("pidfd_open failed ({err}), falling back to polling waitpid"); ProcessWatch::Poll(pid, Instant::now() + Duration::from_millis(PROCESS_POLL_MS)) } } + #[cfg(not(any(target_os = "linux", target_os = "android")))] + { + let _ = p; + ProcessWatch::Poll(pid, Instant::now() + Duration::from_millis(PROCESS_POLL_MS)) + } } } @@ -416,6 +432,7 @@ impl ThreadData { /// An fd that becomes readable when the child exited. pub fn process_fd(&self) -> Option> { match &self.watch { + #[cfg(any(target_os = "linux", target_os = "android"))] ProcessWatch::PidFd(fd, _) => Some(fd.as_fd()), _ => None, } @@ -513,7 +530,9 @@ impl ThreadData { /// reap the child and report the exit. pub fn on_process_ready(&mut self, cmd_pid: Option) { let pid = match &self.watch { - ProcessWatch::PidFd(_, pid) | ProcessWatch::Poll(pid, _) => *pid, + #[cfg(any(target_os = "linux", target_os = "android"))] + ProcessWatch::PidFd(_, pid) => *pid, + ProcessWatch::Poll(pid, _) => *pid, _ => return, }; let _ = cmd_pid; @@ -802,6 +821,12 @@ impl Subprocess { }; env.insert("TERMINFO", terminfo); } else { + if cfg!(target_vendor = "apple") { + log::warn!("ghostty terminfo not found, using xterm-256color"); + log::warn!("the terminfo SHOULD exist on macos, please ensure"); + log::warn!("you're using a valid app bundle."); + } + env.insert("TERM", "xterm-256color"); env.insert("COLORTERM", "truecolor"); } @@ -809,6 +834,14 @@ impl Subprocess { // Add our binary to the path if we can find it. add_ghostty_path(&mut env); + // On macOS, export additional data directories from our + // application bundle. + if cfg!(target_vendor = "apple") + && let Some(resources_dir) = &cfg.resources_dir + { + darwin_bundle_env(&mut env, resources_dir); + } + // Set environment variables used by some programs (such as neovim) to detect // which terminal emulator and version they're running under. env.insert("TERM_PROGRAM", "ghostty"); @@ -1189,6 +1222,34 @@ fn add_ghostty_path(env: &mut EnvMap) { } } +/// macOS: export additional data directories from our application bundle +/// (`XDG_DATA_DIRS` and `MANPATH`). +pub fn darwin_bundle_env(env: &mut EnvMap, resources_dir: &str) { + let xdg_data_dir_key = "XDG_DATA_DIRS"; + let data_dir = format!("{resources_dir}/.."); + let current = env.get(xdg_data_dir_key).map_or_else( + || "/usr/local/share:/usr/share".to_owned(), + |v| v.to_string_lossy().into_owned(), + ); + env.insert( + xdg_data_dir_key, + ghostty_os::append_env(¤t, &data_dir), + ); + + let manpath_key = "MANPATH"; + let man_dir = format!("{resources_dir}/../man"); + // Always append with colon in front, as it mean that if + // `MANPATH` is empty, then it should be treated as an extra + // path instead of overriding all paths set by OS. + let current = env + .get(manpath_key) + .map_or_else(String::new, |v| v.to_string_lossy().into_owned()); + env.insert( + manpath_key, + ghostty_os::append_env_always(¤t, &man_dir), + ); +} + /// Kill the underlying subprocess. This sends a SIGHUP to the child /// process group. This also waits for the command to exit. fn kill_command(command: &Command) -> Result<(), Error> { @@ -1216,6 +1277,10 @@ fn kill_pid(pid_raw: i32) -> Result<(), Error> { loop { match rustix::process::kill_process_group(pgid, Signal::HUP) { Ok(()) => log::debug!("process group killed pgid={}", pgid.as_raw_nonzero()), + // EPERM is expected on Darwin and ignored. + Err(rustix::io::Errno::PERM) if cfg!(target_vendor = "apple") => { + log::debug!("killpg failed with EPERM, expected on Darwin and ignoring"); + } Err(err) => { log::warn!( "error killing process group pgid={} err={err}", @@ -1286,9 +1351,34 @@ fn getpgid(pid: Pid) -> Option { /// configured command. This isn't as straightforward as it seems since /// we deal with shell-wrapping, etc. /// -/// (The macOS `login(1)` branch of the Zig function is not ported, Linux -/// only; neither is the Windows `cmd.exe` branch.) +/// On macOS the command is wrapped in `login(1)` (see +/// [`exec_command_with`]). pub fn exec_command(command: &ConfigCommand) -> Vec { + #[cfg(unix)] + let passwd = ghostty_os::passwd::get; + #[cfg(not(unix))] + let passwd = || Err(std::io::Error::from(std::io::ErrorKind::Unsupported)); + exec_command_with(command, cfg!(target_vendor = "apple"), &passwd) +} + +/// [`exec_command`] with the platform and passwd lookup injected (Zig takes +/// the passwd package as a comptime argument and `isDarwin()` at comptime), +/// so the macOS `login(1)` logic is testable on every platform. +/// +/// The `passwd` function is used by macOS to determine the username and +/// home directory for the login shell. It is unused on other platforms. +pub fn exec_command_with( + command: &ConfigCommand, + darwin: bool, + passwd: &dyn Fn() -> std::io::Result, +) -> Vec { + // If we're on macOS, we have to use `login(1)` to get all of + // the proper environment variables set, a login shell, and proper + // hushlogin behavior. + if darwin && let Some(args) = darwin_login_command(command, passwd) { + return args; + } + match command { // We need to clone the command since there's no guarantee the config remains valid. ConfigCommand::Direct(args) => args.clone(), @@ -1312,6 +1402,124 @@ pub fn exec_command(command: &ConfigCommand) -> Vec { } } +/// The macOS `login(1)` wrapper; `None` falls back to POSIX behavior (Zig +/// `break :darwin`). +fn darwin_login_command( + command: &ConfigCommand, + passwd: &dyn Fn() -> std::io::Result, +) -> Option> { + let passwd = match passwd() { + Ok(p) => p, + Err(err) => { + log::warn!("failed to read passwd, not using a login shell err={err}"); + return None; + } + }; + + let Some(username) = passwd.name else { + log::warn!("failed to get username, not using a login shell"); + return None; + }; + + let hush = match &passwd.home { + Some(home) => { + if std::fs::read_dir(home).is_err() { + log::warn!("failed to open home dir, not checking for hushlogin"); + false + } else { + std::path::Path::new(home).join(".hushlogin").exists() + } + } + None => false, + }; + + // If we made it this far we're going to start building + // the actual command. + // + // This capacity is chosen based on what we'd need to + // execute a shell command (very common). We can/will + // grow if necessary for a longer command (uncommon). + let mut args: Vec = Vec::with_capacity(9); + + // The reason for executing login this way is unclear. This + // comment will attempt to explain but prepare for a truly + // unhinged reality. + // + // The first major issue is that on macOS, a lot of users + // put shell configurations in ~/.bash_profile instead of + // ~/.bashrc (or equivalent for another shell). This file is only + // loaded for a login shell so macOS users expect all their terminals + // to be login shells. No other platform behaves this way and its + // totally braindead but somehow the entire dev community on + // macOS has cargo culted their way to this reality so we have to + // do it... + // + // To get a login shell, you COULD just prepend argv0 with a `-` + // but that doesn't fully work because `getlogin()` C API will + // return the wrong value, SHELL won't be set, and various + // other login behaviors that macOS users expect. + // + // The proper way is to use `login(1)`. But login(1) forces + // the working directory to change to the home directory, + // which we may not want. If we specify "-l" then we can avoid + // this behavior but now the shell isn't a login shell. + // + // There is another issue: `login(1)` on macOS 14.3 and earlier + // checked for ".hushlogin" in the working directory. This means + // that if we specify "-l" then we won't get hushlogin honored + // if its in the home directory (which is standard). To get + // around this, we check for hushlogin ourselves and if present + // specify the "-q" flag to login(1). + // + // So to get all the behaviors we want, we specify "-l" but + // execute "bash" (which is built-in to macOS). We then use + // the bash builtin "exec" to replace the process with a login + // shell ("-l" on exec) with the command we really want. + // + // We use "bash" instead of other shells that ship with macOS + // because as of macOS Sonoma, we found with a microbenchmark + // that bash can `exec` into the desired command ~2x faster + // than zsh. + // + // To figure out a lot of this logic I read the login.c + // source code in the OSS distribution Apple provides for + // macOS. + // + // Awesome. + args.push("/usr/bin/login".into()); + if hush { + args.push("-q".into()); + } + args.push("-flp".into()); + args.push(username); + + match command { + // Direct args can be passed directly to login, since + // login uses execvp we don't need to worry about PATH + // searching. + ConfigCommand::Direct(v) => args.extend(v.iter().cloned()), + + ConfigCommand::Shell(v) => { + // Use "exec" to replace the bash process with + // our intended command so we don't have a parent + // process hanging around. + let cmd = format!("exec -l {v}"); + + // We execute bash with "--noprofile --norc" so that it doesn't + // load startup files so that (1) our shell integration doesn't + // break and (2) user configuration doesn't mess this process + // up. + args.push("/bin/bash".into()); + args.push("--noprofile".into()); + args.push("--norc".into()); + args.push("-c".into()); + args.push(cmd); + } + } + + Some(args) +} + impl Drop for ThreadData { fn drop(&mut self) { // Join the reader if thread_exit wasn't called (shouldn't happen). @@ -1328,10 +1536,95 @@ impl Drop for ThreadData { mod tests { use super::*; + fn pw(name: Option<&str>) -> impl Fn() -> std::io::Result { + let name = name.map(str::to_owned); + move || { + Ok(ghostty_os::passwd::Entry { + name: name.clone(), + ..Default::default() + }) + } + } + + #[test] + fn exec_command_darwin_shell_command() { + let result = exec_command_with( + &ConfigCommand::Shell("foo bar baz".into()), + true, + &pw(Some("testuser")), + ); + assert_eq!( + result, + [ + "/usr/bin/login", + "-flp", + "testuser", + "/bin/bash", + "--noprofile", + "--norc", + "-c", + "exec -l foo bar baz" + ] + ); + } + + #[test] + fn exec_command_darwin_direct_command() { + let result = exec_command_with( + &ConfigCommand::Direct(vec!["foo".into(), "bar baz".into()]), + true, + &pw(Some("testuser")), + ); + assert_eq!( + result, + ["/usr/bin/login", "-flp", "testuser", "foo", "bar baz"] + ); + } + + #[test] + fn exec_command_darwin_hushlogin() { + let dir = std::env::temp_dir().join(format!("ghostty-hush-{}", std::process::id())); + std::fs::create_dir_all(&dir).unwrap(); + std::fs::write(dir.join(".hushlogin"), "").unwrap(); + let home = dir.to_str().unwrap().to_owned(); + let passwd = move || { + Ok(ghostty_os::passwd::Entry { + name: Some("u".into()), + home: Some(home.clone()), + ..Default::default() + }) + }; + let result = exec_command_with(&ConfigCommand::Direct(vec!["x".into()]), true, &passwd); + assert_eq!(result, ["/usr/bin/login", "-q", "-flp", "u", "x"]); + std::fs::remove_dir_all(&dir).unwrap(); + } + + #[test] + fn exec_command_darwin_falls_back_without_username() { + // Empty passwd entry means we can't construct a macOS login + // command and falls back to POSIX behavior. + let result = + exec_command_with(&ConfigCommand::Shell("foo bar baz".into()), true, &pw(None)); + assert_eq!(result, ["/bin/sh", "-c", "foo bar baz"]); + + // Failed passwd entry also falls back. + let fail = || Err(std::io::Error::other("fail")); + let result = exec_command_with( + &ConfigCommand::Direct(vec!["foo".into(), "bar baz".into()]), + true, + &fail, + ); + assert_eq!(result, ["foo", "bar baz"]); + } + #[test] fn exec_command_shell_command() { // execCommand: shell command, empty passwd - let result = exec_command(&ConfigCommand::Shell("foo bar baz".into())); + let result = exec_command_with( + &ConfigCommand::Shell("foo bar baz".into()), + false, + &pw(None), + ); assert_eq!(result, ["/bin/sh", "-c", "foo bar baz"]); } diff --git a/crates/ghostty-termio/src/lib.rs b/crates/ghostty-termio/src/lib.rs index 5499889..c77cfbc 100644 --- a/crates/ghostty-termio/src/lib.rs +++ b/crates/ghostty-termio/src/lib.rs @@ -1,6 +1,7 @@ //! ghostty-termio: see docs/rewrite/ARCHITECTURE.md for the module map. //! -//! Linux only. Ported: `pty.zig`, `Command.zig`, the `terminfo/*` source +//! Linux, with a Windows (ConPTY) port behind `cfg(windows)` (the `*_windows.rs` +//! files, selected with `#[path]`). Ported: `pty.zig`, `Command.zig`, the `terminfo/*` source //! generator and everything in `termio/` (`Options`, `backend`, `message`, //! `mailbox`, `Termio`, `Thread`, `Exec`, `stream_handler`, //! `shell_integration`). See `docs/rewrite/notes/ghostty-termio-IO1.md` and @@ -19,11 +20,14 @@ )] pub mod backend; +#[cfg_attr(windows, path = "command_windows.rs")] pub mod command; +#[cfg_attr(windows, path = "exec_windows.rs")] pub mod exec; pub mod mailbox; pub mod message; pub mod options; +#[cfg_attr(windows, path = "pty_windows.rs")] pub mod pty; pub mod shell_integration; pub mod sinks; @@ -32,7 +36,10 @@ pub mod stream_handler; pub mod terminfo; pub mod termio; pub mod thread; +#[cfg_attr(windows, path = "wakeup_windows.rs")] pub mod wakeup; +/// Pure Windows `CreateProcessW` input encoders (tested on every host). +pub mod windows_args; pub use backend::Backend; pub use command::Command; diff --git a/crates/ghostty-termio/src/mailbox.rs b/crates/ghostty-termio/src/mailbox.rs index 171e32d..41e280d 100644 --- a/crates/ghostty-termio/src/mailbox.rs +++ b/crates/ghostty-termio/src/mailbox.rs @@ -152,6 +152,31 @@ mod tests { assert!(matches!(back, Message::Focused(true))); } + /// Block until the wakeup is signaled. + #[cfg(unix)] + fn wait_readable(w: &Wakeup) { + let mut fds = [rustix::event::PollFd::from_borrowed_fd( + w.as_fd(), + rustix::event::PollFlags::IN, + )]; + rustix::event::poll(&mut fds, None).unwrap(); + } + + /// Block until the wakeup is signaled (this consumes the auto-reset + /// event; the callers' `drain` after it is then a harmless no-op). + #[cfg(windows)] + #[allow(unsafe_code)] + fn wait_readable(w: &Wakeup) { + use std::os::windows::io::AsRawHandle; + // SAFETY: valid event handle owned by the wakeup. + unsafe { + windows_sys::Win32::System::Threading::WaitForSingleObject( + w.as_handle().as_raw_handle().cast(), + windows_sys::Win32::System::Threading::INFINITE, + ) + }; + } + #[test] fn blocking_send_wakes_consumer_and_completes() { let mb = Mailbox::new().unwrap(); @@ -161,11 +186,7 @@ mod tests { let consumer = mb.clone(); let t = std::thread::spawn(move || { // Wait for the wakeup the full-queue path posts, then drain. - let mut fds = [rustix::event::PollFd::from_borrowed_fd( - consumer.wakeup().as_fd(), - rustix::event::PollFlags::IN, - )]; - rustix::event::poll(&mut fds, None).unwrap(); + wait_readable(consumer.wakeup()); consumer.wakeup().drain(); consumer.drain().count() }); @@ -185,11 +206,7 @@ mod tests { let m2 = Arc::clone(&m); // The consumer needs the lock to make progress (like the IO thread). let t = std::thread::spawn(move || { - let mut fds = [rustix::event::PollFd::from_borrowed_fd( - consumer.wakeup().as_fd(), - rustix::event::PollFlags::IN, - )]; - rustix::event::poll(&mut fds, None).unwrap(); + wait_readable(consumer.wakeup()); let mut g = m2.lock().unwrap(); *g += 1; consumer.drain().count() diff --git a/crates/ghostty-termio/src/pty.rs b/crates/ghostty-termio/src/pty.rs index d14178a..ad65b6b 100644 --- a/crates/ghostty-termio/src/pty.rs +++ b/crates/ghostty-termio/src/pty.rs @@ -4,7 +4,9 @@ use std::os::fd::{AsRawFd, OwnedFd, RawFd}; -use rustix::{fs, process, pty, termios}; +#[cfg(not(target_vendor = "apple"))] +use rustix::{fs, pty}; +use rustix::{process, termios}; /// Redeclare the winsize struct so we can just use a Rust struct. The /// defaults on this are some reasonable screen size but you should probably @@ -106,25 +108,82 @@ pub struct Pty { pub slave: Option, } +/// Equivalent of glibc openpty(): the master is CLOEXEC (Ghostty sets this +/// explicitly: only the slave fd should be inherited by the child), the +/// slave is not. +#[cfg(not(target_vendor = "apple"))] +fn open_pair(_size: Winsize) -> Result<(OwnedFd, OwnedFd), Error> { + let master = + pty::openpt(pty::OpenptFlags::RDWR | pty::OpenptFlags::NOCTTY | pty::OpenptFlags::CLOEXEC) + .map_err(|_| Error::OpenptyFailed)?; + pty::grantpt(&master).map_err(|_| Error::OpenptyFailed)?; + pty::unlockpt(&master).map_err(|_| Error::OpenptyFailed)?; + let name = pty::ptsname(&master, Vec::new()).map_err(|_| Error::OpenptyFailed)?; + let slave = fs::open( + name.as_c_str(), + fs::OFlags::RDWR | fs::OFlags::NOCTTY, + fs::Mode::empty(), + ) + .map_err(|_| Error::OpenptyFailed)?; + Ok((master, slave)) +} + +/// Darwin: libc `openpty()` (`util.h`), exactly as Zig does, followed by +/// setting CLOEXEC on the master fd (only the slave fd should be inherited +/// by the child process). +#[cfg(target_vendor = "apple")] +#[allow(unsafe_code)] +fn open_pair(size: Winsize) -> Result<(OwnedFd, OwnedFd), Error> { + use std::os::fd::FromRawFd; + + let mut ws = libc::winsize { + ws_row: size.ws_row, + ws_col: size.ws_col, + ws_xpixel: size.ws_xpixel, + ws_ypixel: size.ws_ypixel, + }; + let mut master_fd: libc::c_int = -1; + let mut slave_fd: libc::c_int = -1; + // SAFETY: out-pointers reference live locals; the name/termios + // arguments are allowed to be null; `ws` is only read. + let rc = unsafe { + libc::openpty( + &raw mut master_fd, + &raw mut slave_fd, + std::ptr::null_mut(), + std::ptr::null_mut(), + &raw mut ws, + ) + }; + if rc < 0 { + return Err(Error::OpenptyFailed); + } + // SAFETY: openpty succeeded so both fds are valid and exclusively ours. + let (master, slave) = unsafe { + ( + OwnedFd::from_raw_fd(master_fd), + OwnedFd::from_raw_fd(slave_fd), + ) + }; + + // Set CLOEXEC on the master fd, only the slave fd should be inherited + // by the child process (shell/command). + match rustix::io::fcntl_getfd(&master) { + Ok(flags) => { + if let Err(err) = rustix::io::fcntl_setfd(&master, flags | rustix::io::FdFlags::CLOEXEC) + { + log::warn!("error setting CLOEXEC on master fd err={err}"); + } + } + Err(err) => log::warn!("error getting flags for master fd err={err}"), + } + Ok((master, slave)) +} + impl Pty { /// Open a new PTY with the given initial size. pub fn open(size: Winsize) -> Result { - // Equivalent of glibc openpty(): the master is CLOEXEC (Ghostty sets - // this explicitly: only the slave fd should be inherited by the - // child), the slave is not. - let master = pty::openpt( - pty::OpenptFlags::RDWR | pty::OpenptFlags::NOCTTY | pty::OpenptFlags::CLOEXEC, - ) - .map_err(|_| Error::OpenptyFailed)?; - pty::grantpt(&master).map_err(|_| Error::OpenptyFailed)?; - pty::unlockpt(&master).map_err(|_| Error::OpenptyFailed)?; - let name = pty::ptsname(&master, Vec::new()).map_err(|_| Error::OpenptyFailed)?; - let slave = fs::open( - name.as_c_str(), - fs::OFlags::RDWR | fs::OFlags::NOCTTY, - fs::Mode::empty(), - ) - .map_err(|_| Error::OpenptyFailed)?; + let (master, slave) = open_pair(size)?; termios::tcsetwinsize(&master, size.into()).map_err(|_| Error::OpenptyFailed)?; diff --git a/crates/ghostty-termio/src/shell_integration.rs b/crates/ghostty-termio/src/shell_integration.rs index 23bae47..7c34a85 100644 --- a/crates/ghostty-termio/src/shell_integration.rs +++ b/crates/ghostty-termio/src/shell_integration.rs @@ -101,13 +101,30 @@ fn basename(path: &str) -> &str { } fn detect_shell(command: &Command) -> Option { + detect_shell_for(command, cfg!(target_vendor = "apple")) +} + +/// [`detect_shell`] for an explicit platform (`darwin == isDarwin()`). +fn detect_shell_for(command: &Command, darwin: bool) -> Option { let mut it = command.arg_iterator(); let arg0 = it.next()?; let exe = basename(&arg0); match exe { - // (The Darwin /bin/bash special case does not apply on Linux.) - "bash" => Some(Shell::Bash), + "bash" => { + // Apple distributes their own patched version of Bash 3.2 + // on macOS that disables the ENV-based POSIX startup path. + // This means we're unable to perform our automatic shell + // integration sequence in this specific environment. + // + // If we're running "/bin/bash" on Darwin, we can assume + // we're using Apple's Bash because /bin is non-writable + // on modern macOS due to System Integrity Protection. + if darwin && arg0 == "/bin/bash" { + return None; + } + Some(Shell::Bash) + } "elvish" => Some(Shell::Elvish), "fish" => Some(Shell::Fish), "nu" => Some(Shell::Nushell), @@ -450,6 +467,14 @@ mod tests { assert_eq!(detect_shell(&sh("nu")), Some(Shell::Nushell)); assert_eq!(detect_shell(&sh("zsh")), Some(Shell::Zsh)); + // Apple's patched /bin/bash can't use the ENV-based integration. + assert_eq!(detect_shell_for(&sh("/bin/bash"), true), None); + assert_eq!(detect_shell_for(&sh("/bin/bash"), false), Some(Shell::Bash)); + assert_eq!( + detect_shell_for(&sh("/opt/homebrew/bin/bash"), true), + Some(Shell::Bash) + ); + assert_eq!(detect_shell(&sh("bash -c 'command'")), Some(Shell::Bash)); assert_eq!(detect_shell(&sh("\"/a b/bash\"")), Some(Shell::Bash)); assert_eq!(detect_shell(&sh("/usr/bin/zsh")), Some(Shell::Zsh)); diff --git a/crates/ghostty-termio/src/thread.rs b/crates/ghostty-termio/src/thread.rs index 9403715..efd12bd 100644 --- a/crates/ghostty-termio/src/thread.rs +++ b/crates/ghostty-termio/src/thread.rs @@ -27,17 +27,22 @@ use ghostty_apprt::surface::Message as SurfaceMessage; use ghostty_datastruct::blocking_queue::Timeout; use ghostty_renderer::size::Size; use ghostty_vt::{csi, modes::Mode}; +#[cfg(unix)] use rustix::event::{PollFd, PollFlags}; use crate::{ backend::ThreadData as BackendThreadData, - exec, message::Message, state::SharedStateExt, termio::{Error, Termio, ThreadData}, wakeup::Wakeup, }; +/// The Windows event loop (`WaitForMultipleObjects`). +#[cfg(windows)] +#[path = "thread_windows.rs"] +mod windows_loop; + /// The number of milliseconds to coalesce certain messages like resize for. /// Not all message types are coalesced. pub const COALESCE_MIN_MS: u64 = 25; @@ -81,6 +86,9 @@ pub struct Thread { sync_reset_deadline: Option, flags: Flags, + + /// The surface dimensions recorded in crash reports. + pub crash_dims: Option>, } impl Thread { @@ -96,6 +104,7 @@ impl Thread { coalesce_resize: None, sync_reset_deadline: None, flags: Flags::default(), + crash_dims: None, }) } @@ -109,6 +118,10 @@ impl Thread { /// once the stop handle was notified; dropping the termio afterwards /// cleans everything up. pub fn thread_main(mut self, mut io: Termio) { + // Setup our crash metadata + let _crash = + ghostty_crash::ThreadGuard::new(ghostty_crash::ThreadType::Io, self.crash_dims.clone()); + // Call child function so we can use errors... let result = self.thread_main_(&mut io); if let Err(err) = result { @@ -140,6 +153,7 @@ impl Thread { } /// After an abnormal failure: discard messages until the stop signal. + #[cfg(unix)] fn drain_until_stop(&mut self, io: &Termio) { let mailbox = io.mailbox().clone(); loop { @@ -183,6 +197,7 @@ impl Thread { } /// The event loop. Returns when stopped. + #[cfg(unix)] fn run(&mut self, io: &mut Termio, td: &mut ThreadData) { let mailbox = io.mailbox().clone(); @@ -386,7 +401,8 @@ fn report_start_error(io: &Termio, err: &Error) { t.modes.set(Mode::CursorVisible, false); let msg: String = match err { - Error::Exec(exec::Error::Pty(crate::pty::Error::OpenptyFailed)) => "\ + #[cfg(unix)] + Error::Exec(crate::exec::Error::Pty(crate::pty::Error::OpenptyFailed)) => "\ Your system cannot allocate any more pty devices. Ghostty requires a pty device to launch a new terminal. diff --git a/crates/ghostty-termio/src/wakeup.rs b/crates/ghostty-termio/src/wakeup.rs index 103301a..2432821 100644 --- a/crates/ghostty-termio/src/wakeup.rs +++ b/crates/ghostty-termio/src/wakeup.rs @@ -1,7 +1,8 @@ //! A tiny cross-thread wakeup primitive (replaces `xev.Async`). //! -//! Backed by a non-blocking `eventfd`, so it can be included in a `poll(2)` -//! set next to a pty fd or a pidfd. [`Wakeup::notify`] may be called from any +//! Backed by a non-blocking `eventfd` (a non-blocking pipe on Darwin, which +//! has no eventfd; libxev uses kqueue there), so it can be included in a +//! `poll(2)` set next to a pty fd or a pidfd. [`Wakeup::notify`] may be called from any //! thread, any number of times; the waiter calls [`Wakeup::drain`] after //! `poll` reports the fd readable. @@ -11,30 +12,65 @@ use std::{ sync::Arc, }; -use rustix::event::{EventfdFlags, eventfd}; +/// The fds backing a wakeup: a single eventfd (read == write) on Linux, the +/// two ends of a pipe on Darwin. +struct Fds { + read: OwnedFd, + write: Option, +} -/// See the module docs. Cloning shares the same underlying eventfd. +impl Fds { + #[cfg(not(target_vendor = "apple"))] + fn new() -> io::Result { + use rustix::event::{EventfdFlags, eventfd}; + Ok(Fds { + read: eventfd(0, EventfdFlags::CLOEXEC | EventfdFlags::NONBLOCK)?, + write: None, + }) + } + + #[cfg(target_vendor = "apple")] + fn new() -> io::Result { + use rustix::{ + fs::{OFlags, fcntl_getfl, fcntl_setfl}, + io::{FdFlags, fcntl_setfd}, + }; + let (read, write) = rustix::pipe::pipe()?; + for fd in [&read, &write] { + fcntl_setfd(fd, FdFlags::CLOEXEC)?; + fcntl_setfl(fd, fcntl_getfl(fd)? | OFlags::NONBLOCK)?; + } + Ok(Fds { + read, + write: Some(write), + }) + } + + fn write_fd(&self) -> &OwnedFd { + self.write.as_ref().unwrap_or(&self.read) + } +} + +/// See the module docs. Cloning shares the same underlying fds. #[derive(Clone)] -pub struct Wakeup(Arc, Option>); +pub struct Wakeup(Arc, Option>); impl std::fmt::Debug for Wakeup { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - f.debug_tuple("Wakeup").field(&self.0).finish() + f.debug_tuple("Wakeup").field(&self.0.read).finish() } } impl Wakeup { pub fn new() -> io::Result { - let fd = eventfd(0, EventfdFlags::CLOEXEC | EventfdFlags::NONBLOCK)?; - Ok(Wakeup(Arc::new(fd), None)) + Ok(Wakeup(Arc::new(Fds::new()?), None)) } /// A wakeup that calls `hook` instead of signalling an eventfd. Used to /// bridge into waiters that are not `poll(2)`-based (the renderer /// thread's channel). `as_fd`/`drain` are meaningless for these. pub fn with_hook(hook: Arc) -> io::Result { - let fd = eventfd(0, EventfdFlags::CLOEXEC | EventfdFlags::NONBLOCK)?; - Ok(Wakeup(Arc::new(fd), Some(hook))) + Ok(Wakeup(Arc::new(Fds::new()?), Some(hook))) } /// Wake up the waiter. Multiple notifications coalesce. @@ -44,7 +80,7 @@ impl Wakeup { return Ok(()); } loop { - match rustix::io::write(&*self.0, &1u64.to_ne_bytes()) { + match rustix::io::write(self.0.write_fd(), &1u64.to_ne_bytes()) { Ok(_) => return Ok(()), Err(rustix::io::Errno::INTR) => {} // Counter would overflow: the waiter is already guaranteed @@ -58,19 +94,23 @@ impl Wakeup { /// Reset the notification state. Returns true if a notification was /// pending. pub fn drain(&self) -> bool { - let mut buf = [0u8; 8]; + let mut buf = [0u8; 64]; + // An eventfd resets with one read; a pipe is read until empty. + let mut any = false; loop { - match rustix::io::read(&*self.0, &mut buf) { - Ok(_) => return true, + match rustix::io::read(&self.0.read, &mut buf) { + Ok(_) if self.0.write.is_none() => return true, + Ok(0) => return any, + Ok(_) => any = true, Err(rustix::io::Errno::INTR) => {} - Err(_) => return false, + Err(_) => return any, } } } /// The fd to poll for `POLLIN`. pub fn as_fd(&self) -> BorrowedFd<'_> { - self.0.as_fd() + self.0.read.as_fd() } } diff --git a/crates/ghostty-termio/tests/exec_integration.rs b/crates/ghostty-termio/tests/exec_integration.rs index af03f49..65f303c 100644 --- a/crates/ghostty-termio/tests/exec_integration.rs +++ b/crates/ghostty-termio/tests/exec_integration.rs @@ -1,3 +1,4 @@ +#![cfg(unix)] #![allow(clippy::unwrap_used)] //! End-to-end tests: real child processes on a real pty, driven through //! `Termio` + `Thread` (IO thread) + the pty read thread, against a real diff --git a/crates/ghostty-termio/tests/pty_command.rs b/crates/ghostty-termio/tests/pty_command.rs index 72c19d5..179a453 100644 --- a/crates/ghostty-termio/tests/pty_command.rs +++ b/crates/ghostty-termio/tests/pty_command.rs @@ -1,5 +1,6 @@ //! Integration test: run a real child attached to a pty. +#![cfg(unix)] #![allow(clippy::unwrap_used)] use std::{ diff --git a/docs/rewrite/STATUS.md b/docs/rewrite/STATUS.md index dcf9edc..372efba 100644 --- a/docs/rewrite/STATUS.md +++ b/docs/rewrite/STATUS.md @@ -56,7 +56,7 @@ Legend: โฌœ todo ยท ๐ŸŸจ in progress ยท โœ… done (gate passed, committed) ยท โš  | L1 | libghostty C API (apprt/embedded.zig, main_c.zig, config CApi/c_get, ghostty.h ABI) | โ€“ | โฌœ | batch B | | M1 | macOS: Metal renderer, CoreText face/shaper/discovery, KeymapDarwin, os macos/mach | sonnet | ๐ŸŸจ | | | WIN | Windows: ConPTY pty, os/windows, Command | sonnet | ๐ŸŸจ | | -| W1b | wasm: align JS imports with zig-js ABI | sonnet | ๐ŸŸจ | | +| W1b | wasm: align JS imports with zig-js ABI | sonnet | โœ… | | | GC | GTK/adw compat fallbacks, bench wcwidth/uucode modes, helpgen generators | โ€“ | โฌœ | batch B | ## Log diff --git a/docs/rewrite/notes/macos-M1-font.md b/docs/rewrite/notes/macos-M1-font.md new file mode 100644 index 0000000..6a269e8 --- /dev/null +++ b/docs/rewrite/notes/macos-M1-font.md @@ -0,0 +1,56 @@ +# macOS CoreText font backend (M1 / font) + +Crate: `ghostty-font`. Compile-checked with +`cargo check|clippy --target aarch64-apple-darwin|x86_64-apple-darwin -p ghostty-font --all-targets` +(clean, `-D warnings`); never run on real macOS. Linux behaviour unchanged +(`cargo test -p ghostty-font`: 203 pass / 3 ignored, clippy `-D warnings`, nightly fmt). + +## Backend selection (`font/backend.zig`) +`build.rs` emits cfgs from target + cargo features: `font_freetype`, `font_fontconfig`, +`font_harfbuzz`, `font_coretext` (Apple), `font_coretext_face`, `font_coretext_shaper`, `font_noshape`. +* Linux/other: fontconfig + FreeType + HarfBuzz (unchanged). wasm32: web_canvas (unchanged). +* Apple targets, default `Backend::Coretext`: CoreText discovery/face/shaper; freetype-sys, + harfbuzz-sys, fontconfig are NOT linked (freetype/harfbuzz are optional deps, `bundled`, apple only). +* Cargo features (apple only): `coretext-freetype` (CT discovery, FreeType face, HarfBuzz shaper), + `coretext-harfbuzz` (CT face + HarfBuzz via `hb_coretext_font_create`), `coretext-noshape` + (no-op shaper). `backend::OPTIONS` follows the features. `face::DEFAULT_DPI` is 72 on macOS. + +## Module map (Zig -> Rust, `crates/ghostty-font/src/`) +| Zig | Rust | +|---|---| +| face/coretext.zig | `face/coretext.rs` (CTFont face, CoreGraphics bitmap rasterization, `ColorState`) + `face/coretext_math.rs` (pure math, tested on Linux) | +| shaper/coretext.zig | `shaper/coretext.rs` (CTTypesetter/CTLine/CTRun, feature dicts, per-font attr-dict cache) + `shaper/coretext_cells.rs` (pure cluster/ligature logic, tested) + `shaper/cf_release.rs` | +| discovery.zig CoreText + `toCoreTextDescriptor` | `discovery_coretext.rs` (`CoreText`, `Score`-based sorting, CJK / `CTFontCreateForString` fallbacks) | +| DeferredFace.zig CoreText | `deferred_face_coretext.rs` (+ `coretext_freetype` file-path load) | +| library.zig (noop for CT) | `library.rs` (empty library when no FreeType) | +| SharedGridSet.zig Apple Emoji branch | `shared_grid_set.rs` (Apple Color Emoji via discovery on CT; embedded Noto emoji otherwise) | +| (CF helpers of the `macos` package) | `coretext_util.rs` (`CfBox` Send+Sync wrapper, tables, glyph lookup, variation axes) | + +API unification (so collection/grid/resolver stop naming `face::freetype::*`): `face::{Face, FaceError, +RenderError, HbFont}`, `shape::{Shaper, ShapeError, ShapeRunError}`, `discovery::{Discover, NextError}`. +`discover_fallback` now takes `&mut Collection` (CT needs `get_face`); no-op shaper gained `shape_run`, +`ShapeError` and `new -> Result`. + +## Tests +Ported & platform-independent (run on Linux): metrics table priority (OS/2 typo vs hhea vs win), +underline/strikethrough fallbacks, synthetic-bold width, glyph bearing/canvas layout (incl. sbix, thicken, +bold), UTF-16/surrogate table, CoreText cell building incl. ligature/out-of-order-cluster/non-LTR heuristics, +discovery `Score` ordering, style fuzzy/exact scoring, variation-axis bold/italic thresholds, backend +capability predicates. macOS-only tests written but not runnable here (`#[cfg(font_coretext...)]`): +face (Monaco ASCII, name, emoji, in-memory, variable, set variation, SVG table, glyphIndex colored vs text), +discovery (coretext, codepoint, sorting), deferred face. The Zig CoreText shaper tests (23, need +Terminal/RenderState + system fonts) are NOT ported; resolver/collection tests adjusted for CT +(`Coretext doesn't support Noto's format`, text-emoji index 1). + +## Caveats / TODO(integration) +* Never executed on macOS: rasterization (CGBitmapContext flags, colour spaces), shaping and discovery are + faithful ports but unverified at runtime. +* `coretext-freetype` / `coretext-harfbuzz` cannot be cross-checked here: bundled harfbuzz with CoreText + needs the macOS SDK headers (`ApplicationServices`). `coretext-noshape` and the default are checked. +* `face_metrics` returns `Some(..)` for cap/ex/ascii height always (Zig returns plain values for CT). +* CF release thread is local to the font crate (`shaper/cf_release.rs`, std thread + channel); + switch to `ghostty_os::cf_release_thread` once that exists. +* `Face::init` copies in-memory font data into a CFData (Zig used no-copy). +* Renderer crate must use `ghostty_font::shape::{ShapeError, ShapeRunError}` instead of + `shaper::harfbuzz::{ShapeError, ShapeRunError}` (generic.rs lines ~144-146) to build on macOS. +* Cross-check env: `CC_`/`AR_` zig-cc wrappers (onig_sys C build); `GETTEXT_DIR` dummy. diff --git a/docs/rewrite/notes/macos-M1-renderer.md b/docs/rewrite/notes/macos-M1-renderer.md new file mode 100644 index 0000000..2a94f90 --- /dev/null +++ b/docs/rewrite/notes/macos-M1-renderer.md @@ -0,0 +1,92 @@ +# macOS M1 โ€” renderer (Metal backend + GraphicsApi abstraction) + +Scope: `crates/ghostty-renderer` only. Zig: `renderer/Metal.zig`, `renderer/metal/*`, the macOS parts of +`generic.zig` / `Thread.zig` / `renderer.zig` / `shadertoy.zig`. + +## GraphicsApi abstraction (`graphics_api.rs`) +Zig's `GenericRenderer(comptime GraphicsAPI)` duck-types its API; Rust has the `GraphicsApi` trait with +associated types (`Target`, `Texture`, `Sampler`, `Buffer` (GAT), `BufferRef`, `Pipeline`, `Shaders`, `GpuUniforms`, +`Frame`, `RenderPass<'a>`), associated consts (`CUSTOM_SHADER_TARGET`, `CUSTOM_SHADER_Y_IS_DOWN`, `SWAP_CHAIN_COUNT`) and +operations (`init_*`, `begin_frame`, `surface_size`, `present_last_target`, `draw_frame_start/end`, `loop_enter/exit`, ...). +Helper traits: `GpuBuffer`, `GpuTexture`, `GpuTarget`, `GpuShaders`, `GpuFrame`, `GpuRenderPass`; shared value types +`Attachment`, `AttachmentTarget`, `Draw`, `Step`, `Primitive`, `ImageTextureFormat`, `TextureKind`, `ApiError`. +* `generic::Renderer`, `image::{State,Image,Entry}`, `thread::Thread`. +* `lib.rs`: `Api = metal::Metal` on `target_vendor = "apple"`, `opengl::OpenGL` otherwise; `Renderer = generic::Renderer` + (`Backend::default_for_target` agrees, tested). +* The OpenGL backend implements the trait in `opengl/api.rs` by delegating to its (unchanged) inherent API, so apprt-gtk + and `tests/egl_headless.rs` keep using the GL-typed API. `opengl::render_pass::{Attachment,Step,Draw}` are now aliases of the + generic types; `gl::Primitive` re-exports `graphics_api::Primitive`. +* CPU-side GPU structs shared by every API moved to `shader_types.rs` (`Uniforms` keeps the std140 layout with bit-packed `bools`; + Metal converts via `GraphicsApi::gpu_uniforms` to `MetalUniforms`, whose bools are four bytes like the MSL struct). +* Behaviour changes needed for asynchronous (Metal) frames, equal to Zig's `frame_sema`/`frameCompleted`: + the swap chain hands frame states back from the frame completion callback (`SwapChainInner` + `Condvar`); + `begin_frame(&self)` no longer borrows the API; `GpuFrame::complete(sync, completion)` reports health through a + `FrameCompletion` (inline for GL / sync Metal, on Metal's completion thread otherwise); `display_unrealized` waits for + in-flight frames; font grid changes bump an `atlas_epoch` so in-flight frames resync atlases. +* `Renderer::display_realized(loader)` / `last_frame_pixels` exist only for `Renderer`; the generic hook is + `display_realized_with(|api| ..)`. +* GL: `OpenGL::last_target` is now `Arc>>>` (frames present without borrowing the API); `present`, + `present_last_target`, `clear_last_target`, `begin_frame` take `&self`; `last_target()` returns a clone. + +## Metal (`src/metal/`, `cfg(target_vendor = "apple")`, objc2 0.6 + objc2-metal 0.3) +| Zig | Rust | +|---|---| +| Metal.zig | `metal/mod.rs` (`Metal`, `ViewInfo`, `Metal::init(blending, &ViewInfo)`) | +| metal/api.zig | `metal_layout.rs` (raw values, plain Rust, unit tested) + `objc2_metal` types | +| metal/buffer.zig | `metal/buffer.rs` | +| metal/Frame.zig | `metal/frame.rs` (command buffer, completion block via `block2::RcBlock`) | +| metal/IOSurfaceLayer.zig | `metal/io_surface_layer.rs` (`define_class!` CALayer subclass `IOSurfaceLayer`: `display`, `actionForKey:`; main-thread `set_surface` via `dispatch2`) | +| metal/Pipeline.zig | `metal/pipeline.rs` (vertex attributes from `MetalVertex::ATTRIBUTES` instead of reflection) | +| metal/RenderPass.zig | `metal/render_pass.rs` (buffer slots: vertex 0, uniforms 1, rest from 2) | +| metal/Sampler.zig, Target.zig, Texture.zig | `metal/{sampler,target,texture}.rs` (Target = IOSurface BGRA + Display P3 colorspace + MTLTexture) | +| metal/shaders.zig | `metal/shaders.rs` (+ `shader_types`, `metal_layout::PIPELINE_DESCS`) | +| shaders/shaders.metal | `shaders/shaders.metal` (verbatim) | +* The metallib: Zig precompiles `shaders.metal` with `xcrun metal`; we embed the source and compile it with + `newLibraryWithSource` at `Shaders::init` (same as post-process shaders already do). Entry points are looked up by name. +* View attachment is done with `msg_send!` on the `NSView`/`UIView` (no AppKit dependency): macOS `setLayer:` then + `setWantsLayer:`, iOS `addSublayer:`. `setClipsToBounds:` is guarded with `respondsToSelector:` (NSView API since macOS 14). +* `Metal::init` expects to run on the main thread with the view the apprt (L1) provides; the apprt builds `Metal` and passes it + to `Renderer::new(opts, api)`; `Thread::draw_on_app_thread` defaults to false on Apple targets. +* iOS: compiled via `target_vendor = "apple"` with the UIView branch; not checked here (only macOS targets are installed). + +## macOS parts of generic / thread +* `display_link.rs`: `CVDisplayLink` wrapper (`objc2-core-video`, macOS only). On other platforms `DisplayLink` is uninhabited + and the renderer holds `None` (Zig: `DisplayLink = void`). `Renderer` creates it when `window-vsync` is set (macOS), + `loop_enter(draw_now)` installs the callback and starts it, `loop_exit` stops, `set_focus`/`set_visible` start/stop it, + `set_macos_display_id`, `has_vsync` (= link running). +* `loop_enter` also sets the Metal layer's display callback to `draw_frame(true)` (holding a `Weak`). +* `thread.rs`: `pthread_setname_np("renderer")`, QoS class (`qos_for(visible, focused)`, tested on every platform), + autorelease pool around `drain_mailbox`, `Message::MacosDisplayId`, `thread_enter/exit`, `loop_enter/exit` guards. +* `generic.rs`: with `background-blur = macos-glass-*` the background alpha is forced to 0 (macOS only). + `Renderer::{thread_enter,thread_exit,loop_enter,loop_exit,set_macos_display_id}` are new. + +## Shadertoy MSL +`shadertoy::msl_from_compiled` (naga `msl-out`, enabled on all platforms so it is tested on Linux): entry point renamed to +`ghostty_main` (spirv-cross' `main0` becomes `main0_` in naga, which suffixes names ending in a digit), `Globals` at +`[[buffer(1)]]`, `iChannel0` at `[[texture(0)]]` / `[[sampler(0)]]` (what the Metal render pass binds), MSL 2.1. +`Target::Msl` no longer returns `Unsupported`. Known limitation: the unused `iChannelTime` float array has std140 stride 16 +in the CPU struct but naga emits tightly packed `float[4]` (spirv-cross emits padded types); no Ghostty shader uses it. + +## Tests +* Linux (run): `metal_layout` (resource options / texture usage bits, storage mode, max texture size, surface size clamp, + pixel formats, vertex attribute tables, buffer slots, blend state, `MetalUniforms` offsets == MSL struct, conversion from the + std140 uniforms, shader function names exist in `shaders.metal`), shadertoy `shadertoy_to_msl` (port of "shadertoy to msl", + +focus shader, +file loading), `thread::qos_class_policy`, `backend` tests, `tests/mock_api.rs` (generic renderer over a mock + API with asynchronous triple-buffered completion, health reporting, unrealize waiting for in-flight frames). + Totals: 97 unit + 9 EGL + 2 mock_api, all passing; the 9 EGL tests are unchanged apart from dropping an unneeded `mut`. +* macOS (compile-checked only): `metal/target.rs`, `render_pass.rs`, `frame.rs` unit tests (pixel format fourcc, primitive + mapping, health from command buffer status) compile with `--all-targets`. +* Not ported: Zig has no tests in the Metal files other than `shadertoy to msl` (ported) and the `CellText` size test (covered + by `shader_types` layout tests). + +## Deviations / notes +* `Buffer::sync` (Metal) allocates exactly double the required bytes; Zig multiplies by `@sizeOf(T)` again. +* `Texture::replace_region` rejects too-short data (safe fn), Zig does not check. +* `Image` vertex stays 48 bytes (GL layout padded to 16); Metal's stride is `size_of` of that struct, offsets unchanged. +* `ghostty-core`'s `surface/mod.rs` imports `ghostty_renderer::Renderer` (the platform alias) but builds an `OpenGL` API (GTK); + on macOS that alias is `Renderer`. Until L1 provides the embedded surface, core should import + `ghostty_renderer::generic::Renderer` (= `Renderer`, identical type on Linux) so `cargo check --target *-apple-darwin + -p ghostty-core` passes. `Thread` is generic over the API for this reason. +* Cross-check recipe (no macOS SDK): `GETTEXT_DIR=/nonexistent` (skip gettext-sys source build), `CC_` = a wrapper + around `zig cc -target aarch64-macos` for `onig_sys` (drop `-arch`/`--target`/`-mmacosx-version-min`), `CARGO_BUILD_RUSTC_WRAPPER=` + (kache is picked up as cc wrapper), `ZIG_GLOBAL_CACHE_DIR` on a writable fs. diff --git a/docs/rewrite/notes/macos-M1.md b/docs/rewrite/notes/macos-M1.md new file mode 100644 index 0000000..c60f5e6 --- /dev/null +++ b/docs/rewrite/notes/macos-M1.md @@ -0,0 +1,96 @@ +# macOS port (M1): os, input, config, termio, core, crash glue + +Scope of this note: everything of M1 except the two big sub-ports, which have their own notes: +* `macos-M1-font.md` โ€“ CoreText face/shaper/discovery, backend selection (`ghostty-font`). +* `macos-M1-renderer.md` โ€“ Metal backend, `GraphicsApi` abstraction (`ghostty-renderer`). + +Nothing here has ever run on macOS. It is compile-checked (`cargo check|clippy --target +aarch64-apple-darwin|x86_64-apple-darwin`) and every piece of platform-independent logic was +extracted so its macOS variant is unit-tested on Linux (via explicit `darwin: bool` parameters, or +`ghostty_input::platform::with_darwin` in test builds). Linux behaviour is unchanged. + +## Cross-checking on Linux (no macOS SDK) +The C build scripts of the dependency tree (onig_sys, gettext-sys, dear-imgui-sys, oslog) cannot +cross-compile without the SDK. For the *check only* (no repo change) use `zig cc`/`zig c++` +wrappers (`-target aarch64-macos`/`x86_64-macos`): +``` +export CARGO_BUILD_RUSTC_WRAPPER= # kache breaks cc-rs +export CC_aarch64_apple_darwin= CXX_aarch64_apple_darwin= AR_aarch64_apple_darwin= +export GETTEXT_DIR=/nonexistent # makes gettext-sys skip its from-source build +export CFLAGS_=-I # oslog's wrapper.c needs +export ZIG_GLOBAL_CACHE_DIR/ZIG_LOCAL_CACHE_DIR= +cargo check --target aarch64-apple-darwin -p ghostty-vt -p ghostty-unicode -p ghostty-datastruct \ + -p ghostty-os -p ghostty-input -p ghostty-config -p ghostty-terminal -p ghostty-font \ + -p ghostty-renderer -p ghostty-termio -p ghostty-core +``` +(the wrappers drop `-arch`, `-mmacosx-version-min` and `--target=` that cc-rs adds). Real macOS builds +need none of this: onig_sys compiles with the system clang, `gettext-rs` builds libintl from source +(like Zig's `pkg/libintl`), `oslog`/imgui use the SDK. Root crate (`ghostty`, GTK) and +`ghostty-apprt-gtk` are Linux-only and are not part of the macOS check (the macOS app links the embedded +`libghostty`, L1's job). + +## ghostty-os +| Zig | Rust | +|---|---| +| `os/macos.zig` | `macos.rs`: `QosClass`, `set_qos_class`, `pthread_setname_np`, `is_at_least_version`, `app_support_dir`, `cache_dir` (NSFileManager via objc2-foundation), pure `join_dir`/`QosClass` values tested everywhere | +| `os/mach.zig` | `mach.rs`: `VmTag` (+`make`), `tagged::TaggedPageAllocator` (mmap with VM tag in the fd arg; Rust has no stable allocator API so it is an alloc/unmap/realloc struct) | +| `os/cf_release_thread.zig` | `cf_release_thread.rs`: bounded(64) mailbox + stop channel instead of xev; injectable release fn (tests count releases on Linux); `CFRelease` via `CFRetained` | +| `os/args.zig` IteratorMacOS | `args::iterator()` on macOS reads `NSProcessInfo.arguments` | +| `os/mouse.zig` | `click_interval()` = `NSEvent.doubleClickInterval` ceil ms (`click_interval_ms` tested) | +| `os/desktop.zig` | macOS: ppid == 1 (+ `GHOSTTY_MAC_LAUNCH_SOURCE=app` for the lib artifact: `launched_from_desktop_lib`), `DesktopEnvironment::Macos`, iOS true | +| `os/homedir.zig` | macOS `homeDirectoryForCurrentUser` after the `HOME` check | +| `os/locale.zig` | Darwin `ensure_locale`: LANG from NSLocale (`_.UTF-8`) + `LANGUAGE` from preferred languages (`preferred_language_value`), setlocale fallback chain (module `darwin`, unsafe allowed there) | +| `os/open.zig` | `open [-t] url`; iOS `Unsupported`; Windows branch kept | +| `os/resourcesdir.zig` | `Contents/Resources` bundle lookup first, `terminfo/78/xterm-ghostty` sentinel | +| `os/pipe.zig` | Darwin: `pipe` + `FD_CLOEXEC` on both ends (no `pipe2`) | +| `os/TempDir.zig` | non-Linux random via `/dev/urandom` (no `getrandom`) | +| `os/systemd.zig`, `passwd.zig` flatpak | Linux/Android only; `launched_by_systemd` false and `notify` no-ops elsewhere | +| `os/i18n.zig` canonicalize | still the pure-Rust approximation on all platforms (the real `_libintl_locale_name_canonicalize` is only linked in Zig's own libintl build) | +`xdg::Error` gained `AppleApiFailed`. Deps (apple only): objc2, objc2-foundation, objc2-app-kit, objc2-core-foundation, libc. + +## ghostty-input +* `platform.rs`: `is_darwin()`/`is_macos()` consts with a test-only per-thread override (`with_darwin`). +* Darwin branches enabled: `Mods::translation` (option-as-alt with sides), `ctrl_or_super` (Mods, Key, free fn + + `ctrl_or_super_for(mods, darwin)`), keycode table column 4 (mac) on Apple (`native_for_code` for tests), + key_encode: kitty `alt_prevents_text`, modifyOtherKeys alt handling, `legacy_alt_prefix` option-as-alt, + "super+text encodes nothing" on macOS. The 7 macOS-only Zig tests + `translation macos-option-as-alt` are ported + and run on Linux through the override. +* `keymap_darwin.rs` (`KeymapDarwin.zig`): Carbon `TISCopyCurrentKeyboardLayoutInputSource`/`UCKeyTranslate` + FFI (macOS only); `CarbonMods`, `carbon_modifier_state`, `utf16_to_utf8` tested on Linux; `keymap_noop.rs`; + `ghostty_input::{Keymap, KeymapState, KeymapTranslation}` pick by platform. Crate lint is now `unsafe_code = "deny"`. + +## ghostty-config +macOS defaults: `font-size` 13, `alpha-blending` native, `copy-on-select` (true on Linux/macOS, false elsewhere), +`quick-terminal-autohide` true, `quit-after-last-window-closed` Linux-only, `linux-cgroup` never off Linux. +`Keybinds::init_for(darwin)` is now a faithful port of the whole Zig `init` (platform lists + the +Mac-specific block: cmd+q/k/a, undo/redo, mac windowing/splits/search/inspector, natural text editing); +`init()` selects with the target. Linux output verified identical to the previous dump; macOS tested. +`file_load`: Application Support paths (`default/legacy/preferred_app_support_path`, `preferred_default_file_path` +macOS order), `edit::config_path_candidates`, `load_default_files` loads app-support + XDG and writes the +template to app support; `probable_cli_environment` macOS desktop branch; `load_cli_args` reads +`ghostty_os::args::iterator()` (NSProcessInfo) and the Linux/BSD-only `xdg-terminal-exec` + no-args fast path. + +## ghostty-termio / crash / core / terminal +* `pty.rs`: Darwin `openpty()` (libc) + CLOEXEC on the master; `command.rs`: dup2 path clears CLOEXEC on the + source fd, hand-rolled `execvpe` (PATH search like Zig's `execvpeZ`, candidates precomputed pre-fork, + tested), `__error` for errno; `wakeup.rs`: non-blocking pipe instead of eventfd; `exec.rs`: no pidfd (poll + `waitpid`), `login(1)` wrapping (`exec_command_with(command, darwin, passwd)`, `-q` for `.hushlogin`, tests ported + + extra), `XDG_DATA_DIRS`/`MANPATH` bundle env (`darwin_bundle_env`), killpg EPERM ignored on Darwin, terminfo-missing + warnings; `shell_integration.rs`: `/bin/bash` is not integrated on Darwin (`detect_shell_for`). +* `ghostty-crash`: `getentropy` instead of `getrandom` on Apple. +* `ghostty-terminal` search thread: `pthread_setname_np` + `QosClass::Utility` on Apple. +* `ghostty-core`: Darwin child-exit handling (any quick exit is abnormal, no exit code line), macOS + `encode_key_opts` (`macos-option-as-alt` from config or `RtApp::keyboard_layout().detect_option_as_alt()`), + rectangle select = option only, `global::Logging.macos` + `os_log` logger (oslog crate, unified logging, levels + debug/info/err/fault like Zig; `GHOSTTY_LOG=macos` parsed). +* `ghostty-apprt` (tiny): `RtApp::keyboard_layout()` default method (`Unknown`); the embedded runtime (L1) overrides it + with `ghostty_input::Keymap::source_id` โ†’ `KeyboardLayout::map_apple_id`. + +## Not done / for L1 and later +* apprt embedded.zig macOS bits (NSView/surface plumbing, CVDisplayLink hosting, `keyboardLayout`, app menu actions): L1. +* Render/IO thread names on Darwin rely on `std::thread::Builder::name` (std calls `pthread_setname_np` for the + new thread on Darwin, which is what the Zig explicit call works around). QoS for the renderer thread: see renderer note. +* The font crate keeps a private CF release thread (`shaper/cf_release.rs`); `ghostty_os::cf_release_thread` is available + for the renderer to own one like Zig's `renderer/Thread.zig`. +* Skipped Zig tests: `macos.zig cacheDir paths` runs only on macOS (ported as a pure + `cfg(apple)` test), + KeymapDarwin's empty `test {}`, `Command.zig` `/private/tmp` cwd expectation (Linux test untouched). diff --git a/justfile b/justfile index 2ab1f16..b024e4b 100644 --- a/justfile +++ b/justfile @@ -25,6 +25,19 @@ _fix: fix: _fix fmt +# Build libghostty-vt (C ABI, include/ghostty/vt.h) as target//libghostty-vt.{so,a} +# and, if the target is installed, target/wasm32-unknown-unknown//ghostty-vt.wasm. +vt-lib profile="release": + cargo build -p ghostty-vt-capi --profile {{profile}} + cp target/{{profile}}/libghostty_vt_capi.so target/{{profile}}/libghostty-vt.so + cp target/{{profile}}/libghostty_vt_capi.a target/{{profile}}/libghostty-vt.a + cargo build -p ghostty-vt-capi --profile {{profile}} --target wasm32-unknown-unknown + cp target/wasm32-unknown-unknown/{{profile}}/ghostty_vt_capi.wasm target/wasm32-unknown-unknown/{{profile}}/ghostty-vt.wasm + +# Run the libghostty-vt ABI check and the C/wasm example tests. +vt-test: + cargo test -p ghostty-vt-capi + clippy: cargo clippy --workspace --all-targets --all-features -- -D warnings diff --git a/src/main.rs b/src/main.rs index 6a605ab..60510df 100644 --- a/src/main.rs +++ b/src/main.rs @@ -13,6 +13,13 @@ use ghostty_cli::build_info::{self, BuildInfo, Version}; use ghostty_core::global::{self, InitOptions}; fn main() -> ExitCode { + let code = real_main(); + // Flush/disable crash capture (Zig: `state.deinit()`). + global::deinit(); + code +} + +fn real_main() -> ExitCode { // Byte-safe argv (`std::env::args()` panics on non-UTF-8 arguments). let args: Vec = ghostty_os::args::collect_lossy();