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<G>; 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)
This commit is contained in:
1 parent
3c8fae1089
commit
452b782149
124 files changed
+14680
-986
No files matched your search
Generated
+263
@@ -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",
|
||||
]
|
||||
|
||||
|
||||
+14
@@ -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"
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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<SurfaceId>, 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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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::ffi::OsString> = std::env::args_os().collect();
|
||||
// (On macOS this reads NSProcessInfo, libc argc/argv are not valid
|
||||
// for NSApplicationMain-based applications.)
|
||||
let argv: Vec<std::ffi::OsString> = 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<std::ffi::CString> = 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") {
|
||||
|
||||
@@ -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<String> {
|
||||
@@ -80,8 +80,15 @@ fn config_path() -> io::Result<String> {
|
||||
/// Returns a list of possible paths the main config file could be
|
||||
/// in for the current OS.
|
||||
fn config_path_candidates() -> io::Result<Vec<String>> {
|
||||
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)
|
||||
}
|
||||
@@ -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<String, xdg::Error> {
|
||||
Ok(xdg_path)
|
||||
}
|
||||
|
||||
/// Default path for the macOS Application Support configuration file.
|
||||
#[cfg(target_os = "macos")]
|
||||
pub fn default_app_support_path() -> Result<String, xdg::Error> {
|
||||
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<String, xdg::Error> {
|
||||
app_support_dir("config")
|
||||
}
|
||||
|
||||
#[cfg(target_os = "macos")]
|
||||
fn app_support_dir(sub_path: &str) -> Result<String, xdg::Error> {
|
||||
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<String, xdg::Error> {
|
||||
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<String, xdg::Error>,
|
||||
) -> Result<String, xdg::Error> {
|
||||
// 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<String, xdg::Error> {
|
||||
// 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<String, xdg::Error> {
|
||||
// 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()) {
|
||||
|
||||
@@ -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,6 +133,7 @@ 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
|
||||
if !darwin {
|
||||
set.put(
|
||||
phys(Key::Insert, mods(true, false, false, false)),
|
||||
Action::CopyToClipboard(CopyToClipboard::Mixed),
|
||||
@@ -132,36 +142,42 @@ impl Keybinds {
|
||||
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,13 +197,12 @@ 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
|
||||
if !darwin {
|
||||
set.put(uni('n', cs), Action::NewWindow);
|
||||
set.put(uni('w', cs), Action::CloseSurface);
|
||||
set.put(uni('q', cs), Action::Quit);
|
||||
@@ -199,7 +214,6 @@ impl Keybinds {
|
||||
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));
|
||||
@@ -262,11 +276,15 @@ impl Keybinds {
|
||||
|
||||
// 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<usize> {
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
@@ -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}");
|
||||
|
||||
@@ -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 }
|
||||
@@ -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);
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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<IoMessage> {
|
||||
// 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<u8> = Vec::with_capacity(64);
|
||||
if let Err(err) = key_encode::encode(&mut data, event, &opts) {
|
||||
|
||||
@@ -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<Size>,
|
||||
|
||||
/// The dimensions recorded in crash reports (see `ghostty_crash`).
|
||||
crash_dims: Arc<ghostty_crash::SurfaceDims>,
|
||||
|
||||
/// The configuration derived from the main config.
|
||||
config: RefCell<Rc<DerivedConfig>>,
|
||||
|
||||
@@ -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,6 +1098,9 @@ impl Surface {
|
||||
t.print_string(&runtime_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()?;
|
||||
@@ -1091,6 +1108,7 @@ impl Surface {
|
||||
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<InputEffect, CoreError> {
|
||||
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<bool, CoreError> {
|
||||
let _crash = self.crash_guard();
|
||||
Surface::mouse_button_callback(self, state, button, mods)
|
||||
}
|
||||
|
||||
fn cursor_pos_callback(&self, pos: CursorPos, mods: Option<Mods>) -> 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));
|
||||
}
|
||||
}
|
||||
@@ -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 {
|
||||
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,
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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<T: ?Sized>(pub(crate) CFRetained<T>);
|
||||
|
||||
// SAFETY: see the type docs; the wrapped objects are immutable CoreText
|
||||
// objects, safe to use from any thread.
|
||||
unsafe impl<T: ?Sized> Send for CfBox<T> {}
|
||||
// SAFETY: as above.
|
||||
unsafe impl<T: ?Sized> Sync for CfBox<T> {}
|
||||
|
||||
impl<T: ?Sized> CfBox<T> {
|
||||
pub(crate) fn new(v: CFRetained<T>) -> Self {
|
||||
CfBox(v)
|
||||
}
|
||||
}
|
||||
|
||||
impl<T: ?Sized> Deref for CfBox<T> {
|
||||
type Target = T;
|
||||
fn deref(&self) -> &T {
|
||||
&self.0
|
||||
}
|
||||
}
|
||||
|
||||
impl<T: objc2_core_foundation::Type> Clone for CfBox<T> {
|
||||
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<CFRetained<CFData>> {
|
||||
// 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<Vec<u8>> {
|
||||
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<Vec<Axis>> {
|
||||
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<CFDictionary> = 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::<CFString, CFString>(),
|
||||
dict.cast_unchecked::<CFString, CFNumber>(),
|
||||
)
|
||||
};
|
||||
// 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::<CFType>;
|
||||
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<std::collections::HashMap<u32, f64>> {
|
||||
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<CFNumber, CFNumber> = 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(),
|
||||
)
|
||||
}
|
||||
@@ -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.
|
||||
|
||||
@@ -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<CTFont>,
|
||||
|
||||
/// 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<Variation>,
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
pub struct DeferredFace {
|
||||
/// CoreText
|
||||
pub ct: Option<CoreText>,
|
||||
}
|
||||
|
||||
impl DeferredFace {
|
||||
pub fn from_coretext(font: CFRetained<CTFont>, variations: Vec<Variation>) -> DeferredFace {
|
||||
DeferredFace {
|
||||
ct: Some(CoreText {
|
||||
font: CfBox::new(font),
|
||||
variations,
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
||||
/// Returns the family name of the font.
|
||||
pub fn family_name(&self) -> Result<String, LoadError> {
|
||||
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<String, LoadError> {
|
||||
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<Face, LoadError> {
|
||||
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<Face, LoadError> {
|
||||
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::<CFURL>()
|
||||
.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<Presentation>) -> 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());
|
||||
}
|
||||
}
|
||||
@@ -1,8 +1,23 @@
|
||||
//! 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`).
|
||||
|
||||
#[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;
|
||||
|
||||
#[cfg(font_fontconfig)]
|
||||
mod fc_impl {
|
||||
use std::sync::Arc;
|
||||
|
||||
use super::Descriptor;
|
||||
use crate::{
|
||||
collection::Collection,
|
||||
deferred_face::{DeferredFace, Fontconfig as DeferredFontconfig},
|
||||
@@ -13,8 +28,8 @@ use crate::{
|
||||
/// Discover implementation for the compile options.
|
||||
pub type Discover = Fontconfig;
|
||||
|
||||
/// Descriptor is used to search for fonts (canonical definition in `ghostty-config`).
|
||||
pub use ghostty_config::font_types::Descriptor;
|
||||
/// 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
|
||||
@@ -100,7 +115,7 @@ impl Fontconfig {
|
||||
|
||||
pub fn discover_fallback(
|
||||
&self,
|
||||
_collection: &Collection,
|
||||
_collection: &mut Collection,
|
||||
desc: &Descriptor,
|
||||
) -> Result<DiscoverIterator, DiscoverError> {
|
||||
self.discover(desc)
|
||||
@@ -124,8 +139,8 @@ impl DiscoverIterator {
|
||||
|
||||
// Get the copied pattern from our fontset that has the
|
||||
// attributes configured for rendering.
|
||||
let font_pattern = self
|
||||
.config
|
||||
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).
|
||||
@@ -141,6 +156,7 @@ impl DiscoverIterator {
|
||||
})))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
@@ -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();
|
||||
|
||||
@@ -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<T: AsRef<CFType> + objc2_core_foundation::Type>(v: &T) -> CFRetained<CFType> {
|
||||
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<CFRetained<CTFontDescriptor>, DiscoverError> {
|
||||
let mut keys: Vec<&CFString> = Vec::new();
|
||||
// Owned values; we collect references below.
|
||||
let mut values: Vec<CFRetained<CFType>> = 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::<CFString, CFType>::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::<CFString, CFType>::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<DiscoverIterator, DiscoverError> {
|
||||
// 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<CFRetained<CTFontDescriptor>> = match list {
|
||||
Some(list) => {
|
||||
// SAFETY: the matching list contains CTFontDescriptors.
|
||||
let typed: &CFArray<CTFontDescriptor> = 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<DiscoverIterator, DiscoverError> {
|
||||
// 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<CFRetained<CTFontDescriptor>> {
|
||||
// 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<CFRetained<CTFontDescriptor>> {
|
||||
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<CTFontDescriptor>]) {
|
||||
// Compute the scores once. Higher score is "less" (earlier).
|
||||
let mut scored: Vec<(u32, CFRetained<CTFontDescriptor>)> = 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<CTFont> =
|
||||
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::<CFDictionary>().ok()?;
|
||||
// SAFETY: the traits dictionary maps CFString keys to CFNumber values.
|
||||
let traits: &CFDictionary<CFString, CFNumber> = 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::<CFString>().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<CfBox<CTFontDescriptor>>,
|
||||
variations: Vec<Variation>,
|
||||
i: usize,
|
||||
}
|
||||
|
||||
impl DiscoverIterator {
|
||||
#[allow(clippy::should_implement_trait)]
|
||||
pub fn next(&mut self) -> Result<Option<DeferredFace>, 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::<CFString, CFType>::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"));
|
||||
}
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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<harfbuzz_sys::hb_font_t>);
|
||||
|
||||
// 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<CTFont>,
|
||||
|
||||
/// 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<f64>,
|
||||
|
||||
/// 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<ColorState>,
|
||||
|
||||
/// 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<Face, FaceError> {
|
||||
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<Face, FaceError> {
|
||||
// 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<CTFont>, opts: Options) -> Result<Face, FaceError> {
|
||||
// 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<Face, FaceError> {
|
||||
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<Face, FaceError> {
|
||||
// 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<u32> {
|
||||
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<Glyph, RenderError> {
|
||||
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<u16> = (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<Option<Vec<u8>>, 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<objc2_core_text::CTFontDescriptor> {
|
||||
// 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<SvgTable>,
|
||||
}
|
||||
|
||||
/// An owned copy of the `SVG ` table and the glyph range it covers.
|
||||
struct SvgTable {
|
||||
data: Vec<u8>,
|
||||
}
|
||||
|
||||
impl ColorState {
|
||||
fn new(f: &CTFont) -> Result<ColorState, FaceError> {
|
||||
// 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<CTFont> {
|
||||
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));
|
||||
}
|
||||
}
|
||||
@@ -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<f64>,
|
||||
}
|
||||
|
||||
/// 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<f64>,
|
||||
opts: &RenderOptions,
|
||||
) -> Option<GlyphLayout> {
|
||||
// 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<Item = (String, f64)>,
|
||||
) -> (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));
|
||||
}
|
||||
}
|
||||
@@ -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"))]
|
||||
|
||||
@@ -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<LibraryInner>,
|
||||
}
|
||||
|
||||
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<Library, LibraryError> {
|
||||
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<Library, LibraryError> {
|
||||
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
|
||||
|
||||
@@ -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},
|
||||
};
|
||||
|
||||
@@ -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<CFType>;
|
||||
|
||||
/// Convert any retained CF object into a pool entry.
|
||||
pub(crate) fn pooled<T: objc2_core_foundation::Type>(v: CFRetained<T>) -> 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::<CFType>(v) })
|
||||
}
|
||||
|
||||
pub(crate) struct ReleaseThread {
|
||||
tx: Option<Sender<Vec<Pooled>>>,
|
||||
handle: Option<JoinHandle<()>>,
|
||||
}
|
||||
|
||||
impl ReleaseThread {
|
||||
pub(crate) fn new() -> Option<ReleaseThread> {
|
||||
let (tx, rx) = channel::<Vec<Pooled>>();
|
||||
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<Pooled>) {
|
||||
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();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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:
|
||||
//! <https://github.com/harfbuzz/harfbuzz/discussions/4525>)
|
||||
//!
|
||||
//! - Hyphens (U+2010) can be synthesized by CoreText but not by HarfBuzz.
|
||||
//! See: <https://github.com/mitchellh/ghostty/issues/1643>
|
||||
//!
|
||||
//! 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<CFDictionary> {
|
||||
let mut items: Vec<CFRetained<CFDictionary<CFString, CFType>>> = 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::<CFString, CFType>::from_slices(
|
||||
&[k_tag, k_val],
|
||||
&[key.as_ref(), value.as_ref()],
|
||||
);
|
||||
items.push(dict);
|
||||
}
|
||||
|
||||
let item_refs: Vec<&CFDictionary<CFString, CFType>> = items.iter().map(|d| &**d).collect();
|
||||
let list = CFArray::from_objects(&item_refs);
|
||||
|
||||
// SAFETY: valid static key.
|
||||
let k = unsafe { kCTFontFeatureSettingsAttribute };
|
||||
let dict = CFDictionary::<CFString, CFType>::from_slices(&[k], &[list.as_ref()]);
|
||||
// SAFETY: the attribute dictionary is used as an untyped CFDictionary.
|
||||
CfBox::new(unsafe { CFRetained::cast_unchecked::<CFDictionary>(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<CFDictionary>,
|
||||
/// A version of the features dictionary with the default features excluded.
|
||||
features_no_default: CfBox<CFDictionary>,
|
||||
|
||||
/// The shared memory used for shaping results.
|
||||
cell_buf: Vec<Cell>,
|
||||
|
||||
/// 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<CFDictionary>,
|
||||
|
||||
/// 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<Option<CfBox<CFDictionary>>>,
|
||||
|
||||
/// 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<Pooled>,
|
||||
|
||||
/// Dedicated thread for releasing CoreFoundation objects. Some objects,
|
||||
/// such as those produced by CoreText, have excessively slow release
|
||||
/// callback logic.
|
||||
cf_release_thread: Option<ReleaseThread>,
|
||||
}
|
||||
|
||||
impl Shaper {
|
||||
/// The cell buffer holds the shaped results.
|
||||
pub fn new(opts: &shape::Options) -> Result<Shaper, ShapeError> {
|
||||
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<Feature> = 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::<CFString, CFType>::from_slices(&[k], &[num.as_ref()]);
|
||||
// SAFETY: used as an untyped CFDictionary.
|
||||
CfBox::new(unsafe { CFRetained::cast_unchecked::<CFDictionary>(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<CTLine> = 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<CTRun> = unsafe { glyph_runs.cast_unchecked() };
|
||||
let mut runs: Vec<CtRun> = 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<CFDictionary> {
|
||||
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<CTFont> = 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::<CFString, CFType>::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::<CFDictionary>(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<CfBox<CFDictionary>, 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(),
|
||||
}
|
||||
}
|
||||
@@ -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<Codepoint>,
|
||||
pub unichars: Vec<u16>,
|
||||
}
|
||||
|
||||
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<Cell>) {
|
||||
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<RunGlyph>,
|
||||
}
|
||||
|
||||
#[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<Cell>) {
|
||||
// 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);
|
||||
}
|
||||
}
|
||||
@@ -12,7 +12,7 @@
|
||||
|
||||
use super::hb;
|
||||
use crate::{
|
||||
face::freetype::Face,
|
||||
face::Face,
|
||||
shape::{self, Cell, DEFAULT_FEATURES, FeatureList, TextRun},
|
||||
shared_grid::SharedGrid,
|
||||
};
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
@@ -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<Shaper, ShapeError> {
|
||||
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);
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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,7 +321,35 @@ impl SharedGridSet {
|
||||
fallback(Style::Regular, SizeAdjustment::None),
|
||||
)?;
|
||||
|
||||
// Emoji fallback. (Zig skips this on macOS, which is out of scope.)
|
||||
// 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.
|
||||
@@ -335,6 +363,7 @@ impl SharedGridSet {
|
||||
)?,
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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"
|
||||
@@ -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.
|
||||
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());
|
||||
|
||||
@@ -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<W: Write>(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<W: Write>(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<W: Write>(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<W: Write>(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<u8> {
|
||||
// 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<u8> {
|
||||
let mut w = Vec::new();
|
||||
legacy(&mut w, ev, opts).unwrap();
|
||||
w
|
||||
}
|
||||
|
||||
fn enc_kitty(ev: &KeyEvent, opts: &Options) -> Vec<u8> {
|
||||
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"");
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -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,14 +594,72 @@ 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()
|
||||
};
|
||||
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 {
|
||||
match k {
|
||||
"ctrl" => m(|x| x.ctrl = true),
|
||||
|
||||
@@ -27,19 +27,60 @@ pub struct Entry {
|
||||
pub fn entries() -> &'static [Entry] {
|
||||
static ENTRIES: OnceLock<Vec<Entry>> = 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<u32> {
|
||||
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]
|
||||
|
||||
@@ -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, Error> {
|
||||
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<CFType>,
|
||||
|
||||
/// 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<Keymap, Error> {
|
||||
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<CFType>, *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::<CFType>()).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::<CFData>() };
|
||||
|
||||
// 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<String> {
|
||||
// SAFETY: valid source and constant key; the result is a
|
||||
// CFStringRef owned by the source.
|
||||
let id_raw = unsafe {
|
||||
TISGetInputSourceProperty(
|
||||
std::ptr::from_ref::<CFType>(&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::<CFString>() };
|
||||
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<Translation, Error> {
|
||||
// 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));
|
||||
}
|
||||
}
|
||||
@@ -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<Keymap, std::convert::Infallible> {
|
||||
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<Translation, std::convert::Infallible> {
|
||||
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());
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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<Option<(bool, bool)>> =
|
||||
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<R>(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()
|
||||
}
|
||||
@@ -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 = []
|
||||
|
||||
@@ -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<std::ffi::OsString> {
|
||||
use objc2_foundation::NSProcessInfo;
|
||||
|
||||
NSProcessInfo::processInfo()
|
||||
.arguments()
|
||||
.iter()
|
||||
.map(|s| std::ffi::OsString::from(s.to_string()))
|
||||
.collect::<Vec<_>>()
|
||||
.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;
|
||||
|
||||
@@ -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<c_void>);
|
||||
|
||||
// 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<CfRef>),
|
||||
}
|
||||
|
||||
/// 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::<CFType>()));
|
||||
}
|
||||
}
|
||||
|
||||
/// Handle used to talk to a running [`Thread`].
|
||||
#[derive(Clone)]
|
||||
pub struct Handle {
|
||||
mailbox: Sender<Message>,
|
||||
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<Message>,
|
||||
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<Running> {
|
||||
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);
|
||||
}
|
||||
}
|
||||
@@ -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::<i32>() else {
|
||||
// (`std::process::id` is `getpid` / `GetCurrentProcessId`.)
|
||||
let pid = std::process::id();
|
||||
let Ok(gio_pid) = gio_pid_str.to_string_lossy().parse::<u32>() 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<std::ffi::OsString>,
|
||||
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();
|
||||
|
||||
@@ -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<OsStr>, value: impl AsRef<OsStr>) -> Option<OsString> {
|
||||
/// 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<OsStr>, value: impl AsRef<OsStr>) -> Option<OsString> {
|
||||
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<OsStr>) -> 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<OsStr>) -> Option<OsString> {
|
||||
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<OsStr>) -> 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<String> {
|
||||
|
||||
#[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]
|
||||
|
||||
@@ -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<u64>) -> Rlimit {
|
||||
Rlimit {
|
||||
current: if cur == INFINITY { None } else { Some(cur) },
|
||||
@@ -22,6 +28,7 @@ fn mk(cur: u64, max: Option<u64>) -> 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<Rlimit> {
|
||||
let old = getrlimit(Resource::Nofile);
|
||||
|
||||
@@ -58,12 +65,17 @@ pub fn fix_max_files() -> Option<Rlimit> {
|
||||
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<String> {
|
||||
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<String> {
|
||||
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();
|
||||
|
||||
@@ -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<Option<String>, 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<Option<String>, Error> {
|
||||
let check = |s: String| {
|
||||
if cap < s.len() {
|
||||
@@ -42,6 +45,18 @@ pub fn home_with_capacity(cap: usize) -> Result<Option<String>, 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<Option<String>, Error> {
|
||||
Ok(None)
|
||||
}
|
||||
|
||||
/// Windows: `%HOMEDRIVE%%HOMEPATH%` (`homeWindows`). Returns `None` if
|
||||
/// either variable is missing.
|
||||
#[cfg(windows)]
|
||||
pub fn home_with_capacity(cap: usize) -> Result<Option<String>, 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<Option<String>, 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<String, ExpandError> {
|
||||
|
||||
/// 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<String, ExpandError> {
|
||||
// `~/` 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<String, ExpandError> {
|
||||
if !path.starts_with("~/") {
|
||||
return Ok(path.to_owned());
|
||||
@@ -100,6 +151,25 @@ pub fn expand_home_with_capacity(path: &str, cap: usize) -> Result<String, Expan
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn home_windows_joins_drive_and_path() {
|
||||
assert_eq!(
|
||||
home_windows(Some("C:"), Some("\\Users\\me"), 4096),
|
||||
Ok(Some("C:\\Users\\me".to_owned()))
|
||||
);
|
||||
assert_eq!(home_windows(None, Some("\\x"), 4096), Ok(None));
|
||||
assert_eq!(home_windows(Some("C:"), None, 4096), Ok(None));
|
||||
assert_eq!(
|
||||
home_windows(Some("C:"), Some("\\Users"), 3),
|
||||
Err(Error::BufferTooSmall)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(all(test, unix))]
|
||||
mod tests_unix {
|
||||
use super::*;
|
||||
use crate::testutil::with_env_lock;
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -84,12 +84,22 @@ pub fn is_valid(hostname: &str) -> bool {
|
||||
true
|
||||
}
|
||||
|
||||
/// The current machine's hostname (`GetComputerNameA`).
|
||||
#[cfg(windows)]
|
||||
fn our_hostname() -> Option<String> {
|
||||
crate::windows::computer_name()
|
||||
}
|
||||
|
||||
/// The current machine's hostname (`gethostname`).
|
||||
fn our_hostname() -> String {
|
||||
#[cfg(unix)]
|
||||
#[allow(clippy::unnecessary_wraps)]
|
||||
fn our_hostname() -> Option<String> {
|
||||
Some(
|
||||
rustix::system::uname()
|
||||
.nodename()
|
||||
.to_string_lossy()
|
||||
.into_owned()
|
||||
.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<bool, LocalHostnameValidationError> {
|
||||
}
|
||||
|
||||
// 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());
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
|
||||
@@ -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<String>,
|
||||
pub home: Option<String>,
|
||||
pub name: Option<String>,
|
||||
}
|
||||
|
||||
/// No passwd database on Windows: always an empty entry.
|
||||
pub fn get() -> std::io::Result<Entry> {
|
||||
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");
|
||||
}
|
||||
}
|
||||
@@ -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<String> {
|
||||
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<String> {
|
||||
darwin::ensure_locale()
|
||||
}
|
||||
|
||||
/// Format the system locale as `<lang>_<country>.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<S: AsRef<str>>(preferred: &[S]) -> Option<String> {
|
||||
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<String> {
|
||||
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<String> = 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<String> {
|
||||
// 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);
|
||||
|
||||
@@ -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::<u8>();
|
||||
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);
|
||||
}
|
||||
}
|
||||
@@ -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`).
|
||||
///
|
||||
/// <https://developer.apple.com/library/archive/documentation/Performance/Conceptual/power_efficiency_guidelines_osx/PrioritizeWorkAtTheTaskLevel.html>
|
||||
#[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<PathBuf, DirError> {
|
||||
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<PathBuf, DirError> {
|
||||
common_dir(
|
||||
SearchPathDirectory::CachesDirectory,
|
||||
&[crate::i18n::BUNDLE_ID, sub_path],
|
||||
)
|
||||
}
|
||||
|
||||
#[cfg(target_vendor = "apple")]
|
||||
fn common_dir(directory: SearchPathDirectory, sub_paths: &[&str]) -> Result<PathBuf, DirError> {
|
||||
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.
|
||||
///
|
||||
/// <https://developer.apple.com/documentation/apple-silicon/tuning-your-code-s-performance-for-apple-silicon?preferredLanguage=occ>
|
||||
#[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));
|
||||
}
|
||||
}
|
||||
@@ -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<u32> {
|
||||
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<u32> {
|
||||
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);
|
||||
}
|
||||
}
|
||||
@@ -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<Command> {
|
||||
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<Command> {
|
||||
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<Command> {
|
||||
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<Command> {
|
||||
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
|
||||
|
||||
@@ -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<Entry> {
|
||||
// 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<Entry> {
|
||||
/// 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<Entry> {
|
||||
use std::io::Read;
|
||||
|
||||
@@ -112,7 +115,9 @@ fn host_entry(name: &str) -> std::io::Result<Entry> {
|
||||
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");
|
||||
|
||||
@@ -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<Option<String>, 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<Option<String>, 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<Option<String>, 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();
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
@@ -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<String>) -> 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<String> {
|
||||
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<String> {
|
||||
None
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[cfg(all(test, unix))]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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<TempDir> {
|
||||
@@ -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),
|
||||
|
||||
@@ -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<String, Er
|
||||
]));
|
||||
}
|
||||
|
||||
// First check the env var.
|
||||
if let Some(env) = getenv_not_empty(env) {
|
||||
// First check the env var. On Windows we treat `LOCALAPPDATA` as a
|
||||
// fallback for the XDG variable.
|
||||
let env_value = getenv_not_empty(env);
|
||||
#[cfg(windows)]
|
||||
let env_value = env_value.or_else(|| getenv_not_empty("LOCALAPPDATA"));
|
||||
if let Some(env) = env_value {
|
||||
// If we have a subdir, then we join it.
|
||||
if let Some(subdir) = opts.subdir {
|
||||
return Ok(path_join(&[&env, subdir]));
|
||||
@@ -90,7 +98,7 @@ pub fn parse_terminal_exec<'a>(argv: &'a [&'a CStr]) -> Option<&'a [&'a CStr]> {
|
||||
Some(&argv[start..])
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[cfg(all(test, unix))]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
|
||||
@@ -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"
|
||||
@@ -0,0 +1,853 @@
|
||||
#include <metal_stdlib>
|
||||
|
||||
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<float> 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<float> 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<float> textureGrayscale [[texture(0)]],
|
||||
texture2d<float> 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<uint> 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<float> 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;
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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<dyn Fn() + Send + Sync>;
|
||||
|
||||
#[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<DisplayLink> {
|
||||
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<CVDisplayLink>,
|
||||
|
||||
/// The callback handed to CoreVideo as `user_info` (a leaked
|
||||
/// `Box<Callback>`), freed when the link is dropped or the callback is
|
||||
/// replaced.
|
||||
callback: Mutex<Option<NonNull<Callback>>>,
|
||||
}
|
||||
|
||||
// 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<DisplayLink> {
|
||||
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::<c_void>())
|
||||
};
|
||||
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<CVDisplayLink>,
|
||||
_in_now: NonNull<CVTimeStamp>,
|
||||
_in_output_time: NonNull<CVTimeStamp>,
|
||||
_flags_in: CVOptionFlags,
|
||||
_flags_out: NonNull<CVOptionFlags>,
|
||||
user_info: *mut c_void,
|
||||
) -> CVReturn {
|
||||
// SAFETY: `user_info` is the leaked `Box<Callback>` set in
|
||||
// `set_output_callback`, alive until the link is stopped.
|
||||
if let Some(cb) = unsafe { user_info.cast::<Callback>().as_ref() } {
|
||||
cb();
|
||||
}
|
||||
kCVReturnSuccess
|
||||
}
|
||||
}
|
||||
@@ -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,19 +93,32 @@ 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::{
|
||||
opengl::OpenGL,
|
||||
options::Options,
|
||||
overlay::{self, Overlay},
|
||||
row::never_extend_bg,
|
||||
shader_types::{
|
||||
BgImage,
|
||||
BgImageFit,
|
||||
BgImageInfo,
|
||||
@@ -114,34 +128,23 @@ use crate::{
|
||||
CellText,
|
||||
CellTextAtlas,
|
||||
PaddingExtend,
|
||||
Shaders,
|
||||
Uniforms,
|
||||
},
|
||||
},
|
||||
options::Options,
|
||||
overlay::{self, Overlay},
|
||||
row::never_extend_bg,
|
||||
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<Uniforms>,
|
||||
cells: Buffer<CellText>,
|
||||
cells_bg: Buffer<CellBg>,
|
||||
struct FrameState<G: GraphicsApi> {
|
||||
uniforms: G::Buffer<G::GpuUniforms>,
|
||||
cells: G::Buffer<CellText>,
|
||||
cells_bg: G::Buffer<CellBg>,
|
||||
|
||||
grayscale: Texture,
|
||||
grayscale: G::Texture,
|
||||
grayscale_modified: usize,
|
||||
color: Texture,
|
||||
color: G::Texture,
|
||||
color_modified: usize,
|
||||
|
||||
target: Arc<Target>,
|
||||
target: Arc<G::Target>,
|
||||
/// 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<BgImage>,
|
||||
bg_image_buffer: G::Buffer<BgImage>,
|
||||
/// 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<CustomShaderState>,
|
||||
custom_shader_state: Option<CustomShaderState<G>>,
|
||||
|
||||
/// 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<FrameState, Error> {
|
||||
let gl = api.gl().ok_or(opengl::Error::NoContext)?;
|
||||
|
||||
impl<G: GraphicsApi> FrameState<G> {
|
||||
fn new(api: &G, custom_shaders: bool) -> Result<FrameState<G>, 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::<Uniforms>::init(gl, api.uniform_buffer_options(), 1)?;
|
||||
let uniforms = api.init_buffer::<G::GpuUniforms>(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::<CellText>::init(gl, api.fg_buffer_options(), 1)?;
|
||||
let cells_bg = Buffer::<CellBg>::init(gl, api.bg_buffer_options(), 1)?;
|
||||
let cells = api.init_buffer::<CellText>(1)?;
|
||||
let cells_bg = api.init_buffer::<CellBg>(1)?;
|
||||
|
||||
// Create a GPU buffer for our background image info.
|
||||
let bg_image_buffer = Buffer::<BgImage>::init(gl, api.bg_image_buffer_options(), 1)?;
|
||||
let bg_image_buffer = api.init_buffer::<BgImage>(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<G: GraphicsApi> {
|
||||
/// 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<shadertoy::Uniforms>,
|
||||
uniforms: G::Buffer<shadertoy::Uniforms>,
|
||||
}
|
||||
|
||||
impl CustomShaderState {
|
||||
fn new(api: &OpenGL) -> Result<CustomShaderState, Error> {
|
||||
let gl = api.gl().ok_or(opengl::Error::NoContext)?;
|
||||
|
||||
impl<G: GraphicsApi> CustomShaderState<G> {
|
||||
fn new(api: &G) -> Result<CustomShaderState<G>, Error> {
|
||||
// Create a GPU buffer to hold our uniforms.
|
||||
let uniforms = Buffer::<shadertoy::Uniforms>::init(gl, api.uniform_buffer_options(), 1)?;
|
||||
let uniforms = api.init_buffer::<shadertoy::Uniforms>(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<G: GraphicsApi> {
|
||||
/// `None` while the slot's frame is in flight (or being drawn).
|
||||
slots: Mutex<Vec<Option<FrameState<G>>>>,
|
||||
/// 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<Option<FrameState>>,
|
||||
/// 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<G: GraphicsApi> {
|
||||
inner: Arc<SwapChainInner<G>>,
|
||||
/// 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<SwapChain, Error> {
|
||||
let mut frames = Vec::with_capacity(opengl::SWAP_CHAIN_COUNT);
|
||||
for _ in 0..opengl::SWAP_CHAIN_COUNT {
|
||||
impl<G: GraphicsApi> SwapChain<G> {
|
||||
fn new(api: &G, custom_shaders: bool) -> Result<SwapChain<G>, 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<G>), 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<SwapChainInner<G>> {
|
||||
Arc::clone(&self.inner)
|
||||
}
|
||||
|
||||
fn release_frame(&self, idx: usize, frame: FrameState<G>) {
|
||||
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<G: GraphicsApi> SwapChainInner<G> {
|
||||
/// Hand a frame state back to its slot (Zig: `releaseFrame`).
|
||||
fn release(this: &Arc<Self>, idx: usize, frame: FrameState<G>) {
|
||||
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<Item = &mut FrameState> {
|
||||
self.frames.iter_mut().flatten()
|
||||
drop(slots);
|
||||
this.returned.notify_all();
|
||||
}
|
||||
}
|
||||
|
||||
/// The background image state (`bg_image: ?imagepkg.Image`).
|
||||
type BgImageState = Option<image::Image>;
|
||||
type BgImageState<G> = Option<image::Image<G>>;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Renderer state
|
||||
|
||||
/// State protected by the draw mutex: everything `draw_frame` reads.
|
||||
struct DrawState {
|
||||
struct DrawState<G: GraphicsApi> {
|
||||
/// The configuration we need derived from the main config.
|
||||
config: DerivedConfig,
|
||||
|
||||
@@ -566,13 +641,13 @@ struct DrawState {
|
||||
font_grid: Arc<SharedGrid>,
|
||||
|
||||
/// The images that we may render.
|
||||
images: image::State,
|
||||
images: image::State<G>,
|
||||
|
||||
/// 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<G>,
|
||||
/// 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<G>,
|
||||
|
||||
/// 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>,
|
||||
shaders: Option<G::Shaders>,
|
||||
}
|
||||
|
||||
/// 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<DrawState>,
|
||||
pub struct Renderer<G: GraphicsApi = OpenGL> {
|
||||
draw: Mutex<DrawState<G>>,
|
||||
thread: Mutex<ThreadState>,
|
||||
|
||||
/// Health of the most recently completed frame.
|
||||
health: AtomicU8,
|
||||
health: Arc<AtomicU8>,
|
||||
|
||||
/// 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<Option<DisplayLink>>,
|
||||
|
||||
/// The surface/apprt callbacks.
|
||||
host: Arc<dyn Host>,
|
||||
}
|
||||
@@ -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<String> {
|
||||
match shadertoy::load_from_files(&config.custom_shaders, opengl::CUSTOM_SHADER_TARGET) {
|
||||
fn load_custom_shaders<G: GraphicsApi>(config: &DerivedConfig) -> Vec<String> {
|
||||
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<String> {
|
||||
}
|
||||
}
|
||||
|
||||
impl DrawState {
|
||||
impl<G: GraphicsApi> DrawState<G> {
|
||||
/// 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::<G>(&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<G: GraphicsApi>(bg_image: &mut BgImageState<G>, 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<image::Image> {
|
||||
fn load_background_image(path: &str) -> Option<image::Pending> {
|
||||
// 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<image::Image> {
|
||||
}
|
||||
};
|
||||
|
||||
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<G: GraphicsApi> Renderer<G> {
|
||||
/// 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<DerivedConfig>, api: OpenGL) -> Result<Renderer, Error> {
|
||||
pub fn new(opts: Options<DerivedConfig>, api: G) -> Result<Renderer<G>, 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<F>(&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<Self>, draw_now: Arc<dyn Fn() + Send + Sync>) {
|
||||
// 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<OpenGL> {
|
||||
/// 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<F>(&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<u8>)> {
|
||||
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<G: GraphicsApi> Renderer<G> {
|
||||
/// 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<G: GraphicsApi>(
|
||||
images: &mut image::State<G>,
|
||||
terminal: &mut ghostty_terminal::terminal::Terminal,
|
||||
cell_size: CellSize,
|
||||
) {
|
||||
@@ -1955,7 +2161,7 @@ enum Selected {
|
||||
SearchSelected,
|
||||
}
|
||||
|
||||
impl DrawState {
|
||||
impl<G: GraphicsApi> DrawState<G> {
|
||||
/// 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<G: GraphicsApi> DrawState<G> {
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
fn rebuild_row(
|
||||
&mut self,
|
||||
@@ -2968,7 +3174,7 @@ impl DrawState {
|
||||
// ---------------------------------------------------------------------------
|
||||
// draw_frame (GL thread)
|
||||
|
||||
impl Renderer {
|
||||
impl<G: GraphicsApi> Renderer<G> {
|
||||
/// 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<G>, 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,
|
||||
);
|
||||
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);
|
||||
|
||||
let health = result?;
|
||||
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<G: GraphicsApi> DrawState<G> {
|
||||
/// 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<G>,
|
||||
surface_size: (u32, u32),
|
||||
size_changed: bool,
|
||||
) -> Result<Health, Error> {
|
||||
) -> Result<G::Frame, Error> {
|
||||
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::<G>::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 the background image to the GPU as necessary.
|
||||
self.upload_background_image(&gpu);
|
||||
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());
|
||||
|
||||
@@ -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<dyn StdError + Send + Sync>);
|
||||
|
||||
impl ApiError {
|
||||
/// Wrap a concrete error.
|
||||
pub fn new<E: StdError + Send + Sync + 'static>(err: E) -> Self {
|
||||
ApiError(Box::new(err))
|
||||
}
|
||||
|
||||
/// An error that is only a message.
|
||||
pub fn msg(msg: impl Into<String>) -> 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<dyn Fn(Health) + Send + Sync>;
|
||||
|
||||
/// 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<G::BufferRef>,
|
||||
pub buffers: &'a [Option<G::BufferRef>],
|
||||
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<G: GraphicsApi, T: Pod> {
|
||||
/// 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<usize, ApiError>;
|
||||
}
|
||||
|
||||
/// 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<G: GraphicsApi> {
|
||||
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<G: GraphicsApi> {
|
||||
/// 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<T: Pod>: GpuBuffer<Self, T> + Send + 'static;
|
||||
/// The raw buffer handle bound by render pass steps.
|
||||
type BufferRef: Clone;
|
||||
type Pipeline;
|
||||
type Shaders: GpuShaders<Self> + Send + 'static;
|
||||
/// The GPU-side layout of the uniform block (`shaders.Uniforms`).
|
||||
type GpuUniforms: Pod;
|
||||
type Frame: GpuFrame<Self>;
|
||||
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<Self::Shaders, ApiError>;
|
||||
|
||||
/// 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<Self::Target, ApiError>;
|
||||
|
||||
/// `Buffer.init` with the API's buffer options.
|
||||
fn init_buffer<T: Pod>(&self, len: usize) -> Result<Self::Buffer<T>, ApiError>;
|
||||
|
||||
/// `Buffer.initFill` with the API's buffer options.
|
||||
fn init_buffer_fill<T: Pod>(&self, data: &[T]) -> Result<Self::Buffer<T>, ApiError>;
|
||||
|
||||
/// Initialize a texture with the options of `kind`.
|
||||
fn init_texture(
|
||||
&self,
|
||||
kind: TextureKind,
|
||||
width: usize,
|
||||
height: usize,
|
||||
data: Option<&[u8]>,
|
||||
) -> Result<Self::Texture, ApiError>;
|
||||
|
||||
/// Initializes a Texture suitable for the provided font atlas.
|
||||
fn init_atlas_texture(&self, atlas: &Atlas) -> Result<Self::Texture, ApiError>;
|
||||
|
||||
/// Initialize the sampler used by custom shaders (`samplerOptions`).
|
||||
fn init_sampler(&self) -> Result<Self::Sampler, ApiError>;
|
||||
|
||||
/// Begin a frame. The target is presented once the frame is completed
|
||||
/// ([`GpuFrame::complete`]).
|
||||
fn begin_frame(&self, target: Arc<Self::Target>) -> 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<dyn Fn() + Send + Sync>) {
|
||||
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);
|
||||
}
|
||||
}
|
||||
@@ -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<G: GraphicsApi> {
|
||||
/// The full image state for the renderer that specifies what images
|
||||
/// need to be uploaded, pruned, etc.
|
||||
pub images: HashMap<Id, Entry>,
|
||||
pub images: HashMap<Id, Entry<G>>,
|
||||
|
||||
/// The placements for the Kitty image protocol.
|
||||
pub kitty_placements: Vec<Placement>,
|
||||
@@ -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<G: GraphicsApi> {
|
||||
pub image: Image<G>,
|
||||
pub transmit_time: Instant,
|
||||
}
|
||||
|
||||
@@ -96,8 +91,22 @@ pub enum PrepImageError {
|
||||
ImageConversionError,
|
||||
}
|
||||
|
||||
impl State {
|
||||
pub fn new() -> State {
|
||||
impl<G: GraphicsApi> Default for State<G> {
|
||||
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<G: GraphicsApi> State<G> {
|
||||
pub fn new() -> State<G> {
|
||||
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,10 +171,7 @@ 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::<ImageVertex>::init_fill(
|
||||
gpu.gl,
|
||||
gpu.buffer_options,
|
||||
&[ImageVertex {
|
||||
let buf = match gpu.api.init_buffer_fill::<ImageVertex>(&[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: [
|
||||
@@ -176,8 +182,7 @@ impl State {
|
||||
],
|
||||
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<G: GraphicsApi> {
|
||||
/// 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<G: GraphicsApi> Image<G> {
|
||||
/// 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<G>) {
|
||||
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,9 +916,14 @@ impl Image {
|
||||
};
|
||||
|
||||
// Create our texture
|
||||
let texture = Texture::init(
|
||||
gpu.gl,
|
||||
gpu.texture_options,
|
||||
// (`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),
|
||||
@@ -938,6 +954,7 @@ impl Pending {
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::opengl::OpenGL;
|
||||
|
||||
fn pending(fmt: PixelFormat, data: Vec<u8>, 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<OpenGL> = 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<OpenGL> = 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<OpenGL> = State::new();
|
||||
s.kitty_update(
|
||||
&storage,
|
||||
&t.screen().pages,
|
||||
|
||||
@@ -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<Api>;
|
||||
|
||||
/// The renderer's derived configuration (`Renderer.DerivedConfig`).
|
||||
pub type DerivedConfig = generic::DerivedConfig;
|
||||
|
||||
@@ -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<T: Pod> {
|
||||
/// The options this buffer was initialized with.
|
||||
opts: Options,
|
||||
|
||||
/// The underlying MTLBuffer object.
|
||||
buffer: Retained<ProtocolObject<dyn MTLBuffer>>,
|
||||
|
||||
/// 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<fn() -> 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<T: Pod> Send for Buffer<T> {}
|
||||
// SAFETY: see above; shared access only reads the handle.
|
||||
unsafe impl<T: Pod> Sync for Buffer<T> {}
|
||||
|
||||
impl<T: Pod> Buffer<T> {
|
||||
/// The underlying MTLBuffer.
|
||||
pub fn buffer(&self) -> &ProtocolObject<dyn MTLBuffer> {
|
||||
&self.buffer
|
||||
}
|
||||
|
||||
/// A retained handle to the underlying MTLBuffer (what render pass steps
|
||||
/// bind).
|
||||
pub fn raw(&self) -> Retained<ProtocolObject<dyn MTLBuffer>> {
|
||||
self.buffer.clone()
|
||||
}
|
||||
|
||||
/// Initialize a buffer with the given length pre-allocated.
|
||||
pub fn init(opts: Options, len: usize) -> Result<Self, Error> {
|
||||
let buffer = opts
|
||||
.device
|
||||
.newBufferWithLength_options(len * size_of::<T>(), 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<Self, Error> {
|
||||
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<usize, Error> {
|
||||
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::<T>();
|
||||
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::<u8>();
|
||||
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<T: Pod> GpuBuffer<super::Metal, T> for Buffer<T> {
|
||||
fn raw(&self) -> Retained<ProtocolObject<dyn MTLBuffer>> {
|
||||
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<usize, ApiError> {
|
||||
Buffer::sync_from_slices(self, lists).map_err(ApiError::new)
|
||||
}
|
||||
}
|
||||
@@ -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<Retained<ProtocolObject<dyn MTLCommandBuffer>>>,
|
||||
|
||||
/// The layer the target is presented on.
|
||||
layer: IOSurfaceLayer,
|
||||
|
||||
/// The target is presented via the layer when completed.
|
||||
target: Arc<Target>,
|
||||
}
|
||||
|
||||
impl Frame {
|
||||
/// Begin encoding a frame.
|
||||
pub fn begin(queue: &Queue, layer: IOSurfaceLayer, target: Arc<Target>) -> 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<dyn MTLCommandBuffer>, 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<ProtocolObject<dyn MTLCommandBuffer>>| {
|
||||
// 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<Metal> 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
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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<dyn Fn() + Send + Sync>;
|
||||
|
||||
/// Instance variables of the layer subclass (`display_cb` / `display_ctx`).
|
||||
#[derive(Default)]
|
||||
pub struct Ivars {
|
||||
display_cb: Mutex<Option<DisplayCallback>>,
|
||||
}
|
||||
|
||||
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<Retained<NSNull>> {
|
||||
Some(NSNull::null())
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
impl IOSurfaceLayerClass {
|
||||
fn create() -> Retained<Self> {
|
||||
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>(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<T> Send for SendWrapper<T> {}
|
||||
|
||||
/// A CALayer which displays IOSurfaces.
|
||||
#[derive(Clone)]
|
||||
pub struct IOSurfaceLayer {
|
||||
/// The underlying CALayer
|
||||
layer: Retained<IOSurfaceLayerClass>,
|
||||
}
|
||||
|
||||
// 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<IOSurfaceLayer, Error> {
|
||||
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<IOSurfaceRef>) {
|
||||
// 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<IOSurfaceRef>) {
|
||||
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<DisplayCallback>) {
|
||||
*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<IOSurfaceRef>) -> &AnyObject {
|
||||
let cf: &objc2_core_foundation::CFType = surface;
|
||||
cf.as_ref()
|
||||
}
|
||||
|
||||
fn set_surface_callback(layer: &IOSurfaceLayerClass, surface: &CFRetained<IOSurfaceRef>) {
|
||||
// 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))) };
|
||||
}
|
||||
@@ -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<ProtocolObject<dyn MTLDevice>>;
|
||||
/// A `MTLCommandQueue`.
|
||||
pub type Queue = Retained<ProtocolObject<dyn MTLCommandQueue>>;
|
||||
|
||||
/// 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<PoolToken>,
|
||||
}
|
||||
|
||||
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<Metal, Error> {
|
||||
// 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<Retained<AnyObject>> = 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, Error> {
|
||||
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<Texture, Error> {
|
||||
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<dyn MTLDevice>) -> 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<Device, Error> {
|
||||
let devices = objc2_metal::MTLCopyAllDevices();
|
||||
|
||||
let mut chosen_device: Option<Device> = 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<Device, Error> {
|
||||
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<T: Pod> = Buffer<T>;
|
||||
type BufferRef = Retained<ProtocolObject<dyn MTLBuffer>>;
|
||||
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, ApiError> {
|
||||
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<Target, ApiError> {
|
||||
Metal::init_target(self, width, height).map_err(ApiError::new)
|
||||
}
|
||||
|
||||
fn init_buffer<T: Pod>(&self, len: usize) -> Result<Buffer<T>, ApiError> {
|
||||
Buffer::init(self.buffer_options(), len).map_err(ApiError::new)
|
||||
}
|
||||
|
||||
fn init_buffer_fill<T: Pod>(&self, data: &[T]) -> Result<Buffer<T>, 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<Texture, ApiError> {
|
||||
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<Texture, ApiError> {
|
||||
Metal::init_atlas_texture(self, atlas).map_err(ApiError::new)
|
||||
}
|
||||
|
||||
fn init_sampler(&self) -> Result<Sampler, ApiError> {
|
||||
Sampler::init(&self.sampler_options()).map_err(ApiError::new)
|
||||
}
|
||||
|
||||
fn begin_frame(&self, target: Arc<Target>) -> 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<dyn Fn() + Send + Sync>) {
|
||||
self.layer.set_display_callback(Some(draw));
|
||||
}
|
||||
}
|
||||
@@ -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<dyn MTLLibrary>,
|
||||
/// MTLLibrary to get the fragment function from
|
||||
pub fragment_library: &'a ProtocolObject<dyn MTLLibrary>,
|
||||
|
||||
/// 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<ProtocolObject<dyn MTLRenderPipelineState>>,
|
||||
}
|
||||
|
||||
// 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<dyn MTLRenderPipelineState> {
|
||||
&self.state
|
||||
}
|
||||
|
||||
/// Create a pipeline whose vertex attributes are those of `V`.
|
||||
pub fn init<V: MetalVertex>(opts: &Options<'_>) -> Result<Pipeline, Error> {
|
||||
Self::init_impl(Some((V::ATTRIBUTES, size_of::<V>())), opts)
|
||||
}
|
||||
|
||||
/// Create a pipeline without a vertex descriptor.
|
||||
pub fn init_no_attributes(opts: &Options<'_>) -> Result<Pipeline, Error> {
|
||||
Self::init_impl(None, opts)
|
||||
}
|
||||
|
||||
fn init_impl(
|
||||
vertex_attributes: Option<(&[VertexAttribute], usize)>,
|
||||
opts: &Options<'_>,
|
||||
) -> Result<Pipeline, Error> {
|
||||
// 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 })
|
||||
}
|
||||
}
|
||||
@@ -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<Retained<ProtocolObject<dyn MTLRenderCommandEncoder>>>,
|
||||
}
|
||||
|
||||
impl RenderPass {
|
||||
/// Begin a render pass.
|
||||
pub fn begin(
|
||||
command_buffer: Option<&ProtocolObject<dyn MTLCommandBuffer>>,
|
||||
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);
|
||||
}
|
||||
}
|
||||
@@ -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<ProtocolObject<dyn MTLSamplerState>>,
|
||||
}
|
||||
|
||||
impl Sampler {
|
||||
/// The underlying MTLSamplerState.
|
||||
pub fn sampler(&self) -> &ProtocolObject<dyn MTLSamplerState> {
|
||||
&self.sampler
|
||||
}
|
||||
|
||||
/// Initialize a sampler
|
||||
pub fn init(opts: &Options) -> Result<Sampler, Error> {
|
||||
// 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 })
|
||||
}
|
||||
}
|
||||
@@ -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<ProtocolObject<dyn MTLLibrary>>,
|
||||
|
||||
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<Pipeline>,
|
||||
}
|
||||
|
||||
// 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<S: AsRef<str>>(
|
||||
device: &Device,
|
||||
post_shaders: &[S],
|
||||
pixel_format: PixelFormat,
|
||||
) -> Result<Shaders, Error> {
|
||||
let library = init_library(device)?;
|
||||
|
||||
let make = |desc: &PipelineDesc| -> Result<Pipeline, Error> {
|
||||
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::<CellText>(&opts),
|
||||
"image" => Pipeline::init::<ImageVertex>(&opts),
|
||||
"bg_image" => Pipeline::init::<BgImage>(&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<dyn MTLLibrary> {
|
||||
&self.library
|
||||
}
|
||||
}
|
||||
|
||||
impl GpuShaders<super::Metal> 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<Retained<ProtocolObject<dyn MTLLibrary>>, 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<S: AsRef<str>>(
|
||||
device: &Device,
|
||||
library: &ProtocolObject<dyn MTLLibrary>,
|
||||
shaders: &[S],
|
||||
pixel_format: PixelFormat,
|
||||
) -> Result<Vec<Pipeline>, 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<dyn MTLLibrary>,
|
||||
data: &str,
|
||||
pixel_format: PixelFormat,
|
||||
) -> Result<Pipeline, Error> {
|
||||
// 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,
|
||||
}],
|
||||
})
|
||||
}
|
||||
@@ -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<IOSurfaceRef>,
|
||||
|
||||
/// The underlying MTLTexture.
|
||||
texture: Retained<ProtocolObject<dyn MTLTexture>>,
|
||||
|
||||
/// 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<IOSurfaceRef> {
|
||||
&self.surface
|
||||
}
|
||||
|
||||
/// The underlying MTLTexture.
|
||||
pub fn texture(&self) -> &ProtocolObject<dyn MTLTexture> {
|
||||
&self.texture
|
||||
}
|
||||
|
||||
pub fn init(opts: &Options) -> Result<Target, Error> {
|
||||
// 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<CFRetained<IOSurfaceRef>, 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::<CFString, CFNumber>::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"));
|
||||
}
|
||||
}
|
||||
@@ -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<ProtocolObject<dyn MTLTexture>>,
|
||||
|
||||
/// 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<dyn MTLTexture> {
|
||||
&self.texture
|
||||
}
|
||||
|
||||
/// Initialize a texture
|
||||
pub fn init(
|
||||
opts: &Options,
|
||||
width: usize,
|
||||
height: usize,
|
||||
data: Option<&[u8]>,
|
||||
) -> Result<Texture, Error> {
|
||||
// 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::<c_void>()) 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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
/// (<https://developer.apple.com/metal/Metal-Feature-Set-Tables.pdf>).
|
||||
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<PixelFormat> {
|
||||
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::<MetalUniforms>(), 144);
|
||||
// Same size as the OpenGL std140 block.
|
||||
assert_eq!(size_of::<MetalUniforms>(), size_of::<Uniforms>());
|
||||
}
|
||||
|
||||
#[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",
|
||||
]
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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<T: Pod> = Buffer<T>;
|
||||
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<Shaders, ApiError> {
|
||||
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<Target, ApiError> {
|
||||
OpenGL::init_target(self, width, height).map_err(ApiError::new)
|
||||
}
|
||||
|
||||
fn init_buffer<T: Pod>(&self, len: usize) -> Result<Buffer<T>, 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<T: Pod>(&self, data: &[T]) -> Result<Buffer<T>, 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<Texture, ApiError> {
|
||||
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<Texture, ApiError> {
|
||||
OpenGL::init_atlas_texture(self, atlas).map_err(ApiError::new)
|
||||
}
|
||||
|
||||
fn init_sampler(&self) -> Result<Sampler, ApiError> {
|
||||
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<Target>) -> 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<T: Pod> GpuBuffer<OpenGL, T> for Buffer<T> {
|
||||
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<usize, ApiError> {
|
||||
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<OpenGL> 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<OpenGL> 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);
|
||||
}
|
||||
}
|
||||
@@ -37,7 +37,7 @@ pub struct Buffer<T: Pod> {
|
||||
/// Note this is the number of `T`s, not the size in bytes.
|
||||
pub len: usize,
|
||||
|
||||
_marker: PhantomData<T>,
|
||||
_marker: PhantomData<fn() -> T>,
|
||||
}
|
||||
|
||||
impl<T: Pod> Buffer<T> {
|
||||
|
||||
@@ -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<Target>,
|
||||
}
|
||||
|
||||
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<Target>) -> Frame<'a> {
|
||||
Frame { api, target }
|
||||
pub fn begin(_opts: Options, api: &OpenGL, target: Arc<Target>) -> 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}");
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
@@ -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,29 +88,56 @@ pub struct OpenGL {
|
||||
pub blending: AlphaBlending,
|
||||
|
||||
/// The most recently presented target, in case we need to present it again.
|
||||
last_target: Option<Arc<Target>>,
|
||||
/// 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<Mutex<Option<Arc<Target>>>>;
|
||||
|
||||
/// 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<Target>) -> 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}");
|
||||
}
|
||||
|
||||
impl ImageTextureFormat {
|
||||
fn to_pixel_format(self) -> PixelFormat {
|
||||
match self {
|
||||
// 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(())
|
||||
}
|
||||
|
||||
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,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl OpenGL {
|
||||
/// Create the backend. `OpenGL.init`; the GL context is attached later
|
||||
@@ -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<Target>) -> 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<Target>) -> 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<Target>> {
|
||||
self.last_target.as_ref()
|
||||
pub fn last_target(&self) -> Option<Arc<Target>> {
|
||||
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<Target>) -> Frame<'_> {
|
||||
pub fn begin_frame(&self, target: Arc<Target>) -> Frame {
|
||||
Frame::begin(frame::Options::default(), self, target)
|
||||
}
|
||||
}
|
||||
@@ -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<RawBuffer>,
|
||||
pub buffers: &'a [Option<RawBuffer>],
|
||||
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,
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
@@ -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<String, Error> {
|
||||
|
||||
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<String, Error> {
|
||||
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<String, Error> {
|
||||
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 <metal_stdlib>"));
|
||||
// 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();
|
||||
}
|
||||
|
||||
|
||||
@@ -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<G: GraphicsApi>(Arc<Renderer<G>>);
|
||||
|
||||
impl<G: GraphicsApi> Drop for LoopGuard<G> {
|
||||
fn drop(&mut self) {
|
||||
self.0.loop_exit();
|
||||
}
|
||||
}
|
||||
|
||||
/// Runs `Renderer::thread_exit` when the thread exits.
|
||||
struct ThreadGuard<G: GraphicsApi>(Arc<Renderer<G>>);
|
||||
|
||||
impl<G: GraphicsApi> Drop for ThreadGuard<G> {
|
||||
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<R>(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<G: GraphicsApi = Api> {
|
||||
/// The underlying renderer implementation.
|
||||
renderer: Arc<Renderer>,
|
||||
renderer: Arc<Renderer<G>>,
|
||||
|
||||
/// 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<Arc<ghostty_crash::SurfaceDims>>,
|
||||
|
||||
handle: Handle,
|
||||
wakeup_rx: Receiver<()>,
|
||||
wakeup_tx: Sender<()>,
|
||||
@@ -198,16 +263,16 @@ pub struct Thread {
|
||||
draw_deadline: Option<Instant>,
|
||||
}
|
||||
|
||||
impl Thread {
|
||||
impl<G: GraphicsApi> Thread<G> {
|
||||
/// 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>,
|
||||
renderer: Arc<Renderer<G>>,
|
||||
state: SharedState,
|
||||
host: Arc<dyn Host>,
|
||||
) -> Thread {
|
||||
) -> Thread<G> {
|
||||
// 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));
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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<Mutex<Vec<String>>>;
|
||||
|
||||
struct MockApi {
|
||||
blending: AlphaBlending,
|
||||
log: Log,
|
||||
completed: Arc<AtomicUsize>,
|
||||
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<T> {
|
||||
id: usize,
|
||||
len: usize,
|
||||
_marker: std::marker::PhantomData<fn() -> T>,
|
||||
}
|
||||
impl<T: Pod> GpuBuffer<MockApi, T> for MockBuffer<T> {
|
||||
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<usize, ApiError> {
|
||||
self.len = lists.iter().map(|l| l.len()).sum();
|
||||
Ok(self.len)
|
||||
}
|
||||
}
|
||||
|
||||
struct MockPipeline(&'static str);
|
||||
|
||||
struct MockShaders {
|
||||
pipelines: [MockPipeline; 5],
|
||||
post: Vec<MockPipeline>,
|
||||
}
|
||||
impl GpuShaders<MockApi> 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<AtomicUsize>,
|
||||
}
|
||||
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<MockApi> 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<T: Pod> = MockBuffer<T>;
|
||||
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<MockShaders, ApiError> {
|
||||
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<MockTarget, ApiError> {
|
||||
Ok(MockTarget(w, h))
|
||||
}
|
||||
fn init_buffer<T: Pod>(&self, len: usize) -> Result<MockBuffer<T>, ApiError> {
|
||||
Ok(MockBuffer {
|
||||
id: 1,
|
||||
len,
|
||||
_marker: std::marker::PhantomData,
|
||||
})
|
||||
}
|
||||
fn init_buffer_fill<T: Pod>(&self, data: &[T]) -> Result<MockBuffer<T>, ApiError> {
|
||||
self.init_buffer(data.len())
|
||||
}
|
||||
fn init_texture(
|
||||
&self,
|
||||
_kind: TextureKind,
|
||||
w: usize,
|
||||
h: usize,
|
||||
_data: Option<&[u8]>,
|
||||
) -> Result<MockTexture, ApiError> {
|
||||
Ok(MockTexture { w, h })
|
||||
}
|
||||
fn init_atlas_texture(&self, atlas: &Atlas) -> Result<MockTexture, ApiError> {
|
||||
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<MockTarget>) -> 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<Vec<Health>>,
|
||||
}
|
||||
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<MockApi>, Arc<Mutex<State>>, Log, Arc<AtomicUsize>) {
|
||||
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::<MockApi>::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);
|
||||
}
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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<String> {
|
||||
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<String> {
|
||||
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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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"
|
||||
@@ -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<CString>,
|
||||
envp: Vec<CString>,
|
||||
/// 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<CString>,
|
||||
}
|
||||
|
||||
/// 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 `<dir>/<file>` 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<String> {
|
||||
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::<Result<_, _>>()?,
|
||||
};
|
||||
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::<Result<Vec<_>, _>>()?;
|
||||
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<i32, Errno> {
|
||||
}
|
||||
}
|
||||
|
||||
#[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"]
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
// 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 || read_thread_main(&read_fd, &output, &quit_read))?;
|
||||
.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<BorrowedFd<'_>> {
|
||||
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<i32>) {
|
||||
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<Pid> {
|
||||
/// 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<String> {
|
||||
#[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<ghostty_os::passwd::Entry>,
|
||||
) -> Vec<String> {
|
||||
// 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<String> {
|
||||
}
|
||||
}
|
||||
|
||||
/// 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<ghostty_os::passwd::Entry>,
|
||||
) -> Option<Vec<String>> {
|
||||
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<String> = 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<ghostty_os::passwd::Entry> {
|
||||
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"]);
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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,15 +108,13 @@ pub struct Pty {
|
||||
pub slave: Option<OwnedFd>,
|
||||
}
|
||||
|
||||
impl Pty {
|
||||
/// Open a new PTY with the given initial size.
|
||||
pub fn open(size: Winsize) -> Result<Pty, Error> {
|
||||
// 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,
|
||||
)
|
||||
/// 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)?;
|
||||
@@ -125,6 +125,65 @@ impl Pty {
|
||||
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<Pty, Error> {
|
||||
let (master, slave) = open_pair(size)?;
|
||||
|
||||
termios::tcsetwinsize(&master, size.into()).map_err(|_| Error::OpenptyFailed)?;
|
||||
|
||||
|
||||
@@ -101,13 +101,30 @@ fn basename(path: &str) -> &str {
|
||||
}
|
||||
|
||||
fn detect_shell(command: &Command) -> Option<Shell> {
|
||||
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<Shell> {
|
||||
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));
|
||||
|
||||
@@ -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<Instant>,
|
||||
|
||||
flags: Flags,
|
||||
|
||||
/// The surface dimensions recorded in crash reports.
|
||||
pub crash_dims: Option<std::sync::Arc<ghostty_crash::SurfaceDims>>,
|
||||
}
|
||||
|
||||
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.
|
||||
|
||||
@@ -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<OwnedFd>,
|
||||
}
|
||||
|
||||
/// See the module docs. Cloning shares the same underlying eventfd.
|
||||
impl Fds {
|
||||
#[cfg(not(target_vendor = "apple"))]
|
||||
fn new() -> io::Result<Fds> {
|
||||
use rustix::event::{EventfdFlags, eventfd};
|
||||
Ok(Fds {
|
||||
read: eventfd(0, EventfdFlags::CLOEXEC | EventfdFlags::NONBLOCK)?,
|
||||
write: None,
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(target_vendor = "apple")]
|
||||
fn new() -> io::Result<Fds> {
|
||||
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<OwnedFd>, Option<Arc<dyn Fn() + Send + Sync>>);
|
||||
pub struct Wakeup(Arc<Fds>, Option<Arc<dyn Fn() + Send + Sync>>);
|
||||
|
||||
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<Wakeup> {
|
||||
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<dyn Fn() + Send + Sync>) -> io::Result<Wakeup> {
|
||||
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()
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
//! Integration test: run a real child attached to a pty.
|
||||
|
||||
#![cfg(unix)]
|
||||
#![allow(clippy::unwrap_used)]
|
||||
|
||||
use std::{
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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_<target>`/`AR_<target>` zig-cc wrappers (onig_sys C build); `GETTEXT_DIR` dummy.
|
||||
@@ -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<T>` (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<G: GraphicsApi = OpenGL>`, `image::{State,Image,Entry}<G>`, `thread::Thread<G = Api>`.
|
||||
* `lib.rs`: `Api = metal::Metal` on `target_vendor = "apple"`, `opengl::OpenGL` otherwise; `Renderer = generic::Renderer<Api>`
|
||||
(`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<F>(loader)` / `last_frame_pixels` exist only for `Renderer<OpenGL>`; the generic hook is
|
||||
`display_realized_with(|api| ..)`.
|
||||
* GL: `OpenGL::last_target` is now `Arc<Mutex<Option<Arc<Target>>>>` (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<Renderer>`).
|
||||
* `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<Metal>`. Until L1 provides the embedded surface, core should import
|
||||
`ghostty_renderer::generic::Renderer` (= `Renderer<OpenGL>`, 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_<target>` = 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.
|
||||
@@ -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=<zcc-arm> CXX_aarch64_apple_darwin=<zcxx-arm> AR_aarch64_apple_darwin=<zig ar>
|
||||
export GETTEXT_DIR=/nonexistent # makes gettext-sys skip its from-source build
|
||||
export CFLAGS_<target>=-I<dir with a stub os/log.h> # oslog's wrapper.c needs <os/log.h>
|
||||
export ZIG_GLOBAL_CACHE_DIR/ZIG_LOCAL_CACHE_DIR=<writable 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 (`<lang>_<country>.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).
|
||||
@@ -25,6 +25,19 @@ _fix:
|
||||
|
||||
fix: _fix fmt
|
||||
|
||||
# Build libghostty-vt (C ABI, include/ghostty/vt.h) as target/<profile>/libghostty-vt.{so,a}
|
||||
# and, if the target is installed, target/wasm32-unknown-unknown/<profile>/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
|
||||
|
||||
|
||||
@@ -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<String> = ghostty_os::args::collect_lossy();
|
||||
|
||||
|
||||
Reference in new issue
Block a user