diff --git a/.github/workflows/desktop.yml b/.github/workflows/desktop.yml index 383ef6029..91a606725 100644 --- a/.github/workflows/desktop.yml +++ b/.github/workflows/desktop.yml @@ -105,3 +105,54 @@ jobs: run: cargo clippy --locked --all-targets -- -D warnings - name: Build app run: cargo build --locked -p hack-desktop + + # The terminal surface's libghostty-vt feature (HACK-2252). The provision + # step is the only one that fetches libghostty-vt, Zig or the symbols font, and it verifies + # each file against a sha256 committed in the script. The build, lint and + # tests then run in a network namespace with no interfaces but a down + # loopback, so they cannot download anything. + term-ghostty: + name: Desktop terminal surface (Linux, libghostty-vt, offline build) + runs-on: ubuntu-24.04 + timeout-minutes: 45 + defaults: + run: + working-directory: apps/desktop + steps: + - uses: actions/checkout@v4 + - name: Install GPUI build dependencies + run: | + sudo apt-get update + sudo apt-get install -y --no-install-recommends \ + clang libfontconfig-dev libwayland-dev libxkbcommon-x11-dev \ + libx11-xcb-dev libssl-dev libzstd-dev libvulkan-dev pkg-config xz-utils \ + mesa-vulkan-drivers + - uses: dtolnay/rust-toolchain@1.98.1 + with: + components: clippy + - uses: actions/cache@v4 + with: + path: | + ~/.cargo/registry + ~/.cargo/git + apps/desktop/target + key: desktop-term-ghostty-${{ runner.os }}-${{ hashFiles('apps/desktop/Cargo.lock') }} + restore-keys: desktop-term-ghostty-${{ runner.os }}- + - uses: actions/cache@v4 + with: + path: ${{ runner.temp }}/ghostty-inputs + key: desktop-ghostty-inputs-${{ runner.os }}-${{ hashFiles('apps/desktop/term/scripts/provision-ghostty.sh') }} + - name: Provision pinned Zig and libghostty-vt inputs (checksummed) + run: bash term/scripts/provision-ghostty.sh --dir "$RUNNER_TEMP/ghostty-inputs" --emit github >> "$GITHUB_ENV" + - name: Install pinned per-test process runner + run: | + bash ../../scripts/install-cargo-nextest.sh "$RUNNER_TEMP/desktop-nextest" + echo "$RUNNER_TEMP/desktop-nextest" >> "$GITHUB_PATH" + - name: Fetch locked crates + run: cargo fetch --locked + # The render test draws glyphs offscreen and inspects the pixels. Mesa's + # software Vulkan driver (installed above) is its renderer here. + - name: Build, lint and test the ghostty feature with networking disabled + env: + HACK_TERM_REQUIRE_RENDERER: "1" + run: bash term/scripts/no-network.sh bash term/scripts/check-ghostty.sh diff --git a/apps/desktop/.cargo/config.toml b/apps/desktop/.cargo/config.toml new file mode 100644 index 000000000..5df01d9e9 --- /dev/null +++ b/apps/desktop/.cargo/config.toml @@ -0,0 +1,9 @@ +# hack-desktop-term's `ghostty` feature builds libghostty-vt through the +# ghostty-vt-sys build script. That script downloads its release archives +# when GHOSTTY_RELEASE_DIR is unset. This workspace builds only from +# provisioned, checksummed inputs (term/scripts/provision-ghostty.sh), so the +# script's download base is set to a URL that cannot be fetched: without +# GHOSTTY_RELEASE_DIR the build fails instead of using the network. +# GHOSTTY_RELEASE_DIR, when set, takes precedence and this value is unused. +[env] +GHOSTTY_RELEASE_URL = "downloads-disabled://run-term/scripts/provision-ghostty.sh-and-export-GHOSTTY_RELEASE_DIR" diff --git a/apps/desktop/Cargo.lock b/apps/desktop/Cargo.lock index 7074abb5e..a43cedd61 100644 --- a/apps/desktop/Cargo.lock +++ b/apps/desktop/Cargo.lock @@ -572,6 +572,12 @@ version = "0.23.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ac07cdecf99051d9a5238b80f35af32cdeba5b336e55d957b318b50137e18da5" +[[package]] +name = "base64ct" +version = "1.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2af50177e190e07a26ab74f8b1efbfe2ef87da2116221318cb1c2e82baf7de06" + [[package]] name = "bit-set" version = "0.10.0" @@ -968,7 +974,7 @@ dependencies = [ "cocoa-foundation", "core-foundation 0.9.4", "core-graphics 0.23.2", - "foreign-types", + "foreign-types 0.5.0", "libc", "objc", ] @@ -1049,6 +1055,15 @@ dependencies = [ "tiny-keccak", ] +[[package]] +name = "constant_time_eq" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9eb6feb175df65d0a3ccd3e73484e940d0ac664e8034f0519fd49a93db3071d9" +dependencies = [ + "rustversion", +] + [[package]] name = "convert_case" version = "0.10.0" @@ -1102,7 +1117,7 @@ dependencies = [ "bitflags 1.3.2", "core-foundation 0.9.4", "core-graphics-types 0.1.3", - "foreign-types", + "foreign-types 0.5.0", "libc", ] @@ -1115,7 +1130,7 @@ dependencies = [ "bitflags 2.13.2", "core-foundation 0.10.1", "core-graphics-types 0.2.0", - "foreign-types", + "foreign-types 0.5.0", "libc", ] @@ -1128,7 +1143,7 @@ dependencies = [ "bitflags 2.13.2", "core-foundation 0.9.4", "core-graphics-types 0.1.3", - "foreign-types", + "foreign-types 0.5.0", "libc", ] @@ -1162,7 +1177,7 @@ checksum = "a593227b66cbd4007b2a050dfdd9e1d1318311409c8d600dc82ba1b15ca9c130" dependencies = [ "core-foundation 0.10.1", "core-graphics 0.24.0", - "foreign-types", + "foreign-types 0.5.0", "libc", ] @@ -1342,6 +1357,16 @@ version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "be1e0bca6c3637f992fc1cc7cbc52a78c1ef6db076dbf1059c4323d6a2048376" +[[package]] +name = "der" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6a996a108feb8b427a6e12fcf6af4aa83409b79d72c15337d7741368131ee004" +dependencies = [ + "pem-rfc7468", + "zeroize", +] + [[package]] name = "deranged" version = "0.5.9" @@ -1734,6 +1759,16 @@ dependencies = [ "winapi", ] +[[package]] +name = "filetime" +version = "0.2.29" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c287a33c7f0a620c38e641e7f60827713987b3c0f26e8ddc9462cc69cf75759" +dependencies = [ + "cfg-if", + "libc", +] + [[package]] name = "find-msvc-tools" version = "0.1.15" @@ -1848,6 +1883,15 @@ dependencies = [ "ttf-parser", ] +[[package]] +name = "foreign-types" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f6f339eb8adc052cd2ca78910fda869aefa38d22d5cb648e6485e4d3fc06f3b1" +dependencies = [ + "foreign-types-shared 0.1.1", +] + [[package]] name = "foreign-types" version = "0.5.0" @@ -1855,7 +1899,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d737d9aa519fb7b749cbc3b962edcf310a8dd1f4b67c91c4f83975dbdd17d965" dependencies = [ "foreign-types-macros", - "foreign-types-shared", + "foreign-types-shared 0.3.1", ] [[package]] @@ -1869,6 +1913,12 @@ dependencies = [ "syn 3.0.10", ] +[[package]] +name = "foreign-types-shared" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "00b0228411908ca8685dba7fc2cdd70ec9990a6e753e89b6ac91a84c40fbaf4b" + [[package]] name = "foreign-types-shared" version = "0.3.1" @@ -2080,6 +2130,31 @@ dependencies = [ "wasm-bindgen", ] +[[package]] +name = "ghostty-vt" +version = "0.9.0+ghostty-vt.1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5f231031b00f8d9ccc373eb692877f4ac1df715daac4fc7784019cfd12fa4fcf" +dependencies = [ + "bitflags 2.13.2", + "dirs 6.0.0", + "ghostty-vt-sys", + "int-enum", +] + +[[package]] +name = "ghostty-vt-sys" +version = "0.9.0+ghostty-vt.1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c76694a0234d400d12615c0c6ee95977437b42621ac431ad4e2102ec98ce9f66" +dependencies = [ + "flate2", + "sha2 0.10.9", + "tar", + "ureq", + "zip", +] + [[package]] name = "gif" version = "0.13.3" @@ -2455,7 +2530,7 @@ dependencies = [ "core-graphics 0.24.0", "ctor", "dispatch2", - "foreign-types", + "foreign-types 0.5.0", "futures", "gpui-fast", "gpui-fast-apple", @@ -2876,7 +2951,7 @@ dependencies = [ "core-graphics 0.24.0", "ctor", "dispatch2", - "foreign-types", + "foreign-types 0.5.0", "futures", "gpui-pre", "gpui-pre-apple", @@ -3193,6 +3268,18 @@ dependencies = [ "serde_json", ] +[[package]] +name = "hack-desktop-term" +version = "0.0.0" +dependencies = [ + "ghostty-vt", + "gpui-kit", + "image", + "libc", + "sha2 0.10.9", + "ttf-parser", +] + [[package]] name = "half" version = "2.7.1" @@ -3720,6 +3807,18 @@ dependencies = [ "web-sys", ] +[[package]] +name = "int-enum" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "98e2fe9fba0e74e6b4cbc96a06d473ee6f2dd675f4b26ef6391a4973060c7ed1" +dependencies = [ + "proc-macro2", + "proc-macro2-diagnostics", + "quote", + "syn 3.0.10", +] + [[package]] name = "interpolate_name" version = "0.2.4" @@ -4333,6 +4432,23 @@ dependencies = [ "thiserror 2.0.21", ] +[[package]] +name = "native-tls" +version = "0.2.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "465500e14ea162429d264d44189adc38b199b62b1c21eea9f69e4b73cb03bbf2" +dependencies = [ + "libc", + "log", + "openssl", + "openssl-probe", + "openssl-sys", + "schannel", + "security-framework", + "security-framework-sys", + "tempfile", +] + [[package]] name = "ndk-sys" version = "0.6.0+11769913" @@ -4348,6 +4464,18 @@ version = "1.0.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "650eef8c711430f1a879fdd01d4745a7deea475becfb90269c06775983bbf086" +[[package]] +name = "nix" +version = "0.32.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77730ed34f01f4990cb2ea67fec715ecebc6fab9a6396cf83c5529b13f581bdc" +dependencies = [ + "bitflags 2.13.2", + "cfg-if", + "cfg_aliases", + "libc", +] + [[package]] name = "no_std_io2" version = "0.9.4" @@ -4980,12 +5108,49 @@ dependencies = [ "libc", ] +[[package]] +name = "openssl" +version = "0.10.82" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4de8111e94a56adca7ebfe6c365b6d42f99deb69b74b6becacd4204e2f193aa2" +dependencies = [ + "bitflags 2.13.2", + "cfg-if", + "foreign-types 0.3.2", + "libc", + "openssl-macros", + "openssl-sys", +] + +[[package]] +name = "openssl-macros" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a948666b637a0f465e8564c73e89d4dde00d72d4d473cc972f390fc3dcee7d9c" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + [[package]] name = "openssl-probe" version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7e036b411754132fd6b74e94b74e9a13c5cf606fa93d1885c35860d3060820b1" +[[package]] +name = "openssl-sys" +version = "0.9.118" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "19c413d6ec9ebaf5e1c829d355cb3e9f783778d700f135be0921d52fce3c60ae" +dependencies = [ + "cc", + "libc", + "pkg-config", + "vcpkg", +] + [[package]] name = "option-ext" version = "0.2.0" @@ -5091,6 +5256,15 @@ dependencies = [ "hmac", ] +[[package]] +name = "pem-rfc7468" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a6305423e0e7738146434843d1694d621cce767262b2a86910beab705e4493d9" +dependencies = [ + "base64ct", +] + [[package]] name = "percent-encoding" version = "2.3.2" @@ -5437,6 +5611,17 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "proc-macro2-diagnostics" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df921e1ef974ebc8dd9effdb0c037a96928ae8cbf871438e02d6fd67204b595e" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.10", +] + [[package]] name = "profiling" version = "1.0.18" @@ -6115,6 +6300,7 @@ version = "0.23.46" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "48e13bd8c0e9365c43cfa5c9e8f9ad49d3c8444926c9aac819e0e4dc503c8fdf" dependencies = [ + "log", "once_cell", "ring", "rustls-pki-types", @@ -6883,6 +7069,17 @@ dependencies = [ "objc", ] +[[package]] +name = "tar" +version = "0.4.46" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f6221d9a6003c78398e3b239969f352578258df48c8eb051caadae0015bc840" +dependencies = [ + "filetime", + "libc", + "xattr", +] + [[package]] name = "target-features" version = "0.1.6" @@ -7295,6 +7492,12 @@ dependencies = [ "core_maths", ] +[[package]] +name = "typed-path" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ce898823d8cd0f028662d2f349694b1af0a5cc9be83f1d213a00d3147445e1e" + [[package]] name = "typenum" version = "1.20.1" @@ -7396,6 +7599,37 @@ version = "0.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1" +[[package]] +name = "ureq" +version = "3.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3ac5cecf619ca81f66a96482db09687c9f7465af65f4300a8c30cca05bd5a59d" +dependencies = [ + "base64 0.23.1", + "der", + "log", + "native-tls", + "percent-encoding", + "rustls", + "rustls-pki-types", + "ureq-proto", + "utf8-zero", + "webpki-root-certs", + "webpki-roots", +] + +[[package]] +name = "ureq-proto" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "622dce33c252115466d83f63e9744a18f12b4406aae51a01c15ad8fcf1185eb3" +dependencies = [ + "base64 0.23.1", + "http", + "httparse", + "log", +] + [[package]] name = "url" version = "2.5.8" @@ -7468,6 +7702,12 @@ version = "0.7.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "09cc8ee72d2a9becf2f2febe0205bbed8fc6615b7cb429ad062dc7b7ddd036a9" +[[package]] +name = "utf8-zero" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8c0a043c9540bae7c578c88f91dda8bd82e59ae27c21baca69c8b191aaf5a6e" + [[package]] name = "utf8_iter" version = "1.1.0" @@ -7507,6 +7747,12 @@ dependencies = [ "wasm-bindgen", ] +[[package]] +name = "vcpkg" +version = "0.2.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "accd4ea62f7bb7a82fe23066fb0957d48ef677f6eeb8215f372f52e48bb32426" + [[package]] name = "version_check" version = "0.9.5" @@ -7781,6 +8027,24 @@ dependencies = [ "wasm-bindgen", ] +[[package]] +name = "webpki-root-certs" +version = "1.0.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "293198dcebfb821155b5092ab94b5a82ff180eddd7241df3bd6e66a4ffd1a585" +dependencies = [ + "rustls-pki-types", +] + +[[package]] +name = "webpki-roots" +version = "1.0.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7265c9301c16a8711c992c2d8da90ee77ef54b1ed51482d6dfeb00dc756b1297" +dependencies = [ + "rustls-pki-types", +] + [[package]] name = "weezl" version = "0.1.12" @@ -8671,6 +8935,16 @@ version = "0.13.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ea6fc2961e4ef194dcbfe56bb845534d0dc8098940c7e5c012a258bfec6701bd" +[[package]] +name = "xattr" +version = "1.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "963d6fab92d5504b971dc8bd90ca743c2575e590bc8071ea5bc571598667ae08" +dependencies = [ + "libc", + "rustix", +] + [[package]] name = "xcb" version = "1.7.1" @@ -9115,6 +9389,25 @@ dependencies = [ "syn 3.0.10", ] +[[package]] +name = "zip" +version = "9.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "80cc68510ea7553c30a69c732de5278e94183f68765af6c642dbccefd6ec777e" +dependencies = [ + "constant_time_eq", + "crc32fast", + "flate2", + "getrandom 0.4.3", + "indexmap", + "libc", + "memchr", + "nix", + "typed-path", + "windows-sys 0.61.2", + "zeroize", +] + [[package]] name = "zlib-rs" version = "0.6.8" diff --git a/apps/desktop/Cargo.toml b/apps/desktop/Cargo.toml index 662da4c86..bc6fa6a70 100644 --- a/apps/desktop/Cargo.toml +++ b/apps/desktop/Cargo.toml @@ -3,7 +3,7 @@ # release of packages/runtime-core or packages/config-compiler. [workspace] resolver = "3" -members = ["core", "app"] +members = ["core", "app", "term"] [workspace.package] version = "0.0.0" @@ -17,6 +17,12 @@ serde = { version = "1", features = ["derive"] } serde_json = "1" gpui-kit = { version = "=0.9.1" } hack-desktop-core = { path = "core" } +hack-desktop-term = { path = "term" } +# libghostty-vt 1.0.1 (tag vt-1.0.1). Cargo ignores build metadata in a +# requirement, so the exact pin is `=0.9.0`; the lockfile records +# `0.9.0+ghostty-vt.1.0.1`. See term/DECISION.md. +ghostty-vt = { version = "=0.9.0", default-features = false } +libc = "=0.2.191" [profile.release] strip = true diff --git a/apps/desktop/term/Cargo.toml b/apps/desktop/term/Cargo.toml new file mode 100644 index 000000000..f459ad019 --- /dev/null +++ b/apps/desktop/term/Cargo.toml @@ -0,0 +1,38 @@ +[package] +name = "hack-desktop-term" +description = "Terminal surface for the Hack desktop app: app-owned PTY, read-only byte streams, libghostty-vt emulation drawn with GPUI" +version.workspace = true +edition.workspace = true +rust-version.workspace = true +license.workspace = true +publish.workspace = true + +[features] +default = [] +# libghostty-vt emulation and the GPUI terminal view. Off by default until +# desktop CI provides Zig 0.17.1 on Linux (see DECISION.md, "Build and CI"). +ghostty = ["dep:ghostty-vt", "dep:gpui-kit", "dep:sha2", "dep:ttf-parser"] +# Offscreen PNG capture of the spike window (GPUI test-support). +screenshots = ["ghostty", "gpui-kit/test-support", "dep:image"] + +[dependencies] +libc.workspace = true +ghostty-vt = { workspace = true, optional = true } +gpui-kit = { workspace = true, optional = true } +image = { version = "=0.25.10", optional = true, default-features = false, features = ["png"] } +# Reads glyph outlines from the embedded symbols font. Already in the +# lockfile through GPUI's Linux text system. +ttf-parser = { version = "=0.25.1", optional = true, default-features = false, features = ["std"] } + +[build-dependencies] +# Verifies the embedded symbols font against its pinned sha256. Already in +# the lockfile through ghostty-vt-sys's build script. +sha2 = { version = "=0.10.9", optional = true } + +[[example]] +name = "term_spike" +required-features = ["ghostty"] + +[[example]] +name = "term_bench" +required-features = ["screenshots"] diff --git a/apps/desktop/term/DECISION.md b/apps/desktop/term/DECISION.md new file mode 100644 index 000000000..04d905bd3 --- /dev/null +++ b/apps/desktop/term/DECISION.md @@ -0,0 +1,945 @@ +# Terminal surface: decision and spike (HACK-2252) + +Status: spike, unsupported preview. Researched and written 2026-11-15. + +## Decision + +Use **libghostty-vt 1.0.1** for terminal emulation, through the +`ghostty-vt` / `ghostty-vt-sys` crates (`=0.9.0+ghostty-vt.1.0.1`), and +**draw the cells ourselves with GPUI**. Keep the emulator behind this +crate's plain `Frame` model, so the renderer and the Logs/Exec views do not +depend on libghostty types. + +We do not host Ghostty's own renderer in a child surface. + +## What was checked (primary sources) + +| Claim in the brief | Finding | Source | +|---|---|---| +| libghostty-vt 1.0 released Nov 6 2026 | Confirmed: "The 1.0 release means both the library itself and the C ABI are now stable." | mitchellh.com/writing/libghostty-1-0 | +| Stable C ABI | Confirmed: semver, opaque handles, int-pinned enums, libabigail checks in CI, versioned ELF symbols | same | +| MIT | Confirmed: MIT, "no copyleft code in the dependency tree" | same; `LICENSE` in the release tarball | +| Current 1.0.1 | Confirmed: "The latest version of libghostty-vt is 1.0.1." | lib.ghostty.org/vt | +| Emulation core, not a renderer | Confirmed: it provides terminal state, a render-state API (dirty rows, cells, colours, cursor), formatter, key/mouse encoders, selection, search. It does not draw | lib.ghostty.org/vt; the crate's `render` module | +| Zig needed | Confirmed for source builds: `build.zig.zon` has `.minimum_zig_version = "0.17.0"`; `ghostty-vt-sys` requires Zig 0.17.x. Official prebuilt libraries exist only for Apple (XCFramework), Windows (GNU zip) and WASM, **not Linux** | release tarball; `ghostty-vt-sys` README and build script | +| Release integrity | `release.files.ghostty.org/libghostty-vt/1.0.1/SHA256SUMS` matches `ghostty-vt-sys`'s `GHOSTTY.lock` for the source tarball, deps tarball and XCFramework. A minisign signature is also published (not verified here) | release server | + +Third-party code linked into libghostty-vt 1.0.1, from the release's +`deps/libghostty-vt-third-party.json`: highway 1.2.0 (Apache-2.0 OR +BSD-3-Clause), simdutf 9.0.0 (Apache-2.0 OR MIT), translate_c 2.0.0 (MIT), +uucode 0.2.0 (MIT AND Unicode-3.0), wuffs 0.4.0-alpha.10 (Apache-2.0 OR +MIT). Build-only: aro (MIT AND Unicode-3.0). + +## Options compared + +(a) **Host Ghostty's renderer** in a child surface: `gpui-libghostty` +(github.com/behzade/gpui-libghostty, crate 0.4.1-rc.1, MIT, alpha). +(b) **libghostty-vt + GPUI-drawn cells**: `gpui-ghostty` +(github.com/Xuanwo/gpui-ghostty, Apache-2.0), `chda` (github.com/magicsih/chda, +MIT), `janjibar` (github.com/mrprofessor/janjibar, Apache-2.0, its `term` +crate), and the `ghostty-vt` crates from `gpui-cn` (Apache-2.0), which this +spike uses. +(c) Control: **alacritty_terminal** 0.26.0 (Apache-2.0, pure Rust; Zed's +terminal uses it) with GPUI-drawn cells. + +| | (a) Ghostty renderer in a child surface | (b) libghostty-vt + GPUI cells (chosen) | (c) alacritty_terminal + GPUI cells | +|---|---|---|---| +| Text quality | Ghostty's own font stack and shaping; best-in-class, but a second font system beside GPUI's | GPUI's text system (CoreText / cosmic-text), the same as the rest of the app; per-cell grid with forced advances. Ligatures off by design | Same as (b) | +| Scrolling, selection | Ghostty's, complete | libghostty scrollback, viewport, selection snapshots, word/line selection, gesture state machine; the view wires drag-select, select-all, wheel/keys | Alacritty's grid and selection; comparable | +| Resize / reflow | Ghostty's | libghostty reflows soft-wrapped lines (tested) | Alacritty reflows | +| Copy | Ghostty clipboard via an adapter (plain text only) | `format_selection` (plain, VT or HTML), unwrap and trim | Selection to string | +| Links | Ghostty's link handling | OSC 8 URIs per cell (tested); plain-URL detection not yet written | Regex URL hints exist in Alacritty, not in the crate | +| Search | Ghostty's search UI | libghostty incremental search over scrollback (byte-exact, ASCII case-insensitive), match snapshots and scroll-to-match (tested) | Regex search in the crate | +| Theme following | `update_theme` API | Default fg/bg/cursor and 16-colour palette set from the GPUI theme each render; OSC overrides still work (tested) | Same approach | +| GPU / memory | A second renderer and GPU context per terminal (Metal; OpenGL 4.3 on Linux) | One GPUI scene; libghostty's page-based scrollback with a line or byte cap | One GPUI scene; Alacritty grid | +| macOS | Metal layer; supported by the project | libghostty prebuilt XCFramework (no Zig); GPUI Metal | Pure Rust | +| Linux Wayland | Wayland subsurface, EGL, libc++ 22 | GPUI (Vulkan) | GPUI | +| Linux X11 | **Not supported** ("Supports macOS and Linux with Wayland") | GPUI | GPUI | +| Composition in a GPUI layout | A native child surface: clipping inside scroll containers, z-order of GPUI overlays (search bar, menus, toasts) above it, and focus hand-off all cross a toolkit boundary. On Wayland a subsurface sits above or below the whole parent surface, not between GPUI layers (architectural inference, not tested here) | Plain GPUI element: clips, stacks, focuses and overlays like any other (the spike draws the search bar over the grid) | Same as (b) | +| Build | Zig 0.17, full Ghostty source **plus local patches** to Ghostty's app runtime and renderers (`patches/ghostty/*.patch`); not the stable C ABI | Zig 0.17.x on Linux from a pinned, sha256-checked source + deps tarball (offline capable); prebuilt checksummed static lib on macOS; stable C ABI | Cargo only, no C or Zig | +| GPUI coupling | `bind_gpui!` macro; checked against a Zed revision and gpui-pre 0.3.4 | None in the binding; our renderer targets gpui-kit 0.9.1 directly | None | +| Maturity / bus factor | Alpha, about 2 contributors, 10 stars | libghostty: Ghostty org, used by JetBrains IDEs, 1.0 with ABI promise. The Rust crates: one maintainer (gpui-cn, 1 active contributor); upstream of them, `libghostty-rs` (Uzaaft, MIT OR Apache-2.0, ~400 stars) still pins a pre-1.0 commit | Alacritty project, mature, widely used (Zed) | +| Licence | MIT | MIT (lib), Apache-2.0 (crates) | Apache-2.0 | + +Why (b): it is the only option that is both Ghostty-grade emulation with a +stable ABI and a normal GPUI element. "Feels native, smooth and crisp" in +this app mostly means the terminal behaves like the rest of the window: +same fonts, same theme, overlays and focus that just work, X11 too. Option +(a) gives Ghostty's pixels but makes every one of those a cross-toolkit +problem, ships patches against Ghostty's non-stable app runtime, and has no +X11. + +Why not (c): it removes the Zig step, which is real cost (below), but it +would trade away the owner's stated direction (libghostty) and libghostty's +richer state API (render-state dirty tracking, search, selection gestures, +kitty keyboard and graphics, shell-integration semantics). It stays the +fallback: because the view consumes `emulator::Frame`, swapping the +emulator is a contained change. + +### What would change this recommendation + +- The `ghostty-vt` crates going unmaintained. Mitigation: they are thin + over a stable C ABI; we can vendor or fork them, or move to + `libghostty-rs` once it pins 1.x. +- Zig-on-Linux builds proving unworkable in CI or packaging, with no + upstream Linux prebuilt. Then (c) or a self-hosted prebuilt static lib. +- Measured GPUI text rendering of large, fast output (for example a + full-screen TUI at 120 Hz) falling short where Ghostty's renderer does + not. See "Performance" for the first numbers. +- (a) gaining X11, the stable ABI, and solved overlay/clipping composition. + +## Why not (a), even though it matches Ghostty's pixels + +Option (a) would give exactly Ghostty's rendering. It still loses for this +app, for five reasons: + +1. **The stable ABI does not cover it.** The 1.0 stability promise is for + libghostty-vt, the emulation core. Option (a) builds Ghostty's whole app + runtime and its Metal and OpenGL renderers, which have no such promise. +2. **It patches Ghostty internals and is alpha.** `gpui-libghostty` carries + its own patches to Ghostty's embedded runtime, font and renderer sources, + says "expect bugs and instability", and has about two contributors. Any + Ghostty update can break it. +3. **No X11, and more system requirements.** It supports macOS and Linux on + Wayland only, and on Linux needs EGL, desktop OpenGL 4.3 and libc++ 22, + which Ubuntu 24.04 does not ship by default. +4. **A native child surface fights the rest of the window.** The Logs tab + needs a search bar over the terminal, menus and toasts above it, + clipping inside a scrolling panel, and focus moving between it and other + controls. With a separate native surface each of these crosses a toolkit + boundary; on Wayland a subsurface sits wholly above or below the parent, + not between GPUI layers. *This is reasoning from how such surfaces work. + It was not tested here.* +5. **Ghostty's runtime would own the shell.** Its API starts the process + itself. The guarantees in "PTY ownership" below (we hold the session, + end every member on close, never leave a process or zombie, never block + the UI) would move into code we do not control. + +### What (b) still has to prove + +- **Throughput and smoothness** with full-screen programs (`vim`, `top`) + and log floods, in the real window. The numbers under "Performance" + below are a first measurement of emulation and CPU-side paint cost on one + Linux machine; they are not display latency and not a Mac. +- **macOS.** Nothing in this crate has been run on a Mac except what hosted + CI runs of the default-feature tests show. On macOS a job in another + process group that ignores SIGHUP survives a closed tab and is reported, + not ended (see "PTY ownership"). +- **Polish we have to build ourselves**: cursor blink, IME, mouse + reporting to programs, plain-URL detection, ligature policy, per-row + caching keyed on libghostty's row ids, waking on output instead of the + spike's timer. Done so far: cursor shapes, and prompt glyphs (see + "Glyphs"). + +### Optional: a spike of (a) + +If the owner wants evidence rather than reasoning for point 4, a short +spike of `gpui-libghostty` on Linux (Wayland) and macOS would answer: + +1. Does a GPUI overlay (search bar, menu, tooltip) draw above the terminal + surface, on both platforms? +2. Is the surface clipped correctly inside a scrolling or resizing panel, + and does it track the layout without lag or tearing during resize? +3. Does keyboard focus move correctly between the surface and GPUI + controls, including IME? +4. Can it show a byte stream we feed it (logs), or only a process it + spawns? +5. Who owns the child: can we get the session id and enforce our teardown, + and what is left running when the surface is dropped or the app quits? +6. Does it build offline from pinned inputs with the system packages our + CI has, and how does it behave on X11 (expected: not at all)? + +A "yes" to 1 to 5 on both platforms, plus an X11 answer we can live with, +would reopen the decision. + +## Build and CI + +libghostty-vt is built by the `ghostty-vt-sys` build script: + +- **macOS**: links the official prebuilt static library from the 1.0.1 + XCFramework. No Zig. +- **Linux**: builds the official 1.0.1 source tarball with **Zig 0.17.x** + (we pin **0.17.1**), resolving Zig packages from the release's deps + tarball (`zig build --system`), so Zig fetches nothing. + +**The build never downloads.** Left alone, that build script fetches its +release archives when `GHOSTTY_RELEASE_DIR` is unset. Three things stop it: + +1. `term/scripts/provision-ghostty.sh` is the one step that uses the + network. It fetches the pinned inputs into a cache directory, verifies + each against a sha256 committed in the script, and prints the + environment to export (`GHOSTTY_RELEASE_DIR`, `HACK_TERM_SYMBOLS_FONT`, + and `ZIG` on Linux). + Re-running it verifies the cache; `--offline` only verifies. +2. `apps/desktop/.cargo/config.toml` sets the build script's download base + (`GHOSTTY_RELEASE_URL`) to a URL with a scheme that cannot be fetched. + Without provisioned inputs the dependency fails at once with "unknown + scheme", before any network use. `GHOSTTY_RELEASE_DIR` takes precedence + when set. This applies to cargo run from `apps/desktop` or below, which + is how the scripts, CI and README run it. +3. `term/build.rs` fails with instructions when the `ghostty` feature is on + and the inputs or `ZIG` are missing. (Cargo may run the dependency's + script first; either way the build stops and names the script.) + +The dependency's build script also verifies each archive's sha256 against +its own lock file, so a file changed after provisioning fails the build. + +| Input | sha256 | +|---|---| +| `libghostty-vt-1.0.1.tar.gz` | `333dfce97641ef9b536bb9e08907ad86abaaada50e8f7417b70d9284adef37aa` | +| `libghostty-vt-1.0.1-deps.tar.gz` | `91a4e10960a52bf5bf4b15aa2a2cddaff8a89d6cd903d3055bda4fd17de3356d` | +| `ghostty-vt-1.0.1.xcframework.zip` (macOS) | `c0f9631277f726154c8f521954d17cdc41aac4626fc0df8cf925a19ee58e829b` | +| `zig-x86_64-linux-0.17.1.tar.xz` | `3581863e3cbcfd0fce0efc682ce7e9e93da96668a977791d356a26d2a7e9b17a` | +| `zig-aarch64-linux-0.17.1.tar.xz` | `a1546d5646eb184437918d6635ca6b762a0e889eac09cef720ee7a1ed3fe7795` | +| `NerdFontsSymbolsOnly.tar.xz` (Nerd Fonts v3.5.1) | `01172f37db8543edb102e5cb5c64101c9f4686630804d49b419aa07b23a69996` | +| `SymbolsNerdFontMono-Regular.ttf`, from that archive | `fe471e538392f51910faab985fa8e192a39dd3426125edd15b71b3680df0e749` | + +Sources: `release.files.ghostty.org/libghostty-vt/1.0.1/SHA256SUMS`, +`ziglang.org/download/index.json`, and the Nerd Fonts release's +`SHA-256.txt`. The font file's checksum is pinned in the provisioning +script and in `term/build.rs`, which refuses to embed any other file. + +```sh +cd apps/desktop +eval "$(bash term/scripts/provision-ghostty.sh)" # network, checksummed +cargo fetch --locked # network, crates +bash term/scripts/no-network.sh bash term/scripts/check-ghostty.sh # Linux: build, clippy, tests with no network +bash term/scripts/check-ghostty.sh # macOS: same checks (cargo --offline; no namespace) +``` + +**CI.** `.github/workflows/desktop.yml` has a job, "Desktop terminal +surface (Linux, libghostty-vt, offline build)": provision (cached, keyed on +the script), fetch crates, then build, clippy and tests of +`hack-desktop-term --features ghostty` inside a network namespace whose +only interface is a down loopback. There is no macOS job for the feature +yet; the Mac commands are in the PR. + +**Defaults chosen (flagged for the owner):** + +- The `ghostty` feature stays **off by default**. The app does not depend + on it yet, and the two existing desktop jobs are unchanged. +- Inputs are **provisioned from upstream and checksummed**, not vendored + into the repository (about 67 MB with Zig) and not prebuilt by us. +- `ZIG` must be set explicitly for a Linux build of the feature; a Zig that + happens to be on `PATH` is not used as the pinned one. The repo root + pins Zig 0.15.2 for other tooling. +- `apps/desktop/.cargo/config.toml` is a new workspace file outside this + crate's directory; it is the only place the download base can be set for + every cargo invocation in the workspace. + +## The terminal panel + +Owner decision (Nov 15 2026): the terminal is a per-project bottom panel +with tabs. The panel's design is specified in `DESIGN-V1.md`; this crate +supplies the component, the session kinds, the tab identity and the +actions. + +```sh +cd apps/desktop +eval "$(bash term/scripts/provision-ghostty.sh)" +cargo run -p hack-desktop-term --features ghostty --example term_spike +cargo run -p hack-desktop-term --features ghostty --example term_spike -- --file some.log +# Real CLI sessions, in a real Hack project with a running service (manual): +cargo run -p hack-desktop-term --features ghostty --example term_spike -- \ + --project /path/to/project --hack "$(command -v hack)" --service api --env staging --scope api +cargo run -p hack-desktop-term --features screenshots --example term_spike -- --screenshots /tmp/shots +cargo run --release -p hack-desktop-term --features screenshots --example term_bench +bash scripts/test.sh --features hack-desktop-term/screenshots +``` + +The example opens four tabs in one panel: `logs`, a container shell, the +host login shell, and a host shell with an env overlay. Without `--hack` +and `--service` the two CLI-backed tabs run a stand-in script that checks +the argv the app would pass to `hack` and then starts a plain shell. + +### Sessions + +The app owns the PTY and runs the CLI in it. It never builds an environment +and never sees a value. The only variables it sets are the terminal's own +identity (`TERM=xterm-256color`, `COLORTERM=truecolor`, +`TERM_PROGRAM=hack`); everything else is inherited, or injected by the CLI +inside the PTY. The only strings it puts in argv are the CLI's subcommands, +validated names and a shell name. (`launch.rs`.) + +| Tab | What runs, in the project root | +|---|---| +| Logs | Nothing here: a read-only stream fed by the app's log reader | +| Shell in a service | `hack exec -- bash`, then `hack exec -- sh` if the image has no bash | +| Host shell | The user's login shell (`$SHELL`, else the passwd entry, else `/bin/sh`), started as a login shell by the `argv[0]` convention (`-zsh`). On a PTY that makes it interactive, so profile and rc files load as in the user's own terminal | +| Host shell with env | `hack host shell --env --scope `. The CLI resolves and injects the values and starts `$SHELL -l` | + +Details that were checked against the CLI source on `next` and, for the +last two rows, by running it: + +- **`hack exec`** takes `` then the command after an optional + `--`, with `--path`, `--project`, `--env`, `--branch`, `--workdir` and + `--profile`. The compose backend runs `docker compose exec` and allocates + a TTY exactly when its own stdin and stdout are terminals, which they are + on our PTY. The native backend has its own interactive exec path and + refuses `--env` and `--profile`; we pass neither. *Not run against a real + container here (no Docker in this work).* +- **bash, then sh.** There is no flag for "first shell that exists", and + probing first would be a second exec. The session starts `bash`; if that + attempt ends with exit status 126 or 127 within 10 s and before the user + has typed anything, the terminal is reset and `sh` is started in the same + tab, and the tab's label changes from `api · bash` to `api · sh`. An exit + with those codes after the user has typed is their session ending and is + not retried. *The exit status for a missing program comes from the + container engine; see CLI request 3.* +- **`hack host shell`** exists, with `--path`, `--project`, `--env`, + `--scope` and `--target`. It runs `[$SHELL or /bin/sh, "-l"]` in the + project root with the process environment plus the injected values and + inherited stdio. *Run for real* on a pseudo-terminal in a throwaway + project with a temporary home: the profile loaded, the profile's prompt + showed, the shell was interactive, global and service-scoped overlay + values were present, `TERM_PROGRAM` and `COLORTERM` came through, and the + working directory was the project root. So it can start the user's + interactive login shell with their rc files, as long as `SHELL` is set. +- **`hack env list --json`** exists. *Run for real* on the same project: it + returns `env_selection` (`requested`, `default`, `effective`, + `overlay_path`, `overlay_exists`), `scopes` (for example `["api", + "global"]`), `selected_scope`, `files`, and `vars` with names and + metadata and no `value` field unless `--show-values` is given. `is_default` + for the badge is `overlay == env_selection.default`. It does **not** list + the overlays that exist (request 1). +- **`TERM`.** `xterm-256color` everywhere by default: containers rarely + have other terminfo entries, and `xterm-ghostty` breaks ssh to hosts that + lack it. `TermChoice::GhosttyIfPresent` switches host shells to + `xterm-ghostty` only when its terminfo entry is installed (first-letter + and macOS hex layouts are both checked). This default is flagged. +- **Names are validated** before they reach argv: letters, digits, `_`, `-` + and `.`, not starting with `-` or `.`. A service called `--env` cannot + become an option. + +### CLI requests + +These are the smallest CLI changes that would close gaps. The app does not +work around them. + +1. **`hack env list --json`: add `overlays`, the names of the overlays that + exist.** Today the payload carries the selection and the scopes but only + the files of the selected overlay, so the app cannot offer a list of + overlays to open a shell with. The library already has + `listProjectEnvOverlayNames`. Names only. +2. **`hack host shell`: when `SHELL` is unset or empty, use the user's + passwd shell before falling back to `/bin/sh`.** Run for real with + `SHELL` unset, it started `/bin/sh`. A GUI-launched app usually has + `SHELL`, but not always. Until this changes, the tab is labelled by the + same rule the CLI uses. +3. **`hack exec`: document, and test, that a command that cannot be started + in the container exits 127 (or 126) on both backends.** The bash-to-sh + fallback depends on it, and it is currently the engine's behaviour, not + the CLI's contract. + +Not a request, but worth knowing: `env_selection.default` is `null` for a +project with no default overlay. Any named overlay is then "not the +default" and its badge has the warning tone. + +### Why this and not attaching to tmux + +- **One mechanism.** The same PTY, emulator and teardown serve logs, + container shells and host shells. Attaching to tmux would add a second + terminal emulator between the program and ours, with its own scrollback, + key table, copy mode and resize rules to reconcile. +- **It works without tmux.** tmux is not installed on every machine, and + never inside most service containers. +- **The CLI stays the only thing that touches env values.** With `hack host + shell` the values go from the CLI straight into the child's environment + inside the PTY. A tmux route would need the app or a wrapper to place + values into a tmux server's environment or command line, where they + outlive the tab and are visible to every other pane and client of that + server. +- **Ownership.** We end what we start: the session is ours, and closing a + tab ends it. A tmux server is a long-lived process the user owns; ending + "our" part of it safely is a harder and different problem. + +Nothing stops a user from running `tmux` inside a host shell tab. + +### Tab identity and badge + +`SessionKind` is what a tab is, with names only: + +| Kind | Label | Badge | +|---|---|---| +| `Logs` | `logs` | none | +| `ContainerShell { service, shell }` | `api · sh` | none | +| `HostShell { shell }` | `host · zsh` | none | +| `HostShellWithEnv { shell, overlay, scope, is_default }` | `host · zsh` | `env staging · api`, warning tone when the overlay is not the default one | + +`TerminalView::kind()` and `TerminalPanel::kinds()` expose it; `label()` +and `badge()` produce the strings and the tone. A container shell's kind +changes if `bash` was missing. + +### Look: the user's Ghostty config + +`ghostty_config::GhosttyLook::load` reads the user's Ghostty config so the +terminal matches their own: `font-family` (the first is the font, later +ones are fallbacks), `font-size`, `background`, `foreground`, `palette`, +`cursor-style`, `window-padding-x` and `window-padding-y`. Whatever is not +set falls back to the app's tokens. Rules follow Ghostty's documentation: + +- Files, in load order: `$XDG_CONFIG_HOME/ghostty/config.ghostty`, then + `config` (`~/.config` when the variable is unset); on macOS also + `~/Library/Application Support/com.mitchellh.ghostty/` with the same two + names, after the XDG ones. Later values win. +- `config-file` includes are processed at the end of the file that names + them, relative to it; `?` marks one optional; cycles end. +- `theme = name`, an absolute path, or `light:A,dark:B` picked by the app's + appearance. Names are looked up in the user's `themes` directory, then in + Ghostty's resources. The user's own settings override the theme's. A + theme cannot set `theme` or `config-file`, and a name containing a path + separator is refused. + +An empty value resets a key; `palette =` drops every palette entry set so +far, the theme's included, and entries after it apply. + +Only those keys are parsed; every other line is skipped without being +kept, nothing from the file is logged, and a missing, oversized or +malformed file is treated as empty. + +Reading is bounded end to end. A file is identified by its canonical path +before it is opened, and is never read twice, however often it is included. +It is opened once (without blocking on a FIFO or device, and only if the +opened handle is a regular file) and read through a 1 MiB limit on that +handle, so a file that grows or is replaced after any earlier look cannot +exceed it. One load reads at most 4 MiB and 64 files in total across the +config files, every include and the theme; includes nest at most 8 deep. Sizes are points as Ghostty counts +them: a logical pixel on macOS, 96/72 of one elsewhere. Colours accept hex; +X11 colour names are accepted when libghostty is linked in. + +Not mapped yet: `cursor-color`, `selection-*`, `font-feature`, bold and +italic font overrides, `adjust-cell-height`, `background-opacity`. + +### Keyboard actions + +`actions.rs` defines the actions in the `hack_term` namespace and the +components handle them. **The crate binds no keys.** The only keys it +interprets itself are the ones it forwards to the program on the PTY +(encoded by libghostty for the program's modes) and text typed into the +find field while it is open. A key the app has bound to an action never +reaches the terminal. + +| Action | Handled by | Effect | +|---|---|---| +| `TogglePanel` | panel | Show or hide; hidden tabs keep running | +| `NewTab` | panel | Emits `PanelEvent::NewTabRequested`; the app chooses the kind and calls `add_tab` | +| `CloseTab` | panel | Closes the active tab and ends its session | +| `NextTab` / `PrevTab` | panel | Move through the tabs, wrapping | +| `ToggleMaximize` | panel | Sets `is_maximized()`; the app's layout gives it the room | +| `Copy` / `Paste` | view | Selection to the clipboard; clipboard into the program (bracketed when it asked) | +| `Find` | view (and panel, for the active tab) | Opens find | +| `FindOlder` / `FindNewer` / `CloseFind` | view | Step through matches; close find | +| `ScrollPageUp` / `ScrollPageDown` / `ScrollToTop` / `ScrollToBottom` / `ToggleFollow` | view | Scrollback and follow | + +Each action is also a public method, so the app can call it from its own +handlers for keys that must work when focus is elsewhere (toggling a hidden +panel). Key contexts for binding predicates: `Terminal` on every terminal, +`TerminalLogs` on a read-only stream, `TerminalFind` while the find field +is open, `TerminalPanel` on the panel. + +The bindings live in the app's keymap and in `examples/term_spike.rs`, from +the app's design: + +| Action | macOS | Linux | +|---|---|---| +| Toggle panel | ⌘J | Ctrl-Shift-J | +| New tab | ⌘T | Ctrl-Shift-T | +| Close tab | ⌘W | Ctrl-Shift-W | +| Next / previous tab | ⌘⇧] / ⌘⇧[ | Ctrl-Shift-PageDown / Ctrl-Shift-PageUp | +| Maximize | ⌘⇧↩ | Ctrl-Shift-Enter | +| Find | ⌘F | Ctrl-Shift-F | +| Copy / paste | ⌘C / ⌘V | Ctrl-Shift-C / Ctrl-Shift-V | + +The example also binds keys the design does not list yet, as its own +choice: Enter, Shift-Enter and Escape in the find field (`FindOlder`, +`FindNewer`, `CloseFind`), Shift-PageUp and Shift-PageDown for scrollback, +and PageUp, PageDown, Home, End and `f` in a log view. + +### Glyphs: prompt symbols, powerline and box drawing + +The owner's goal is that starship and oh-my-zsh themes look right. Those +prompts print three kinds of glyph that an ordinary font does not have, or +does not draw to fit the cell. On a Mac with no Nerd Font installed, the +first Mac run of this crate drew them as boxed question marks. + +**What libghostty-vt gives.** The code points and how many cells each one +takes. Nothing about drawing them: its headers export no font, glyph or +sprite API. That is Ghostty's renderer, which is not part of the library. + +**What Ghostty does** (read from the source tarball that ships with +libghostty-vt 1.0.1; MIT): + +- It embeds a symbols-only Nerd Font (`SymbolsNerdFont-Regular.ttf`, pinned + at Nerd Fonts 3.4.0 in that source) as a fallback, with a generated table + of per-glyph size and alignment rules. +- It draws box drawing, block elements, braille, the geometric powerline + glyphs (U+E0B0..U+E0BF and two more) and some legacy-computing symbols + itself, in `src/font/sprite/draw/*.zig`, so they meet the cell's edges. + It does not draw the pictographs U+E0A0..U+E0A3 (branch, line, lock, + column); those come from the symbols font. + +That code is Zig inside the renderer and is not exported by libghostty-vt. +The licence would allow porting it with attribution. It was not ported: +the geometry here is written for this crate, and the box-drawing table is +derived from the Unicode character names. + +**What this crate does.** + +1. **Drawn as geometry** (`sprite.rs`): box drawing U+2500..U+257F (light, + heavy, double, mixed, dashed, arcs, diagonals), block elements + U+2580..U+259F, and the powerline separators U+E0B0..U+E0BF. Every + rectangle is in whole device pixels and cell edges are snapped to the + pixel grid, for backgrounds too, so lines continue across cells and + prompt segments meet without seams at any font size, with any font. +2. **A bundled symbols font** (`glyphs.rs`): Symbols Nerd Font Mono from + Nerd Fonts **v3.5.1**, for the rest of the Nerd Font ranges (about + 10,600 glyphs, the pictographs U+E0A0..U+E0A3 included). It is fetched + by the provisioning script, checked against a pinned sha256 there and + again by `build.rs`, and embedded in the binary: **2.6 MB**. + +**Which font a symbol comes from.** A private-use code point the bundled +font has is drawn from the bundled font, unless the terminal's font is +itself a Nerd Font (by its family name) and has the glyph; then the user's +patched font is used, as in their own terminal. An ordinary Unicode +character the bundled font also has (the heavy angle bracket `❯`, the power +symbols) comes from it only when the terminal's font has no glyph for it. + +**Departures from the suggested default, and why.** + +- *The bundled font is not loaded with `add_fonts`; its outlines are drawn + as vector paths.* GPUI's Linux text system refuses to load any family + that has no glyph for the letter "m", as a font or as a fallback, so a + symbols-only font can never be selected there. What gets drawn instead is + whatever installed font happens to define that code point: on the + development machine a system UI font has an unrelated glyph at U+E0A0, + and the branch symbol came out as a small division-like sign. Reading the + outlines with `ttf-parser` (already in the lockfile) and filling them as + paths is the same on Linux and macOS and does not depend on installed + fonts or on either platform's fallback rules. +- *U+E0A0..U+E0A3 come from the bundled font, not from hand-drawn paths.* + They are pictographs, not cell geometry. Ghostty makes the same split. + +**Room, size and clipping of a symbol.** A symbol's room is the cells it +may draw in. A double-width symbol (U+26A1, say) has the two cells the +emulator gave it. A narrow one has its own cell, and the next cell too when +that one is blank: each glyph in the bundled font sits in a box one em wide, +which fitted to one narrow cell is about 60% of the text's size, prompt +themes put a space after their icons, and Ghostty and other terminals treat +that space the same way. A double-width symbol is not widened into a third +cell: it already has the two cells the blank-cell rule exists to give. + +In its room the glyph's em is made as wide as the room, never taller than +the cell, and its box is centred, so symbols of one size line up. The font's +metrics do not bound the ink, though: U+F0DC and U+F0DE rise to y = 1804 +where the ascender is 1638, and other glyphs hang past their advance. So +the scale is then reduced until the glyph's ink box fits the room, and the +glyph is moved by as little as brings its ink inside +(`SymbolOutline::place`; a unit test places every glyph of the font in +eight room sizes and checks the ink is inside). Most glyphs are not moved +at all. + +Everything self-drawn is also clipped to its room: a symbol to its one or +two cells, and box drawing, blocks and separators to their one cell. A +diagonal's stroke is wider than the cell at its corners and is cut off at +the cell's edges, so nothing self-drawn can land in a neighbouring cell. + +`TermFont::symbols` can be set to `BundledSymbols::Always`: every symbol +the bundled font has is then drawn from it, whatever the terminal's font +has. The default is the rule above. The render test uses it to exercise a +double-width symbol on any machine, since many fonts have U+26A1. + +**Licence and size.** The archive's licence file is MIT (Nerd Fonts). The +font is a collection, and the archive's README lists each icon set's own +licence: + +| Icon set | Licence | +|---|---| +| Codicons, Font Awesome | CC BY 4.0 | +| Material Design | Apache 2.0 | +| Pomicons | OFL 1.1 (reserved font name) | +| Weather Icons | OFL 1.1 | +| Font Logos 1.3.0 | The Unlicense, a public-domain dedication ([LICENSE at v1.3.0](https://github.com/lukas-w/font-logos/blob/v1.3.0/LICENSE)); the archive's README writes this as "unlicensed" | +| Devicons, Octicons, Seti, Powerline, Powerline Extra, IEC power symbols, Font Awesome Extension, extra glyphs from Hack | MIT | + +These are all permissive or public domain and none is copyleft, so +embedding is allowed. CC BY 4.0 and the OFL require attribution: a shipped +app must carry the Nerd Fonts licence and that list, as it must carry +Ghostty's notice. Ghostty ships the same collection. 2.6 MB in the binary +is acceptable for a desktop app (the window toolkit alone is far larger); +it is the same order as what Ghostty embeds. + +**Limits.** + +- Symbol outlines are tessellated as paths each frame and are not hinted. + Fine for a prompt or a file listing; a screen full of thousands of icons + has not been measured. +- Icons taken from a user's own Nerd Font are drawn by the text system at + that font's natural width, not fitted to the cell. +- Braille and the legacy-computing symbols are not drawn as geometry. +- The app's default monospace font on the capture machine has no italic + face, so italics show only with a font that has one. + +**The render tests** (`tests/render.rs`). The first draws a row offscreen +with blank cells and blank rows around every glyph: the two solid powerline +arrows, branch U+E0A0, three icons (U+F418, U+E718, U+F07B), the two sort +arrows whose ink rises above the ascender (U+F0DC, U+F0DE), four +box-drawing characters, two diagonals (U+2571, U+2573) and two blocks, plus +U+E050, a private-use code point no font has, as this machine's +missing-glyph reference. It fails if: + +- a glyph is blank, matches the missing-glyph rendering, is identical to + another icon (missing-glyph boxes all look alike), or is a speck less + than half the cell tall; +- the view did not take the symbols from the bundled font (without this + the test passed on a machine whose own fonts cover the same code points); +- a symbol's ink box is not where the placement rule puts the bundled + glyph, to a pixel and a half on each side: this catches a glyph cut off + by the clip, or one of the wrong size or shape; +- **any pixel around a glyph's room differs from the background**: the + rows above and below it and the blank cells before and after it; +- the geometry is off: the full block fills its cell, the half block fills + exactly the lower half, lines reach the edges their arms point at and no + others, and each arrow is flat and full height on one side and comes to + a point on the other. + +The second draws the double-width U+26A1 with an "x" straight after it and +fails unless the symbol is placed in both of its cells, is taller than it +would be in one, reaches into the second cell, and paints nothing above, +below or before them. + +Each check was tried against the fault it is for: without the sprite clip +the diagonal paints 4 pixels outside its cell; with the old em-and-ascender +placement U+F0DC is cut off at the top of its room; with a one-cell room +the double-width symbol is half the size. The first version of the test +also caught the wrong-font branch glyph described above. The tests need an +offscreen renderer; CI installs Mesa's software Vulkan driver for them and +requires it there. + +### What the renders and tests show + +All in the same component, in the same panel: + +| Item | Render | Test | +|---|---|---| +| Truecolour, 256 and 16 colours | [host](screenshots/panel-host-dark.png) | `ansi_colours_and_attributes_reach_the_frame`, `logs_keep_their_ansi_colours_and_prompt_glyphs_exactly` | +| Bold, italic, underline, strike, faint | host; real italics with a font that has them: [with a Ghostty look](screenshots/panel-host-ghostty-look-dark.png) | emulator tests | +| Wide glyphs | host | `wide_characters_take_two_cells` | +| Nerd Font prompt glyphs, a starship-style prompt | host | each private-use glyph is one cell holding that character | +| OSC 8 hyperlinks | host, [logs](screenshots/panel-logs-dark.png) | `osc8_hyperlinks_are_readable_and_queries_get_replies`, and in the logs test | +| Host login shell with prompt theming | host, [light](screenshots/panel-host-light.png) | a real `/bin/sh` login shell loads its profile, prompt and `ENV` file and is interactive; the same for zsh where it is installed | +| Container shell | [container](screenshots/panel-container-dark.png) (stand-in for `hack exec`) | stand-in CLI: argv checked, shell usable, bash-to-sh fallback and its label | +| Host shell with env, and the badge | [host with env](screenshots/panel-host-env-dark.png) (stand-in for `hack host shell`) | stand-in CLI: argv is names only; the real command was run by hand | +| Read-only log stream | logs | ANSI kept exactly as printed; input is never sent | +| The user's Ghostty look | [with a Ghostty look](screenshots/panel-host-ghostty-look-dark.png) | fixture config files in a temporary home | + +The prompt in the renders is `/bin/sh` with a fixture profile that draws a +themed prompt, so the capture is deterministic. In the live example the tab +runs the user's own login shell. + +In the renders, the prompt's separators are drawn as geometry and its +icons come from the bundled symbols font, except in the Ghostty-look +render, where the configured font is itself a Nerd Font and supplies them. +See "Glyphs" above for what is not fixed. + +### PTY ownership + +`pty::Pty` starts the child with `setsid()` and `TIOCSCTTY` (its own +session and process group, the PTY as controlling terminal) and marks +every inherited descriptor above stderr close-on-exec in the child. + +**Teardown** (`session::OwnedSession`). The session leader is held +**unreaped** until the last signal has been sent. While it is unreaped its +pid cannot be reused, so neither can the group id or session id: `killpg` +on the leader's group reaches our group or nothing, and a process whose +session id is the leader is ours. Exit is observed with `waitid(WNOWAIT)`, +which does not reap. + +**Signals fail closed.** A signal is sent only through a handle whose +identity cannot be recycled: + +- The leader's process group, on every platform, by `killpg` under the + anchor. Closing the PTY master also hangs up the controlling terminal: the + kernel sends SIGHUP to the foreground group, and an interactive shell + sends its own SIGHUP to its jobs. +- On Linux, members in other process groups (an interactive shell's jobs) + through a **pidfd**: open it, re-read the session id, then + `pidfd_send_signal`. The pidfd names one process for life. +- Where there is no such handle (macOS, and Linux kernels before 5.3), + members outside the leader's group are **never signalled by number**. No + `kill(pid)` after a check is used anywhere. Test helpers follow the same + rule: an assertion that a process is gone only reports, and cleanup of a + leftover goes through a pidfd held for that session. + +The cost, on macOS: a process that is in another process group of the +session and ignores or never receives SIGHUP can outlive the tab, as it +would outlive any terminal app that is closed. It is found by a scan of the +session and returned in `Teardown::survivors` (`terminate_blocking`, or +`terminate_in_background` for a result without waiting); the panel shows +how many processes outlived closed tabs. Teardown stops as soon as nothing +more can be reached instead of waiting out its deadline. + +Drop sends SIGHUP and SIGCONT, then a reaper thread sends SIGKILL after +300 ms, re-sends it every 50 ms to catch late forks, and reaps the leader +last. A descendant that calls `setsid()` itself is out of reach without +cgroups or job objects. + +**No blocking on the UI thread.** + +- Output: a worker reads the terminal into a queue bounded at 1 MiB of + pending bytes. When the consumer stalls the worker stops reading and the + child blocks on its own write (backpressure). Closing cancels the worker. +- Input: `Pty::write` only queues (bound 1 MiB). A second worker delivers + with non-blocking writes and handles partial writes. Input that does not + fit is refused **whole** (a truncated paste could run half a command) + and the view shows a notice. +- Quit: the view ends the session on GPUI's background executor with a + 100 ms SIGKILL grace, inside GPUI's 200 ms shutdown window. This is + registered for both kinds of backend, a PTY and a process-backed stream, + and GPUI awaits it. +- Any input, and any change to the notice about undelivered input, + notifies the view, so it is drawn in the next frame even when the child + prints nothing. + +**End of output is not child exit.** The terminal core keeps pumping until +the terminal reports end of file. If the child has exited but something +still holds the terminal (a background job), it waits 500 ms without +output, at most 5 s, then closes. + +**Thread-spawn failure.** If a worker cannot be started the child is +killed and reaped before the error returns. If the reaper cannot be +started, Drop kills the session and reaps on the calling thread. A session +dropped on any other path is killed and reaped by its own Drop. + +`stream::ByteStream` runs a process in its own session (no controlling +terminal) with stdin closed and stdout+stderr on one pipe, through the same +bounded queue and the same teardown (SIGTERM first). + +### Tests (hermetic) + +Children run `/bin/sh` and POSIX tools with an emptied environment, a fixed +`PATH` and a private temporary `HOME`. Tests signal only pids they +recorded; nothing matches processes by name. Deadlines are 60 s scheduling +bounds. + +Default build (both CI hosts run these): + +- Session teardown: a same-group sleeper and one that ignores SIGHUP and + SIGTERM are gone after Drop, and the leader is reaped. +- A job in another process group: ended on Linux; on macOS left alone and + named in `survivors` (it then ends by itself). +- Fail closed, with a fake process table: a pid replaced by another + process between verification and signalling receives nothing; without a + handle no per-process signal is attempted at all. +- Fail closed, with real processes: a member that is killed and reaped at + that boundary is not signalled by number (Linux); with handles disabled, + a job in another group survives, is reported, is not signalled, and + teardown returns in under three seconds. +- Identity: a scan that returns a pid from another session and a pid that + does not exist; both are skipped, the other session is untouched. +- Anchor: after the shell exits, its pid still exists and its job is still + found by session id. +- Escalation: SIGKILL is reported only for a session that ignores SIGHUP, + and only after the grace period. +- Writes: 16 MiB offered to a child in raw mode that never reads returns + at once, the queue stays within its bound, the excess is reported as + dropped, and teardown cancels the parked writer. +- Queues: a fast producer with a stalled consumer stops at the bound + (unit test, and end to end with `yes` through a stream). +- Final output: a tiny and a 300,000-line output followed by exit arrive + complete, with end of file after the last byte. +- Thread-spawn failure, injected for the reader, the writer and the reaper, + for PTY and stream: no running child and no zombie. +- Sessions: labels and badges, argv and environment for each kind, name + validation, login shell resolution, terminfo detection; a real login + shell loading its profile; the CLI-backed kinds through a stand-in. +- Glyph geometry (`sprite.rs`), with a small rasteriser: every box-drawing + character reaches exactly the edges its arms point at; an arm of each + weight meets the cell edge the same way in every character, so neighbours + join; corners are closed and double crossings hollow; dashes, blocks and + shades have the right coverage; powerline separators span the cell. +- Ghostty config: fixture files in a temporary home (keys, malformed + values, resets, load order, macOS location, includes and cycles, themes); + a fanned-out include tree is read once per file and no byte twice; the + limit holds on the open handle for a reader that never ends; a FIFO in + place of the config is not read and does not block; the total byte and + file budgets; `palette =` after earlier entries and after a theme's. +- Also: resize reaching the child, session leadership with a controlling + tty, the child's environment, a 10,000-line file stream. + +Checked to fail when the fix they cover is removed (mutations run by hand, +sources restored): the identity, anchor, queue-bound, final-output-in-grid, +thread-spawn-failure, notice, stream-quit, include fan-out, read-limit, +budget and palette-reset tests. The spawn-failure tests +only fail when both cleanup layers are removed, because either one alone +still kills and reaps the child. + +Feature `ghostty`: `tests/emulator.rs` (colours and attributes, LNM, +bounded scrollback at 80 and 200 columns, follow, search, one search run +per change and none for an unchanged redraw, selection and +copy, reflow, wide characters, OSC 8, query replies, key encoding, paste +hardening, theme, the user's palette and cursor style), `tests/terminal.rs` +(a real child's final output is in the grid and scrollback; a descendant +holding the terminal does not keep it open; a stream's last line; the +bash-to-sh fallback and its label; an exit 127 after input is not a +fallback; logs keep ANSI and prompt glyphs) and the panel's tab logic. + +Feature `ghostty`, `glyphs.rs`: the embedded font is the pinned file; the +table of its code points matches the font; outlines are closed contours +inside the font's box; Nerd Font family names are recognised. + +Feature `screenshots`, `tests/render.rs`: the pixel test described under +"Glyphs". It needs an offscreen renderer (a GPU, or software Vulkan). + +Feature `screenshots` (GPUI test support; no display or GPU needed), +`tests/view.rs`: a rejected paste to a quiet child shows its notice in +exactly one new frame with no other event; with find open, output that +adds matches and then stops leaves the count shown equal to the number of +highlighted matches, with no further frame; redrawing an unchanged view +with find open runs no search; quitting the app ends a stream +process that ignores SIGTERM, with its descendant, and a PTY session that +ignores SIGHUP; with no bindings the usual chords are not shortcuts, the +`Find`, `CloseFind`, `Paste` and `Copy` actions do their work, and a chord +becomes a shortcut only once the app binds it. + +### Performance + +A first measurement, from `term_bench` (three runs, release build without +LTO, Linux, a 10-core desktop Intel CPU with a discrete GPU). It is a +sanity check on one machine, not a qualification. + +| What | Result | +|---|---| +| Emulation, coloured log flood (64 MiB) | 217 to 223 MiB/s | +| Emulation, full-screen 200×60 repaints | 230 to 253 MiB/s, about 10,000 repaints/s | +| Building a `Frame` for 200×60, every row changed | 0.84 to 0.89 ms mean, 1.4 ms worst | +| One offscreen GPUI frame of a 208×60 grid, every row changed | 3.0 ms mean, 5.1 ms worst | +| The same with nothing changed | 2.9 to 3.0 ms mean, 4.7 ms worst | + +Reading: emulation is far faster than any program prints. Frame building +plus paint is about 4 ms of CPU for a full-screen change on this machine, +inside one 120 Hz frame (8.3 ms). + +What this does **not** show: GPU time, presentation latency, key-to-screen +latency, a laptop, or anything on macOS. "Paint" is the CPU side of a GPUI +frame (layout, text shaping, scene building, submission) with the offscreen +renderer. + +Known costs still on the table, none optimised yet: + +- An unchanged frame costs as much as a changed one. libghostty reports + dirty rows and stable row ids; the view rebuilds and repaints every row. +- `Emulator::frame` allocates a `String` per cell. +- The view polls for output on a timer (8 ms when busy, 33 ms idle), which + adds up to one tick of latency. It should wake on output. + +**Find with many matches.** A libghostty search learns about new output +and viewport moves only when it is run, and each run checks every match it +holds. The emulator therefore keeps a revision counter (output, a resize, +a viewport move, the needle, the selected match) and runs the search at +most once per revision; the find count and a frame's highlights come from +that one pass, and redrawing an unchanged terminal runs nothing. Timed in +a dev build with a 10,000-line scrollback and 390,000 matches, medians of +30: + +| Unchanged terminal | Before | After | +|---|---|---| +| Count, then frame (what a redraw does) | 19.4 to 20.5 ms | 1.8 ms | +| Frame alone | 11.2 to 11.6 ms | 1.8 ms | + +A frame after one new line of output takes 12.4 ms in the same case: one +run, where it was two. That run is libghostty's cost for this many +matches; making it incremental (driving `tick` and `feed` across frames) +is not done. + +One thing the benchmark caught: libghostty's default 10 MB scrollback byte +limit applies before the line limit, and on a 200-column grid kept only +about 5,500 lines. The emulator now sets its own byte ceiling (256 MiB) so +the 10,000-line bound is the one that applies; a test covers it. + +## Uses + +**Logs tab.** A read-only `TerminalView` fed by core's existing log stream +reader; core's ndjson parser stays and decides what bytes to render (for +structured entries, the app formats one line per entry with its own ANSI +colours; raw lines pass through). ANSI printed by a service is kept exactly +as printed. LNM is on so bare `\n` lines render correctly. Follow, the +scrollback cap and the search hook (`TerminalView::search`, `search_step`) +back the tab's Follow toggle and Find field. + +**Shells.** The three interactive kinds under "Sessions" above: a shell in +a service, the host shell, and a host shell with env. Each is a +`SessionPlan` from `launch.rs`; `TerminalCore::session(plan)` starts it and +`TerminalView::from_core` shows it. Closing the tab or window, or quitting +the app, ends that PTY's session (and with it the `hack` client). Processes +inside the container are ended by `hack exec` and the engine, not by this +crate. + +**Security rules.** + +- The app never types secrets into a terminal: input is only what the user + types or pastes. Paste is sanitised (no control characters except tab, CR and + LF) so it cannot break out of bracketed paste. +- The app never logs terminal contents: no stream or PTY bytes go to logs, + telemetry or crash reports. Copy goes to the clipboard only on user action. +- The app never passes values in argv: argv holds only the CLI's + subcommands, validated service, overlay and scope names, and a shell + name. The app sets three fixed variables that say what terminal this is, + and nothing else; env values are resolved and injected by the CLI inside + the PTY and never pass through the app. +- The user's Ghostty config is read for a fixed list of keys; nothing else + in it is kept, and nothing from it is logged. +- What a service prints is shown as printed; the app does not redact, + rewrite or interpret it beyond terminal emulation. Program-initiated + clipboard writes (OSC 52) and notifications are not acted on by this + view. + +## Licences of new dependencies + +Pinned in `Cargo.toml`: `ghostty-vt =0.9.0` (lock: `0.9.0+ghostty-vt.1.0.1`), +`libc =0.2.191`, `image =0.25.10` (screenshots feature only), +`ttf-parser =0.25.1` (MIT OR Apache-2.0; reads the symbols font's outlines) +and, for the build script, `sha2 =0.10.9` (MIT OR Apache-2.0; verifies the +embedded font). The last two were already in the lockfile through GPUI and +`ghostty-vt-sys`; they are now direct dependencies of this crate. + +Embedded, not a crate: **Symbols Nerd Font Mono**, Nerd Fonts v3.5.1, +2.6 MB. MIT for the collection, with the icon-set licences listed under +"Glyphs" (CC BY 4.0, Apache 2.0, OFL 1.1, MIT, and the Unlicense). + +Every package this branch adds to `Cargo.lock`: + +| Package | Version | Licence | Role | +|---|---|---|---| +| ghostty-vt | 0.9.0+ghostty-vt.1.0.1 | Apache-2.0 (bundled terminfo and shell-integration files MIT) | linked | +| ghostty-vt-sys | 0.9.0+ghostty-vt.1.0.1 | Apache-2.0; links libghostty-vt (MIT, third-party list above) | linked | +| int-enum | 1.3.0 | MIT OR Apache-2.0 | proc-macro | +| proc-macro2-diagnostics | 0.11.0 | MIT/Apache-2.0 | proc-macro | +| ureq | 3.5.2 | MIT OR Apache-2.0 | build script (download) | +| ureq-proto | 0.7.1 | MIT OR Apache-2.0 | build script | +| utf8-zero | 0.8.1 | MIT OR Apache-2.0 | build script | +| webpki-roots | 1.0.12 | CDLA-Permissive-2.0 | build script (TLS roots, Linux host) | +| webpki-root-certs | 1.0.12 | CDLA-Permissive-2.0 | build script | +| der | 0.8.3 | Apache-2.0 OR MIT | build script | +| pem-rfc7468 | 1.0.0 | Apache-2.0 OR MIT | build script | +| base64ct | 1.8.3 | Apache-2.0 OR MIT | build script | +| tar | 0.4.46 | MIT OR Apache-2.0 | build script | +| filetime | 0.2.29 | MIT/Apache-2.0 | build script | +| xattr | 1.7.0 | MIT OR Apache-2.0 | build script | +| zip | 9.6.0 | MIT | build script | +| typed-path | 0.13.0 | MIT OR Apache-2.0 | build script | +| constant_time_eq | 0.6.1 | CC0-1.0 OR MIT-0 OR Apache-2.0 | build script | +| nix | 0.32.0 | MIT | build script (zip) | +| native-tls | 0.2.18 | MIT OR Apache-2.0 | build script on macOS/Windows hosts | +| openssl | 0.10.82 | Apache-2.0 | lockfile only (native-tls on other hosts; not built) | +| openssl-sys | 0.9.118 | MIT | lockfile only | +| openssl-macros | 0.1.1 | MIT/Apache-2.0 | lockfile only | +| foreign-types | 0.3.2 | MIT/Apache-2.0 | lockfile only | +| foreign-types-shared | 0.1.1 | MIT/Apache-2.0 | lockfile only | +| vcpkg | 0.2.15 | MIT/Apache-2.0 | lockfile only | + +Already in the lockfile and reused: bitflags, dirs 6 (MIT OR Apache-2.0) +with `option-ext` (MPL-2.0, already present through gpui), flate2, sha2, +rustls/ring (build script TLS). There is no cargo-deny or licence config +for this workspace. A shipped app must carry Ghostty's MIT notice, the +third-party notices above, and the Nerd Fonts licence with its icon-set +attributions. diff --git a/apps/desktop/term/build.rs b/apps/desktop/term/build.rs new file mode 100644 index 000000000..954a2537b --- /dev/null +++ b/apps/desktop/term/build.rs @@ -0,0 +1,118 @@ +//! Build steps for the `ghostty` feature. Nothing here uses the network. +//! +//! 1. Fail with instructions when the pinned libghostty-vt inputs are not +//! provisioned locally. The dependency's own build script would +//! otherwise try to download them; the workspace's `.cargo/config.toml` +//! already makes that download impossible, and this says what to do +//! instead. It checks only that the inputs are present: their sha256 is +//! verified by the provisioning script and by the dependency. +//! 2. Embed the symbols fallback font: read the provisioned file, verify it +//! against the sha256 pinned here, and copy it to `OUT_DIR` for +//! `include_bytes!`. A different file fails the build. + +use std::env; +use std::path::PathBuf; + +const VERSION: &str = "1.0.1"; +#[cfg(feature = "ghostty")] +const SYMBOLS_FONT: &str = "SymbolsNerdFontMono-Regular.ttf"; +/// Symbols Nerd Font Mono from Nerd Fonts v3.5.1. +#[cfg(feature = "ghostty")] +const SYMBOLS_FONT_SHA256: &str = + "fe471e538392f51910faab985fa8e192a39dd3426125edd15b71b3680df0e749"; +#[cfg(feature = "ghostty")] +const SYMBOLS_FONT_DIR: &str = "nerd-fonts-symbols-3.5.1"; + +fn fail(problem: &str) -> ! { + eprintln!( + "\nhack-desktop-term: the `ghostty` feature builds from local, checksummed\n\ + inputs and never downloads during the build.\n\n\ + problem: {problem}\n\n\ + Provision them once (the only step that uses the network), then export\n\ + what the script prints:\n\n\ + \x20 eval \"$(bash term/scripts/provision-ghostty.sh)\"\n\n\ + See apps/desktop/term/DECISION.md, \"Build and CI\".\n" + ); + std::process::exit(1); +} + +fn set(name: &str) -> Option { + env::var_os(name) + .filter(|v| !v.is_empty()) + .map(PathBuf::from) +} + +fn main() { + for var in [ + "GHOSTTY_RELEASE_DIR", + "GHOSTTY_VT_LIB_DIR", + "GHOSTTY_SOURCE_DIR", + "HACK_TERM_SYMBOLS_FONT", + "ZIG", + ] { + println!("cargo:rerun-if-env-changed={var}"); + } + if env::var_os("CARGO_FEATURE_GHOSTTY").is_none() || env::var_os("DOCS_RS").is_some() { + return; + } + check_ghostty_inputs(); + #[cfg(feature = "ghostty")] + embed_symbols_font(); +} + +fn check_ghostty_inputs() { + // Explicit local overrides the dependency supports; both avoid downloads. + if set("GHOSTTY_VT_LIB_DIR").is_some() || set("GHOSTTY_SOURCE_DIR").is_some() { + return; + } + let target_os = env::var("CARGO_CFG_TARGET_OS").unwrap_or_default(); + let apple = matches!(target_os.as_str(), "macos" | "ios"); + let needed: &[String] = &if apple { + vec![format!("ghostty-vt-{VERSION}.xcframework.zip")] + } else { + vec![ + format!("libghostty-vt-{VERSION}.tar.gz"), + format!("libghostty-vt-{VERSION}-deps.tar.gz"), + ] + }; + let Some(dir) = set("GHOSTTY_RELEASE_DIR") else { + fail("GHOSTTY_RELEASE_DIR is not set"); + }; + if let Some(name) = needed.iter().find(|name| !dir.join(name).is_file()) { + fail(&format!("{} is missing {name}", dir.display())); + } + if !apple && set("ZIG").is_none() { + fail("ZIG is not set (the pinned Zig 0.17.1 from the provisioning script)"); + } +} + +/// Copy the provisioned symbols font into `OUT_DIR` after checking that it +/// is exactly the pinned file. +#[cfg(feature = "ghostty")] +fn embed_symbols_font() { + use sha2::{Digest, Sha256}; + let source = set("HACK_TERM_SYMBOLS_FONT").or_else(|| { + set("GHOSTTY_RELEASE_DIR").map(|d| d.join(SYMBOLS_FONT_DIR).join(SYMBOLS_FONT)) + }); + let Some(source) = source else { + fail("HACK_TERM_SYMBOLS_FONT is not set"); + }; + println!("cargo:rerun-if-changed={}", source.display()); + let Ok(bytes) = std::fs::read(&source) else { + fail(&format!( + "the symbols font is missing: {} (run the provisioning script again)", + source.display() + )); + }; + let actual = format!("{:x}", Sha256::digest(&bytes)); + if actual != SYMBOLS_FONT_SHA256 { + fail(&format!( + "{} has sha256 {actual}, expected {SYMBOLS_FONT_SHA256}", + source.display() + )); + } + let out = PathBuf::from(env::var_os("OUT_DIR").expect("OUT_DIR")).join(SYMBOLS_FONT); + if std::fs::write(&out, &bytes).is_err() { + fail(&format!("could not write {}", out.display())); + } +} diff --git a/apps/desktop/term/examples/term_bench.rs b/apps/desktop/term/examples/term_bench.rs new file mode 100644 index 000000000..43bf5b56d --- /dev/null +++ b/apps/desktop/term/examples/term_bench.rs @@ -0,0 +1,203 @@ +//! Rough throughput numbers for the terminal surface. Run it in release: +//! +//! ```sh +//! cargo run --release -p hack-desktop-term --features screenshots --example term_bench +//! ``` +//! +//! It measures three things on the current machine: +//! +//! 1. emulation: bytes per second through `Emulator::feed` (libghostty-vt) +//! for a coloured log flood and for full-screen repaints; +//! 2. frame extraction: `Emulator::frame` for a full 200x60 grid; +//! 3. paint: one offscreen GPUI frame of the same grid, with every row +//! changed (worst case) and with nothing changed. This is the CPU side +//! (layout, shaping, scene building, submission), not GPU time or +//! display latency. +//! +//! These are a sanity check, not a qualification. A debug build is far +//! slower; libghostty-vt is built unoptimised there. + +use std::sync::Arc; +use std::time::{Duration, Instant}; + +use gpui_kit::component::{Theme, ThemeMode}; +use gpui_kit::test::TestWindowExt as _; +use gpui_kit::{ + AppContext as _, Bounds, HeadlessAppContext, WindowBounds, WindowOptions, px, size, +}; +use hack_desktop_term::emulator::{Emulator, EmulatorOptions}; +use hack_desktop_term::stream::StreamSource; +use hack_desktop_term::view::TerminalView; + +const COLS: u16 = 200; +const ROWS: u16 = 60; + +fn log_flood(bytes: usize) -> Vec { + let mut out = Vec::with_capacity(bytes + 256); + let mut i = 0u64; + while out.len() < bytes { + out.extend_from_slice( + format!( + "\x1b[90m12:{:02}:{:02}.{:03}\x1b[0m \x1b[36mworker\x1b[0m \x1b[32mINFO \x1b[0m request {i} handled in \x1b[38;2;120;170;255m{}ms\x1b[0m path=/api/v1/items/{} status=200\n", + (i / 60) % 60, + i % 60, + (i * 37) % 1000, + (i * 7) % 250, + i * 31 + ) + .as_bytes(), + ); + i += 1; + } + out +} + +/// One full-screen repaint, the way a TUI redraws: home, then every row +/// with a few colour changes. `seed` makes each frame differ. +fn tui_frame(seed: u32) -> Vec { + let mut out = b"\x1b[H".to_vec(); + for r in 0..u32::from(ROWS) { + out.extend_from_slice(format!("\x1b[{};1H", r + 1).as_bytes()); + for c in 0..u32::from(COLS) / 10 { + let n = (seed + r * 7 + c * 13) % 256; + out.extend_from_slice(format!("\x1b[38;5;{n}m{:>9} ", seed + r * c).as_bytes()); + } + } + out.extend_from_slice(b"\x1b[0m"); + out +} + +fn mean_and_worst(samples: &[Duration]) -> (f64, f64) { + let ms = |d: &Duration| d.as_secs_f64() * 1000.0; + let mean = samples.iter().map(ms).sum::() / samples.len() as f64; + let worst = samples.iter().map(ms).fold(0.0, f64::max); + (mean, worst) +} + +fn main() { + if cfg!(debug_assertions) { + eprintln!("note: this is a debug build; run with --release for meaningful numbers"); + } + let opts = EmulatorOptions { + cols: COLS, + rows: ROWS, + scrollback_lines: 10_000, + newline_is_crlf: true, + ..EmulatorOptions::default() + }; + + // 1. Emulation throughput. + let flood = log_flood(64 << 20); + let mut emu = Emulator::new(opts).expect("emulator"); + let start = Instant::now(); + for chunk in flood.chunks(64 * 1024) { + emu.feed(chunk); + } + let secs = start.elapsed().as_secs_f64(); + println!( + "emulate log flood: {:>8.1} MiB/s ({} MiB, {} lines of scrollback kept)", + flood.len() as f64 / secs / (1 << 20) as f64, + flood.len() >> 20, + emu.scrollback_rows() + ); + let frames: Vec> = (0..500).map(tui_frame).collect(); + let bytes: usize = frames.iter().map(Vec::len).sum(); + let mut emu = Emulator::new(opts).expect("emulator"); + let start = Instant::now(); + for f in &frames { + emu.feed(f); + } + let secs = start.elapsed().as_secs_f64(); + println!( + "emulate TUI repaint: {:>8.1} MiB/s ({:.0} full {COLS}x{ROWS} repaints/s)", + bytes as f64 / secs / (1 << 20) as f64, + frames.len() as f64 / secs + ); + + // 2. Frame extraction for a full grid. + let mut samples = Vec::new(); + for f in frames.iter().take(200) { + emu.feed(f); + let start = Instant::now(); + let frame = emu.frame().expect("frame"); + samples.push(start.elapsed()); + assert_eq!(frame.rows.len(), usize::from(ROWS)); + } + let (mean, worst) = mean_and_worst(&samples); + println!( + "extract frame: {mean:>8.3} ms mean, {worst:.3} ms worst ({COLS}x{ROWS}, all rows changed)" + ); + + // 3. Offscreen GPUI paint of the same grid. + let mut cx = HeadlessAppContext::with_platform( + gpui_kit::platform::current_platform(true).text_system(), + Arc::new(gpui_kit::assets::Assets), + gpui_kit::platform::current_headless_renderer, + ); + cx.update(gpui_kit::init); + cx.update(|cx| Theme::change(ThemeMode::Dark, None, cx)); + let tmp = std::env::temp_dir().join(format!("hack-term-bench-{}", std::process::id())); + std::fs::create_dir_all(&tmp).expect("temp dir"); + let empty = tmp.join("empty.log"); + std::fs::write(&empty, b"").expect("write"); + let window = WindowOptions { + focus: false, + show: false, + // 200x60 cells at the default 13 px font is about 1600x1050. + window_bounds: Some(WindowBounds::Windowed(Bounds { + origin: Default::default(), + size: size(px(1640.), px(1040.)), + })), + ..WindowOptions::default() + }; + let (handle, view) = cx + .update(|cx| { + gpui_kit::open_window(window, cx, |_, cx| { + cx.new(|cx| { + TerminalView::new_stream(&StreamSource::File(empty.clone()), 10_000, cx) + .expect("open stream") + }) + }) + }) + .expect("open offscreen window"); + let draw = |cx: &mut HeadlessAppContext| { + let start = Instant::now(); + cx.update_window(handle, |_, window, cx| window.render_frame(cx)) + .expect("render"); + start.elapsed() + }; + draw(&mut cx); + let grid = cx.update(|cx| view.update(cx, |v, _| (v.emulator().cols(), v.emulator().rows()))); + // Warm up glyph and layout caches, then measure. + let mut changed = Vec::new(); + for (i, f) in frames.iter().enumerate().take(260) { + cx.update(|cx| { + view.update(cx, |v, cx| { + v.emulator().feed(f); + cx.notify(); + }); + }); + let took = draw(&mut cx); + if i >= 60 { + changed.push(took); + } + } + let unchanged: Vec = (0..200) + .map(|_| { + cx.update(|cx| view.update(cx, |_, cx| cx.notify())); + draw(&mut cx) + }) + .collect(); + let (mean, worst) = mean_and_worst(&changed); + println!( + "paint, all changed: {mean:>8.3} ms mean, {worst:.3} ms worst ({}x{} grid; {:.0} frames/s of CPU budget)", + grid.0, + grid.1, + 1000.0 / mean + ); + let (mean, worst) = mean_and_worst(&unchanged); + println!("paint, unchanged: {mean:>8.3} ms mean, {worst:.3} ms worst"); + cx.update_window(handle, |_, window, _| window.remove_window()) + .ok(); + let _ = std::fs::remove_dir_all(&tmp); +} diff --git a/apps/desktop/term/examples/term_spike.rs b/apps/desktop/term/examples/term_spike.rs new file mode 100644 index 000000000..5c3465e20 --- /dev/null +++ b/apps/desktop/term/examples/term_spike.rs @@ -0,0 +1,743 @@ +//! Spike window: a per-project bottom panel with terminal tabs, all drawn +//! by the same libghostty-vt component. +//! +//! Tabs: `logs` (a read-only stream), a container shell, the host login +//! shell, and a host shell with an env overlay (with its badge). +//! +//! ```sh +//! cargo run -p hack-desktop-term --features ghostty --example term_spike +//! cargo run -p hack-desktop-term --features ghostty --example term_spike -- --file app.log +//! # Real CLI sessions, in a real Hack project (manual; needs a running service): +//! cargo run -p hack-desktop-term --features ghostty --example term_spike -- \ +//! --project /path/to/project --hack "$(command -v hack)" --service api --env staging --scope api +//! cargo run -p hack-desktop-term --features screenshots --example term_spike -- --screenshots DIR +//! ``` +//! +//! Without `--hack` and `--service`, the two CLI-backed tabs run a stand-in +//! script that checks the argv the app would pass to `hack` and then starts +//! a plain shell. The stand-in can pretend the container has no `bash` +//! (the default), which shows the fallback to `sh`. +//! +//! The crate binds no keys. This example binds the ones in the app's design +//! (`bind_keys` below): toggle panel ⌘J / Ctrl-Shift-J, new tab ⌘T / +//! Ctrl-Shift-T, close tab ⌘W / Ctrl-Shift-W, next and previous tab ⌘⇧] and +//! ⌘⇧[ / Ctrl-Shift-PageDown and Ctrl-Shift-PageUp, maximize ⌘⇧↩ / +//! Ctrl-Shift-Enter, find ⌘F / Ctrl-Shift-F, copy and paste ⌘C and ⌘V / +//! Ctrl-Shift-C and Ctrl-Shift-V. Closing the window ends every session. + +use std::path::{Path, PathBuf}; + +use gpui_kit::component::{ActiveTheme as _, Theme, ThemeMode}; +use gpui_kit::{ + App, AppContext as _, Bounds, Context, Entity, InteractiveElement as _, IntoElement, + KeyBinding, ParentElement as _, Render, Styled as _, Window, WindowBounds, WindowOptions, div, + px, size, +}; +use hack_desktop_term::actions::{ + CloseFind, CloseTab, Copy, Find, FindNewer, FindOlder, NewTab, NextTab, Paste, PrevTab, + ScrollPageDown, ScrollPageUp, ScrollToBottom, ScrollToTop, ToggleFollow, ToggleMaximize, + TogglePanel, +}; +use hack_desktop_term::ghostty_config::{ConfigEnv, GhosttyLook}; +use hack_desktop_term::launch::{ + HackCli, LoginShell, Name, SessionPlan, TermChoice, container_shell, host_shell, + host_shell_with_env, +}; +use hack_desktop_term::panel::{PanelEvent, TerminalPanel}; +use hack_desktop_term::stream::{ProcessSpec, StreamSource}; +use hack_desktop_term::terminal::TerminalCore; +use hack_desktop_term::view::TerminalView; + +struct Args { + source: Option, + theme: Option, + screenshots: Option, + project: Option, + hack: Option, + service: String, + overlay: String, + scope: String, + use_ghostty_config: bool, +} + +fn usage() -> ! { + eprintln!( + "usage: term_spike [--file PATH | --cmd PROGRAM [ARGS...]] [--theme light|dark]\n\ + \x20 [--project DIR] [--hack PATH --service NAME [--env OVERLAY --scope SCOPE]]\n\ + \x20 [--no-ghostty-config] [--screenshots DIR]" + ); + std::process::exit(2) +} + +fn parse_args() -> Args { + let mut a = Args { + source: None, + theme: None, + screenshots: None, + project: None, + hack: None, + service: "api".into(), + overlay: "staging".into(), + scope: "api".into(), + use_ghostty_config: true, + }; + let mut it = std::env::args().skip(1); + let next = |it: &mut dyn Iterator| it.next().unwrap_or_else(|| usage()); + while let Some(arg) = it.next() { + match arg.as_str() { + "--file" => a.source = Some(StreamSource::File(next(&mut it).into())), + "--cmd" => { + let mut spec = ProcessSpec::new(next(&mut it)); + spec.args = it.by_ref().map(Into::into).collect(); + a.source = Some(StreamSource::Process(spec)); + } + "--theme" => { + a.theme = Some(match next(&mut it).as_str() { + "light" => ThemeMode::Light, + "dark" => ThemeMode::Dark, + _ => usage(), + }) + } + "--screenshots" => a.screenshots = Some(next(&mut it).into()), + "--project" => a.project = Some(next(&mut it).into()), + "--hack" => a.hack = Some(next(&mut it).into()), + "--service" => a.service = next(&mut it), + "--env" => a.overlay = next(&mut it), + "--scope" => a.scope = next(&mut it), + "--no-ghostty-config" => a.use_ghostty_config = false, + _ => usage(), + } + } + a +} + +/// A 10,000-line log with levels, colours, a wide-character line, an OSC 8 +/// link and a progress line, written to a private temp file. +fn demo_log(dir: &Path) -> PathBuf { + let path = dir.join("demo.log"); + let mut s = String::new(); + let services = ["api", "worker", "web", "db"]; + for i in 0..10_000u32 { + let svc = services[(i % 4) as usize]; + let (lvl, colour) = match i % 17 { + 0 => ("ERROR", "\x1b[1;31m"), + 3 | 11 => ("WARN ", "\x1b[33m"), + 5 => ("DEBUG", "\x1b[2m"), + _ => ("INFO ", "\x1b[32m"), + }; + s.push_str(&format!( + "\x1b[90m12:{:02}:{:02}.{:03}\x1b[0m \x1b[36m{svc:<6}\x1b[0m {colour}{lvl}\x1b[0m request {i} handled in \x1b[38;2;120;170;255m{}ms\x1b[0m", + (i / 60) % 60, + i % 60, + (i * 37) % 1000, + (i * 7) % 250 + )); + if i % 500 == 250 { + s.push_str(" — 日本語 wide text ✓"); + } + if i % 1000 == 999 { + s.push_str(" docs: \x1b]8;;https://example.invalid/docs\x1b\\example.invalid/docs\x1b]8;;\x1b\\"); + } + s.push('\n'); + } + std::fs::write(&path, s).expect("write demo log"); + path +} + +/// A stand-in for the `hack` CLI: it checks the argv the app passes, then +/// starts a plain shell. See the module docs. +fn write_stand_in(dir: &Path) -> PathBuf { + use std::os::unix::fs::PermissionsExt; + let script = r#"#!/bin/sh +case "$1" in + exec) + [ "$#" -eq 4 ] && [ "$3" = "--" ] || { echo "stand-in: bad exec argv: $*" >&2; exit 2; } + if [ "$4" = bash ] && [ -z "$STAND_IN_HAS_BASH" ]; then + echo 'exec: "bash": executable file not found in $PATH' >&2 + exit 127 + fi + printf '\033[2m(stand-in for: hack exec %s -- %s)\033[0m\n' "$2" "$4" + HOME=/root PS1="root@$2:/app# " exec /bin/sh + ;; + host) + [ "$#" -eq 6 ] && [ "$2" = shell ] && [ "$3" = --env ] && [ "$5" = --scope ] \ + || { echo "stand-in: bad host argv: $*" >&2; exit 2; } + printf '\033[2m(stand-in for: hack host shell --env %s --scope %s)\033[0m\n' "$4" "$6" + exec "${SHELL:-/bin/sh}" -l + ;; + *) echo "stand-in: unknown command: $*" >&2; exit 2 ;; +esac +"#; + let pending = dir.join("hack-stand-in.pending"); + std::fs::write(&pending, script).expect("write stand-in"); + std::fs::set_permissions(&pending, std::fs::Permissions::from_mode(0o755)).expect("chmod"); + let path = dir.join("hack-stand-in"); + std::fs::rename(&pending, &path).expect("rename"); + path +} + +/// A home directory whose `.profile` gives `/bin/sh` a prompt in the style +/// of a themed zsh or starship prompt: truecolour segments and Nerd Font +/// glyphs. Also a `showcase` command for the rendering checks. +fn write_themed_home(dir: &Path) -> PathBuf { + let home = dir.join("home"); + std::fs::create_dir_all(&home).expect("home"); + let e = '\x1b'; + // Folder U+F07C, branch U+E0A0, powerline separator U+E0B0. + let profile = format!( + "PS1='\n{e}[48;2;76;86;106m{e}[38;2;236;239;244m \u{f07c} project {e}[48;2;94;129;172m{e}[38;2;76;86;106m\u{e0b0}{e}[38;2;236;239;244m \u{e0a0} main {e}[0m{e}[38;2;94;129;172m\u{e0b0}{e}[0m\n{e}[38;2;163;190;140m\u{276f}{e}[0m '\n\ + showcase() {{\n\ + printf '\\033[1mbold\\033[0m \\033[3mitalic\\033[0m \\033[1;3mbold italic\\033[0m \\033[4munderline\\033[0m \\033[9mstrike\\033[0m \\033[2mfaint\\033[0m\\n'\n\ + printf 'wide: 日本語 한국어 ✓ glyphs: \u{e0b0} \u{e0a0} \u{f07c} \u{f418} \u{f120} link: \\033]8;;https://example.invalid/docs\\033\\\\example.invalid/docs\\033]8;;\\033\\\\\\n'\n\ + i=0; while [ $i -lt 64 ]; do printf '\\033[48;2;%d;%d;%dm ' $((i * 4)) $((255 - i * 4)) 160; i=$((i + 1)); done; printf '\\033[0m\\n'\n\ + i=0; while [ $i -lt 16 ]; do printf '\\033[48;5;%dm ' $i; i=$((i + 1)); done; printf '\\033[0m\\n'\n\ + }}\n" + ); + std::fs::write(home.join(".profile"), profile).expect("profile"); + home +} + +/// How each tab is started, decided once from the arguments. +struct Sessions { + logs: StreamSource, + project: PathBuf, + cli: HackCli, + real_cli: bool, + service: Name, + overlay: Name, + scope: Name, + shell: LoginShell, + /// Extra environment for the fixture sessions of the screenshot run. + fixture_env: Vec<(String, String)>, +} + +impl Sessions { + fn new(args: &Args, tmp: &Path, fixtures: bool) -> Self { + std::fs::create_dir_all(tmp).expect("session temp dir"); + let name = |s: &str| { + Name::new(s).unwrap_or_else(|e| { + eprintln!("{s:?}: {e}"); + std::process::exit(2) + }) + }; + let project = args + .project + .clone() + .or_else(|| std::env::current_dir().ok()) + .unwrap_or_else(|| tmp.to_owned()); + let real_cli = args.hack.is_some() && !fixtures; + let (shell, fixture_env, project) = if fixtures { + // Deterministic and hermetic for the capture. + let home = write_themed_home(tmp); + ( + LoginShell { + path: "/bin/sh".into(), + }, + vec![ + ("HOME".to_owned(), home.display().to_string()), + ("SHELL".to_owned(), "/bin/sh".to_owned()), + ("PATH".to_owned(), "/usr/bin:/bin".to_owned()), + ], + home, + ) + } else { + (LoginShell::detect(), Vec::new(), project) + }; + Self { + logs: args + .source + .clone() + .unwrap_or_else(|| StreamSource::File(demo_log(tmp))), + cli: HackCli { + program: match (&args.hack, real_cli) { + (Some(hack), true) => hack.clone(), + _ => write_stand_in(tmp), + }, + project_root: project.clone(), + }, + project, + real_cli, + service: name(&args.service), + overlay: name(&args.overlay), + scope: name(&args.scope), + shell, + fixture_env, + } + } + + fn prepare(&self, mut plan: SessionPlan) -> SessionPlan { + if !self.fixture_env.is_empty() { + for attempt in &mut plan.attempts { + attempt.options.env_clear = true; + for (k, v) in &self.fixture_env { + attempt.options.env.push((k.into(), v.into())); + } + } + } + plan + } + + fn container(&self) -> SessionPlan { + self.prepare(container_shell(&self.cli, &self.service)) + } + + fn host(&self) -> SessionPlan { + self.prepare(host_shell( + &self.project, + &self.shell, + TermChoice::default(), + )) + } + + fn host_with_env(&self) -> SessionPlan { + // A real app reads the default overlay from `hack env list --json`. + let is_default = self.overlay.as_str() == "default"; + self.prepare(host_shell_with_env( + &self.cli, + &self.shell, + &self.overlay, + &self.scope, + is_default, + TermChoice::default(), + )) + } +} + +/// The window: a stand-in for the project's content above, the terminal +/// panel below. +struct Spike { + panel: Entity, + sessions: Sessions, + look: GhosttyLook, +} + +impl Spike { + fn add( + &mut self, + core: std::io::Result, + window: &mut Window, + cx: &mut Context, + ) -> Option> { + let core = match core { + Ok(core) => core, + Err(error) => { + eprintln!("could not start the session: {error}"); + return None; + } + }; + let look = self.look.clone(); + let view = cx.new(|cx| { + let mut view = TerminalView::from_core(core, cx); + view.set_look(look, cx); + view + }); + self.panel + .update(cx, |p, cx| p.add_tab(view.clone(), window, cx)); + Some(view) + } + + /// The four tabs, in order: logs, container shell, host shell, host + /// shell with env. + fn open_all( + &mut self, + window: &mut Window, + cx: &mut Context, + ) -> Vec>> { + let logs = TerminalCore::stream(&self.sessions.logs, 10_000); + let container = TerminalCore::session(self.sessions.container()); + let host = TerminalCore::session(self.sessions.host()); + let host_env = TerminalCore::session(self.sessions.host_with_env()); + [logs, container, host, host_env] + .into_iter() + .map(|core| self.add(core, window, cx)) + .collect() + } + + fn new_host_shell(&mut self, window: &mut Window, cx: &mut Context) { + let core = TerminalCore::session(self.sessions.host()); + self.add(core, window, cx); + } +} + +impl Render for Spike { + fn render(&mut self, _: &mut Window, cx: &mut Context) -> impl IntoElement { + let t = cx.theme(); + let (visible, maximized) = { + let p = self.panel.read(cx); + (p.is_visible(), p.is_maximized()) + }; + let cli_note = if self.sessions.real_cli { + "CLI tabs run the real hack CLI." + } else { + "CLI tabs run a stand-in for hack (pass --hack and --service for the real CLI)." + }; + let content = (!maximized).then(|| { + div() + .flex_1() + .min_h_0() + .p(px(16.)) + .flex() + .flex_col() + .gap(px(6.)) + .text_size(px(12.)) + .text_color(t.muted_foreground) + .child( + div() + .text_size(px(14.)) + .text_color(t.foreground) + .child("Project content"), + ) + .child(cli_note) + .child(if cfg!(target_os = "macos") { + "⌘J panel · ⌘T new host shell · ⌘W close · ⌘⇧] and ⌘⇧[ tabs · \ + ⌘⇧↩ maximize · ⌘F find · ⌘C and ⌘V copy and paste" + } else { + "Ctrl-Shift-J panel · Ctrl-Shift-T new host shell · Ctrl-Shift-W close · \ + Ctrl-Shift-PageDown and PageUp tabs · Ctrl-Shift-Enter maximize · \ + Ctrl-Shift-F find · Ctrl-Shift-C and V copy and paste" + }) + }); + // The panel stays in the tree when hidden (it draws nothing), so + // its actions remain reachable. + let slot = div().border_color(t.border).child(self.panel.clone()); + let slot = match (visible, maximized) { + (false, _) => slot.flex_none(), + (true, true) => slot.flex_1().min_h_0(), + (true, false) => slot.h(px(420.)).flex_none().border_t_1(), + }; + div() + .id("spike") + .size_full() + .flex() + .flex_col() + .bg(t.background) + // These also work when focus is outside the panel. + .on_action(cx.listener(|this, _: &TogglePanel, window, cx| { + this.panel.update(cx, |p, cx| p.toggle(window, cx)); + })) + .on_action(cx.listener(|this, _: &NewTab, window, cx| { + this.new_host_shell(window, cx); + })) + .children(content) + .child(slot) + } +} + +fn window_options(height: f32) -> WindowOptions { + WindowOptions { + window_bounds: Some(WindowBounds::Windowed(Bounds { + origin: Default::default(), + size: size(px(1280.), px(height)), + })), + app_id: Some("dance.hack.desktop.term-spike".into()), + ..WindowOptions::default() + } +} + +type Tabs = Vec>>; + +fn build( + sessions: Sessions, + look: GhosttyLook, + window: &mut Window, + cx: &mut App, +) -> (Entity, Tabs) { + let panel = cx.new(TerminalPanel::new); + let spike = cx.new(|cx| { + cx.subscribe_in( + &panel, + window, + |this: &mut Spike, _, event, window, cx| match event { + PanelEvent::NewTabRequested => this.new_host_shell(window, cx), + PanelEvent::Changed => cx.notify(), + }, + ) + .detach(); + Spike { + panel, + sessions, + look, + } + }); + let tabs = spike.update(cx, |s, cx| s.open_all(window, cx)); + (spike, tabs) +} + +/// The key bindings. The first group is the table in the app's design; an +/// app's keymap would carry the same entries. Panel and tab keys have no +/// context, so they work wherever focus is; the rest apply in a terminal. +fn bind_keys(cx: &mut App) { + const TERMINAL: Option<&str> = Some("Terminal"); + if cfg!(target_os = "macos") { + cx.bind_keys([ + KeyBinding::new("cmd-j", TogglePanel, None), + KeyBinding::new("cmd-t", NewTab, None), + KeyBinding::new("cmd-w", CloseTab, None), + KeyBinding::new("cmd-shift-]", NextTab, None), + KeyBinding::new("cmd-shift-[", PrevTab, None), + KeyBinding::new("cmd-shift-enter", ToggleMaximize, None), + KeyBinding::new("cmd-f", Find, TERMINAL), + KeyBinding::new("cmd-c", Copy, TERMINAL), + KeyBinding::new("cmd-v", Paste, TERMINAL), + ]); + } else { + cx.bind_keys([ + KeyBinding::new("ctrl-shift-j", TogglePanel, None), + KeyBinding::new("ctrl-shift-t", NewTab, None), + KeyBinding::new("ctrl-shift-w", CloseTab, None), + KeyBinding::new("ctrl-shift-pagedown", NextTab, None), + KeyBinding::new("ctrl-shift-pageup", PrevTab, None), + KeyBinding::new("ctrl-shift-enter", ToggleMaximize, None), + KeyBinding::new("ctrl-shift-f", Find, TERMINAL), + KeyBinding::new("ctrl-shift-c", Copy, TERMINAL), + KeyBinding::new("ctrl-shift-v", Paste, TERMINAL), + ]); + } + // Not in the design table: this example's choices for the find field + // and for scrolling. `f` toggles follow in a log view unless the find + // field is taking text. + cx.bind_keys([ + KeyBinding::new("enter", FindOlder, Some("TerminalFind")), + KeyBinding::new("shift-enter", FindNewer, Some("TerminalFind")), + KeyBinding::new("escape", CloseFind, Some("TerminalFind")), + KeyBinding::new("shift-pageup", ScrollPageUp, TERMINAL), + KeyBinding::new("shift-pagedown", ScrollPageDown, TERMINAL), + KeyBinding::new("pageup", ScrollPageUp, Some("TerminalLogs")), + KeyBinding::new("pagedown", ScrollPageDown, Some("TerminalLogs")), + KeyBinding::new("home", ScrollToTop, Some("TerminalLogs")), + KeyBinding::new("end", ScrollToBottom, Some("TerminalLogs")), + KeyBinding::new("f", ToggleFollow, Some("TerminalLogs && !TerminalFind")), + ]); +} + +fn main() { + let args = parse_args(); + let tmp = std::env::temp_dir().join(format!("hack-term-spike-{}", std::process::id())); + std::fs::create_dir_all(&tmp).expect("temp dir"); + + if let Some(dir) = &args.screenshots { + #[cfg(feature = "screenshots")] + screenshots::run(&args, &tmp, dir); + let _ = std::fs::remove_dir_all(&tmp); + if cfg!(not(feature = "screenshots")) { + eprintln!( + "--screenshots {} needs --features screenshots", + dir.display() + ); + std::process::exit(2); + } + return; + } + + let sessions = Sessions::new(&args, &tmp, false); + let theme = args.theme; + let use_config = args.use_ghostty_config; + gpui_kit::application() + .with_assets(gpui_kit::assets::Assets) + .run(move |cx| { + gpui_kit::init(cx); + bind_keys(cx); + cx.on_window_closed(|cx, _| cx.quit()).detach(); + gpui_kit::open_window(window_options(760.), cx, move |window, cx| { + match theme { + Some(mode) => Theme::change(mode, Some(window), cx), + None => Theme::sync_system_appearance(Some(window), cx), + } + // The user's own Ghostty look, if they have a config. + let look = if use_config { + GhosttyLook::load(&ConfigEnv::from_process(), cx.theme().is_dark()) + } else { + GhosttyLook::default() + }; + let (spike, _tabs) = build(sessions, look, window, cx); + // Start on the host shell. + let panel = spike.read(cx).panel.clone(); + panel.update(cx, |p, cx| p.activate(2, window, cx)); + spike + }) + .expect("open window"); + }); + let _ = std::fs::remove_dir_all(&tmp); +} + +#[cfg(feature = "screenshots")] +mod screenshots { + use std::path::Path; + use std::sync::Arc; + use std::time::Duration; + + use gpui_kit::component::{Theme, ThemeMode}; + use gpui_kit::test::TestWindowExt as _; + use gpui_kit::{Entity, HeadlessAppContext, WindowOptions}; + use hack_desktop_term::ghostty_config::{ConfigEnv, GhosttyLook}; + use hack_desktop_term::view::TerminalView; + + use super::{Args, Sessions, build, window_options}; + + const QUIET: Duration = Duration::from_millis(350); + const DEADLINE: Duration = Duration::from_secs(20); + + /// A Ghostty config a user might have, written into the fixture home: + /// a Nerd Font, a smaller size, a theme, a bar cursor and more padding. + fn fixture_look(tmp: &Path) -> GhosttyLook { + let home = tmp.join("ghostty-home"); + let dir = home.join(".config/ghostty"); + std::fs::create_dir_all(dir.join("themes")).expect("config dir"); + std::fs::write( + dir.join("config"), + "font-family = JetBrainsMono Nerd Font\nfont-size = 10.5\ntheme = Fixture Mocha\n\ + cursor-style = bar\nwindow-padding-x = 12\nwindow-padding-y = 8\n", + ) + .expect("config"); + std::fs::write( + dir.join("themes/Fixture Mocha"), + "background = #1e1e2e\nforeground = #cdd6f4\n\ + palette = 0=#45475a\npalette = 1=#f38ba8\npalette = 2=#a6e3a1\npalette = 3=#f9e2af\n\ + palette = 4=#89b4fa\npalette = 5=#f5c2e7\npalette = 6=#94e2d5\npalette = 7=#bac2de\n\ + palette = 8=#585b70\npalette = 9=#f38ba8\npalette = 10=#a6e3a1\npalette = 11=#f9e2af\n\ + palette = 12=#89b4fa\npalette = 13=#f5c2e7\npalette = 14=#94e2d5\npalette = 15=#a6adc8\n", + ) + .expect("theme"); + GhosttyLook::load( + &ConfigEnv { + home: Some(home), + ..ConfigEnv::default() + }, + true, + ) + } + + fn settle(cx: &mut HeadlessAppContext, view: &Entity) { + cx.update(|cx| view.update(cx, |v, cx| v.settle(QUIET, DEADLINE, cx))); + } + + fn type_and_settle(cx: &mut HeadlessAppContext, view: &Entity, text: &str) { + cx.update(|cx| view.update(cx, |v, cx| v.type_text(text, cx))); + settle(cx, view); + } + + /// One window with all four tabs; writes a PNG for each tab in `shots`. + fn round( + cx: &mut HeadlessAppContext, + args: &Args, + tmp: &Path, + mode: ThemeMode, + look: &GhosttyLook, + shots: &[(usize, String)], + dir: &Path, + ) { + cx.update(|cx| Theme::change(mode, None, cx)); + let options = WindowOptions { + focus: false, + show: false, + ..window_options(600.) + }; + let sessions = Sessions::new(args, tmp, true); + let look = look.clone(); + let mut tabs = Vec::new(); + let (handle, spike) = cx + .update(|cx| { + gpui_kit::open_window(options, cx, |window, cx| { + let (spike, built) = build(sessions, look, window, cx); + tabs = built; + spike + }) + }) + .expect("open offscreen window"); + let tabs: Vec> = tabs.into_iter().flatten().collect(); + assert_eq!(tabs.len(), 4, "every tab started"); + // The first frame sizes the grids; then let every session reach its prompt. + cx.update_window(handle, |_, window, cx| window.render_frame(cx)) + .expect("layout"); + for view in &tabs { + settle(cx, view); + } + cx.update(|cx| { + tabs[0].update(cx, |v, cx| { + v.search("error", cx); + v.search_step(true, cx); + }); + }); + type_and_settle( + cx, + &tabs[1], + "echo \"a shell inside the service container\"; ls -1 / | head -4\n", + ); + type_and_settle(cx, &tabs[2], "showcase\n"); + type_and_settle( + cx, + &tabs[3], + "echo \"the CLI injected the overlay; the app passed only its name\"\n", + ); + let panel = cx.update(|cx| spike.read(cx).panel.clone()); + for (index, name) in shots { + cx.update_window(handle, |_, window, cx| { + panel.update(cx, |p, cx| p.activate(*index, window, cx)); + }) + .expect("activate"); + cx.update_window(handle, |_, window, cx| window.render_frame(cx)) + .expect("render"); + // The grid is sized on the first frame of a newly shown tab; + // let output for the new size arrive, then draw again. + settle(cx, &tabs[*index]); + cx.update_window(handle, |_, window, cx| window.render_frame(cx)) + .expect("render"); + let image = cx + .capture_screenshot(handle) + .expect("offscreen rendering must be available"); + let path = dir.join(name); + image.save(&path).expect("write png"); + println!("{}", path.display()); + } + cx.update_window(handle, |_, window, _| window.remove_window()) + .ok(); + // Dropping the views ends every session of this round. + drop((tabs, spike, panel)); + cx.update(|_| {}); + } + + pub fn run(args: &Args, tmp: &Path, dir: &Path) { + std::fs::create_dir_all(dir).expect("create screenshot dir"); + let mut cx = HeadlessAppContext::with_platform( + gpui_kit::platform::current_platform(true).text_system(), + Arc::new(gpui_kit::assets::Assets), + gpui_kit::platform::current_headless_renderer, + ); + cx.update(gpui_kit::init); + let plain = GhosttyLook::default(); + let shot = |i: usize, name: &str| (i, name.to_owned()); + round( + &mut cx, + args, + &tmp.join("dark"), + ThemeMode::Dark, + &plain, + &[ + shot(0, "panel-logs-dark.png"), + shot(1, "panel-container-dark.png"), + shot(2, "panel-host-dark.png"), + shot(3, "panel-host-env-dark.png"), + ], + dir, + ); + round( + &mut cx, + args, + &tmp.join("light"), + ThemeMode::Light, + &plain, + &[shot(2, "panel-host-light.png")], + dir, + ); + let look = fixture_look(tmp); + round( + &mut cx, + args, + &tmp.join("look"), + ThemeMode::Dark, + &look, + &[shot(2, "panel-host-ghostty-look-dark.png")], + dir, + ); + } +} diff --git a/apps/desktop/term/screenshots/panel-container-dark.png b/apps/desktop/term/screenshots/panel-container-dark.png new file mode 100644 index 000000000..d5d4b6e07 Binary files /dev/null and b/apps/desktop/term/screenshots/panel-container-dark.png differ diff --git a/apps/desktop/term/screenshots/panel-host-dark.png b/apps/desktop/term/screenshots/panel-host-dark.png new file mode 100644 index 000000000..86a293c09 Binary files /dev/null and b/apps/desktop/term/screenshots/panel-host-dark.png differ diff --git a/apps/desktop/term/screenshots/panel-host-env-dark.png b/apps/desktop/term/screenshots/panel-host-env-dark.png new file mode 100644 index 000000000..96c8bd9a8 Binary files /dev/null and b/apps/desktop/term/screenshots/panel-host-env-dark.png differ diff --git a/apps/desktop/term/screenshots/panel-host-ghostty-look-dark.png b/apps/desktop/term/screenshots/panel-host-ghostty-look-dark.png new file mode 100644 index 000000000..278faec04 Binary files /dev/null and b/apps/desktop/term/screenshots/panel-host-ghostty-look-dark.png differ diff --git a/apps/desktop/term/screenshots/panel-host-light.png b/apps/desktop/term/screenshots/panel-host-light.png new file mode 100644 index 000000000..26d77f45f Binary files /dev/null and b/apps/desktop/term/screenshots/panel-host-light.png differ diff --git a/apps/desktop/term/screenshots/panel-logs-dark.png b/apps/desktop/term/screenshots/panel-logs-dark.png new file mode 100644 index 000000000..3dc4b9141 Binary files /dev/null and b/apps/desktop/term/screenshots/panel-logs-dark.png differ diff --git a/apps/desktop/term/scripts/check-ghostty.sh b/apps/desktop/term/scripts/check-ghostty.sh new file mode 100644 index 000000000..1dddccb91 --- /dev/null +++ b/apps/desktop/term/scripts/check-ghostty.sh @@ -0,0 +1,30 @@ +#!/usr/bin/env bash +# Build, lint and test hack-desktop-term with the `ghostty` feature, using +# only local inputs: cargo runs with --offline, and libghostty-vt comes from +# the directory provision-ghostty.sh filled (GHOSTTY_RELEASE_DIR, and ZIG on +# Linux). Run it through no-network.sh to prove nothing reaches the network. +# +# eval "$(bash term/scripts/provision-ghostty.sh)" +# cargo fetch --locked +# bash term/scripts/no-network.sh bash term/scripts/check-ghostty.sh +set -euo pipefail +root=$(CDPATH= cd -- "$(dirname -- "$0")/../../../.." && pwd -P) +cd "$root/apps/desktop" +: "${GHOSTTY_RELEASE_DIR:?run term/scripts/provision-ghostty.sh and export what it prints}" +export CARGO_NET_OFFLINE=true HACK_DAEMON_AUTOSTART_DISABLE=1 +# Children of the tests get their own environment; this keeps the test +# binaries themselves away from the caller's home. +owned=$(mktemp -d "${TMPDIR:-/tmp}/hack-desktop-term-check.XXXXXXXX") +trap 'rm -rf -- "$owned"' EXIT +mkdir "$owned/home" "$owned/hack" +export CARGO_HOME=${CARGO_HOME:-$HOME/.cargo} RUSTUP_HOME=${RUSTUP_HOME:-$HOME/.rustup} +export HOME="$owned/home" HACK_HOME="$owned/hack" + +cargo build --locked --offline -p hack-desktop-term --features ghostty --all-targets +cargo clippy --locked --offline -p hack-desktop-term --all-targets --features ghostty -- -D warnings +cargo clippy --locked --offline -p hack-desktop-term --all-targets --features screenshots -- -D warnings +# The `screenshots` feature adds GPUI's test support, which the headless +# view tests need. They open no window and need no display or GPU. +cargo nextest run --locked --offline -p hack-desktop-term --features screenshots \ + --build-jobs 2 --config-file "$root/.config/nextest.toml" --profile ci +cargo test --locked --offline -p hack-desktop-term --features screenshots --doc --jobs 2 diff --git a/apps/desktop/term/scripts/no-network.sh b/apps/desktop/term/scripts/no-network.sh new file mode 100644 index 000000000..8d49cf169 --- /dev/null +++ b/apps/desktop/term/scripts/no-network.sh @@ -0,0 +1,30 @@ +#!/usr/bin/env bash +# Run a command with networking disabled: a fresh network namespace whose +# only interface is a loopback that is down. Used to prove that a build and +# its tests need no network once the inputs are provisioned. +# +# bash term/scripts/no-network.sh COMMAND [ARGS...] +# +# Uses an unprivileged user namespace where the host allows one, otherwise +# passwordless sudo (hosted CI runners). Linux only. +set -euo pipefail +if [[ $# -eq 0 ]]; then + echo 'usage: no-network.sh COMMAND [ARGS...]' >&2 + exit 2 +fi +# Inside the namespace: refuse to continue if any interface but lo exists. +inner=' +ifs=$(awk -F: "NR > 2 { gsub(/ /, \"\", \$1); printf \"%s \", \$1 }" /proc/net/dev) +if [ "$ifs" != "lo " ]; then + echo "no-network: unexpected interfaces: $ifs" >&2 + exit 3 +fi +exec "$@"' +if unshare --user --map-root-user --net true 2>/dev/null; then + exec unshare --user --map-root-user --net -- bash -c "$inner" no-network "$@" +elif command -v sudo >/dev/null 2>&1 && sudo -n true 2>/dev/null; then + exec sudo -E unshare --net -- sudo -E -u "$(id -un)" \ + env "PATH=$PATH" "HOME=$HOME" bash -c "$inner" no-network "$@" +fi +echo 'no-network: cannot create a network namespace (needs unprivileged user namespaces or passwordless sudo)' >&2 +exit 2 diff --git a/apps/desktop/term/scripts/provision-ghostty.sh b/apps/desktop/term/scripts/provision-ghostty.sh new file mode 100644 index 000000000..963c9cfe3 --- /dev/null +++ b/apps/desktop/term/scripts/provision-ghostty.sh @@ -0,0 +1,148 @@ +#!/usr/bin/env bash +# Fetch the pinned inputs for the `ghostty` feature of hack-desktop-term into +# a cache directory, verify each against the sha256 committed below, and +# print the environment a build needs. This is the only step that uses the +# network; with its output exported, cargo builds libghostty-vt offline. +# +# Linux: Zig 0.17.1 (official tarball) plus the libghostty-vt 1.0.1 source +# and Zig-package tarballs (built from source). +# macOS: the libghostty-vt 1.0.1 prebuilt XCFramework (no Zig). +# Both: Symbols Nerd Font Mono 3.5.1, the terminal's fallback font for +# prompt symbols. It is embedded in the build. +# +# Usage: +# eval "$(bash term/scripts/provision-ghostty.sh)" # shell +# bash term/scripts/provision-ghostty.sh --emit github >> "$GITHUB_ENV" +# bash term/scripts/provision-ghostty.sh --dir DIR --offline # verify only +# +# Checksums: libghostty-vt files from +# https://release.files.ghostty.org/libghostty-vt/1.0.1/SHA256SUMS (they match +# ghostty-vt-sys 0.9.0's GHOSTTY.lock); Zig from +# https://ziglang.org/download/index.json; the font archive from the Nerd +# Fonts release's SHA-256.txt, and the font file from that archive. To +# change a version, change the URL and the checksum together (the font +# file's checksum is also pinned in term/build.rs). +set -euo pipefail + +ghostty_version=1.0.1 +ghostty_base="https://release.files.ghostty.org/libghostty-vt/${ghostty_version}" +ghostty_source="libghostty-vt-${ghostty_version}.tar.gz" +ghostty_source_sha=333dfce97641ef9b536bb9e08907ad86abaaada50e8f7417b70d9284adef37aa +ghostty_deps="libghostty-vt-${ghostty_version}-deps.tar.gz" +ghostty_deps_sha=91a4e10960a52bf5bf4b15aa2a2cddaff8a89d6cd903d3055bda4fd17de3356d +ghostty_xcframework="ghostty-vt-${ghostty_version}.xcframework.zip" +ghostty_xcframework_sha=c0f9631277f726154c8f521954d17cdc41aac4626fc0df8cf925a19ee58e829b + +nerd_version=3.5.1 +nerd_url="https://github.com/ryanoasis/nerd-fonts/releases/download/v${nerd_version}/NerdFontsSymbolsOnly.tar.xz" +nerd_archive="NerdFontsSymbolsOnly-${nerd_version}.tar.xz" +nerd_archive_sha=01172f37db8543edb102e5cb5c64101c9f4686630804d49b419aa07b23a69996 +nerd_font=SymbolsNerdFontMono-Regular.ttf +nerd_font_sha=fe471e538392f51910faab985fa8e192a39dd3426125edd15b71b3680df0e749 + +zig_version=0.17.1 +zig_base="https://ziglang.org/download/${zig_version}" +zig_sha_x86_64_linux=3581863e3cbcfd0fce0efc682ce7e9e93da96668a977791d356a26d2a7e9b17a +zig_sha_aarch64_linux=a1546d5646eb184437918d6635ca6b762a0e889eac09cef720ee7a1ed3fe7795 + +emit=shell +offline=false +dir=${HACK_DESKTOP_GHOSTTY_CACHE:-${XDG_CACHE_HOME:-$HOME/.cache}/hack-desktop/ghostty-vt-${ghostty_version}} +while [[ $# -gt 0 ]]; do + case "$1" in + --dir) dir=${2:?--dir needs a directory}; shift 2 ;; + --emit) emit=${2:?--emit needs shell or github}; shift 2 ;; + --offline) offline=true; shift ;; + -h|--help) sed -n '2,20p' "$0"; exit 0 ;; + *) echo "unknown argument: $1" >&2; exit 2 ;; + esac +done +case "$emit" in shell|github) ;; *) echo '--emit must be shell or github' >&2; exit 2 ;; esac + +sha256_of() { + if command -v sha256sum >/dev/null 2>&1; then sha256sum "$1" | cut -d' ' -f1 + else shasum -a 256 "$1" | cut -d' ' -f1; fi +} + +# fetch NAME URL SHA256: leave a verified DIR/NAME, downloading if needed. +fetch() { + local name=$1 url=$2 want=$3 got + if [[ -f "$dir/$name" ]]; then + got=$(sha256_of "$dir/$name") + if [[ "$got" == "$want" ]]; then return 0; fi + echo "provision-ghostty: $name has sha256 $got, expected $want; replacing it" >&2 + rm -f -- "$dir/$name" + fi + if [[ "$offline" == true ]]; then + echo "provision-ghostty: $dir/$name is missing and --offline was given" >&2 + exit 1 + fi + echo "provision-ghostty: fetching $name" >&2 + curl --fail --location --silent --show-error --proto '=https' --proto-redir '=https' \ + --connect-timeout 20 --max-time 600 --retry 3 -o "$dir/$name.partial" "$url" + got=$(sha256_of "$dir/$name.partial") + if [[ "$got" != "$want" ]]; then + rm -f -- "$dir/$name.partial" + echo "provision-ghostty: sha256 mismatch for $name: got $got, expected $want" >&2 + exit 1 + fi + mv -f -- "$dir/$name.partial" "$dir/$name" +} + +mkdir -p "$dir" +dir=$(CDPATH= cd -- "$dir" && pwd -P) +zig="" +case "$(uname -s)" in + Darwin) + fetch "$ghostty_xcframework" "$ghostty_base/$ghostty_xcframework" "$ghostty_xcframework_sha" + ;; + Linux) + fetch "$ghostty_source" "$ghostty_base/$ghostty_source" "$ghostty_source_sha" + fetch "$ghostty_deps" "$ghostty_base/$ghostty_deps" "$ghostty_deps_sha" + case "$(uname -m)" in + x86_64) zig_arch=x86_64; zig_sha=$zig_sha_x86_64_linux ;; + aarch64|arm64) zig_arch=aarch64; zig_sha=$zig_sha_aarch64_linux ;; + *) echo "provision-ghostty: no pinned Zig for $(uname -m)" >&2; exit 2 ;; + esac + zig_name="zig-${zig_arch}-linux-${zig_version}" + fetch "$zig_name.tar.xz" "$zig_base/$zig_name.tar.xz" "$zig_sha" + # Extract once per verified tarball; the stamp records which one. + if [[ "$(cat "$dir/$zig_name/.provision-sha256" 2>/dev/null || true)" != "$zig_sha" || ! -x "$dir/$zig_name/zig" ]]; then + rm -rf -- "${dir:?}/$zig_name" "${dir:?}/$zig_name.extract" + mkdir "$dir/$zig_name.extract" + tar -xJf "$dir/$zig_name.tar.xz" -C "$dir/$zig_name.extract" + mv -- "$dir/$zig_name.extract/$zig_name" "$dir/$zig_name" + rmdir "$dir/$zig_name.extract" + printf '%s\n' "$zig_sha" > "$dir/$zig_name/.provision-sha256" + fi + zig="$dir/$zig_name/zig" + if [[ "$("$zig" version)" != "$zig_version" ]]; then + echo "provision-ghostty: $zig is not Zig $zig_version" >&2 + exit 1 + fi + ;; + *) echo "provision-ghostty: unsupported platform $(uname -s)" >&2; exit 2 ;; +esac + +# The symbols font, on every platform: the font file plus the archive's +# licence and its list of icon-set licences, kept beside it. +fetch "$nerd_archive" "$nerd_url" "$nerd_archive_sha" +nerd_dir="$dir/nerd-fonts-symbols-${nerd_version}" +if [[ ! -f "$nerd_dir/$nerd_font" || "$(sha256_of "$nerd_dir/$nerd_font")" != "$nerd_font_sha" ]]; then + rm -rf -- "${nerd_dir:?}" "${nerd_dir:?}.extract" + mkdir "$nerd_dir.extract" + tar -xJf "$dir/$nerd_archive" -C "$nerd_dir.extract" "$nerd_font" LICENSE README.md + mv -- "$nerd_dir.extract" "$nerd_dir" +fi +if [[ "$(sha256_of "$nerd_dir/$nerd_font")" != "$nerd_font_sha" ]]; then + echo "provision-ghostty: sha256 mismatch for $nerd_font" >&2 + exit 1 +fi + +put() { + if [[ "$emit" == github ]]; then printf '%s=%s\n' "$1" "$2" + else printf 'export %s=%q\n' "$1" "$2"; fi +} +put GHOSTTY_RELEASE_DIR "$dir" +put HACK_TERM_SYMBOLS_FONT "$nerd_dir/$nerd_font" +if [[ -n "$zig" ]]; then put ZIG "$zig"; fi diff --git a/apps/desktop/term/src/actions.rs b/apps/desktop/term/src/actions.rs new file mode 100644 index 000000000..394c6bd6e --- /dev/null +++ b/apps/desktop/term/src/actions.rs @@ -0,0 +1,53 @@ +//! Keyboard actions for the terminal and its panel. +//! +//! The crate defines these actions and handles them. It binds **no keys**: +//! the only keys it interprets itself are the ones it forwards to the +//! program on the PTY, and text typed into the find field. The app binds +//! keys to these actions in its keymap; `examples/term_spike.rs` has the +//! bindings from the app's design. +//! +//! Key contexts set by the view, for binding predicates: `Terminal` always, +//! `TerminalLogs` on a read-only stream, `TerminalFind` while the find +//! field is open. The panel sets `TerminalPanel`. + +use gpui_kit::actions; + +actions!( + hack_term, + [ + /// Show or hide the terminal panel. + TogglePanel, + /// Ask for a new tab. The app decides what kind and adds it. + NewTab, + /// Close the active tab and end its session. + CloseTab, + /// Activate the next tab, wrapping. + NextTab, + /// Activate the previous tab, wrapping. + PrevTab, + /// Let the panel take the whole content area, or restore it. + ToggleMaximize, + /// Copy the selection to the clipboard. + Copy, + /// Paste the clipboard into the program (bracketed when it asked). + Paste, + /// Open find in the active terminal. + Find, + /// Select the next match toward older output. + FindOlder, + /// Select the next match toward newer output. + FindNewer, + /// Close find and clear its highlights. + CloseFind, + /// Scroll one screen toward older output. + ScrollPageUp, + /// Scroll one screen toward newer output. + ScrollPageDown, + /// Scroll to the oldest output kept. + ScrollToTop, + /// Scroll to the newest output and follow it. + ScrollToBottom, + /// Turn following the newest output on or off. + ToggleFollow, + ] +); diff --git a/apps/desktop/term/src/color.rs b/apps/desktop/term/src/color.rs new file mode 100644 index 000000000..c827df99c --- /dev/null +++ b/apps/desktop/term/src/color.rs @@ -0,0 +1,37 @@ +//! An sRGB colour, shared by the emulator and the Ghostty config reader. + +/// An sRGB colour. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash)] +pub struct Rgb { + pub r: u8, + pub g: u8, + pub b: u8, +} + +impl Rgb { + pub const fn new(r: u8, g: u8, b: u8) -> Self { + Self { r, g, b } + } + + /// `0xRRGGBB`. + pub fn to_u32(self) -> u32 { + (u32::from(self.r) << 16) | (u32::from(self.g) << 8) | u32::from(self.b) + } + + /// `#RRGGBB`, `RRGGBB`, `#RGB` or `RGB`. + pub fn from_hex(text: &str) -> Option { + let hex = text.strip_prefix('#').unwrap_or(text); + if !hex.bytes().all(|b| b.is_ascii_hexdigit()) { + return None; + } + let v = |s: &str| u8::from_str_radix(s, 16).ok(); + match hex.len() { + 6 => Some(Self::new(v(&hex[0..2])?, v(&hex[2..4])?, v(&hex[4..6])?)), + 3 => { + let d = |i: usize| v(&hex[i..=i]).map(|n| n * 17); + Some(Self::new(d(0)?, d(1)?, d(2)?)) + } + _ => None, + } + } +} diff --git a/apps/desktop/term/src/emulator.rs b/apps/desktop/term/src/emulator.rs new file mode 100644 index 000000000..430061e02 --- /dev/null +++ b/apps/desktop/term/src/emulator.rs @@ -0,0 +1,819 @@ +//! libghostty-vt terminal state behind a small, GUI-free API. +//! +//! [`Emulator`] owns one `ghostty_vt::Terminal` plus its render state and +//! search. It is `!Send`: drive it from one thread (the GPUI main thread). +//! [`Emulator::frame`] turns the viewport into a plain [`Frame`] the GPUI +//! element paints; tests read the same frame. +//! +//! Search is the hook the Logs tab builds on: [`Emulator::search`] sets a +//! needle and returns [`SearchState`]; [`Emulator::search_step`] moves the +//! selected match and scrolls it into view. Matches cover the scrollback. + +use ghostty_vt::key; +use ghostty_vt::render::{CellIterator, Dirty, RowIterator}; +use ghostty_vt::screen::CellWide; +use ghostty_vt::search::{Scroll, Search}; +use ghostty_vt::selection::{FormatOptions, Selection}; +use ghostty_vt::style::{RgbColor, Underline}; +use ghostty_vt::terminal::{Event, Mode, Point, PointCoordinate, PointSpace, ScrollViewport}; +use ghostty_vt::{RenderState, Terminal, TerminalOptions}; + +pub use ghostty_vt::Error as VtError; + +pub use crate::color::Rgb; +pub use crate::ghostty_config::CursorShape; + +impl From for Rgb { + fn from(c: RgbColor) -> Self { + Self::new(c.r, c.g, c.b) + } +} + +impl From for RgbColor { + fn from(c: Rgb) -> Self { + Self { + r: c.r, + g: c.g, + b: c.b, + } + } +} + +/// Colours the host theme supplies. Programs can still set their own. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub struct TermTheme { + pub foreground: Rgb, + pub background: Rgb, + pub cursor: Rgb, + pub selection_bg: Rgb, + pub match_bg: Rgb, + pub active_match_bg: Rgb, + /// ANSI colours 0-15 (programs may still override them with OSC 4). + pub ansi: [Rgb; 16], +} + +impl TermTheme { + pub const DARK: Self = Self { + foreground: Rgb::new(0xd8, 0xdb, 0xe2), + background: Rgb::new(0x16, 0x18, 0x1d), + cursor: Rgb::new(0xd8, 0xdb, 0xe2), + selection_bg: Rgb::new(0x34, 0x4a, 0x6e), + match_bg: Rgb::new(0x5a, 0x4b, 0x1f), + active_match_bg: Rgb::new(0x9a, 0x6b, 0x10), + ansi: [ + Rgb::new(0x2a, 0x2e, 0x36), + Rgb::new(0xf0, 0x6b, 0x6b), + Rgb::new(0x8f, 0xd1, 0x7f), + Rgb::new(0xe8, 0xc2, 0x6a), + Rgb::new(0x6f, 0xa8, 0xf5), + Rgb::new(0xc6, 0x8c, 0xf0), + Rgb::new(0x5f, 0xc8, 0xd1), + Rgb::new(0xc8, 0xcc, 0xd4), + Rgb::new(0x6b, 0x72, 0x80), + Rgb::new(0xff, 0x8a, 0x8a), + Rgb::new(0xa8, 0xe0, 0x98), + Rgb::new(0xf2, 0xd4, 0x8a), + Rgb::new(0x93, 0xbf, 0xff), + Rgb::new(0xd8, 0xa8, 0xff), + Rgb::new(0x86, 0xdc, 0xe3), + Rgb::new(0xf3, 0xf4, 0xf6), + ], + }; + pub const LIGHT: Self = Self { + foreground: Rgb::new(0x1f, 0x23, 0x28), + background: Rgb::new(0xfb, 0xfb, 0xfc), + cursor: Rgb::new(0x1f, 0x23, 0x28), + selection_bg: Rgb::new(0xc5, 0xd9, 0xf7), + match_bg: Rgb::new(0xfb, 0xe6, 0xa2), + active_match_bg: Rgb::new(0xf5, 0xb9, 0x42), + ansi: [ + Rgb::new(0x1f, 0x23, 0x28), + Rgb::new(0xc0, 0x26, 0x2d), + Rgb::new(0x1a, 0x7f, 0x37), + Rgb::new(0x9a, 0x67, 0x00), + Rgb::new(0x0b, 0x5c, 0xd5), + Rgb::new(0x82, 0x50, 0xdf), + Rgb::new(0x0f, 0x76, 0x80), + Rgb::new(0x6e, 0x77, 0x81), + Rgb::new(0x57, 0x60, 0x6a), + Rgb::new(0xd1, 0x24, 0x2f), + Rgb::new(0x2c, 0x97, 0x4b), + Rgb::new(0xb0, 0x88, 0x00), + Rgb::new(0x21, 0x8b, 0xff), + Rgb::new(0xa4, 0x75, 0xf9), + Rgb::new(0x1b, 0x8f, 0x99), + Rgb::new(0x8c, 0x95, 0x9f), + ], + }; +} + +/// Emulator settings. +#[derive(Clone, Copy, Debug)] +pub struct EmulatorOptions { + pub cols: u16, + pub rows: u16, + /// Scrollback bound in lines (pruned at page granularity by libghostty). + pub scrollback_lines: usize, + /// A memory ceiling for scrollback, in bytes. The line bound is the + /// intended limit; this only stops a very wide grid from growing without + /// limit. libghostty applies whichever is reached first, and its own + /// default (10 MB) would cut a 200-column grid to about 5,500 lines. + pub scrollback_max_bytes: usize, + /// Treat a bare LF as CR+LF (ANSI mode 20). On for log streams whose + /// lines end in `\n` only; off for a PTY, whose line discipline adds CR. + pub newline_is_crlf: bool, + pub theme: TermTheme, +} + +impl Default for EmulatorOptions { + fn default() -> Self { + Self { + cols: 80, + rows: 24, + scrollback_lines: 10_000, + scrollback_max_bytes: 256 << 20, + newline_is_crlf: false, + theme: TermTheme::DARK, + } + } +} + +/// One painted cell. `text` is empty for a blank cell. +#[derive(Clone, Debug, Default, PartialEq, Eq)] +pub struct FrameCell { + pub text: String, + pub fg: Rgb, + /// `None` means the frame background. + pub bg: Option, + pub bold: bool, + pub italic: bool, + pub faint: bool, + pub underline: bool, + pub strikethrough: bool, + /// The first cell of a double-width character; the next cell is a spacer. + pub wide: bool, + /// The tail of a double-width character: draw nothing. + pub spacer: bool, +} + +/// The viewport as plain data. +#[derive(Clone, Debug, Default, PartialEq, Eq)] +pub struct Frame { + pub cols: u16, + pub rows: Vec>, + pub foreground: Rgb, + pub background: Rgb, + /// (col, row) in the viewport when the cursor is visible there. + pub cursor: Option<(u16, u16)>, + /// The shape the program asked for, or the configured default. + pub cursor_shape: Option, + pub cursor_color: Rgb, + /// Selected columns per row, inclusive. + pub selection: Vec>, + /// Search highlights in viewport coordinates: (row, start_col, end_col, active). + pub matches: Vec<(u16, u16, u16, bool)>, + /// The search's state from the same pass as `matches`: what a count + /// shown beside these highlights should say. + pub search: SearchState, + /// Scrollback position: total rows, offset of the viewport, viewport rows. + pub scroll: (u64, u64, u64), +} + +impl Frame { + /// A row's text with trailing blanks trimmed (spacer cells skipped). + pub fn row_text(&self, row: usize) -> String { + let mut s: String = self.rows[row] + .iter() + .filter(|c| !c.spacer) + .map(|c| { + if c.text.is_empty() { + " " + } else { + c.text.as_str() + } + }) + .collect(); + s.truncate(s.trim_end().len()); + s + } +} + +/// A key press in host terms. `key` is the host's key name (GPUI names: +/// `a`, `enter`, `up`, `f5`, ...); `text` is what the key would type. +#[derive(Clone, Copy, Debug, Default)] +pub struct KeyInput<'a> { + pub key: &'a str, + pub text: Option<&'a str>, + pub shift: bool, + pub ctrl: bool, + pub alt: bool, + pub meta: bool, +} + +fn map_key(name: &str) -> key::Key { + use key::Key as K; + let named = match name { + "enter" => Some(K::Enter), + "tab" => Some(K::Tab), + "backspace" => Some(K::Backspace), + "escape" => Some(K::Escape), + "space" | " " => Some(K::Space), + "up" => Some(K::ArrowUp), + "down" => Some(K::ArrowDown), + "left" => Some(K::ArrowLeft), + "right" => Some(K::ArrowRight), + "home" => Some(K::Home), + "end" => Some(K::End), + "pageup" => Some(K::PageUp), + "pagedown" => Some(K::PageDown), + "delete" => Some(K::Delete), + "insert" => Some(K::Insert), + "-" => Some(K::Minus), + "=" => Some(K::Equal), + "[" => Some(K::BracketLeft), + "]" => Some(K::BracketRight), + "\\" => Some(K::Backslash), + ";" => Some(K::Semicolon), + "'" => Some(K::Quote), + "," => Some(K::Comma), + "." => Some(K::Period), + "/" => Some(K::Slash), + "`" => Some(K::Backquote), + _ => None, + }; + if let Some(k) = named { + return k; + } + let mut chars = name.chars(); + if let (Some(c), None) = (chars.next(), chars.next()) { + let base = match c.to_ascii_lowercase() { + c @ 'a'..='z' => Some(i32::from(K::A) + (c as i32 - 'a' as i32)), + c @ '0'..='9' => Some(i32::from(K::Digit0) + (c as i32 - '0' as i32)), + _ => None, + }; + if let Some(k) = base.and_then(|v| K::try_from(v).ok()) { + return k; + } + } + if let Some(n) = name.strip_prefix('f').and_then(|n| n.parse::().ok()) + && (1..=12).contains(&n) + && let Ok(k) = K::try_from(i32::from(K::F1) + n - 1) + { + return k; + } + K::Unidentified +} + +/// The outcome of a search call. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] +pub struct SearchState { + pub total: usize, + /// Index of the selected match. + pub selected: Option, +} + +/// libghostty-vt terminal + render state + search. +pub struct Emulator { + term: Terminal, + render: RenderState, + rows_it: RowIterator, + cells_it: CellIterator, + search: Option, + /// Counts every change the search's results depend on: output, a + /// resize (which reflows), a viewport move, the needle and the + /// selected match. See [`Self::search_refresh`]. + revision: u64, + /// The revision the search last ran at, and its state then. + searched: Option<(u64, SearchState)>, + search_runs: u64, + keys: key::Encoder, + key_event: key::Event, + theme: TermTheme, + follow: bool, + /// Selection anchor in screen (scrollback-inclusive) coordinates. + anchor: Option, +} + +impl std::fmt::Debug for Emulator { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("Emulator") + .field("follow", &self.follow) + .finish_non_exhaustive() + } +} + +impl Emulator { + pub fn new(opts: EmulatorOptions) -> Result { + let mut term = Terminal::new(TerminalOptions { + cols: opts.cols.max(1), + rows: opts.rows.max(1), + ..TerminalOptions::default() + })?; + term.set_scrollback_max_bytes(Some(opts.scrollback_max_bytes))? + .set_scrollback_max_lines(Some(opts.scrollback_lines))?; + if opts.newline_is_crlf { + term.set_mode(Mode::LINEFEED, true)?; + } + let mut emu = Self { + term, + render: RenderState::new()?, + rows_it: RowIterator::new()?, + cells_it: CellIterator::new()?, + search: None, + revision: 0, + searched: None, + search_runs: 0, + keys: key::Encoder::new()?, + key_event: key::Event::new()?, + theme: opts.theme, + follow: true, + anchor: None, + }; + emu.set_theme(opts.theme)?; + Ok(emu) + } + + /// Process output bytes. Returns bytes the terminal wants written back + /// to the PTY (query responses); a read-only stream discards them. + pub fn feed(&mut self, bytes: &[u8]) -> Vec { + if !bytes.is_empty() { + self.revision += 1; + } + self.term.vt_write(bytes); + let mut reply = Vec::new(); + for ev in self.term.events() { + if let Event::PtyWrite(b) = ev { + reply.extend_from_slice(&b); + } + } + if self.follow { + self.term.scroll_viewport(ScrollViewport::Bottom); + } + reply + } + + pub fn cols(&self) -> u16 { + self.term.cols().unwrap_or(1) + } + + pub fn rows(&self) -> u16 { + self.term.rows().unwrap_or(1) + } + + /// Resize the grid. libghostty reflows soft-wrapped lines. + pub fn resize(&mut self, cols: u16, rows: u16, cell_w_px: u32, cell_h_px: u32) { + let (cols, rows) = (cols.max(1), rows.max(1)); + if cols == self.cols() && rows == self.rows() { + return; + } + self.revision += 1; + let _ = self.term.resize(cols, rows, cell_w_px, cell_h_px); + if self.follow { + self.term.scroll_viewport(ScrollViewport::Bottom); + } + } + + pub fn set_theme(&mut self, theme: TermTheme) -> Result<(), VtError> { + self.theme = theme; + self.term + .set_default_fg_color(Some(theme.foreground.into()))? + .set_default_bg_color(Some(theme.background.into()))? + .set_default_cursor_color(Some(theme.cursor.into()))?; + let mut palette = self.term.default_color_palette()?; + for (slot, c) in palette.0.iter_mut().zip(theme.ansi) { + *slot = c.into(); + } + self.term.set_default_color_palette(Some(palette))?; + Ok(()) + } + + /// Set palette entries beyond the theme's sixteen ANSI colours (from the + /// user's terminal config). Programs can still override them. + pub fn set_palette_entries(&mut self, entries: &[(u8, Rgb)]) -> Result<(), VtError> { + if entries.is_empty() { + return Ok(()); + } + let mut palette = self.term.default_color_palette()?; + for (index, color) in entries { + palette.0[usize::from(*index)] = (*color).into(); + } + self.term.set_default_color_palette(Some(palette))?; + Ok(()) + } + + /// The cursor shape used until a program asks for another. + pub fn set_default_cursor(&mut self, shape: Option) -> Result<(), VtError> { + use ghostty_vt::terminal::CursorStyle; + let style = shape.map(|s| match s { + CursorShape::Block => CursorStyle::Block, + CursorShape::Bar => CursorStyle::Bar, + CursorShape::Underline => CursorStyle::Underline, + CursorShape::BlockHollow => CursorStyle::BlockHollow, + }); + self.term.set_default_cursor_style(style)?; + Ok(()) + } + + /// Follow on: every feed scrolls to the newest line. Follow off: the + /// viewport stays where the user left it while output arrives. + pub fn set_follow(&mut self, follow: bool) { + self.follow = follow; + if follow { + self.revision += 1; + self.term.scroll_viewport(ScrollViewport::Bottom); + } + } + + pub fn follow(&self) -> bool { + self.follow + } + + /// Scroll by rows (negative is up). Scrolling up turns follow off; + /// reaching the bottom turns it back on. + pub fn scroll(&mut self, delta: isize) { + if delta != 0 { + self.revision += 1; + } + self.term.scroll_viewport(ScrollViewport::Delta(delta)); + self.follow = self.at_bottom(); + } + + pub fn at_bottom(&self) -> bool { + self.term + .scrollbar() + .map(|s| s.offset + s.len >= s.total) + .unwrap_or(true) + } + + /// Rows held in scrollback (above the active area). + pub fn scrollback_rows(&self) -> usize { + self.term.scrollback_rows().unwrap_or(0) + } + + /// Whether the running program asked for the alternate screen or mouse + /// reporting, so wheel events should go to it rather than scrollback. + pub fn wants_mouse(&self) -> bool { + self.term.is_mouse_tracking().unwrap_or(false) + } + + pub fn bracketed_paste(&self) -> bool { + self.term.mode(Mode::BRACKETED_PASTE).unwrap_or(false) + } + + pub fn title(&self) -> Option { + self.term + .title() + .ok() + .filter(|t| !t.is_empty()) + .map(str::to_owned) + } + + /// Encode a key press for the running program, honouring the modes it + /// set (cursor-key application mode, kitty keyboard flags, ...). + pub fn encode_key(&mut self, input: KeyInput<'_>) -> Vec { + let mut mods = key::Mods::empty(); + mods.set(key::Mods::SHIFT, input.shift); + mods.set(key::Mods::CTRL, input.ctrl); + mods.set(key::Mods::ALT, input.alt); + mods.set(key::Mods::SUPER, input.meta); + let k = map_key(input.key); + let unshifted = { + let mut c = input.key.chars(); + match (c.next(), c.next()) { + (Some(ch), None) => ch.to_ascii_lowercase(), + _ => '\0', + } + }; + // Control and Alt combinations are encoded from the key, not the text. + let text = input + .text + .filter(|t| !t.is_empty() && !input.ctrl && !input.alt); + self.key_event + .set_action(key::Action::Press) + .set_key(k) + .set_mods(mods) + .set_consumed_mods(if text.is_some() && input.shift { + key::Mods::SHIFT + } else { + key::Mods::empty() + }) + .set_utf8(text.map(str::to_owned)) + .set_unshifted_codepoint(unshifted); + self.keys.set_options_from_terminal(&self.term); + let mut out = Vec::new(); + let _ = self.keys.encode_to_vec(&self.key_event, &mut out); + if out.is_empty() + && let Some(t) = text + { + out.extend_from_slice(t.as_bytes()); + } + out + } + + /// Wrap pasted text for the program (bracketed paste when it asked). + pub fn encode_paste(&self, text: &str) -> Vec { + // Strip control characters a paste could use to escape the bracket. + let clean: String = text + .chars() + .filter(|c| !c.is_control() || matches!(c, '\n' | '\t' | '\r')) + .collect(); + if self.bracketed_paste() { + let mut v = b"\x1b[200~".to_vec(); + v.extend_from_slice(clean.as_bytes()); + v.extend_from_slice(b"\x1b[201~"); + v + } else { + clean.replace("\r\n", "\r").replace('\n', "\r").into_bytes() + } + } + + // --- selection ------------------------------------------------------- + + fn screen_point(&self, col: u16, row: u16) -> Option { + let gr = self + .term + .grid_ref(Point::Viewport(PointCoordinate { + x: col, + y: u32::from(row), + })) + .ok()?; + self.term + .point_from_grid_ref(&gr, PointSpace::Screen) + .ok() + .flatten() + } + + /// Start a selection at a viewport cell. + pub fn select_start(&mut self, col: u16, row: u16) { + self.anchor = self.screen_point(col, row); + let _ = self.term.set_selection(None); + } + + /// Extend the selection from the anchor to a viewport cell. + pub fn select_to(&mut self, col: u16, row: u16) { + let (Some(a), Some(b)) = (self.anchor, self.screen_point(col, row)) else { + return; + }; + let (Ok(start), Ok(end)) = ( + self.term.grid_ref(Point::Screen(a)), + self.term.grid_ref(Point::Screen(b)), + ) else { + return; + }; + let sel = Selection::new(start, end, false); + let _ = self.term.set_selection(Some(&sel)); + } + + pub fn clear_selection(&mut self) { + self.anchor = None; + let _ = self.term.set_selection(None); + } + + /// The selected text as plain UTF-8, soft wraps joined, trailing blanks trimmed. + pub fn selection_text(&self) -> Option { + let bytes = self + .term + .format_selection_alloc(FormatOptions::new().with_unwrap(true).with_trim(true)) + .ok() + .flatten()?; + let text = String::from_utf8_lossy(&bytes).into_owned(); + (!text.is_empty()).then_some(text) + } + + /// Select everything (screen and scrollback). + pub fn select_all(&mut self) { + if let Ok(Some(sel)) = self.term.select_all() { + let _ = self.term.set_selection(Some(&sel)); + } + } + + /// OSC 8 hyperlink URI under a viewport cell. + pub fn link_at(&self, col: u16, row: u16) -> Option { + let gr = self + .term + .grid_ref(Point::Viewport(PointCoordinate { + x: col, + y: u32::from(row), + })) + .ok()?; + let mut buf = vec![0u8; 2048]; + let n = gr.hyperlink_uri(&mut buf).ok()?; + (n > 0).then(|| String::from_utf8_lossy(&buf[..n]).into_owned()) + } + + // --- search ---------------------------------------------------------- + + /// Set (or clear with `None`/empty) the search needle and run the search + /// to completion over screen and scrollback. ASCII letters match + /// case-insensitively. Selects the newest match and scrolls to it. + pub fn search(&mut self, needle: Option<&str>) -> SearchState { + let needle = needle.filter(|n| !n.is_empty()); + self.revision += 1; + let Some(needle) = needle else { + self.search = None; + self.searched = None; + return SearchState::default(); + }; + if self.search.is_none() { + self.search = Search::new(&self.term).ok(); + } + let Some(search) = self.search.as_mut() else { + return SearchState::default(); + }; + let _ = search.set_select_scroll(Some(Scroll::IfNeeded)); + let _ = search.set_needle(&self.term, Some(needle)); + // From no selection, "next" selects index 0: the newest match, + // nearest the live end of the output. + self.search_step(true) + } + + /// Select the next match toward older output (`older`) or newer output, + /// and scroll it into view. Matches are indexed newest first. + pub fn search_step(&mut self, older: bool) -> SearchState { + // Step among the matches as they are now. + self.search_refresh(); + let Some(search) = self.search.as_mut() else { + return SearchState::default(); + }; + // libghostty indexes matches newest first; "next" moves to older + // output. Both directions wrap. + let moved = if older { + search.select_next(&self.term) + } else { + search.select_prev(&self.term) + }; + // The selection changed and the viewport may have moved to show + // it: the next pass has to run. + self.revision += 1; + if moved.unwrap_or(false) { + self.follow = self.at_bottom(); + } + self.search_state() + } + + /// Bring the search up to date with the terminal and return its state. + /// + /// The search learns about new output and viewport moves only when it + /// is run; feeding the terminal does not run it. A run is not free: + /// libghostty checks every match it holds, so with many matches in + /// scrollback it costs milliseconds. So the search runs at most once + /// per change: output, a resize, a viewport move, a new needle or a + /// new selected match. Until the next change this returns the state + /// from that one pass, and [`Self::frame`] takes its highlights from + /// the same pass. Redrawing an unchanged terminal runs nothing. + /// + /// Call this, not [`Self::search_state`], before showing a count next + /// to a frame's highlights. + pub fn search_refresh(&mut self) -> SearchState { + let Some(search) = self.search.as_mut() else { + return SearchState::default(); + }; + if let Some((at, state)) = self.searched + && at == self.revision + { + return state; + } + let _ = search.run(&self.term); + self.search_runs += 1; + let state = self.search_state(); + self.searched = Some((self.revision, state)); + state + } + + /// How many times the search has run over the terminal: once per + /// change while a search is set, however often frames are drawn. + pub fn search_runs(&self) -> u64 { + self.search_runs + } + + /// The search's state as of the last time it ran. See + /// [`Self::search_refresh`]. + pub fn search_state(&self) -> SearchState { + let Some(search) = self.search.as_ref() else { + return SearchState::default(); + }; + SearchState { + total: search.total_matches().unwrap_or(0), + selected: search.selected_index().ok().flatten(), + } + } + + // --- frame ----------------------------------------------------------- + + /// The viewport as a [`Frame`]. + pub fn frame(&mut self) -> Result { + // One search pass serves this frame's highlights and the count a + // caller shows beside them; it runs only if something changed. + let search = self.search_refresh(); + let matches = self.viewport_matches(); + let scroll = self + .term + .scrollbar() + .map(|s| (s.total, s.offset, s.len)) + .unwrap_or_default(); + let snapshot = self.render.update(&self.term)?; + let colors = snapshot.colors()?; + let cursor = snapshot.cursor()?; + let mut frame = Frame { + cols: snapshot.cols()?, + foreground: colors.foreground.into(), + background: colors.background.into(), + cursor: cursor + .viewport + .filter(|_| cursor.visible) + .map(|v| (v.x, v.y)), + cursor_color: colors.cursor.map_or(self.theme.cursor, Into::into), + cursor_shape: Some(match cursor.visual_style { + ghostty_vt::render::CursorVisualStyle::Bar => CursorShape::Bar, + ghostty_vt::render::CursorVisualStyle::Underline => CursorShape::Underline, + ghostty_vt::render::CursorVisualStyle::BlockHollow => CursorShape::BlockHollow, + _ => CursorShape::Block, + }), + matches, + search, + scroll, + ..Frame::default() + }; + let mut rows = self.rows_it.update(&snapshot)?; + while let Some(row) = rows.next() { + frame + .selection + .push(row.selection()?.map(|s| (s.start_x, s.end_x))); + let mut cells = self.cells_it.update(row)?; + let mut out = Vec::with_capacity(usize::from(frame.cols)); + while let Some(cell) = cells.next() { + let raw = cell.raw_cell()?; + let wide = raw.wide()?; + let style = cell.style()?; + let mut fg: Rgb = cell.fg_color()?.map_or(frame.foreground, Into::into); + let mut bg: Option = cell.bg_color()?.map(Into::into); + if style.inverse { + let old_fg = fg; + fg = bg.unwrap_or(frame.background); + bg = Some(old_fg); + } + let mut text = String::new(); + if !style.invisible { + cell.graphemes_utf8(&mut text)?; + } + out.push(FrameCell { + text, + fg, + bg, + bold: style.bold, + italic: style.italic, + faint: style.faint, + underline: style.underline != Underline::None, + strikethrough: style.strikethrough, + wide: wide == CellWide::Wide, + spacer: matches!(wide, CellWide::SpacerTail | CellWide::SpacerHead), + }); + } + frame.rows.push(out); + } + if snapshot.dirty()? != Dirty::Clean { + snapshot.clean()?; + } + Ok(frame) + } + + fn viewport_matches(&self) -> Vec<(u16, u16, u16, bool)> { + let Some(search) = self.search.as_ref() else { + return Vec::new(); + }; + let active = search.selected_match(&self.term).ok().flatten(); + let Ok(matches) = search.viewport_matches(&self.term) else { + return Vec::new(); + }; + let (cols, rows) = (self.cols(), u32::from(self.rows())); + let mut out = Vec::new(); + for m in &matches { + let is_active = active + .as_ref() + .is_some_and(|a| a.equals(&self.term, m).unwrap_or(false)); + let (Ok(Some(s)), Ok(Some(e))) = ( + self.term + .point_from_grid_ref(&m.start(), PointSpace::Viewport), + self.term + .point_from_grid_ref(&m.end(), PointSpace::Viewport), + ) else { + continue; + }; + // A match can wrap across rows; split it per row. libghostty + // also lists matches that share a page with the viewport but + // lie below it: those rows are not on screen. + for y in s.y..=e.y.min(rows.saturating_sub(1)) { + let x0 = if y == s.y { s.x } else { 0 }; + let x1 = if y == e.y { + e.x + } else { + cols.saturating_sub(1) + }; + if let Ok(row) = u16::try_from(y) { + out.push((row, x0, x1, is_active)); + } + } + } + out + } +} diff --git a/apps/desktop/term/src/fdio.rs b/apps/desktop/term/src/fdio.rs new file mode 100644 index 000000000..50d288051 --- /dev/null +++ b/apps/desktop/term/src/fdio.rs @@ -0,0 +1,124 @@ +//! Cancellable waits on a descriptor, and the cancel token they watch. +//! +//! A [`Cancel`] is the write end of a pipe; every worker thread holds the +//! read end as a [`CancelWatch`]. Dropping the `Cancel` makes the read end +//! readable (hang-up), which wakes any thread parked in [`wait_fd`]. + +use std::io; +use std::os::fd::{AsRawFd, OwnedFd, RawFd}; +use std::sync::Arc; + +#[derive(Debug)] +pub(crate) struct Cancel(#[allow(dead_code, reason = "held only to be dropped")] OwnedFd); + +#[derive(Clone, Debug)] +pub(crate) struct CancelWatch(Arc); + +pub(crate) fn cancel_pair() -> io::Result<(Cancel, CancelWatch)> { + let (r, w) = io::pipe()?; + Ok((Cancel(w.into()), CancelWatch(Arc::new(r.into())))) +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub(crate) enum Wait { + /// The descriptor is ready, or in a state the next read/write reports. + Ready, + Cancelled, + TimedOut, +} + +/// Wait until `fd` is ready for `events` (`POLLIN`/`POLLOUT`), the cancel +/// token is dropped, or `timeout_ms` passes (negative waits forever). +pub(crate) fn wait_fd( + fd: RawFd, + events: libc::c_short, + cancel: &CancelWatch, + timeout_ms: libc::c_int, +) -> io::Result { + loop { + let mut fds = [ + libc::pollfd { + fd, + events, + revents: 0, + }, + libc::pollfd { + fd: cancel.0.as_raw_fd(), + events: libc::POLLIN, + revents: 0, + }, + ]; + // SAFETY: `fds` is a valid array of two pollfd for the call. + let n = unsafe { libc::poll(fds.as_mut_ptr(), 2, timeout_ms) }; + if n < 0 { + let e = io::Error::last_os_error(); + if e.kind() == io::ErrorKind::Interrupted { + continue; + } + return Err(e); + } + if fds[1].revents != 0 { + return Ok(Wait::Cancelled); + } + if n == 0 { + return Ok(Wait::TimedOut); + } + if fds[0].revents & libc::POLLNVAL != 0 { + // A platform whose poll cannot watch this device: fall back to a + // short sleep and let the non-blocking read or write decide. + std::thread::sleep(std::time::Duration::from_millis(5)); + } + return Ok(Wait::Ready); + } +} + +pub(crate) fn set_nonblocking(fd: &OwnedFd) -> io::Result<()> { + // SAFETY: fcntl on a descriptor we own. + unsafe { + let flags = libc::fcntl(fd.as_raw_fd(), libc::F_GETFL); + if flags == -1 || libc::fcntl(fd.as_raw_fd(), libc::F_SETFL, flags | libc::O_NONBLOCK) == -1 + { + return Err(io::Error::last_os_error()); + } + } + Ok(()) +} + +pub(crate) fn set_cloexec(fd: &OwnedFd) -> io::Result<()> { + // SAFETY: fcntl on a descriptor we own. + if unsafe { libc::fcntl(fd.as_raw_fd(), libc::F_SETFD, libc::FD_CLOEXEC) } == -1 { + return Err(io::Error::last_os_error()); + } + Ok(()) +} + +/// In a forked child: mark every descriptor above stderr close-on-exec so +/// descriptors other threads opened without CLOEXEC do not reach the child +/// program. std's exec-error pipe is already CLOEXEC, so marking (not +/// closing) keeps spawn error reporting intact. Async-signal-safe. +pub(crate) fn mark_inherited_fds_cloexec() { + #[cfg(target_os = "linux")] + { + const CLOSE_RANGE_CLOEXEC: libc::c_uint = 1 << 2; + // SAFETY: close_range only changes descriptor flags here. + let rc = unsafe { + libc::syscall( + libc::SYS_close_range, + 3 as libc::c_uint, + libc::c_uint::MAX, + CLOSE_RANGE_CLOEXEC, + ) + }; + if rc == 0 { + return; + } + } + // SAFETY: sysconf and fcntl are async-signal-safe. + let max = unsafe { libc::sysconf(libc::_SC_OPEN_MAX) }; + let max = if max <= 0 { 1024 } else { max.min(65_536) }; + for fd in 3..max as libc::c_int { + unsafe { + libc::fcntl(fd, libc::F_SETFD, libc::FD_CLOEXEC); + } + } +} diff --git a/apps/desktop/term/src/ghostty_config.rs b/apps/desktop/term/src/ghostty_config.rs new file mode 100644 index 000000000..9cf543d18 --- /dev/null +++ b/apps/desktop/term/src/ghostty_config.rs @@ -0,0 +1,1016 @@ +//! Read the look of the user's Ghostty terminal from their Ghostty config, +//! so the app's terminal matches it: font, colours, cursor and padding. +//! +//! Only these keys are read: `font-family`, `font-size`, `background`, +//! `foreground`, `palette`, `cursor-style`, `window-padding-x`, +//! `window-padding-y`, plus `theme` and `config-file` to find the rest. +//! Every other line is skipped without being kept. Nothing from the file is +//! logged, and a missing or unreadable file is the same as an empty one: +//! the caller falls back to the app's own tokens for whatever is `None`. +//! +//! Reading is bounded end to end. Each file is opened once and read +//! through a limit on that handle, so a file that grows or is replaced +//! after it was looked at cannot exceed it. A file is identified by its +//! canonical path before it is opened and is never read twice. One load +//! has a total budget, in bytes and in files, across the config files, +//! every include and the theme. Only regular files are read. +//! +//! Rules follow Ghostty's documentation: +//! +//! - Files: `$XDG_CONFIG_HOME/ghostty/config.ghostty` then `.../config` +//! (`~/.config` when the variable is unset); on macOS also +//! `~/Library/Application Support/com.mitchellh.ghostty/` with the same +//! two names, loaded after the XDG ones. Later values win. +//! - Syntax: `key = value`, `#` comments on their own line, optional double +//! quotes, and an empty value resets the key (`palette =` drops every +//! palette entry set so far, the theme's included). +//! - `config-file = path` is processed at the end of the file that contains +//! it, relative to that file; a leading `?` makes it optional; cycles are +//! ignored. +//! - `theme = name`, `theme = /absolute/path` or `theme = light:A,dark:B`. +//! Names are looked up in `/ghostty/themes`, then in Ghostty's +//! resources. The user's own colour settings override the theme's. A +//! theme file cannot set `theme` or `config-file`. + +use std::collections::{BTreeMap, HashSet}; +use std::fs::File; +use std::io::Read; +use std::path::{Path, PathBuf}; + +use crate::color::Rgb; + +/// The largest config or theme file that is read. +const MAX_FILE_BYTES: u64 = 1 << 20; +/// The most bytes one load reads in total, over every file. +const MAX_TOTAL_BYTES: u64 = 4 << 20; +/// The most files one load opens. +const MAX_FILES: usize = 64; +/// How deep `config-file` includes may nest. +const MAX_INCLUDE_DEPTH: usize = 8; + +/// What one load read. For tests and diagnostics; holds no file content. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] +pub struct LoadStats { + /// Files opened and read (each at most once). + pub files_read: usize, + /// Bytes read from them, including the bytes of files that turned out + /// to be over the limit. + pub bytes_read: u64, +} + +/// The state of one load: which files were visited and what is left of the +/// budget. +struct Walk { + seen: HashSet, + bytes_left: u64, + files_left: usize, + stats: LoadStats, +} + +impl Walk { + fn new() -> Self { + Self { + seen: HashSet::new(), + bytes_left: MAX_TOTAL_BYTES, + files_left: MAX_FILES, + stats: LoadStats::default(), + } + } +} + +/// Where to look. Built from the process environment in the app and by hand +/// in tests, so a test never reads the real home directory. +#[derive(Clone, Debug, Default)] +pub struct ConfigEnv { + pub home: Option, + pub xdg_config_home: Option, + /// Also read the macOS Application Support location. + pub macos: bool, + /// Directories that hold Ghostty's bundled `themes` directory. + pub resource_dirs: Vec, +} + +impl ConfigEnv { + /// The real locations for this process. + pub fn from_process() -> Self { + let var = |name: &str| { + std::env::var_os(name) + .filter(|v| !v.is_empty()) + .map(PathBuf::from) + }; + let mut resource_dirs: Vec = var("GHOSTTY_RESOURCES_DIR").into_iter().collect(); + resource_dirs.extend( + [ + "/usr/share/ghostty", + "/usr/local/share/ghostty", + "/opt/homebrew/share/ghostty", + "/Applications/Ghostty.app/Contents/Resources/ghostty", + ] + .map(PathBuf::from), + ); + Self { + home: var("HOME"), + xdg_config_home: var("XDG_CONFIG_HOME").filter(|p| p.is_absolute()), + macos: cfg!(target_os = "macos"), + resource_dirs, + } + } + + fn xdg_dir(&self) -> Option { + self.xdg_config_home + .clone() + .or_else(|| self.home.as_ref().map(|h| h.join(".config"))) + .map(|d| d.join("ghostty")) + } + + /// The config files Ghostty would load, in load order. They need not exist. + pub fn config_files(&self) -> Vec { + let mut dirs: Vec = self.xdg_dir().into_iter().collect(); + if self.macos + && let Some(home) = &self.home + { + dirs.push(home.join("Library/Application Support/com.mitchellh.ghostty")); + } + dirs.iter() + .flat_map(|d| [d.join("config.ghostty"), d.join("config")]) + .collect() + } + + fn theme_dirs(&self) -> Vec { + self.xdg_dir() + .into_iter() + .chain(self.resource_dirs.iter().cloned()) + .map(|d| d.join("themes")) + .collect() + } +} + +/// Ghostty's `cursor-style` values. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum CursorShape { + Block, + Bar, + Underline, + BlockHollow, +} + +/// What the user's Ghostty config says about the terminal's look. `None` +/// and empty mean "not set": use the app's own value. +#[derive(Clone, Debug, Default, PartialEq)] +pub struct GhosttyLook { + /// Font families in preference order; later ones are fallbacks. + pub font_families: Vec, + /// Font size in points, as Ghostty counts it. + pub font_size_pt: Option, + pub background: Option, + pub foreground: Option, + /// Palette entries the config sets, by index 0 to 255. + pub palette: BTreeMap, + pub cursor_style: Option, + /// Left and right padding in points. + pub padding_x: Option<(f32, f32)>, + /// Top and bottom padding in points. + pub padding_y: Option<(f32, f32)>, +} + +impl GhosttyLook { + /// Whether the config set none of the keys read here. + pub fn is_empty(&self) -> bool { + *self == Self::default() + } + + /// Read the user's config. `dark` picks the side of a `light:A,dark:B` + /// theme. Never fails: anything missing or malformed is left unset. + pub fn load(env: &ConfigEnv, dark: bool) -> Self { + Self::load_counted(env, dark).0 + } + + /// [`Self::load`], also returning how much was read. + pub fn load_counted(env: &ConfigEnv, dark: bool) -> (Self, LoadStats) { + let mut walk = Walk::new(); + let mut user = Raw::default(); + for file in env.config_files() { + load_file(&file, env, &mut user, &mut walk, 0, Source::Config); + } + let mut look = Self::default(); + if let Some(theme) = user.theme.as_deref().and_then(|t| theme_name(t, dark)) + && let Some(path) = find_theme(&theme, env) + { + let mut base = Raw::default(); + load_file(&path, env, &mut base, &mut walk, 0, Source::Theme); + base.apply_to(&mut look); + } + // The user's own settings override the theme's. + user.apply_to(&mut look); + (look, walk.stats) + } +} + +#[derive(Clone, Copy, PartialEq, Eq)] +enum Source { + Config, + /// A theme file: `theme` and `config-file` are not allowed in it. + Theme, +} + +/// Settings as written, before a theme is merged in. An outer `Some` means +/// the key appeared; an inner `None` means it was reset with an empty value. +#[derive(Default)] +struct Raw { + font_families: Option>, + font_size_pt: Option>, + background: Option>, + foreground: Option>, + /// An empty `palette =` was seen: drop every entry from before it, + /// including a theme's. `palette` holds the entries set after it. + palette_reset: bool, + palette: BTreeMap, + cursor_style: Option>, + padding_x: Option>, + padding_y: Option>, + theme: Option, +} + +impl Raw { + fn apply_to(self, look: &mut GhosttyLook) { + if let Some(f) = self.font_families { + look.font_families = f; + } + if let Some(v) = self.font_size_pt { + look.font_size_pt = v; + } + if let Some(v) = self.background { + look.background = v; + } + if let Some(v) = self.foreground { + look.foreground = v; + } + if self.palette_reset { + look.palette.clear(); + } + look.palette.extend(self.palette); + if let Some(v) = self.cursor_style { + look.cursor_style = v; + } + if let Some(v) = self.padding_x { + look.padding_x = v; + } + if let Some(v) = self.padding_y { + look.padding_y = v; + } + } + + fn set(&mut self, key: &str, value: &str, source: Source) { + let empty = value.is_empty(); + match key { + "font-family" => { + let list = self.font_families.get_or_insert_with(Vec::new); + if empty { + list.clear(); + } else if !list.iter().any(|f| f == value) { + list.push(value.to_owned()); + } + } + "font-size" => { + let size = value + .parse::() + .ok() + .filter(|s| (4.0..=128.0).contains(s)); + if empty || size.is_some() { + self.font_size_pt = Some(size); + } + } + "background" => set_color(&mut self.background, value), + "foreground" => set_color(&mut self.foreground, value), + "palette" if empty => { + self.palette_reset = true; + self.palette.clear(); + } + "palette" => { + if let Some((index, color)) = value.split_once('=') + && let Some(index) = parse_index(index.trim()) + && let Some(color) = parse_color(color.trim()) + { + self.palette.insert(index, color); + } + } + "cursor-style" => { + let shape = match value { + "block" => Some(CursorShape::Block), + "bar" => Some(CursorShape::Bar), + "underline" => Some(CursorShape::Underline), + "block_hollow" => Some(CursorShape::BlockHollow), + _ => None, + }; + if empty || shape.is_some() { + self.cursor_style = Some(shape); + } + } + "window-padding-x" => set_padding(&mut self.padding_x, value), + "window-padding-y" => set_padding(&mut self.padding_y, value), + "theme" if source == Source::Config => { + self.theme = (!empty).then(|| value.to_owned()); + } + _ => {} + } + } +} + +fn set_color(slot: &mut Option>, value: &str) { + let color = parse_color(value); + if value.is_empty() || color.is_some() { + *slot = Some(color); + } +} + +fn set_padding(slot: &mut Option>, value: &str) { + let one = |s: &str| { + s.trim() + .parse::() + .ok() + .filter(|v| (0.0..=512.0).contains(v)) + }; + let pair = match value.split_once(',') { + Some((a, b)) => one(a).zip(one(b)), + None => one(value).map(|v| (v, v)), + }; + if value.is_empty() || pair.is_some() { + *slot = Some(pair); + } +} + +/// A palette index: decimal, or `0x`, `0o`, `0b` prefixed. +fn parse_index(text: &str) -> Option { + let (digits, radix) = match text.get(..2) { + Some("0x") => (&text[2..], 16), + Some("0o") => (&text[2..], 8), + Some("0b") => (&text[2..], 2), + _ => (text, 10), + }; + u8::from_str_radix(digits, radix).ok() +} + +/// Hex always; X11 colour names when libghostty is linked in. +fn parse_color(value: &str) -> Option { + if let Some(c) = Rgb::from_hex(value) { + return Some(c); + } + #[cfg(feature = "ghostty")] + if !value.is_empty() + && let Ok(c) = ghostty_vt::style::RgbColor::parse(value) + { + return Some(Rgb::new(c.r, c.g, c.b)); + } + None +} + +/// The theme to use from a `theme` value, for the given appearance. +fn theme_name(value: &str, dark: bool) -> Option { + let mut light_name = None; + let mut dark_name = None; + for part in value.split(',') { + let part = part.trim(); + if let Some(name) = part.strip_prefix("light:") { + light_name = Some(name.trim()); + } else if let Some(name) = part.strip_prefix("dark:") { + dark_name = Some(name.trim()); + } + } + let name = match (light_name, dark_name) { + (None, None) => value.trim(), + (l, d) => (if dark { d } else { l })?, + }; + (!name.is_empty()).then(|| name.to_owned()) +} + +/// The file for a theme: an absolute path as given, or a plain name looked +/// up in the theme directories. A name with a path separator is refused. +fn find_theme(name: &str, env: &ConfigEnv) -> Option { + let path = Path::new(name); + if path.is_absolute() { + return path.is_file().then(|| path.to_owned()); + } + if name.contains(['/', '\\']) || name == "." || name == ".." { + return None; + } + env.theme_dirs() + .into_iter() + .map(|d| d.join(name)) + .find(|p| p.is_file()) +} + +/// Read at most `limit` bytes from `reader`. `None` when it holds more: +/// one byte past the limit is read to find that out, and no more. +fn read_bounded(reader: impl Read, limit: u64) -> Option> { + let mut bytes = Vec::new(); + reader + .take(limit.saturating_add(1)) + .read_to_end(&mut bytes) + .ok()?; + (bytes.len() as u64 <= limit).then_some(bytes) +} + +/// Open `path` for reading if it is a regular file. Opening does not block +/// on a FIFO or a device, and the check is made on the opened handle, so it +/// is about the file that will be read. +fn open_regular(path: &Path) -> Option { + use std::os::unix::fs::OpenOptionsExt; + let file = std::fs::OpenOptions::new() + .read(true) + .custom_flags(libc::O_NONBLOCK | libc::O_NOCTTY) + .open(path) + .ok()?; + file.metadata().ok()?.is_file().then_some(file) +} + +fn unquote(value: &str) -> (&str, bool) { + match value + .strip_prefix('"') + .and_then(|v| v.strip_suffix('"')) + .filter(|_| value.len() >= 2) + { + Some(inner) => (inner, true), + None => (value, false), + } +} + +fn load_file( + path: &Path, + env: &ConfigEnv, + raw: &mut Raw, + walk: &mut Walk, + depth: usize, + source: Source, +) { + // Identity first. A file that was already visited is not opened again: + // this ends include cycles and keeps repeated includes from being + // re-read. A path that does not resolve is a missing file. + let Ok(canonical) = std::fs::canonicalize(path) else { + return; + }; + if !walk.seen.insert(canonical.clone()) { + return; + } + if walk.files_left == 0 || walk.bytes_left == 0 { + return; + } + let Some(file) = open_regular(&canonical) else { + return; + }; + walk.files_left -= 1; + walk.stats.files_read += 1; + // The limit is enforced on the handle, whatever the file's size was a + // moment ago, and never exceeds what is left of the total budget. + let limit = MAX_FILE_BYTES.min(walk.bytes_left); + let bytes = read_bounded(file, limit); + let read = bytes + .as_ref() + .map_or(limit.saturating_add(1), |b| b.len() as u64); + walk.bytes_left = walk.bytes_left.saturating_sub(read); + walk.stats.bytes_read += read; + let Some(bytes) = bytes else { + return; + }; + let text = String::from_utf8_lossy(&bytes); + let mut includes: Vec = Vec::new(); + for line in text.lines() { + let line = line.trim(); + if line.is_empty() || line.starts_with('#') { + continue; + } + let Some((key, value)) = line.split_once('=') else { + continue; + }; + let (key, (value, quoted)) = (key.trim(), unquote(value.trim())); + if key == "config-file" { + if source == Source::Config + // Relative to the file as it was named, as Ghostty does, not + // to where a symlink points. + && let Some(target) = include_target(path, value, quoted, env) + { + includes.push(target); + } + continue; + } + raw.set(key, value, source); + } + // Includes are processed at the end of the file that names them. + if depth < MAX_INCLUDE_DEPTH { + for target in includes { + load_file(&target, env, raw, walk, depth + 1, source); + } + } +} + +/// Resolve a `config-file` value against the file that contains it. A +/// missing file is ignored whether or not it was marked optional with `?`, +/// because this reader never reports errors. +fn include_target(from: &Path, value: &str, quoted: bool, env: &ConfigEnv) -> Option { + let value = if quoted { + value + } else { + value.strip_prefix('?').unwrap_or(value) + }; + if value.is_empty() { + return None; + } + let path = match (value.strip_prefix("~/"), &env.home) { + (Some(rest), Some(home)) => home.join(rest), + _ => PathBuf::from(value), + }; + Some(if path.is_absolute() { + path + } else { + from.parent()?.join(path) + }) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::testutil::TempHome; + + /// A private home with `files` written under it, and an environment + /// that points only inside it. + fn fixture(files: &[(&str, &str)]) -> (TempHome, ConfigEnv) { + let home = TempHome::new(); + for (rel, text) in files { + let path = home.path().join(rel); + std::fs::create_dir_all(path.parent().unwrap()).unwrap(); + std::fs::write(path, text).unwrap(); + } + let env = ConfigEnv { + home: Some(home.path().to_owned()), + xdg_config_home: None, + macos: false, + resource_dirs: vec![home.path().join("resources/ghostty")], + }; + (home, env) + } + + fn rgb(hex: &str) -> Rgb { + Rgb::from_hex(hex).unwrap() + } + + #[test] + fn no_config_means_nothing_is_set() { + let (_home, env) = fixture(&[]); + let look = GhosttyLook::load(&env, true); + assert!(look.is_empty(), "{look:?}"); + // No home at all is fine too. + assert!(GhosttyLook::load(&ConfigEnv::default(), true).is_empty()); + } + + #[test] + fn the_listed_keys_are_read_and_everything_else_is_skipped() { + let (_home, env) = fixture(&[( + ".config/ghostty/config", + r##" +# a comment +font-family = "JetBrainsMono Nerd Font" +font-family = Symbols Nerd Font +font-size = 13.5 +background = 1e1e2e +foreground = #cdd6f4 +palette = 0=#45475a +palette = 0x1=#f38ba8 +palette = 255=fff +cursor-style = bar +window-padding-x = 6 +window-padding-y = 4,10 +keybind = ctrl+shift+c=copy_to_clipboard +command = /bin/secret-wrapper --token do-not-keep +not a setting line +"##, + )]); + let look = GhosttyLook::load(&env, true); + assert_eq!( + look.font_families, + ["JetBrainsMono Nerd Font", "Symbols Nerd Font"] + ); + assert_eq!(look.font_size_pt, Some(13.5)); + assert_eq!(look.background, Some(rgb("1e1e2e"))); + assert_eq!(look.foreground, Some(rgb("cdd6f4"))); + assert_eq!( + look.palette, + BTreeMap::from([(0, rgb("45475a")), (1, rgb("f38ba8")), (255, rgb("ffffff"))]) + ); + assert_eq!(look.cursor_style, Some(CursorShape::Bar)); + assert_eq!(look.padding_x, Some((6., 6.))); + assert_eq!(look.padding_y, Some((4., 10.))); + // Nothing of the skipped lines is held anywhere in the result. + assert!(!format!("{look:?}").contains("do-not-keep")); + } + + #[test] + fn malformed_values_are_ignored_and_empty_values_reset() { + let (_home, env) = fixture(&[( + ".config/ghostty/config", + "font-size = 14\nfont-size = huge\nfont-size = 9000\n\ + background = #12345\nforeground = #abcdef\nforeground =\n\ + cursor-style = block\ncursor-style = wobble\n\ + window-padding-x = -3\nwindow-padding-y = 2,x\n\ + palette = 256=#000000\npalette = 3=nope-not-a-colour\n\ + font-family = A\nfont-family = B\nfont-family = \"\"\nfont-family = C\n", + )]); + let look = GhosttyLook::load(&env, true); + assert_eq!( + look.font_size_pt, + Some(14.), + "bad sizes do not replace a good one" + ); + assert_eq!(look.background, None); + assert_eq!(look.foreground, None, "an empty value resets"); + assert_eq!(look.cursor_style, Some(CursorShape::Block)); + assert_eq!((look.padding_x, look.padding_y), (None, None)); + assert!(look.palette.is_empty()); + assert_eq!(look.font_families, ["C"], "\"\" clears the list"); + } + + #[test] + fn later_files_win_and_macos_loads_after_xdg() { + let files = [ + ( + ".config/ghostty/config.ghostty", + "font-size = 11\ncursor-style = bar\n", + ), + (".config/ghostty/config", "font-size = 12\n"), + ( + "Library/Application Support/com.mitchellh.ghostty/config", + "font-size = 15\n", + ), + ]; + let (_home, mut env) = fixture(&files); + let look = GhosttyLook::load(&env, true); + assert_eq!(look.font_size_pt, Some(12.), "config after config.ghostty"); + assert_eq!(look.cursor_style, Some(CursorShape::Bar)); + env.macos = true; + assert_eq!(GhosttyLook::load(&env, true).font_size_pt, Some(15.)); + } + + #[test] + fn xdg_config_home_replaces_the_default_directory() { + let (home, mut env) = fixture(&[ + (".config/ghostty/config", "font-size = 12\n"), + ("elsewhere/ghostty/config", "font-size = 18\n"), + ]); + env.xdg_config_home = Some(home.path().join("elsewhere")); + assert_eq!(GhosttyLook::load(&env, true).font_size_pt, Some(18.)); + } + + #[test] + fn config_file_includes_load_at_the_end_relative_to_their_file() { + let (home, env) = fixture(&[ + ( + ".config/ghostty/config", + "config-file = extra/fonts.conf\nfont-size = 12\n\ + config-file = ?missing.conf\nconfig-file = ~/abs-colors.conf\n\ + config-file = \"?literal\"\n", + ), + // Loaded after the main file ends, so it overrides font-size. + ( + ".config/ghostty/extra/fonts.conf", + "font-size = 16\nfont-family = Included Mono\nconfig-file = nested.conf\n", + ), + ( + ".config/ghostty/extra/nested.conf", + "cursor-style = underline\n", + ), + ("abs-colors.conf", "background = #000000\n"), + (".config/ghostty/?literal", "window-padding-x = 9\n"), + ]); + let look = GhosttyLook::load(&env, true); + assert_eq!(look.font_size_pt, Some(16.)); + assert_eq!(look.font_families, ["Included Mono"]); + assert_eq!(look.cursor_style, Some(CursorShape::Underline), "nested"); + assert_eq!(look.background, Some(rgb("000000")), "~/ expands to home"); + assert_eq!(look.padding_x, Some((9., 9.)), "a quoted ? is literal"); + drop(home); + } + + #[test] + fn include_cycles_end() { + let (_home, env) = fixture(&[ + ( + ".config/ghostty/config", + "font-size = 12\nconfig-file = a.conf\n", + ), + ( + ".config/ghostty/a.conf", + "font-size = 13\nconfig-file = b.conf\n", + ), + ( + ".config/ghostty/b.conf", + "font-size = 14\nconfig-file = a.conf\nconfig-file = config\n", + ), + ]); + assert_eq!(GhosttyLook::load(&env, true).font_size_pt, Some(14.)); + } + + #[test] + fn a_theme_is_the_base_and_the_users_settings_override_it() { + let (_home, env) = fixture(&[ + ( + ".config/ghostty/config", + "theme = My Theme\nforeground = #ffffff\npalette = 1=#ff0000\n", + ), + ( + ".config/ghostty/themes/My Theme", + "background = #101010\nforeground = #c0c0c0\npalette = 1=#aa0000\n\ + palette = 2=#00aa00\ncursor-style = underline\n\ + theme = Other\nconfig-file = ../config-evil\n", + ), + (".config/ghostty/config-evil", "font-size = 99\n"), + ]); + let look = GhosttyLook::load(&env, true); + assert_eq!(look.background, Some(rgb("101010")), "from the theme"); + assert_eq!(look.foreground, Some(rgb("ffffff")), "the user's wins"); + assert_eq!(look.palette[&1], rgb("ff0000"), "the user's wins"); + assert_eq!(look.palette[&2], rgb("00aa00"), "from the theme"); + assert_eq!(look.cursor_style, Some(CursorShape::Underline)); + assert_eq!(look.font_size_pt, None, "a theme cannot include files"); + } + + #[test] + fn themes_resolve_by_appearance_from_user_then_bundled_directories() { + let (home, env) = fixture(&[ + (".config/ghostty/config", "theme = dark:Night,light:Day\n"), + ("resources/ghostty/themes/Night", "background = #000011\n"), + ("resources/ghostty/themes/Day", "background = #ffffee\n"), + // The user's themes directory is searched first. + (".config/ghostty/themes/Day", "background = #fafafa\n"), + ]); + assert_eq!( + GhosttyLook::load(&env, true).background, + Some(rgb("000011")) + ); + assert_eq!( + GhosttyLook::load(&env, false).background, + Some(rgb("fafafa")) + ); + + // An absolute path is used as given. + let theme = home.path().join("abs-theme"); + std::fs::write(&theme, "background = #123456\n").unwrap(); + std::fs::write( + home.path().join(".config/ghostty/config"), + format!("theme = {}\n", theme.display()), + ) + .unwrap(); + assert_eq!( + GhosttyLook::load(&env, true).background, + Some(rgb("123456")) + ); + + // A missing theme, a one-sided pair and a name with a path in it + // all leave the look unset instead of failing. + for value in [ + "No Such Theme", + "light:Day", + "../../abs-theme", + "themes/Night", + ] { + std::fs::write( + home.path().join(".config/ghostty/config"), + format!("theme = {value}\n"), + ) + .unwrap(); + assert_eq!(GhosttyLook::load(&env, true).background, None, "{value}"); + } + } + + #[test] + fn an_oversized_or_non_text_file_is_skipped() { + let (home, env) = fixture(&[(".config/ghostty/config", "font-size = 12\n")]); + let path = home.path().join(".config/ghostty/config"); + // Exactly at the limit is read; one byte more is not. + let mut big = b"font-size = 12\n".to_vec(); + big.resize(MAX_FILE_BYTES as usize, b'#'); + std::fs::write(&path, &big).unwrap(); + let (look, stats) = GhosttyLook::load_counted(&env, true); + assert_eq!(look.font_size_pt, Some(12.)); + assert_eq!(stats.bytes_read, MAX_FILE_BYTES); + big.push(b'#'); + std::fs::write(&path, &big).unwrap(); + let (look, stats) = GhosttyLook::load_counted(&env, true); + assert!(look.is_empty()); + assert_eq!( + stats.bytes_read, + MAX_FILE_BYTES + 1, + "one byte past the limit, and no more" + ); + // Invalid UTF-8 on one line does not stop the others. + std::fs::write(&path, b"background = \xff\xfe\nfont-size = 12\n").unwrap(); + assert_eq!(GhosttyLook::load(&env, true).font_size_pt, Some(12.)); + // A directory where the file should be. + std::fs::remove_file(&path).unwrap(); + std::fs::create_dir(&path).unwrap(); + assert!(GhosttyLook::load(&env, true).is_empty()); + } + + /// A reader that never ends, counting what is taken from it. + struct Endless(u64); + + impl Read for Endless { + fn read(&mut self, buf: &mut [u8]) -> std::io::Result { + buf.fill(b'#'); + self.0 += buf.len() as u64; + Ok(buf.len()) + } + } + + #[test] + fn the_limit_is_enforced_on_the_open_handle_not_on_a_size_seen_earlier() { + // What a file that grows, or is swapped for a huge one, after any + // size check looks like to the reader: more bytes than expected, + // without end. The bound is on what is read. + let mut endless = Endless(0); + assert_eq!(read_bounded(&mut endless, 1024), None); + assert_eq!( + endless.0, 1025, + "limit + 1 bytes were taken, then it stopped" + ); + assert_eq!(read_bounded(&b"abc"[..], 3), Some(b"abc".to_vec())); + assert_eq!(read_bounded(&b"abcd"[..], 3), None); + assert_eq!(read_bounded(&b""[..], 0), Some(Vec::new())); + } + + #[test] + fn a_fifo_in_place_of_the_config_is_not_read_and_does_not_block() { + let (home, env) = fixture(&[]); + let dir = home.path().join(".config/ghostty"); + std::fs::create_dir_all(&dir).unwrap(); + let fifo = std::ffi::CString::new(dir.join("config").to_str().unwrap()).unwrap(); + // SAFETY: a valid path; the FIFO is created in the test's own directory. + assert_eq!(unsafe { libc::mkfifo(fifo.as_ptr(), 0o600) }, 0); + // Nobody writes to it. A blocking open or read would hang here. + let (look, stats) = GhosttyLook::load_counted(&env, true); + assert!(look.is_empty()); + assert_eq!(stats, LoadStats::default(), "not a regular file: not read"); + } + + #[test] + fn duplicate_and_fanned_out_includes_are_each_read_once() { + // Every level includes the next one three times and the one before + // it once. Read naively that is 3^6 reads of the last file. + let levels = 7; + let mut files: Vec<(String, String)> = Vec::new(); + for i in 0..levels { + let mut text = format!("palette = {i}=#0000{i:02x}\n"); + if i + 1 < levels { + for _ in 0..3 { + text.push_str(&format!("config-file = level{}.conf\n", i + 1)); + } + } + if i > 0 { + text.push_str(&format!("config-file = level{}.conf\n", i - 1)); + } + // The same file under another spelling of its path. + text.push_str(&format!("config-file = ./level{i}.conf\n")); + files.push((format!(".config/ghostty/level{i}.conf"), text)); + } + files.push(( + ".config/ghostty/config".into(), + "config-file = level0.conf\nconfig-file = level0.conf\nconfig-file = ../ghostty/level0.conf\n" + .into(), + )); + let borrowed: Vec<(&str, &str)> = files + .iter() + .map(|(a, b)| (a.as_str(), b.as_str())) + .collect(); + let (_home, env) = fixture(&borrowed); + let (look, stats) = GhosttyLook::load_counted(&env, true); + assert_eq!(look.palette.len(), levels, "every level was reached"); + assert_eq!( + stats.files_read, + levels + 1, + "the config and each level, once each" + ); + let total: u64 = files.iter().map(|(_, text)| text.len() as u64).sum(); + assert_eq!(stats.bytes_read, total, "no byte was read twice"); + } + + #[test] + fn one_load_has_a_total_budget_in_bytes_and_in_files() { + // Bytes: six includes of 900 KiB each are 5.3 MiB; the budget is 4. + let mut files: Vec<(String, String)> = Vec::new(); + let mut main = String::new(); + for i in 0..6 { + let mut text = format!("palette = {i}=#0000{i:02x}\n"); + while text.len() < 900 * 1024 { + text.push_str("# padding padding padding padding padding padding padding\n"); + } + files.push((format!(".config/ghostty/big{i}.conf"), text)); + main.push_str(&format!("config-file = big{i}.conf\n")); + } + files.push((".config/ghostty/config".into(), main)); + let borrowed: Vec<(&str, &str)> = files + .iter() + .map(|(a, b)| (a.as_str(), b.as_str())) + .collect(); + let (_home, env) = fixture(&borrowed); + let (look, stats) = GhosttyLook::load_counted(&env, true); + assert_eq!( + look.palette.keys().copied().collect::>(), + [0, 1, 2, 3], + "the fifth file does not fit in what is left and is not used" + ); + assert!( + stats.bytes_read <= MAX_TOTAL_BYTES + 1, + "read {} bytes", + stats.bytes_read + ); + // The budget is spent: the sixth file is not even opened. + assert_eq!( + stats.files_read, 6, + "config, four that fit, one that did not" + ); + + // Files: a hundred tiny includes; only the budgeted number is opened. + let mut files: Vec<(String, String)> = Vec::new(); + let mut main = String::new(); + for i in 0..100 { + files.push(( + format!(".config/ghostty/tiny{i}.conf"), + format!("palette = {i}=#0000{i:02x}\n"), + )); + main.push_str(&format!("config-file = tiny{i}.conf\n")); + } + files.push((".config/ghostty/config".into(), main)); + let borrowed: Vec<(&str, &str)> = files + .iter() + .map(|(a, b)| (a.as_str(), b.as_str())) + .collect(); + let (_home, env) = fixture(&borrowed); + let (look, stats) = GhosttyLook::load_counted(&env, true); + assert_eq!(stats.files_read, MAX_FILES); + assert_eq!( + look.palette.len(), + MAX_FILES - 1, + "the config counts as one" + ); + } + + #[test] + fn an_empty_palette_value_resets_the_palette() { + // After earlier assignments in the user's own files. + let (_home, env) = fixture(&[( + ".config/ghostty/config", + "palette = 1=#110000\npalette = 2=#220000\npalette =\npalette = 3=#330000\n", + )]); + assert_eq!( + GhosttyLook::load(&env, true).palette, + BTreeMap::from([(3, rgb("330000"))]), + "entries before the reset are gone, entries after it stay" + ); + // After a theme's entries. + let theme = ( + ".config/ghostty/themes/T", + "palette = 1=#aa0000\npalette = 2=#bb0000\nbackground = #101010\n", + ); + let (_home, env) = fixture(&[(".config/ghostty/config", "theme = T\npalette =\n"), theme]); + let look = GhosttyLook::load(&env, true); + assert!(look.palette.is_empty(), "the theme's palette is dropped"); + assert_eq!( + look.background, + Some(rgb("101010")), + "the rest of the theme stays" + ); + let (_home, env) = fixture(&[ + ( + ".config/ghostty/config", + "theme = T\npalette = \"\"\npalette = 5=#550000\n", + ), + theme, + ]); + assert_eq!( + GhosttyLook::load(&env, true).palette, + BTreeMap::from([(5, rgb("550000"))]) + ); + // Without a reset the theme's entries remain under the user's. + let (_home, env) = fixture(&[ + (".config/ghostty/config", "theme = T\npalette = 5=#550000\n"), + theme, + ]); + assert_eq!( + GhosttyLook::load(&env, true) + .palette + .keys() + .copied() + .collect::>(), + [1, 2, 5] + ); + // A reset inside the theme only clears what the theme set before it. + let (_home, env) = fixture(&[ + (".config/ghostty/config", "theme = T2\n"), + ( + ".config/ghostty/themes/T2", + "palette = 1=#aa0000\npalette =\npalette = 2=#bb0000\n", + ), + ]); + assert_eq!( + GhosttyLook::load(&env, true).palette, + BTreeMap::from([(2, rgb("bb0000"))]) + ); + } +} diff --git a/apps/desktop/term/src/glyphs.rs b/apps/desktop/term/src/glyphs.rs new file mode 100644 index 000000000..619e70f89 --- /dev/null +++ b/apps/desktop/term/src/glyphs.rs @@ -0,0 +1,486 @@ +//! The symbols fallback font, and which glyphs it is used for. +//! +//! Prompt themes (starship, oh-my-zsh and the like) print Nerd Font symbols: +//! code points in the private-use areas that only a patched font has. When +//! the terminal's font lacks one, the system has nothing sensible to fall +//! back to. It draws a missing-glyph box, or the unrelated glyph some other +//! installed font happens to have at that code point. Ghostty avoids that +//! by embedding a symbols-only Nerd Font; this module does the same. +//! +//! The font is **Symbols Nerd Font Mono** from Nerd Fonts v3.5.1, embedded +//! in the binary (2.6 MB). +//! +//! Its glyphs are drawn as **vector paths** from the font's outlines, not +//! through the text system. GPUI's text system cannot select this font on +//! Linux: it refuses to load any family that has no glyph for the letter +//! "m", as a font or as a fallback. Drawing the outlines ourselves is the +//! same on every platform and does not depend on what fonts are installed. +//! +//! Which font a symbol comes from: +//! +//! - A private-use code point this font has: from this font, unless the +//! terminal's font is itself a Nerd Font and has the glyph. Then the +//! user's patched font is used, as in their own terminal. +//! - An ordinary Unicode character this font also has (the heavy angle +//! brackets prompts use, the power symbols): from this font only when the +//! terminal's font has no glyph for it. +//! +//! Box drawing, block elements and the powerline separators never come +//! from a font: see [`crate::sprite`]. + +use std::collections::HashMap; +use std::sync::{Arc, Mutex, OnceLock}; + +use gpui_kit::{Font, Hsla, Pixels, SharedString, TextRun, Window}; + +use crate::sprite::Seg; + +/// The family name of the embedded font. +pub const SYMBOLS_FAMILY: &str = "Symbols Nerd Font Mono"; + +/// Symbols Nerd Font Mono, verified against its pinned sha256 by `build.rs`. +static SYMBOLS_FONT: &[u8] = + include_bytes!(concat!(env!("OUT_DIR"), "/SymbolsNerdFontMono-Regular.ttf")); + +/// The code points the embedded font has, as inclusive ranges. From the +/// font's character map (v3.5.1); the render test checks samples of them. +const SYMBOL_RANGES: [(u32, u32); 29] = [ + (0x23fb, 0x23fe), + (0x2630, 0x2630), + (0x2665, 0x2665), + (0x26a1, 0x26a1), + (0x276c, 0x2771), + (0x2b58, 0x2b58), + (0xe000, 0xe00a), + (0xe0a0, 0xe0a3), + (0xe0b0, 0xe0c8), + (0xe0ca, 0xe0ca), + (0xe0cc, 0xe0d2), + (0xe0d4, 0xe0d4), + (0xe0d6, 0xe0d7), + (0xe200, 0xe2a9), + (0xe300, 0xe3e3), + (0xe5fa, 0xe6bb), + (0xe700, 0xe958), + (0xea60, 0xea88), + (0xea8a, 0xea8c), + (0xea8f, 0xeac7), + (0xeac9, 0xeac9), + (0xeacc, 0xeb09), + (0xeb0b, 0xeb4e), + (0xeb50, 0xec5e), + (0xec60, 0xec84), + (0xed00, 0xefcf), + (0xf000, 0xf385), + (0xf400, 0xf533), + (0xf0001, 0xf1af0), +]; + +/// Whether the embedded symbols font has a glyph for `ch`. +pub fn in_symbols_font(ch: char) -> bool { + let cp = ch as u32; + cp >= 0x23fb && SYMBOL_RANGES.iter().any(|&(a, b)| a <= cp && cp <= b) +} + +/// One glyph of the symbols font, in font units with y pointing up. +#[derive(Debug)] +pub struct SymbolOutline { + /// Closed contours, filled with the non-zero rule. + pub path: Vec, + /// The glyph's box is this wide; every glyph in this font has the same. + pub advance: f32, + pub units_per_em: f32, + pub ascender: f32, + pub descender: f32, + /// The ink: x min, y min, x max, y max. + pub bounds: [f32; 4], +} + +/// Where a glyph goes in its room: `scale` is pixels per font unit, `left` +/// is where the glyph's box starts and `baseline` is where y = 0 falls, +/// both measured from the room's top left corner. +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct Placement { + pub scale: f32, + pub left: f32, + pub baseline: f32, +} + +impl Placement { + /// The glyph's ink in the room: left, top, right, bottom. + pub fn ink(&self, outline: &SymbolOutline) -> [f32; 4] { + let [x0, y0, x1, y1] = outline.bounds; + [ + self.left + x0 * self.scale, + self.baseline - y1 * self.scale, + self.left + x1 * self.scale, + self.baseline - y0 * self.scale, + ] + } +} + +impl SymbolOutline { + /// Place the glyph in a room `room_w` by `room_h`, so that all of its + /// ink is inside the room. + /// + /// The glyph's box is one em wide and about one line tall. The em is + /// made as wide as the room, or as tall if that is less, and the box is + /// centred, so glyphs of one size line up with each other. The font's + /// metrics do not bound the ink, though: some glyphs rise above the + /// ascender or hang past their advance. So the scale is then reduced + /// until the ink fits the room, and the glyph is moved by as little as + /// brings its ink inside. + pub fn place(&self, room_w: f32, room_h: f32) -> Placement { + let [x0, y0, x1, y1] = self.bounds; + let mut scale = room_w.min(room_h) / self.units_per_em; + if x1 > x0 { + scale = scale.min(room_w / (x1 - x0)); + } + if y1 > y0 { + scale = scale.min(room_h / (y1 - y0)); + } + let line = (self.ascender - self.descender) * scale; + let left = (room_w - self.advance * scale) / 2.0; + let baseline = (room_h - line) / 2.0 + self.ascender * scale; + // The ink is no larger than the room, so each pair of limits is in + // order (but for rounding, which `max` then `min` tolerates). + Placement { + scale, + left: left.max(-x0 * scale).min(room_w - x1 * scale), + baseline: baseline.max(y1 * scale).min(room_h + y0 * scale), + } + } +} + +/// When a symbol is drawn from the bundled symbols font. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] +pub enum BundledSymbols { + /// When the terminal's font has no glyph for it; see the module docs. + #[default] + WhenMissing, + /// Every symbol the bundled font has, whatever the terminal's font + /// has: symbols then look the same with any font. + Always, +} + +fn face() -> Option<&'static ttf_parser::Face<'static>> { + static FACE: OnceLock>> = OnceLock::new(); + FACE.get_or_init(|| ttf_parser::Face::parse(SYMBOLS_FONT, 0).ok()) + .as_ref() +} + +/// Collects a glyph's contours, turning quadratic curves into cubic ones. +#[derive(Default)] +struct Collect { + path: Vec, + at: (f32, f32), +} + +impl ttf_parser::OutlineBuilder for Collect { + fn move_to(&mut self, x: f32, y: f32) { + self.path.push(Seg::Move(x, y)); + self.at = (x, y); + } + + fn line_to(&mut self, x: f32, y: f32) { + self.path.push(Seg::Line(x, y)); + self.at = (x, y); + } + + fn quad_to(&mut self, qx: f32, qy: f32, x: f32, y: f32) { + let (px, py) = self.at; + let third = 2.0 / 3.0; + self.path.push(Seg::Cubic( + (px + third * (qx - px), py + third * (qy - py)), + (x + third * (qx - x), y + third * (qy - y)), + (x, y), + )); + self.at = (x, y); + } + + fn curve_to(&mut self, ax: f32, ay: f32, bx: f32, by: f32, x: f32, y: f32) { + self.path.push(Seg::Cubic((ax, ay), (bx, by), (x, y))); + self.at = (x, y); + } + + fn close(&mut self) { + self.path.push(Seg::Close); + } +} + +/// The outline of `ch` in the symbols font, if it has one. +pub fn outline(ch: char) -> Option> { + static CACHE: OnceLock>>>> = OnceLock::new(); + let mut cache = CACHE + .get_or_init(Mutex::default) + .lock() + .unwrap_or_else(|e| e.into_inner()); + cache + .entry(ch) + .or_insert_with(|| { + let face = face()?; + let glyph = face.glyph_index(ch)?; + let mut collect = Collect::default(); + let bounds = face.outline_glyph(glyph, &mut collect)?; + Some(Arc::new(SymbolOutline { + path: collect.path, + advance: f32::from(face.glyph_hor_advance(glyph)?), + units_per_em: f32::from(face.units_per_em()), + ascender: f32::from(face.ascender()), + descender: f32::from(face.descender()), + bounds: [ + f32::from(bounds.x_min), + f32::from(bounds.y_min), + f32::from(bounds.x_max), + f32::from(bounds.y_max), + ], + })) + }) + .clone() +} + +/// Whether a font family is a Nerd Font, by the names the project gives +/// its patched fonts: `... Nerd Font`, `... Nerd Font Mono`, `... NF`, +/// `... NFM`, `... NFP`. +pub fn is_nerd_font(family: &str) -> bool { + let name = family.to_ascii_lowercase(); + name.contains("nerd font") || [" nf", " nfm", " nfp"].iter().any(|s| name.ends_with(s)) +} + +/// Whether `ch` is in a private-use area. +fn is_private_use(ch: char) -> bool { + matches!(ch as u32, 0xe000..=0xf8ff | 0xf0000..=0xffffd | 0x100000..=0x10fffd) +} + +/// Remembers, per terminal font, which symbols that font lacks. +#[derive(Default)] +pub struct SymbolFallback { + family: SharedString, + /// For each symbol asked about: whether to draw it from the symbols font. + from_symbols: HashMap, +} + +impl SymbolFallback { + /// Whether `ch` should be drawn from the symbols font. See the module + /// docs for the rule. + pub fn uses_symbols_font( + &mut self, + ch: char, + font: &Font, + size: Pixels, + window: &Window, + ) -> bool { + if !in_symbols_font(ch) { + return false; + } + // A private-use code point means a Nerd Font symbol here. A font + // that is not a Nerd Font may still have some other glyph at that + // code point; that glyph is not what the program meant. + if is_private_use(ch) && !is_nerd_font(&font.family) { + return true; + } + if self.family != font.family { + self.family = font.family.clone(); + self.from_symbols.clear(); + } + if let Some(&known) = self.from_symbols.get(&ch) { + return known; + } + let lacks = !font_has_glyph(ch, font, size, window); + self.from_symbols.insert(ch, lacks); + lacks + } +} + +/// Whether `font` itself has a glyph for `ch`: shaping it must use that +/// font and no other, and must not produce the missing-glyph id. +fn font_has_glyph(ch: char, font: &Font, size: Pixels, window: &Window) -> bool { + let ts = window.text_system(); + let id = ts.resolve_font(font); + let text = ch.to_string(); + let run = TextRun { + len: text.len(), + font: font.clone(), + color: Hsla::default(), + background_color: None, + underline: None, + strikethrough: None, + }; + let line = ts.shape_line(text.into(), size, &[run], None); + !line.runs.is_empty() + && line.runs.iter().all(|r| { + r.font_id == id && !r.glyphs.is_empty() && r.glyphs.iter().all(|g| g.id.0 != 0) + }) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn the_embedded_font_is_the_pinned_file_and_covers_prompt_symbols() { + assert_eq!( + SYMBOLS_FONT.len(), + 2_610_012, + "Symbols Nerd Font Mono v3.5.1" + ); + assert_eq!(&SYMBOLS_FONT[..4], [0, 1, 0, 0], "a TrueType font"); + // Branch, the icons in the render test, folders, a Material icon, + // the heavy angle bracket prompts use. + for cp in [ + 0xe0a0, 0xe0a3, 0xf418, 0xe718, 0xf07b, 0xf07c, 0xf120, 0xf0001, 0x276f, 0x23fb, + ] { + let ch = char::from_u32(cp).unwrap(); + assert!(in_symbols_font(ch), "U+{cp:04X}"); + assert!(outline(ch).is_some(), "U+{cp:04X} has an outline"); + } + // Ordinary text and private-use code points the font does not have. + for cp in [ + 0x41, 0x2500, 0x4e2d, 0xe050, 0xe09f, 0xf8ff, 0xf386, 0xf1af1, + ] { + let ch = char::from_u32(cp).unwrap(); + assert!(!in_symbols_font(ch), "U+{cp:04X}"); + assert!(outline(ch).is_none(), "U+{cp:04X} has no outline"); + } + } + + #[test] + fn the_range_table_matches_the_font() { + // Every code point the table claims has a glyph in the font, and + // the first code point past each range does not. + let face = face().expect("the embedded font parses"); + for &(a, b) in &SYMBOL_RANGES { + for cp in a..=b { + let ch = char::from_u32(cp).unwrap(); + assert!( + face.glyph_index(ch).is_some(), + "U+{cp:04X} is in the table, not the font" + ); + } + for cp in [a - 1, b + 1] { + if let Some(ch) = char::from_u32(cp) + && !in_symbols_font(ch) + { + assert!( + face.glyph_index(ch).is_none() || cp < 0x2000, + "U+{cp:04X} is in the font, not the table" + ); + } + } + } + } + + #[test] + fn every_glyph_is_placed_with_all_its_ink_inside_its_room() { + // One cell and two cells at several font sizes, a room wider than + // tall, and rooms too small to be useful. + let rooms = [ + (9.0, 20.0), + (18.0, 20.0), + (7.8, 16.9), + (15.6, 16.9), + (31.0, 34.0), + (40.0, 12.0), + (3.0, 5.0), + (1.0, 1.0), + ]; + let mut seen = 0; + let mut beyond_metrics = 0; + for &(a, b) in &SYMBOL_RANGES { + for cp in a..=b { + let ch = char::from_u32(cp).unwrap(); + let Some(o) = outline(ch) else { + // A glyph with no contours, such as a space. + continue; + }; + seen += 1; + let [_, y0, _, y1] = o.bounds; + beyond_metrics += usize::from(y1 > o.ascender || y0 < o.descender); + for (w, h) in rooms { + let at = o.place(w, h); + let [l, t, r, b] = at.ink(&o); + let e = 1e-3; + assert!( + l >= -e && t >= -e && r <= w + e && b <= h + e, + "U+{cp:04X} in {w}x{h}: ink {l} {t} {r} {b}" + ); + assert!(at.scale > 0.0 && at.scale <= w.min(h) / o.units_per_em + e); + } + } + } + assert!(seen > 10_000, "the whole font was checked: {seen}"); + // The case this guards: ink outside the font's own line metrics. + assert!(beyond_metrics > 0, "some glyphs exceed the line metrics"); + // The two sort arrows rise above the ascender; in a two-cell room + // they are moved down, not cut off. + for ch in ['\u{f0dc}', '\u{f0de}'] { + let o = outline(ch).unwrap(); + assert!(o.bounds[3] > o.ascender, "{ch:?} rises above the ascender"); + let [_, top, _, bottom] = o.place(18.0, 20.0).ink(&o); + assert!( + top >= -1e-3 && bottom <= 20.0 + 1e-3, + "{ch:?}: {top} {bottom}" + ); + } + // A glyph that already fits keeps its centred box: the branch. + let o = outline('\u{e0a0}').unwrap(); + let at = o.place(18.0, 20.0); + assert_eq!(at.scale, 18.0 / o.units_per_em); + assert_eq!(at.left, (18.0 - o.advance * at.scale) / 2.0); + } + + #[test] + fn outlines_are_closed_contours_inside_the_fonts_box() { + // The branch symbol: taller than wide, inside its one-em box. + let o = outline('\u{e0a0}').unwrap(); + let [x0, y0, x1, y1] = o.bounds; + assert!( + y1 - y0 > x1 - x0, + "the branch is taller than wide: {:?}", + o.bounds + ); + assert!( + x0 >= 0.0 && x1 <= o.advance, + "inside its advance: {:?}", + o.bounds + ); + assert!(y1 - y0 > 0.8 * o.units_per_em, "and nearly an em tall"); + assert!(matches!(o.path[0], Seg::Move(..))); + assert_eq!(o.path.last(), Some(&Seg::Close)); + let closes = o.path.iter().filter(|s| **s == Seg::Close).count(); + let moves = o.path.iter().filter(|s| matches!(s, Seg::Move(..))).count(); + assert_eq!(closes, moves, "every contour is closed"); + // Every glyph of this font has the same box, one em wide. + for ch in ['\u{f418}', '\u{e718}', '\u{f07b}', '\u{f0001}'] { + let other = outline(ch).unwrap(); + assert_eq!(other.advance, o.advance, "{ch:?}"); + assert_eq!(o.advance, o.units_per_em); + } + } + + #[test] + fn nerd_fonts_are_recognised_by_name() { + for name in [ + "JetBrainsMono Nerd Font", + "Hack Nerd Font Mono", + "FiraCode NF", + "Iosevka NFM", + "CaskaydiaCove NFP", + "symbols nerd font mono", + ] { + assert!(is_nerd_font(name), "{name}"); + } + for name in [ + "JetBrains Mono", + "Menlo", + "Adwaita Mono", + "DejaVu Sans Mono", + "Info", + "NF Mono", + ] { + assert!(!is_nerd_font(name), "{name}"); + } + assert!(is_private_use('\u{e0a0}') && is_private_use('\u{f0001}')); + assert!(!is_private_use('\u{276f}') && !is_private_use('a')); + } +} diff --git a/apps/desktop/term/src/launch.rs b/apps/desktop/term/src/launch.rs new file mode 100644 index 000000000..98e94e0c9 --- /dev/null +++ b/apps/desktop/term/src/launch.rs @@ -0,0 +1,622 @@ +//! What a terminal tab is, and how each kind is started. +//! +//! The app owns the PTY and runs the `hack` CLI (or the user's login shell) +//! in it. It never builds an environment and never sees a value: the only +//! variables it sets are the fixed terminal identity ones (`TERM`, +//! `COLORTERM`, `TERM_PROGRAM`), and the only strings it puts in argv are +//! the CLI's subcommands, validated names and a shell name. +//! +//! | Kind | Command (in the project root) | +//! |---|---| +//! | [`SessionKind::ContainerShell`] | `hack exec -- `, trying `bash` then `sh` | +//! | [`SessionKind::HostShell`] | the user's login shell, started as a login shell (`argv[0]` = `-zsh`) | +//! | [`SessionKind::HostShellWithEnv`] | `hack host shell --env --scope ` | +//! | [`SessionKind::Logs`] | a read-only stream; nothing is launched here | +//! +//! Env values for the last kind are resolved and injected by the CLI inside +//! the PTY. Overlay and scope **names** come from a names-only read done by +//! the caller. + +use std::ffi::{OsStr, OsString}; +use std::path::{Path, PathBuf}; + +use crate::pty::PtyOptions; + +/// The scope name that means "not one service". +pub const GLOBAL_SCOPE: &str = "global"; +/// Shells tried in a container, in order. +pub const CONTAINER_SHELLS: [&str; 2] = ["bash", "sh"]; + +/// What a tab is showing. Names only; never a value. +#[derive(Clone, Debug, PartialEq, Eq)] +pub enum SessionKind { + /// A read-only log stream. + Logs, + /// A shell inside a running service container. + ContainerShell { service: String, shell: String }, + /// The user's login shell on the host, in the project root. + HostShell { shell: String }, + /// A host shell with a project env overlay injected by the CLI. + HostShellWithEnv { + shell: String, + overlay: String, + scope: String, + /// Whether `overlay` is the project's default selection. + is_default: bool, + }, +} + +/// How loud a badge is. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum BadgeTone { + Neutral, + /// The session is not what the user gets by default; make it noticeable. + Warning, +} + +/// A short marker shown beside a tab's label. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct Badge { + pub text: String, + pub tone: BadgeTone, +} + +impl SessionKind { + /// The tab label: `logs`, `api · sh`, `host · zsh`. + pub fn label(&self) -> String { + match self { + Self::Logs => "logs".into(), + Self::ContainerShell { service, shell } => format!("{service} · {shell}"), + Self::HostShell { shell } | Self::HostShellWithEnv { shell, .. } => { + format!("host · {shell}") + } + } + } + + /// The env badge, for a host shell with an overlay: `env staging · api`. + /// The tone is a warning when the overlay is not the default one. + pub fn badge(&self) -> Option { + let Self::HostShellWithEnv { + overlay, + scope, + is_default, + .. + } = self + else { + return None; + }; + Some(Badge { + text: format!("env {overlay} · {scope}"), + tone: if *is_default { + BadgeTone::Neutral + } else { + BadgeTone::Warning + }, + }) + } + + /// Whether the user can type into this session. + pub fn is_interactive(&self) -> bool { + !matches!(self, Self::Logs) + } +} + +/// Why a name was refused. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct InvalidName(pub &'static str); + +impl std::fmt::Display for InvalidName { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "invalid name: {}", self.0) + } +} + +impl std::error::Error for InvalidName {} + +/// A service, overlay or scope name that is safe to pass as one CLI +/// argument: it cannot be read as an option and holds no shell syntax. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct Name(String); + +impl Name { + pub fn new(name: &str) -> Result { + if name.is_empty() { + return Err(InvalidName("empty")); + } + if name.len() > 128 { + return Err(InvalidName("longer than 128 bytes")); + } + let mut chars = name.chars(); + let first = chars.next().unwrap_or('-'); + if !(first.is_ascii_alphanumeric() || first == '_') { + return Err(InvalidName( + "must start with a letter, a digit or an underscore", + )); + } + if !chars.all(|c| c.is_ascii_alphanumeric() || matches!(c, '_' | '-' | '.')) { + return Err(InvalidName( + "only letters, digits, '_', '-' and '.' are allowed", + )); + } + Ok(Self(name.to_owned())) + } + + pub fn as_str(&self) -> &str { + &self.0 + } +} + +/// Which `TERM` a host session announces. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] +pub enum TermChoice { + /// `xterm-256color`: understood everywhere, including over ssh and in + /// containers. + #[default] + Xterm256Color, + /// `xterm-ghostty` when its terminfo entry is installed on this + /// machine, otherwise `xterm-256color`. + GhosttyIfPresent, +} + +/// The `hack` executable and the project it acts on. +#[derive(Clone, Debug)] +pub struct HackCli { + pub program: PathBuf, + pub project_root: PathBuf, +} + +/// The user's login shell. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct LoginShell { + pub path: PathBuf, +} + +impl LoginShell { + /// `$SHELL`, else the passwd entry, else `/bin/sh`. + pub fn detect() -> Self { + Self::resolve(std::env::var_os("SHELL").as_deref(), passwd_shell) + } + + /// [`Self::detect`] with its inputs supplied. + pub fn resolve(env_shell: Option<&OsStr>, passwd: impl FnOnce() -> Option) -> Self { + let usable = |p: &PathBuf| p.is_absolute(); + let path = env_shell + .filter(|s| !s.is_empty()) + .map(PathBuf::from) + .filter(usable) + .or_else(|| passwd().filter(usable)) + .unwrap_or_else(|| PathBuf::from("/bin/sh")); + Self { path } + } + + /// The shell's name for labels: `zsh` for `/bin/zsh`. + pub fn name(&self) -> String { + self.path + .file_name() + .map_or_else(|| "sh".into(), |n| n.to_string_lossy().into_owned()) + } + + /// `argv[0]` for a login shell: the name with a leading `-`. This is the + /// convention `login(1)` and terminal apps use, and every common shell + /// (sh, bash, zsh, fish, ksh, dash) honours it; flags such as `-l` are + /// not accepted by all of them. + pub fn login_arg0(&self) -> OsString { + format!("-{}", self.name()).into() + } +} + +/// The current user's shell from the passwd database. +fn passwd_shell() -> Option { + use std::os::unix::ffi::OsStringExt; + let mut buf = vec![0 as libc::c_char; 16 * 1024]; + // SAFETY: passwd is plain old data; getpwuid_r fills it and `result`. + let mut pwd: libc::passwd = unsafe { std::mem::zeroed() }; + let mut result: *mut libc::passwd = std::ptr::null_mut(); + // SAFETY: the buffer and out-pointers are valid for the call. + let rc = unsafe { + libc::getpwuid_r( + libc::getuid(), + &mut pwd, + buf.as_mut_ptr(), + buf.len(), + &mut result, + ) + }; + if rc != 0 || result.is_null() || pwd.pw_shell.is_null() { + return None; + } + // SAFETY: on success pw_shell points at a NUL-terminated string in `buf`. + let shell = unsafe { std::ffi::CStr::from_ptr(pwd.pw_shell) }; + let bytes = shell.to_bytes().to_vec(); + (!bytes.is_empty()).then(|| PathBuf::from(OsString::from_vec(bytes))) +} + +/// Directories searched for a compiled terminfo entry. +pub fn terminfo_dirs() -> Vec { + let mut dirs = Vec::new(); + if let Some(d) = std::env::var_os("TERMINFO").filter(|d| !d.is_empty()) { + dirs.push(PathBuf::from(d)); + } + if let Some(home) = std::env::var_os("HOME").filter(|d| !d.is_empty()) { + dirs.push(Path::new(&home).join(".terminfo")); + } + if let Some(list) = std::env::var_os("TERMINFO_DIRS") { + dirs.extend(std::env::split_paths(&list).filter(|d| !d.as_os_str().is_empty())); + } + for d in [ + "/etc/terminfo", + "/lib/terminfo", + "/usr/lib/terminfo", + "/usr/share/terminfo", + "/usr/local/share/terminfo", + "/opt/homebrew/share/terminfo", + "/Applications/Ghostty.app/Contents/Resources/terminfo", + ] { + dirs.push(PathBuf::from(d)); + } + dirs +} + +/// Whether a compiled terminfo entry for `term` is in one of `dirs`. Entries +/// live under the first letter (`x/xterm-ghostty`) or, on macOS, its hex +/// code (`78/xterm-ghostty`). +pub fn terminfo_present(term: &str, dirs: &[PathBuf]) -> bool { + let Some(first) = term.bytes().next() else { + return false; + }; + dirs.iter().any(|d| { + d.join((first as char).to_string()).join(term).is_file() + || d.join(format!("{first:02x}")).join(term).is_file() + }) +} + +fn term_name(choice: TermChoice, dirs: &[PathBuf]) -> &'static str { + match choice { + TermChoice::GhosttyIfPresent if terminfo_present("xterm-ghostty", dirs) => "xterm-ghostty", + _ => "xterm-256color", + } +} + +/// The only variables the app sets for a session: what kind of terminal +/// this is. Everything else is inherited or injected by the CLI. +fn terminal_identity(term: &str) -> Vec<(OsString, OsString)> { + vec![ + ("TERM".into(), term.into()), + ("COLORTERM".into(), "truecolor".into()), + ("TERM_PROGRAM".into(), "hack".into()), + ] +} + +/// One way to start a session. +#[derive(Clone, Debug)] +pub struct Launch { + pub kind: SessionKind, + pub options: PtyOptions, +} + +/// The attempts for one tab, in order. A later attempt is used only when +/// the one before it could not start its program (exit status 126 or 127 +/// straight away, before any input): see `terminal::TerminalCore::session`. +#[derive(Clone, Debug)] +pub struct SessionPlan { + pub attempts: Vec, +} + +impl SessionPlan { + /// The kind the tab starts as. + pub fn kind(&self) -> &SessionKind { + &self.attempts[0].kind + } +} + +fn base(program: &Path, cwd: &Path, term: &str) -> PtyOptions { + let mut options = PtyOptions::new(program); + options.env = terminal_identity(term); + options.cwd = Some(cwd.to_owned()); + options +} + +/// A shell in a running service container: `hack exec -- bash`, +/// then `-- sh` if the image has no bash. +pub fn container_shell(cli: &HackCli, service: &Name) -> SessionPlan { + let attempts = CONTAINER_SHELLS + .iter() + .map(|shell| { + // Containers rarely carry other terminfo entries. + let mut options = base(&cli.program, &cli.project_root, "xterm-256color"); + options.args = vec![ + "exec".into(), + service.as_str().into(), + "--".into(), + (*shell).into(), + ]; + Launch { + kind: SessionKind::ContainerShell { + service: service.as_str().to_owned(), + shell: (*shell).to_owned(), + }, + options, + } + }) + .collect(); + SessionPlan { attempts } +} + +/// The user's login shell on the host, as an interactive login shell in the +/// project root, so their profile and rc files load as in their terminal. +pub fn host_shell(project_root: &Path, shell: &LoginShell, term: TermChoice) -> SessionPlan { + let mut options = base(&shell.path, project_root, term_name(term, &terminfo_dirs())); + options.arg0 = Some(shell.login_arg0()); + SessionPlan { + attempts: vec![Launch { + kind: SessionKind::HostShell { + shell: shell.name(), + }, + options, + }], + } +} + +/// A host shell with a project env overlay: `hack host shell --env +/// --scope `. The CLI resolves and injects the values and starts +/// `$SHELL -l`; the app passes names only. +/// +/// `shell` labels the tab. The CLI picks the shell itself from `$SHELL` and +/// falls back to `/bin/sh` (not the passwd entry) when it is unset, so pass +/// the same `$SHELL` the session will inherit. +pub fn host_shell_with_env( + cli: &HackCli, + shell: &LoginShell, + overlay: &Name, + scope: &Name, + is_default: bool, + term: TermChoice, +) -> SessionPlan { + let mut options = base( + &cli.program, + &cli.project_root, + term_name(term, &terminfo_dirs()), + ); + options.args = vec![ + "host".into(), + "shell".into(), + "--env".into(), + overlay.as_str().into(), + "--scope".into(), + scope.as_str().into(), + ]; + SessionPlan { + attempts: vec![Launch { + kind: SessionKind::HostShellWithEnv { + shell: shell.name(), + overlay: overlay.as_str().to_owned(), + scope: scope.as_str().to_owned(), + is_default, + }, + options, + }], + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn cli() -> HackCli { + HackCli { + program: "/opt/hack/bin/hack".into(), + project_root: "/work/project".into(), + } + } + + fn argv(o: &PtyOptions) -> Vec { + std::iter::once(o.program.to_string_lossy().into_owned()) + .chain(o.args.iter().map(|a| a.to_string_lossy().into_owned())) + .collect() + } + + fn env_names(o: &PtyOptions) -> Vec { + o.env + .iter() + .map(|(k, _)| k.to_string_lossy().into_owned()) + .collect() + } + + #[test] + fn labels_and_badges_use_names_only() { + assert_eq!(SessionKind::Logs.label(), "logs"); + assert_eq!(SessionKind::Logs.badge(), None); + assert!(!SessionKind::Logs.is_interactive()); + let c = SessionKind::ContainerShell { + service: "api".into(), + shell: "sh".into(), + }; + assert_eq!((c.label().as_str(), c.badge()), ("api · sh", None)); + let h = SessionKind::HostShell { + shell: "zsh".into(), + }; + assert_eq!((h.label().as_str(), h.badge()), ("host · zsh", None)); + let env = |is_default| SessionKind::HostShellWithEnv { + shell: "zsh".into(), + overlay: "staging".into(), + scope: "api".into(), + is_default, + }; + assert_eq!(env(false).label(), "host · zsh"); + assert_eq!( + env(false).badge(), + Some(Badge { + text: "env staging · api".into(), + tone: BadgeTone::Warning + }), + "a non-default overlay is a warning" + ); + assert_eq!(env(true).badge().unwrap().tone, BadgeTone::Neutral); + } + + #[test] + fn names_cannot_be_options_or_shell_syntax() { + for ok in ["api", "web-1", "db_2", "staging", "global", "a.b", "9lives"] { + assert!(Name::new(ok).is_ok(), "{ok}"); + } + for bad in [ + "", "-x", "--env", ".hidden", "a b", "a;b", "a/b", "$(x)", "a\nb", "é", "a=b", + ] { + assert!(Name::new(bad).is_err(), "{bad:?}"); + } + assert!(Name::new(&"a".repeat(129)).is_err()); + } + + #[test] + fn a_container_shell_runs_hack_exec_with_bash_then_sh() { + let plan = container_shell(&cli(), &Name::new("api").unwrap()); + let got: Vec<_> = plan.attempts.iter().map(|a| argv(&a.options)).collect(); + assert_eq!( + got, + [ + ["/opt/hack/bin/hack", "exec", "api", "--", "bash"], + ["/opt/hack/bin/hack", "exec", "api", "--", "sh"], + ] + ); + assert_eq!( + plan.attempts[1].kind, + SessionKind::ContainerShell { + service: "api".into(), + shell: "sh".into() + } + ); + for a in &plan.attempts { + assert_eq!(a.options.cwd.as_deref(), Some(Path::new("/work/project"))); + assert_eq!(a.options.arg0, None); + assert!( + !a.options.env_clear, + "the CLI inherits the app's environment" + ); + assert_eq!(env_names(&a.options), ["TERM", "COLORTERM", "TERM_PROGRAM"]); + } + } + + #[test] + fn a_host_shell_is_the_login_shell_started_as_a_login_shell() { + let shell = LoginShell { + path: "/usr/bin/zsh".into(), + }; + let plan = host_shell(Path::new("/work/project"), &shell, TermChoice::default()); + let [launch] = plan.attempts.as_slice() else { + panic!("one attempt"); + }; + assert_eq!( + argv(&launch.options), + ["/usr/bin/zsh"], + "no flags, no command" + ); + assert_eq!(launch.options.arg0.as_deref(), Some(OsStr::new("-zsh"))); + assert_eq!( + launch.options.cwd.as_deref(), + Some(Path::new("/work/project")) + ); + assert_eq!( + launch.options.env, + [ + ("TERM".into(), "xterm-256color".into()), + ("COLORTERM".into(), "truecolor".into()), + ("TERM_PROGRAM".into(), "hack".into()), + ] as [(OsString, OsString); 3] + ); + assert_eq!( + launch.kind, + SessionKind::HostShell { + shell: "zsh".into() + } + ); + } + + #[test] + fn a_host_shell_with_env_passes_names_to_the_cli_and_nothing_else() { + let shell = LoginShell { + path: "/bin/zsh".into(), + }; + let plan = host_shell_with_env( + &cli(), + &shell, + &Name::new("staging").unwrap(), + &Name::new("api").unwrap(), + false, + TermChoice::default(), + ); + let [launch] = plan.attempts.as_slice() else { + panic!("one attempt"); + }; + assert_eq!( + argv(&launch.options), + [ + "/opt/hack/bin/hack", + "host", + "shell", + "--env", + "staging", + "--scope", + "api" + ] + ); + assert_eq!( + env_names(&launch.options), + ["TERM", "COLORTERM", "TERM_PROGRAM"] + ); + assert_eq!( + launch.kind.badge().map(|b| (b.text, b.tone)), + Some(("env staging · api".into(), BadgeTone::Warning)) + ); + } + + #[test] + fn the_login_shell_is_shell_then_passwd_then_sh() { + let pw = || Some(PathBuf::from("/usr/bin/fish")); + let r = |env: Option<&str>, pw: Option| { + LoginShell::resolve(env.map(OsStr::new), || pw).path + }; + assert_eq!(r(Some("/bin/zsh"), pw()), Path::new("/bin/zsh")); + assert_eq!(r(None, pw()), Path::new("/usr/bin/fish")); + assert_eq!(r(Some(""), pw()), Path::new("/usr/bin/fish")); + assert_eq!(r(Some("zsh"), pw()), Path::new("/usr/bin/fish"), "relative"); + assert_eq!(r(None, None), Path::new("/bin/sh")); + let fish = LoginShell { + path: "/usr/bin/fish".into(), + }; + assert_eq!( + (fish.name().as_str(), fish.login_arg0()), + ("fish", "-fish".into()) + ); + // The real lookup returns something absolute on any machine. + assert!(LoginShell::detect().path.is_absolute()); + } + + #[test] + fn xterm_ghostty_is_used_only_when_its_terminfo_is_installed() { + let dir = crate::testutil::TempHome::new(); + let dirs = vec![dir.path().join("a"), dir.path().join("b")]; + assert!(!terminfo_present("xterm-ghostty", &dirs)); + assert_eq!( + term_name(TermChoice::GhosttyIfPresent, &dirs), + "xterm-256color" + ); + // The first-letter layout. + std::fs::create_dir_all(dirs[0].join("x")).unwrap(); + std::fs::write(dirs[0].join("x/xterm-ghostty"), b"\x1a\x01").unwrap(); + assert!(terminfo_present("xterm-ghostty", &dirs)); + assert_eq!( + term_name(TermChoice::GhosttyIfPresent, &dirs), + "xterm-ghostty" + ); + assert_eq!( + term_name(TermChoice::Xterm256Color, &dirs), + "xterm-256color", + "the default never switches" + ); + // The hex layout macOS uses. + std::fs::create_dir_all(dirs[1].join("78")).unwrap(); + std::fs::write(dirs[1].join("78/xterm-kitty"), b"\x1a\x01").unwrap(); + assert!(terminfo_present("xterm-kitty", &dirs)); + assert!(!terminfo_present("", &dirs)); + } +} diff --git a/apps/desktop/term/src/lib.rs b/apps/desktop/term/src/lib.rs new file mode 100644 index 000000000..c8d3a7140 --- /dev/null +++ b/apps/desktop/term/src/lib.rs @@ -0,0 +1,53 @@ +//! Terminal surface for the Hack desktop app (unsupported preview). +//! +//! - [`pty`]: an app-owned PTY whose child leads its own session; dropping it +//! ends the session (process group and job groups). Input and output go +//! through bounded queues on worker threads, so the caller never blocks. +//! - [`session`]: session ownership, verified signalling and teardown. +//! - [`stream`]: read-only byte sources (a file, or a process in its own +//! session) for log views, through a bounded queue. +//! - [`emulator`] (feature `ghostty`): libghostty-vt terminal state with +//! scrollback, follow, search, selection and copy, exposed as plain frames. +//! - [`terminal`] (feature `ghostty`): an emulator joined to a stream or a +//! PTY, with no GUI dependency in its logic. +//! - [`view`] (feature `ghostty`): the GPUI element and view that paint a +//! frame and route input. +//! - [`launch`]: what a tab is (logs, container shell, host shell, host +//! shell with env), its label and badge, and the command each kind runs. +//! - [`ghostty_config`]: the user's Ghostty font, colours, cursor and +//! padding, read from their config. +//! - [`panel`] and [`actions`] (feature `ghostty`): the tabbed panel and +//! its keyboard actions. +//! - [`sprite`]: box-drawing, block and powerline glyphs as cell-exact +//! geometry; [`glyphs`] (feature `ghostty`): the bundled symbols font +//! used when the terminal's font has no glyph for a Nerd Font symbol. +//! +//! The crate never logs terminal contents, never types secrets and never +//! puts values in argv beyond the program and arguments its caller passes. +//! What a process prints is shown as printed. See `DECISION.md`. + +#![cfg(unix)] + +#[cfg(feature = "ghostty")] +pub mod actions; +pub mod color; +#[cfg(feature = "ghostty")] +pub mod emulator; +mod fdio; +pub mod ghostty_config; +#[cfg(feature = "ghostty")] +pub mod glyphs; +pub mod launch; +#[cfg(feature = "ghostty")] +pub mod panel; +pub mod pty; +mod queue; +pub mod session; +pub mod sprite; +pub mod stream; +#[cfg(feature = "ghostty")] +pub mod terminal; +#[cfg(test)] +mod testutil; +#[cfg(feature = "ghostty")] +pub mod view; diff --git a/apps/desktop/term/src/panel.rs b/apps/desktop/term/src/panel.rs new file mode 100644 index 000000000..ab94e0a76 --- /dev/null +++ b/apps/desktop/term/src/panel.rs @@ -0,0 +1,505 @@ +//! A tabbed terminal panel: one tab per session, with its label and badge. +//! +//! [`PanelState`] is the tab logic with no GUI in it. [`TerminalPanel`] is +//! the GPUI entity that owns the tabs' views, draws the tab strip and the +//! active terminal, and handles the actions in [`crate::actions`]. It binds +//! no keys and decides nothing about what a new tab is: [`NewTab`] becomes +//! a [`PanelEvent::NewTabRequested`] for the app, which builds a view from +//! a [`crate::launch::SessionPlan`] and calls [`TerminalPanel::add_tab`]. +//! +//! Where the panel sits and how large it is belong to the app's layout. The +//! panel reports [`TerminalPanel::is_visible`] and +//! [`TerminalPanel::is_maximized`]; the app sizes it accordingly. +//! +//! Closing a tab ends its session on a reaper thread. If anything in that +//! session could not be ended (see `session.rs`), the panel shows how many +//! processes were left running. + +use std::time::Duration; + +use gpui_kit::component::ActiveTheme as _; +use gpui_kit::{ + App, Context, Entity, EventEmitter, FocusHandle, Focusable, InteractiveElement as _, + IntoElement, ParentElement as _, Render, StatefulInteractiveElement as _, Styled as _, Window, + div, px, +}; + +use crate::actions::{CloseTab, Find, NewTab, NextTab, PrevTab, ToggleMaximize, TogglePanel}; +use crate::launch::{BadgeTone, SessionKind}; +use crate::session::PendingTeardown; +use crate::view::TerminalView; + +/// Tabs, which one is active, and whether the panel is shown or maximized. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct PanelState { + tabs: Vec, + active: usize, + visible: bool, + maximized: bool, +} + +impl Default for PanelState { + fn default() -> Self { + Self { + tabs: Vec::new(), + active: 0, + visible: false, + maximized: false, + } + } +} + +impl PanelState { + pub fn tabs(&self) -> &[T] { + &self.tabs + } + + pub fn active_index(&self) -> Option { + (!self.tabs.is_empty()).then_some(self.active) + } + + pub fn active(&self) -> Option<&T> { + self.tabs.get(self.active) + } + + pub fn is_visible(&self) -> bool { + self.visible + } + + /// Maximized only counts while the panel is shown. + pub fn is_maximized(&self) -> bool { + self.visible && self.maximized + } + + /// Add a tab after the others, activate it and show the panel. + pub fn add(&mut self, tab: T) -> usize { + self.tabs.push(tab); + self.active = self.tabs.len() - 1; + self.visible = true; + self.active + } + + pub fn activate(&mut self, index: usize) -> bool { + if index >= self.tabs.len() { + return false; + } + self.active = index; + true + } + + /// Activate the next tab, wrapping. Nothing to do with fewer than two. + pub fn select_next(&mut self) -> bool { + self.step(1) + } + + /// Activate the previous tab, wrapping. + pub fn select_prev(&mut self) -> bool { + self.step(self.tabs.len().saturating_sub(1)) + } + + fn step(&mut self, by: usize) -> bool { + if self.tabs.len() < 2 { + return false; + } + self.active = (self.active + by) % self.tabs.len(); + true + } + + /// Remove a tab. The tab to its right becomes active, or the new last + /// one. Closing the last tab hides the panel. + pub fn close(&mut self, index: usize) -> Option { + if index >= self.tabs.len() { + return None; + } + let tab = self.tabs.remove(index); + if index < self.active || self.active >= self.tabs.len() { + self.active = self.active.saturating_sub(1); + } + if self.tabs.is_empty() { + self.visible = false; + self.maximized = false; + } + Some(tab) + } + + pub fn close_active(&mut self) -> Option { + self.close(self.active) + } + + /// Show or hide. Returns the new visibility. Hiding keeps the tabs and + /// their sessions running. + pub fn toggle_visible(&mut self) -> bool { + self.visible = !self.visible; + self.visible + } + + /// Maximize or restore; only when the panel is shown. + pub fn toggle_maximized(&mut self) -> bool { + if self.visible { + self.maximized = !self.maximized; + } + self.is_maximized() + } +} + +/// What the app needs to react to. +#[derive(Clone, Debug, PartialEq, Eq)] +pub enum PanelEvent { + /// The user asked for a new tab; the app chooses the kind and adds it. + NewTabRequested, + /// The panel was shown, hidden, maximized or restored, or its tabs + /// changed: the app may need to lay out again. + Changed, +} + +pub struct TerminalPanel { + state: PanelState>, + focus: FocusHandle, + /// Teardowns of closed tabs that have not reported yet. + closing: Vec, + /// Processes that outlived tabs closed so far. + left_running: usize, +} + +impl EventEmitter for TerminalPanel {} + +impl TerminalPanel { + pub fn new(cx: &mut Context) -> Self { + Self { + state: PanelState::default(), + focus: cx.focus_handle(), + closing: Vec::new(), + left_running: 0, + } + } + + pub fn is_visible(&self) -> bool { + self.state.is_visible() + } + + pub fn is_maximized(&self) -> bool { + self.state.is_maximized() + } + + pub fn tab_count(&self) -> usize { + self.state.tabs().len() + } + + pub fn active_index(&self) -> Option { + self.state.active_index() + } + + pub fn active(&self) -> Option<&Entity> { + self.state.active() + } + + /// Each tab's kind, in order: what the labels and badges are made from. + pub fn kinds(&self, cx: &App) -> Vec { + self.state + .tabs() + .iter() + .map(|t| t.read(cx).kind().clone()) + .collect() + } + + /// How many processes outlived tabs closed in this panel. + pub fn left_running(&self) -> usize { + self.left_running + } + + fn changed(&mut self, cx: &mut Context) { + cx.emit(PanelEvent::Changed); + cx.notify(); + } + + fn focus_active(&self, window: &mut Window, cx: &mut App) { + match self.state.active() { + Some(view) if self.state.is_visible() => { + let handle = view.read(cx).focus_handle(cx); + window.focus(&handle, cx); + } + _ => window.focus(&self.focus, cx), + } + } + + /// Add a tab, activate it, show the panel and focus the terminal. + pub fn add_tab( + &mut self, + view: Entity, + window: &mut Window, + cx: &mut Context, + ) { + // A tab's label can change (bash was missing, sh started). + cx.observe(&view, |_, _, cx| cx.notify()).detach(); + self.state.add(view); + self.focus_active(window, cx); + self.changed(cx); + } + + pub fn activate(&mut self, index: usize, window: &mut Window, cx: &mut Context) { + if self.state.activate(index) { + self.focus_active(window, cx); + self.changed(cx); + } + } + + pub fn next_tab(&mut self, window: &mut Window, cx: &mut Context) { + if self.state.select_next() { + self.focus_active(window, cx); + self.changed(cx); + } + } + + pub fn prev_tab(&mut self, window: &mut Window, cx: &mut Context) { + if self.state.select_prev() { + self.focus_active(window, cx); + self.changed(cx); + } + } + + /// Close the active tab and end its session. + pub fn close_active(&mut self, window: &mut Window, cx: &mut Context) { + let Some(view) = self.state.close_active() else { + return; + }; + // End the session now, on a reaper thread, and keep the handle so + // anything that could not be ended is reported. + if let Some(shutdown) = view.update(cx, |v, _| v.take_shutdown()) { + self.closing.push(shutdown.terminate_in_background()); + cx.spawn(async move |this, cx| { + // Long enough for the grace period and SIGKILL to have run. + for _ in 0..20 { + cx.background_executor() + .timer(Duration::from_millis(100)) + .await; + let pending = this.update(cx, |p, cx| p.collect_teardowns(cx)); + if !matches!(pending, Ok(true)) { + break; + } + } + }) + .detach(); + } + self.focus_active(window, cx); + self.changed(cx); + } + + /// Take finished teardown results. Returns whether any are still pending. + fn collect_teardowns(&mut self, cx: &mut Context) -> bool { + let before = self.left_running; + self.closing.retain(|pending| match pending.try_result() { + Some(done) => { + self.left_running += done.survivors.len(); + false + } + None => true, + }); + if self.left_running != before { + cx.notify(); + } + !self.closing.is_empty() + } + + /// Show or hide the panel. Hidden tabs keep running. + pub fn toggle(&mut self, window: &mut Window, cx: &mut Context) { + self.state.toggle_visible(); + self.focus_active(window, cx); + self.changed(cx); + } + + pub fn toggle_maximize(&mut self, cx: &mut Context) { + self.state.toggle_maximized(); + self.changed(cx); + } + + /// Ask the app for a new tab. + pub fn request_new_tab(&mut self, cx: &mut Context) { + cx.emit(PanelEvent::NewTabRequested); + } + + /// Open find in the active terminal. + pub fn find(&mut self, cx: &mut Context) { + if let Some(view) = self.state.active() { + view.update(cx, |v, cx| v.open_find(cx)); + } + } +} + +impl Focusable for TerminalPanel { + fn focus_handle(&self, _: &App) -> FocusHandle { + self.focus.clone() + } +} + +impl Render for TerminalPanel { + fn render(&mut self, _: &mut Window, cx: &mut Context) -> impl IntoElement { + let root = div() + .id("terminal-panel") + .key_context("TerminalPanel") + .track_focus(&self.focus) + .on_action(cx.listener(|this, _: &TogglePanel, window, cx| this.toggle(window, cx))) + .on_action(cx.listener(|this, _: &NewTab, _, cx| this.request_new_tab(cx))) + .on_action(cx.listener(|this, _: &CloseTab, window, cx| { + this.close_active(window, cx); + })) + .on_action(cx.listener(|this, _: &NextTab, window, cx| this.next_tab(window, cx))) + .on_action(cx.listener(|this, _: &PrevTab, window, cx| this.prev_tab(window, cx))) + .on_action(cx.listener(|this, _: &ToggleMaximize, _, cx| this.toggle_maximize(cx))) + .on_action(cx.listener(|this, _: &Find, _, cx| this.find(cx))); + if !self.state.is_visible() { + return root; + } + let t = cx.theme(); + let active = self.state.active_index(); + let tabs = self.state.tabs().iter().enumerate().map(|(i, view)| { + let kind = view.read(cx).kind().clone(); + let is_active = active == Some(i); + let badge = kind.badge().map(|b| { + let (bg, fg) = match b.tone { + BadgeTone::Warning => (t.warning.opacity(0.18), t.warning), + BadgeTone::Neutral => (t.secondary, t.muted_foreground), + }; + div() + .id(("terminal-tab-badge", i)) + .px(px(6.)) + .py(px(1.)) + .rounded(px(4.)) + .bg(bg) + .text_color(fg) + .text_size(px(11.)) + .child(b.text) + }); + div() + .id(("terminal-tab", i)) + .flex() + .items_center() + .gap(px(8.)) + .px(px(12.)) + .h_full() + .text_size(px(12.)) + .border_b_2() + .border_color(if is_active { t.primary } else { t.transparent }) + .text_color(if is_active { + t.foreground + } else { + t.muted_foreground + }) + .cursor_pointer() + .on_click(cx.listener(move |this, _, window, cx| this.activate(i, window, cx))) + .child(kind.label()) + .children(badge) + }); + let left_running = (self.left_running > 0).then(|| { + let n = self.left_running; + div() + .id("terminal-panel-left-running") + .px(px(10.)) + .text_size(px(11.)) + .text_color(t.warning) + .child(if n == 1 { + "1 process from a closed tab is still running".to_owned() + } else { + format!("{n} processes from closed tabs are still running") + }) + }); + root.flex() + .flex_col() + .size_full() + .bg(t.background) + .child( + div() + .id("terminal-tabs") + .flex() + .items_center() + .justify_between() + .h(px(32.)) + .flex_none() + .border_b_1() + .border_color(t.border) + .child(div().flex().h_full().children(tabs)) + .children(left_running), + ) + .children( + self.state + .active() + .map(|view| div().flex_1().min_h_0().child(view.clone())), + ) + } +} + +#[cfg(test)] +mod tests { + use super::PanelState; + + fn panel(tabs: &[&'static str]) -> PanelState<&'static str> { + let mut p = PanelState::default(); + for t in tabs { + p.add(*t); + } + p + } + + #[test] + fn adding_a_tab_activates_it_and_shows_the_panel() { + let mut p = PanelState::default(); + assert!(!p.is_visible() && p.active().is_none() && p.active_index().is_none()); + assert_eq!(p.add("logs"), 0); + assert_eq!(p.add("api · sh"), 1); + assert!(p.is_visible()); + assert_eq!((p.active_index(), p.active()), (Some(1), Some(&"api · sh"))); + assert_eq!(p.tabs(), ["logs", "api · sh"]); + } + + #[test] + fn next_and_previous_wrap() { + let mut p = panel(&["a", "b", "c"]); + assert!(p.select_next()); + assert_eq!(p.active(), Some(&"a"), "wraps from the last to the first"); + assert!(p.select_prev()); + assert_eq!(p.active(), Some(&"c"), "and back"); + assert!(p.select_prev() && p.select_prev()); + assert_eq!(p.active(), Some(&"a")); + let mut one = panel(&["only"]); + assert!( + !one.select_next() && !one.select_prev(), + "nothing to switch to" + ); + assert!(!PanelState::::default().select_next()); + assert!(p.activate(2) && !p.activate(3)); + assert_eq!(p.active(), Some(&"c")); + } + + #[test] + fn closing_keeps_a_sensible_tab_active_and_hides_when_empty() { + let mut p = panel(&["a", "b", "c"]); + p.activate(1); + assert_eq!(p.close_active(), Some("b")); + assert_eq!(p.active(), Some(&"c"), "the tab to the right"); + assert_eq!(p.close_active(), Some("c")); + assert_eq!(p.active(), Some(&"a"), "the new last tab"); + // Closing a tab left of the active one keeps the same tab active. + let mut q = panel(&["a", "b", "c"]); + assert_eq!(q.close(0), Some("a")); + assert_eq!(q.active(), Some(&"c")); + assert_eq!(q.close(9), None); + // The last tab: the panel hides and is no longer maximized. + p.toggle_maximized(); + assert!(p.is_maximized()); + assert_eq!(p.close_active(), Some("a")); + assert!(!p.is_visible() && !p.is_maximized() && p.active().is_none()); + assert_eq!(p.close_active(), None); + } + + #[test] + fn hiding_keeps_tabs_and_maximize_needs_a_visible_panel() { + let mut p = panel(&["a", "b"]); + assert!(p.toggle_maximized()); + assert!(!p.toggle_visible(), "hidden"); + assert_eq!(p.tabs().len(), 2, "tabs and their sessions stay"); + assert!(!p.is_maximized(), "a hidden panel is not maximized"); + assert!(!p.toggle_maximized(), "and cannot become so"); + assert!(p.toggle_visible()); + assert!(p.is_maximized(), "shown again as it was"); + assert!(!p.toggle_maximized()); + } +} diff --git a/apps/desktop/term/src/pty.rs b/apps/desktop/term/src/pty.rs new file mode 100644 index 000000000..f3cae1ded --- /dev/null +++ b/apps/desktop/term/src/pty.rs @@ -0,0 +1,940 @@ +//! An app-owned pseudo-terminal running one child in its own session. +//! +//! Ownership contract: +//! +//! - The child is started with `setsid()`: it leads a new session and a new +//! process group whose id is its pid, and the PTY's slave side becomes its +//! controlling terminal. +//! - [`Pty`] owns the master descriptor and the session +//! ([`crate::session`]). Dropping it ends the whole session: SIGHUP and +//! SIGCONT to the leader's group and to every verified session member +//! (which includes jobs an interactive shell moved into their own process +//! groups), then SIGKILL after [`TERMINATE_GRACE`] on a short-lived reaper +//! thread, which reaps the leader last. +//! - Nothing here blocks the caller on the child. Output is read by a worker +//! into a byte-bounded queue ([`OUTPUT_QUEUE_BYTES`]); when the consumer +//! stalls, the worker stops reading and the child blocks on its own write. +//! Input is queued ([`INPUT_QUEUE_BYTES`]) and written by a second worker +//! with non-blocking writes; input that does not fit is refused whole and +//! reported, never truncated. Both workers stop when the `Pty` goes away. +//! - If a worker or reaper thread cannot be started, the calling thread +//! keeps the responsibility: it kills the session and reaps the child. +//! +//! The app writes only what the user typed or pasted into the master. It +//! never writes secrets, never logs terminal bytes and never passes values +//! through argv beyond the program and arguments the caller supplies. + +use std::collections::VecDeque; +use std::ffi::OsString; +use std::fs::File; +use std::io::{self, Read, Write}; +use std::os::fd::{AsRawFd, FromRawFd, OwnedFd}; +use std::os::unix::process::CommandExt; +use std::path::PathBuf; +use std::process::{Command, Stdio}; +use std::sync::{Arc, Condvar, Mutex, MutexGuard}; +use std::thread::JoinHandle; +use std::time::Duration; + +use crate::fdio::{self, Cancel, CancelWatch, Wait}; +use crate::queue::ByteQueue; +use crate::session::{ + ExitInfo, LastResort, OwnedSession, PendingTeardown, SYNC_DEADLINE, Teardown, ThreadSpawner, +}; + +/// How long a dropped session has after SIGHUP before SIGKILL. +pub const TERMINATE_GRACE: Duration = Duration::from_millis(300); +/// The shorter grace used when the whole app is quitting. +pub const QUIT_GRACE: Duration = Duration::from_millis(100); +/// Most child output held in memory before the reader stops reading. +pub const OUTPUT_QUEUE_BYTES: usize = 1 << 20; +/// Most input (keys, paste) held for a child that is not reading. +pub const INPUT_QUEUE_BYTES: usize = 1 << 20; + +/// Terminal size in cells and pixels. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub struct PtySize { + pub cols: u16, + pub rows: u16, + pub pixel_width: u16, + pub pixel_height: u16, +} + +impl PtySize { + pub fn cells(cols: u16, rows: u16) -> Self { + Self { + cols, + rows, + pixel_width: 0, + pixel_height: 0, + } + } + + fn winsize(self) -> libc::winsize { + libc::winsize { + ws_row: self.rows.max(1), + ws_col: self.cols.max(1), + ws_xpixel: self.pixel_width, + ws_ypixel: self.pixel_height, + } + } +} + +/// What to run on the PTY. +/// +/// `env` entries are added to the inherited environment (or to an empty one +/// when `env_clear` is set); `env_remove` names are removed. Values are +/// never logged. +#[derive(Clone, Debug)] +pub struct PtyOptions { + pub program: PathBuf, + /// The child's `argv[0]` when it must differ from the program path. A + /// leading `-` is how a shell is told it is a login shell. + pub arg0: Option, + pub args: Vec, + pub cwd: Option, + pub env: Vec<(OsString, OsString)>, + pub env_remove: Vec, + pub env_clear: bool, + pub size: PtySize, +} + +impl PtyOptions { + pub fn new(program: impl Into) -> Self { + Self { + program: program.into(), + arg0: None, + args: Vec::new(), + cwd: None, + env: vec![("TERM".into(), "xterm-256color".into())], + env_remove: Vec::new(), + env_clear: false, + size: PtySize::cells(80, 24), + } + } + + pub fn arg(mut self, arg: impl Into) -> Self { + self.args.push(arg.into()); + self + } + + pub fn env(mut self, key: impl Into, value: impl Into) -> Self { + self.env.push((key.into(), value.into())); + self + } +} + +/// What happened to input handed to [`Pty::write`]. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum WriteOutcome { + /// Accepted; the writer will deliver all of it, in order. + Queued, + /// Refused whole: the child is not reading and the queue is full, or the + /// input is larger than the queue. Nothing was delivered. + Dropped { bytes: usize }, + /// The terminal is closed. + Closed, +} + +/// Output taken from the PTY. +#[derive(Clone, Debug, Default, PartialEq, Eq)] +pub struct PtyRead { + pub bytes: Vec, + /// Every holder of the terminal has closed it and all output was + /// delivered. Separate from the child's exit: see [`Pty::exit`]. + pub eof: bool, +} + +#[derive(Debug, Default)] +struct InputState { + chunks: VecDeque>, + /// Queued bytes plus the unsent part of the chunk being written. + pending: usize, + closed: bool, +} + +#[derive(Debug)] +struct InputQueue { + state: Mutex, + ready: Condvar, + cap: usize, +} + +impl InputQueue { + fn lock(&self) -> MutexGuard<'_, InputState> { + self.state.lock().unwrap_or_else(|e| e.into_inner()) + } + + fn offer(&self, bytes: &[u8]) -> WriteOutcome { + let mut s = self.lock(); + if s.closed { + return WriteOutcome::Closed; + } + if bytes.is_empty() { + return WriteOutcome::Queued; + } + if s.pending + bytes.len() > self.cap { + return WriteOutcome::Dropped { bytes: bytes.len() }; + } + s.pending += bytes.len(); + s.chunks.push_back(bytes.to_vec()); + drop(s); + self.ready.notify_all(); + WriteOutcome::Queued + } + + fn close(&self) { + let mut s = self.lock(); + s.closed = true; + s.chunks.clear(); + s.pending = 0; + drop(s); + self.ready.notify_all(); + } + + /// Writer: the next chunk, or `None` once closed. + fn next(&self) -> Option> { + let mut s = self.lock(); + loop { + if s.closed { + return None; + } + if let Some(chunk) = s.chunks.pop_front() { + return Some(chunk); + } + s = self.ready.wait(s).unwrap_or_else(|e| e.into_inner()); + } + } + + fn sent(&self, n: usize) { + let mut s = self.lock(); + s.pending = s.pending.saturating_sub(n); + } +} + +/// Why [`Pty::spawn`] failed, with the child's pid when one had started. +#[derive(Debug)] +pub(crate) struct SpawnFailure { + pub error: io::Error, + #[cfg_attr(not(test), allow(dead_code, reason = "read by tests"))] + pub leader: Option, +} + +/// A child process on an app-owned PTY. See the module docs for ownership. +#[derive(Debug)] +pub struct Pty { + leader: libc::pid_t, + /// `Some` until teardown starts. + pub(crate) session: Option, + master: Option, + output: Arc, + input: Arc, + cancel: Option, + threads: Vec>, + spawner: ThreadSpawner, +} + +impl Pty { + /// Start `opts.program` on a new PTY in its own session. + pub fn spawn(opts: &PtyOptions) -> io::Result { + Self::spawn_with(opts, ThreadSpawner::os()).map_err(|f| f.error) + } + + pub(crate) fn spawn_with( + opts: &PtyOptions, + spawner: ThreadSpawner, + ) -> Result { + let early = |error| SpawnFailure { + error, + leader: None, + }; + let (master, slave) = open_pty(opts.size).map_err(early)?; + // Non-blocking is a property of the open file description, so it + // covers the reader's and writer's duplicates too. The slave is a + // different description; the child is unaffected. + fdio::set_nonblocking(&master).map_err(early)?; + let (cancel, watch) = fdio::cancel_pair().map_err(early)?; + let reader = File::from(master.try_clone().map_err(early)?); + let writer = File::from(master.try_clone().map_err(early)?); + + let mut cmd = Command::new(&opts.program); + if let Some(arg0) = &opts.arg0 { + cmd.arg0(arg0); + } + cmd.args(&opts.args); + if let Some(cwd) = &opts.cwd { + cmd.current_dir(cwd); + } + if opts.env_clear { + cmd.env_clear(); + } + for name in &opts.env_remove { + cmd.env_remove(name); + } + for (k, v) in &opts.env { + cmd.env(k, v); + } + cmd.stdin(Stdio::from(slave.try_clone().map_err(early)?)) + .stdout(Stdio::from(slave.try_clone().map_err(early)?)) + .stderr(Stdio::from(slave)); + // SAFETY: only async-signal-safe calls (setsid, ioctl, fcntl, + // syscall) run between fork and exec. + unsafe { + cmd.pre_exec(|| { + if libc::setsid() == -1 { + return Err(io::Error::last_os_error()); + } + // stdin is the slave: make it the controlling terminal. + #[allow(clippy::useless_conversion, reason = "TIOCSCTTY's type differs by OS")] + if libc::ioctl(0, libc::TIOCSCTTY.into(), 0) == -1 { + return Err(io::Error::last_os_error()); + } + fdio::mark_inherited_fds_cloexec(); + Ok(()) + }); + } + let child = cmd.spawn().map_err(early)?; + // The command holds the parent's copies of the slave; close them so + // the terminal reports end of file when the session is gone. + drop(cmd); + // From here the child is owned: an early return ends and reaps it. + let session = OwnedSession::new(child).map_err(early)?; + let leader = session.leader(); + let mut pty = Self { + leader, + session: Some(session), + master: Some(master), + output: ByteQueue::new(OUTPUT_QUEUE_BYTES), + input: Arc::new(InputQueue { + state: Mutex::default(), + ready: Condvar::new(), + cap: INPUT_QUEUE_BYTES, + }), + cancel: Some(cancel), + threads: Vec::new(), + spawner, + }; + let (queue, watch_r) = (pty.output.clone(), watch.clone()); + let read_job = Box::new(move || read_loop(reader, &watch_r, &queue)); + let input = pty.input.clone(); + let write_job = Box::new(move || write_loop(&writer, &watch, &input)); + for (name, job) in [ + ("hack-term-pty-read", read_job as crate::session::Job), + ("hack-term-pty-write", write_job), + ] { + match pty.spawner.spawn(name, job) { + Ok(handle) => pty.threads.push(handle), + Err(error) => { + pty.abort(); + return Err(SpawnFailure { + error, + leader: Some(leader), + }); + } + } + } + Ok(pty) + } + + /// The session leader's pid, which is also the session and process group id. + pub fn pid(&self) -> u32 { + self.leader.unsigned_abs() + } + + /// Take output without blocking: whole chunks until at least `max_bytes`. + pub fn read(&self, max_bytes: usize) -> PtyRead { + let d = self.output.drain(max_bytes); + PtyRead { + bytes: d.bytes, + eof: d.end.is_some(), + } + } + + /// [`Self::read`], first waiting up to `timeout` for output or the end. + pub fn read_timeout(&self, max_bytes: usize, timeout: Duration) -> PtyRead { + self.output.wait(timeout); + self.read(max_bytes) + } + + /// Output bytes read from the child and not yet taken. + pub fn pending_output_bytes(&self) -> usize { + self.output.pending_bytes() + } + + /// Queue input (keys, paste, query replies) for the child. Never blocks. + pub fn write(&self, bytes: &[u8]) -> WriteOutcome { + self.input.offer(bytes) + } + + /// Input accepted and not yet taken by the terminal. + pub fn pending_input_bytes(&self) -> usize { + self.input.lock().pending + } + + /// Tell the child its new size (it receives SIGWINCH). + pub fn resize(&self, size: PtySize) -> io::Result<()> { + let master = self + .master + .as_ref() + .ok_or_else(|| io::Error::other("pty closed"))?; + let ws = size.winsize(); + // SAFETY: TIOCSWINSZ reads a winsize from a valid pointer. + if unsafe { libc::ioctl(master.as_raw_fd(), libc::TIOCSWINSZ, &ws) } == -1 { + return Err(io::Error::last_os_error()); + } + Ok(()) + } + + /// The child's exit, if it has exited. Does not reap it and does not + /// mean its output has all arrived: see [`PtyRead::eof`]. + pub fn exit(&self) -> Option { + self.session.as_ref().and_then(OwnedSession::exit) + } + + /// End the session on this thread and wait until the leader is reaped + /// and no session member remains, or `deadline` passes. + pub fn terminate_blocking(self, deadline: Duration) -> Teardown { + self.terminate_blocking_with(TERMINATE_GRACE, deadline) + } + + /// [`Self::terminate_blocking`] with the SIGKILL grace chosen by the caller. + pub fn terminate_blocking_with(mut self, grace: Duration, deadline: Duration) -> Teardown { + let Some(session) = self.session.take() else { + return Teardown::nothing(); + }; + hang_up(&session); + let threads = self.stop_io(); + session.retire( + grace, + deadline, + move || join_all(threads), + LastResort::GiveUp, + ) + } + + /// End the session on a reaper thread and get a handle to the result, + /// so the caller can report [`Teardown::survivors`] without waiting. + pub fn terminate_in_background(mut self) -> PendingTeardown { + let (tx, rx) = std::sync::mpsc::channel(); + match self.session.take() { + None => { + let _ = tx.send(Teardown::nothing()); + } + Some(session) => { + hang_up(&session); + let threads = self.stop_io(); + session.retire_in_background( + &self.spawner, + TERMINATE_GRACE, + Box::new(move || join_all(threads)), + Some(tx), + ); + } + } + PendingTeardown(rx) + } + + /// Kill the session now and reap it, on this thread. + fn abort(mut self) { + if let Some(session) = self.session.take() { + let threads = self.stop_io(); + session.retire( + Duration::ZERO, + SYNC_DEADLINE, + move || join_all(threads), + LastResort::GiveUp, + ); + } + } + + /// Cancel both workers and close our side of the terminal. + fn stop_io(&mut self) -> Vec> { + self.input.close(); + self.output.close(); + self.cancel.take(); + self.master.take(); + std::mem::take(&mut self.threads) + } + + #[cfg(test)] + pub(crate) fn set_spawner(&mut self, spawner: ThreadSpawner) { + self.spawner = spawner; + } +} + +impl Drop for Pty { + fn drop(&mut self) { + let Some(session) = self.session.take() else { + return; + }; + hang_up(&session); + let threads = self.stop_io(); + // Escalation and reaping happen off the caller's thread; if that + // thread cannot start, `retire_in_background` does both here. + session.retire_in_background( + &self.spawner, + TERMINATE_GRACE, + Box::new(move || join_all(threads)), + None, + ); + } +} + +/// SIGHUP + SIGCONT to the leader's group and to every other session member +/// that has a race-free handle. +fn hang_up(session: &OwnedSession) { + session.signal(libc::SIGHUP); + session.signal(libc::SIGCONT); +} + +fn join_all(threads: Vec>) { + for t in threads { + let _ = t.join(); + } +} + +/// Read the terminal into the bounded queue until end of file, an error, or +/// cancellation. Linux reports EIO once the last slave descriptor closes. +fn read_loop(mut master: File, cancel: &CancelWatch, queue: &ByteQueue) { + let mut buf = vec![0u8; 64 * 1024]; + let end = loop { + match fdio::wait_fd(master.as_raw_fd(), libc::POLLIN, cancel, -1) { + Ok(Wait::Cancelled) => return, + Ok(Wait::Ready | Wait::TimedOut) => {} + Err(e) => break Err(e.kind().to_string()), + } + match master.read(&mut buf) { + Ok(0) => break Ok(()), + Ok(n) => { + // Blocks while the queue is full; false once the consumer left. + if !queue.push(buf[..n].to_vec()) { + return; + } + } + Err(e) + if matches!( + e.kind(), + io::ErrorKind::WouldBlock | io::ErrorKind::Interrupted + ) => {} + Err(e) if e.raw_os_error() == Some(libc::EIO) => break Ok(()), + Err(e) => break Err(e.kind().to_string()), + } + }; + queue.finish(end); +} + +/// Deliver queued input with non-blocking writes, waiting for room without +/// holding up anyone else. Stops on close, cancellation or a write error. +fn write_loop(mut master: &File, cancel: &CancelWatch, input: &InputQueue) { + while let Some(chunk) = input.next() { + let mut off = 0; + while off < chunk.len() { + match master.write(&chunk[off..]) { + Ok(0) => return input.close(), + Ok(n) => { + off += n; + input.sent(n); + } + Err(e) if e.kind() == io::ErrorKind::Interrupted => {} + Err(e) if e.kind() == io::ErrorKind::WouldBlock => { + match fdio::wait_fd(master.as_raw_fd(), libc::POLLOUT, cancel, -1) { + Ok(Wait::Ready | Wait::TimedOut) => {} + Ok(Wait::Cancelled) | Err(_) => return input.close(), + } + } + Err(_) => return input.close(), + } + } + } +} + +fn open_pty(size: PtySize) -> io::Result<(OwnedFd, OwnedFd)> { + let mut master: libc::c_int = -1; + let mut slave: libc::c_int = -1; + let mut ws = size.winsize(); + // `*const` on Linux, `*mut` on macOS; a raw pointer coerces to both. + let ws_ptr: *mut libc::winsize = &mut ws; + // SAFETY: openpty writes two descriptors on success; null name/termios + // are allowed. + let rc = unsafe { + libc::openpty( + &mut master, + &mut slave, + std::ptr::null_mut(), + std::ptr::null_mut(), + ws_ptr, + ) + }; + if rc == -1 { + return Err(io::Error::last_os_error()); + } + // SAFETY: both descriptors are fresh and owned by us. + let (master, slave) = unsafe { (OwnedFd::from_raw_fd(master), OwnedFd::from_raw_fd(slave)) }; + // openpty has no CLOEXEC flag; close the window as soon as possible. + fdio::set_cloexec(&master)?; + fdio::set_cloexec(&slave)?; + Ok((master, slave)) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::session::{Open, OsTable, ProcTable, exists, is_alive, members}; + use crate::testutil::{DEADLINE, Recorded, assert_no_trace, sh, tagged, wait_gone}; + use std::time::Instant; + + /// Collect output until `done` accepts it, the end, or the deadline. + fn read_until(pty: &Pty, done: impl Fn(&str) -> bool) -> String { + let start = Instant::now(); + let mut out = Vec::new(); + while start.elapsed() < DEADLINE { + let r = pty.read_timeout(usize::MAX, Duration::from_millis(100)); + out.extend_from_slice(&r.bytes); + if r.eof || done(&String::from_utf8_lossy(&out)) { + break; + } + } + String::from_utf8_lossy(&out).into_owned() + } + + #[test] + fn a_large_paste_to_a_child_that_never_reads_does_not_block_the_caller() { + // Raw mode: unread input is kept, so the terminal fills and a + // blocking write would hang. The child never reads stdin. + let (opts, _home) = sh("stty raw -echo; echo ready; exec sleep 120"); + let pty = Pty::spawn(&opts).expect("spawn"); + let mut rec = Recorded::session(pty.pid()); + assert!(read_until(&pty, |t| t.contains("ready")).contains("ready")); + + let chunk = vec![b'x'; 64 * 1024]; + let (mut queued, mut dropped) = (0usize, 0usize); + let start = Instant::now(); + for _ in 0..256 { + match pty.write(&chunk) { + WriteOutcome::Queued => queued += chunk.len(), + WriteOutcome::Dropped { bytes } => dropped += bytes, + WriteOutcome::Closed => panic!("terminal closed"), + } + assert!(pty.pending_input_bytes() <= INPUT_QUEUE_BYTES); + } + let offered = start.elapsed(); + assert!( + offered < Duration::from_secs(10), + "offering 16 MiB took {offered:?}" + ); + assert!( + queued > 0 && dropped > 0, + "queued {queued}, dropped {dropped}" + ); + // The terminal took what fits; the rest is parked with the writer. + // A write on the caller's thread would be stuck right here. + let start = Instant::now(); + let mut pending = pty.pending_input_bytes(); + loop { + std::thread::sleep(Duration::from_millis(200)); + let now = pty.pending_input_bytes(); + if now == pending || start.elapsed() >= DEADLINE { + break; + } + pending = now; + } + assert!( + pending > 0, + "the child is not reading, so input stays queued" + ); + assert!(pending <= INPUT_QUEUE_BYTES); + // Input larger than the queue is refused whole, never truncated. + assert_eq!( + pty.write(&vec![b'y'; INPUT_QUEUE_BYTES + 1]), + WriteOutcome::Dropped { + bytes: INPUT_QUEUE_BYTES + 1 + } + ); + // Teardown cancels the parked writer: it joins both workers before + // it reaps, so returning at all proves they stopped. + let t = pty.terminate_blocking(DEADLINE); + assert!(t.gone, "{t:?}"); + rec.done(); + } + + #[test] + fn a_thread_spawn_failure_at_start_leaves_no_child_and_no_zombie() { + // Fail the reader (0 successes), then the writer (1 success). + for ok in 0..2 { + let (opts, _home) = sh("exec sleep 120"); + let failure = Pty::spawn_with(&opts, ThreadSpawner::failing_after(ok)) + .expect_err("spawn must fail"); + let leader = failure.leader.expect("the child had started"); + assert!( + failure.error.to_string().contains("injected"), + "{:?}", + failure.error + ); + // If it was left running, this handle ends it when the test + // unwinds; the assertion itself only reports. + let mut rec = Recorded::session(leader.unsigned_abs()); + // Killed and reaped before the error returned: not even a zombie. + assert_no_trace(leader, &format!("spawn failure after {ok} threads")); + rec.done(); + } + } + + #[test] + fn a_reaper_spawn_failure_still_ends_and_reaps_the_session() { + let (opts, _home) = sh("sleep 120 & echo \"kid=$!\"; wait"); + let mut pty = Pty::spawn(&opts).expect("spawn"); + let mut rec = Recorded::session(pty.pid()); + let kid = tagged(&read_until(&pty, |t| t.contains("kid=")), "kid").expect("kid"); + let leader = pty.leader; + assert!(is_alive(kid) && is_alive(leader)); + pty.set_spawner(ThreadSpawner::failing_after(0)); + drop(pty); + // No reaper thread could start, so Drop did the work itself. (If it + // did not, `rec` kills what is left when this test unwinds.) + assert!(members(leader).is_empty(), "session members remain"); + assert_no_trace(leader, "the leader was not reaped"); + assert!(wait_gone(&[kid]).is_empty()); + rec.done(); + } + + /// The real process table with the scan result, and what happens at + /// the verification boundary, chosen by the test. + struct Staged<'a> { + scan: Vec, + at_boundary: Box, + } + + impl ProcTable for Staged<'_> { + fn scan(&self, _leader: libc::pid_t) -> Vec { + self.scan.clone() + } + fn open(&self, pid: libc::pid_t) -> Open { + OsTable.open(pid) + } + fn identity(&self, pid: libc::pid_t) -> Option { + OsTable.identity(pid) + } + fn verified(&self, pid: libc::pid_t) { + (self.at_boundary)(pid); + } + } + + const JOB_AND_SAME: &str = + "set -m; sleep 120 & echo \"job=$!\"; set +m; sleep 121 & echo \"same=$!\"; wait"; + + #[test] + fn a_scanned_pid_that_is_no_longer_in_the_session_is_not_signalled() { + // `job` is in its own process group, `same` in the leader's group. + let (opts, _home) = sh(JOB_AND_SAME); + let pty = Pty::spawn(&opts).expect("spawn"); + let mut rec = Recorded::session(pty.pid()); + let out = read_until(&pty, |t| t.contains("same=")); + let job = tagged(&out, "job").expect("job"); + let same = tagged(&out, "same").expect("same"); + // A process in a different session: what a recycled pid would be. + let (other_opts, _home2) = sh("exec sleep 120"); + let other = Pty::spawn(&other_opts).expect("spawn other"); + let mut rec2 = Recorded::session(other.pid()); + let stranger = other.leader; + assert!(is_alive(job) && is_alive(same) && is_alive(stranger)); + const NOBODY: libc::pid_t = 0x3fff_fff0; + assert!(!exists(NOBODY)); + + let table = Staged { + scan: vec![job, same, stranger, NOBODY], + at_boundary: Box::new(|_| {}), + }; + let session = pty.session.as_ref().expect("session"); + let report = session.signal_with(libc::SIGTERM, &table); + assert_eq!(report.skipped, vec![stranger, NOBODY], "{report:?}"); + assert!( + wait_gone(&[same]).is_empty(), + "the leader's group was signalled" + ); + if cfg!(target_os = "linux") { + // A pidfd reaches the job in its own group. + assert_eq!(report.signalled, vec![job, same], "{report:?}"); + assert!(report.unreachable.is_empty()); + assert!(wait_gone(&[job]).is_empty()); + } else { + // No race-free handle here: the job is reported, never signalled + // by number. + assert_eq!(report.signalled, vec![same], "{report:?}"); + assert_eq!(report.unreachable, vec![job], "{report:?}"); + } + assert!(is_alive(stranger), "the other session was not touched"); + + assert!(other.terminate_blocking(DEADLINE).gone); + let t = pty.terminate_blocking(DEADLINE); + if cfg!(target_os = "linux") { + assert!(t.gone, "{t:?}"); + } + rec.done(); + rec2.done(); + } + + /// The reuse window for real: the verified member dies and is reaped + /// between verification and signalling. Its pidfd then names nobody. + #[cfg(target_os = "linux")] + #[test] + fn a_member_that_exits_at_the_boundary_is_not_signalled_by_number() { + let (opts, _home) = sh(JOB_AND_SAME); + let pty = Pty::spawn(&opts).expect("spawn"); + let mut rec = Recorded::session(pty.pid()); + let out = read_until(&pty, |t| t.contains("same=")); + let job = tagged(&out, "job").expect("job"); + // The test's own handle to the job, taken while it is known to be it. + let Open::Handle(own) = OsTable.open(job) else { + panic!("pidfd for the job"); + }; + let table = Staged { + scan: vec![job], + at_boundary: Box::new(|pid| { + assert_eq!(pid, job); + assert!(own.send(libc::SIGKILL)); + // The shell's `wait` reaps it; then the number is free. + let start = Instant::now(); + while exists(job) && start.elapsed() < DEADLINE { + std::thread::sleep(Duration::from_millis(5)); + } + assert!(!exists(job), "the job was not reaped"); + }), + }; + let session = pty.session.as_ref().expect("session"); + let report = session.signal_with(libc::SIGTERM, &table); + // The send went to the pinned, now dead, process: nobody got it. + assert_eq!(report.skipped, vec![job], "{report:?}"); + assert!(report.signalled.is_empty() && report.unreachable.is_empty()); + assert!(pty.terminate_blocking(DEADLINE).gone); + rec.done(); + } + + /// The OS process table, minus race-free handles: what teardown has to + /// work with on macOS and on Linux kernels without pidfd. + struct NoHandles; + + impl ProcTable for NoHandles { + fn scan(&self, leader: libc::pid_t) -> Vec { + OsTable.scan(leader) + } + fn open(&self, _pid: libc::pid_t) -> Open { + Open::Unsupported + } + fn identity(&self, pid: libc::pid_t) -> Option { + OsTable.identity(pid) + } + } + + #[test] + fn without_a_race_free_handle_a_job_in_another_group_is_left_alone_and_reported() { + // The job ignores nothing; it simply never receives a signal, + // because a non-interactive shell does not pass SIGHUP to its jobs + // and we refuse to signal it by number. It ends by itself. + let (opts, _home) = + sh("set -m; sleep 4 & echo \"job=$!\"; set +m; sleep 120 & echo \"same=$!\"; wait"); + let mut pty = Pty::spawn(&opts).expect("spawn"); + let mut rec = Recorded::session(pty.pid()); + let leader = pty.leader; + let out = read_until(&pty, |t| t.contains("same=")); + let job = tagged(&out, "job").expect("job"); + let same = tagged(&out, "same").expect("same"); + pty.session + .as_mut() + .expect("session") + .set_table(std::sync::Arc::new(NoHandles)); + + let start = Instant::now(); + let t = pty.terminate_blocking(DEADLINE); + let took = start.elapsed(); + assert!(!t.gone, "{t:?}"); + assert_eq!(t.survivors, vec![job], "the survivor is named"); + assert!(is_alive(job), "and it was not signalled"); + assert!( + !is_alive(same) && !exists(leader), + "the anchored group is gone" + ); + assert!( + took < Duration::from_secs(3), + "teardown stops once nothing more can be reached, took {took:?}" + ); + assert!(wait_gone(&[job]).is_empty(), "it ends by itself"); + rec.done(); + } + + #[test] + fn the_leader_stays_unreaped_until_signalling_is_done() { + // The shell exits at once and leaves a job in another process group. + let (opts, _home) = sh("set -m; sleep 3 & echo \"job=$!\"; exit 0"); + let pty = Pty::spawn(&opts).expect("spawn"); + let mut rec = Recorded::session(pty.pid()); + let leader = pty.leader; + let job = tagged(&read_until(&pty, |t| t.contains("job=")), "job").expect("job"); + let start = Instant::now(); + while pty.exit().is_none() && start.elapsed() < DEADLINE { + std::thread::sleep(Duration::from_millis(10)); + } + assert_eq!(pty.exit(), Some(ExitInfo::Exited(0))); + // Observing the exit did not reap: the pid, and with it the session + // id, is still held, so the job is still findable as ours. + assert!(exists(leader), "the leader must stay as the anchor"); + assert!(!is_alive(leader)); + assert_eq!(members(leader), vec![job]); + let t = pty.terminate_blocking(DEADLINE); + assert!(!exists(leader), "reaped after the last signal"); + if cfg!(target_os = "linux") { + assert!(t.gone && t.survivors.is_empty(), "{t:?}"); + } else { + // The job is in another group and gets no SIGHUP from a + // non-interactive shell. Without a race-free handle it is left + // alone and reported; it ends by itself after three seconds. + assert_eq!( + (t.gone, t.survivors.as_slice()), + (false, &[job][..]), + "{t:?}" + ); + } + assert!(wait_gone(&[job]).is_empty()); + rec.done(); + } + + #[test] + fn sigkill_escalation_happens_only_for_a_session_that_ignores_hangup() { + let (opts, _home) = sh("sleep 120 & echo \"kid=$!\"; wait"); + let pty = Pty::spawn(&opts).expect("spawn"); + let mut rec = Recorded::session(pty.pid()); + read_until(&pty, |t| t.contains("kid=")); + let t = pty.terminate_blocking(DEADLINE); + assert_eq!( + t, + Teardown { + gone: true, + escalated: false, + survivors: Vec::new() + }, + "SIGHUP is enough for a cooperative session" + ); + rec.done(); + + let (opts, _home) = + sh("trap '' HUP TERM; ( trap '' HUP TERM; exec sleep 120 ) & echo \"deaf=$!\"; wait"); + let pty = Pty::spawn(&opts).expect("spawn"); + let mut rec = Recorded::session(pty.pid()); + let deaf = tagged(&read_until(&pty, |t| t.contains("deaf=")), "deaf").expect("deaf"); + let start = Instant::now(); + let t = pty.terminate_blocking(DEADLINE); + assert_eq!( + t, + Teardown { + gone: true, + escalated: true, + survivors: Vec::new() + } + ); + assert!( + start.elapsed() >= TERMINATE_GRACE, + "SIGKILL came only after the grace period" + ); + assert!(wait_gone(&[deaf]).is_empty()); + rec.done(); + } +} diff --git a/apps/desktop/term/src/queue.rs b/apps/desktop/term/src/queue.rs new file mode 100644 index 000000000..9cb6eded9 --- /dev/null +++ b/apps/desktop/term/src/queue.rs @@ -0,0 +1,219 @@ +//! A byte-bounded queue between one producer thread and one consumer. +//! +//! The bound is on pending bytes, not messages. A producer that would exceed +//! it blocks until the consumer drains (backpressure, which reaches the +//! child through the pipe or PTY it writes to) or closes the queue +//! (cancellation). The consumer never blocks unless it asks to. + +use std::collections::VecDeque; +use std::sync::{Arc, Condvar, Mutex, MutexGuard}; +use std::time::{Duration, Instant}; + +#[derive(Debug, Default)] +struct State { + chunks: VecDeque>, + bytes: usize, + high_water: usize, + /// The producer finished: `Ok` at end of input, `Err` on a read failure. + finished: Option>, + /// The consumer went away. + closed: bool, +} + +#[derive(Debug)] +pub(crate) struct ByteQueue { + state: Mutex, + space: Condvar, + data: Condvar, + cap: usize, +} + +/// What a drain returned. `end` is set once, when the producer has finished +/// and every byte has been handed over. +#[derive(Debug, Default)] +pub(crate) struct Drained { + pub bytes: Vec, + pub end: Option>, +} + +impl ByteQueue { + pub fn new(cap: usize) -> Arc { + Arc::new(Self { + state: Mutex::default(), + space: Condvar::new(), + data: Condvar::new(), + cap: cap.max(1), + }) + } + + fn lock(&self) -> MutexGuard<'_, State> { + self.state.lock().unwrap_or_else(|e| e.into_inner()) + } + + /// Producer: add a chunk, blocking while the queue is full. Returns + /// `false` when the consumer closed the queue; the producer must stop. + /// A chunk larger than the bound is accepted only into an empty queue. + pub fn push(&self, chunk: Vec) -> bool { + if chunk.is_empty() { + return !self.lock().closed; + } + let mut s = self.lock(); + while !s.closed && s.bytes > 0 && s.bytes + chunk.len() > self.cap { + s = self.space.wait(s).unwrap_or_else(|e| e.into_inner()); + } + if s.closed { + return false; + } + s.bytes += chunk.len(); + s.high_water = s.high_water.max(s.bytes); + s.chunks.push_back(chunk); + drop(s); + self.data.notify_all(); + true + } + + /// Producer: no more data will come. + pub fn finish(&self, end: Result<(), String>) { + let mut s = self.lock(); + if s.finished.is_none() { + s.finished = Some(end); + } + drop(s); + self.data.notify_all(); + } + + /// Consumer: take whole chunks until at least `max_bytes` are taken or + /// the queue is empty. Never blocks. + pub fn drain(&self, max_bytes: usize) -> Drained { + let mut out = Drained::default(); + let mut s = self.lock(); + while out.bytes.len() < max_bytes { + let Some(chunk) = s.chunks.pop_front() else { + break; + }; + s.bytes -= chunk.len(); + out.bytes.extend_from_slice(&chunk); + } + if s.chunks.is_empty() { + out.end = s.finished.clone(); + } + drop(s); + if !out.bytes.is_empty() { + self.space.notify_all(); + } + out + } + + /// Consumer: wait until there is data or the producer finished, or + /// `timeout` passes. Returns whether a drain would return something. + pub fn wait(&self, timeout: Duration) -> bool { + let deadline = Instant::now() + timeout; + let mut s = self.lock(); + loop { + if !s.chunks.is_empty() || s.finished.is_some() { + return true; + } + let left = deadline.saturating_duration_since(Instant::now()); + if left.is_zero() { + return false; + } + s = self + .data + .wait_timeout(s, left) + .unwrap_or_else(|e| e.into_inner()) + .0; + } + } + + /// Consumer: stop. A blocked or later `push` returns `false`. + pub fn close(&self) { + let mut s = self.lock(); + s.closed = true; + s.chunks.clear(); + s.bytes = 0; + drop(s); + self.space.notify_all(); + } + + pub fn pending_bytes(&self) -> usize { + self.lock().bytes + } + + /// The most bytes ever pending at once. + pub fn high_water(&self) -> usize { + self.lock().high_water + } +} + +#[cfg(test)] +mod tests { + use super::*; + use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering}; + + #[test] + fn a_fast_producer_with_a_stalled_consumer_stays_under_the_bound() { + const CAP: usize = 256 * 1024; + let q = ByteQueue::new(CAP); + let pushed = Arc::new(AtomicUsize::new(0)); + let stopped = Arc::new(AtomicBool::new(false)); + let producer = { + let (q, pushed, stopped) = (q.clone(), pushed.clone(), stopped.clone()); + std::thread::spawn(move || { + // 64 MiB if nothing stopped it. + for _ in 0..1024 { + if !q.push(vec![7u8; 64 * 1024]) { + stopped.store(true, Ordering::SeqCst); + return; + } + pushed.fetch_add(64 * 1024, Ordering::SeqCst); + } + }) + }; + // The consumer stalls. The producer fills the queue and blocks. + let start = Instant::now(); + while q.pending_bytes() < CAP && start.elapsed() < Duration::from_secs(60) { + std::thread::sleep(Duration::from_millis(1)); + } + std::thread::sleep(Duration::from_millis(100)); + assert_eq!(q.pending_bytes(), CAP, "full, and no further"); + assert_eq!( + pushed.load(Ordering::SeqCst), + CAP, + "the producer is blocked" + ); + assert!(!producer.is_finished()); + // Draining lets it continue, still under the bound. + assert_eq!(q.drain(CAP).bytes.len(), CAP); + let start = Instant::now(); + while pushed.load(Ordering::SeqCst) < 2 * CAP && start.elapsed() < Duration::from_secs(60) { + std::thread::sleep(Duration::from_millis(1)); + } + assert!(q.high_water() <= CAP, "high water {}", q.high_water()); + // Closing cancels the blocked producer. + q.close(); + producer.join().unwrap(); + assert!(stopped.load(Ordering::SeqCst), "push reported the close"); + assert!(q.high_water() <= CAP); + } + + #[test] + fn the_end_is_reported_only_after_the_last_byte() { + let q = ByteQueue::new(1024); + assert!(q.push(b"abc".to_vec())); + q.finish(Ok(())); + assert!(q.wait(Duration::ZERO)); + // Whole chunks are handed over, so one byte asked gives the chunk. + let d = q.drain(1); + assert_eq!(d.bytes, b"abc"); + assert_eq!(d.end, Some(Ok(())), "empty and finished"); + let q = ByteQueue::new(1024); + q.push(b"ab".to_vec()); + q.push(b"cd".to_vec()); + q.finish(Err("boom".into())); + let d = q.drain(1); + assert_eq!((d.bytes.as_slice(), d.end), (&b"ab"[..], None)); + let d = q.drain(10); + assert_eq!(d.bytes, b"cd"); + assert_eq!(d.end, Some(Err("boom".into()))); + } +} diff --git a/apps/desktop/term/src/session.rs b/apps/desktop/term/src/session.rs new file mode 100644 index 000000000..15de2de47 --- /dev/null +++ b/apps/desktop/term/src/session.rs @@ -0,0 +1,882 @@ +//! Owning, signalling and retiring the processes of one session. +//! +//! A child started with `setsid()` has `sid == pgid == leader pid`. An +//! interactive shell moves each job into its own process group inside that +//! session, so signalling only the leader's group would miss them. +//! +//! # Ownership anchor +//! +//! [`OwnedSession`] holds the session leader **unreaped** until every signal +//! has been sent. While the leader is unreaped (running or a zombie) the +//! kernel cannot reuse its pid, and so cannot hand that number out as a new +//! process group or session id. Two things follow: +//! +//! - `killpg(leader)` always reaches our group or nothing; +//! - a process whose session id is `leader` is in our session. +//! +//! Exit is observed with `waitid(WNOWAIT)`, which does not reap. The leader +//! is reaped once, after the last signal. +//! +//! # Members outside the leader's group: fail closed +//! +//! Members are found by scanning the process table, and a scanned pid can +//! exit and be reused before it is signalled. A signal is therefore sent +//! only through a handle whose identity cannot be recycled: +//! +//! - **The leader's group**, on every platform: `killpg(leader)` under the +//! anchor. Closing the PTY master also hangs up the controlling terminal, +//! so the kernel sends SIGHUP to the foreground group and an interactive +//! shell sends its own SIGHUP to its jobs. +//! - **Linux, other groups**: a pidfd. Open it, re-read `/proc//stat` +//! and require the session id to still be `leader`, then +//! `pidfd_send_signal`. The pidfd names one process for its whole life, +//! so the signal reaches the process that was verified or nobody. +//! - **No handle, no signal.** macOS has no pidfd, and neither do Linux +//! kernels before 5.3. There a member outside the leader's group is never +//! signalled by number: it is left alone and reported. `kill(pid)` after +//! a check is not used anywhere, because the check and the kill can +//! straddle a pid reuse. +//! +//! What that costs: on macOS (and old Linux) a process that is in another +//! process group of the session **and** ignores or never receives SIGHUP +//! can outlive the terminal, as it would outlive any terminal app that is +//! closed. It is found by a scan of the session and returned in +//! [`Teardown::survivors`] so the caller can tell the user. +//! +//! A descendant that calls `setsid()` leaves the session and is out of reach. + +use std::io; +use std::process::Child; +use std::sync::{Arc, Mutex}; +use std::thread::JoinHandle; +use std::time::{Duration, Instant}; + +/// How long a synchronous (caller-thread) teardown may take after SIGKILL. +pub(crate) const SYNC_DEADLINE: Duration = Duration::from_secs(5); +/// How long a background reaper keeps signalling before it only waits. +pub(crate) const BACKGROUND_DEADLINE: Duration = Duration::from_secs(10); +/// After escalation, SIGKILL is re-sent this often to catch late forks. +const REKILL_EVERY: Duration = Duration::from_millis(50); +/// How long unreachable survivors get to exit on their own after SIGKILL +/// has gone to everything that can be reached. +const UNREACHABLE_SETTLE: Duration = Duration::from_millis(200); + +/// Live (non-zombie) processes whose session id is `leader`. +pub fn members(leader: libc::pid_t) -> Vec { + imp::members(leader) +} + +/// Whether `pid` is a live (non-zombie) process. +pub fn is_alive(pid: libc::pid_t) -> bool { + imp::identity(pid).is_some_and(|i| i.live) +} + +/// Whether `pid` is in the process table at all, zombies included. +pub fn exists(pid: libc::pid_t) -> bool { + // SAFETY: signal 0 only checks for existence. + let rc = unsafe { libc::kill(pid, 0) }; + rc == 0 || io::Error::last_os_error().raw_os_error() == Some(libc::EPERM) +} + +/// How a session leader ended. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum ExitInfo { + Exited(i32), + Signaled(i32), + /// The exit was observed but its status was not available. + Unknown, +} + +impl std::fmt::Display for ExitInfo { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Self::Exited(code) => write!(f, "exit status {code}"), + Self::Signaled(sig) => write!(f, "signal {sig}"), + Self::Unknown => write!(f, "exited"), + } + } +} + +/// Whether child `pid` has exited, without reaping it. +pub(crate) fn peek_exit(pid: libc::pid_t) -> Option { + // SAFETY: siginfo_t is plain old data; waitid fills it. + let mut info: libc::siginfo_t = unsafe { std::mem::zeroed() }; + let Ok(id) = libc::id_t::try_from(pid) else { + return Some(ExitInfo::Unknown); + }; + loop { + // SAFETY: WNOWAIT leaves the child waitable; the buffer is valid. + let rc = unsafe { + libc::waitid( + libc::P_PID, + id, + &mut info, + libc::WEXITED | libc::WNOHANG | libc::WNOWAIT, + ) + }; + if rc == 0 { + break; + } + match io::Error::last_os_error().raw_os_error() { + Some(libc::EINTR) => {} + // Not our child any more: it was reaped. + _ => return Some(ExitInfo::Unknown), + } + } + // SAFETY: waitid succeeded, so the SIGCHLD fields are initialised. + let (si_pid, status) = unsafe { (info.si_pid(), info.si_status()) }; + if si_pid == 0 { + return None; + } + Some(if info.si_code == libc::CLD_EXITED { + ExitInfo::Exited(status) + } else { + ExitInfo::Signaled(status) + }) +} + +/// What one signalling pass did to the scanned members. +#[derive(Clone, Debug, Default, PartialEq, Eq)] +pub struct SignalReport { + /// Verified members of our session that were signalled: through their + /// own handle, or as part of the leader's group. + pub signalled: Vec, + /// Scanned pids that were gone or no longer in our session: not touched. + pub skipped: Vec, + /// Members of our session outside the leader's group for which this + /// platform has no race-free handle: not signalled. See the module docs. + pub unreachable: Vec, +} + +/// The result of ending a session. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct Teardown { + /// The leader is reaped and no live session member remains. + pub gone: bool, + /// SIGKILL was needed: the session outlived its grace period. + pub escalated: bool, + /// Session members still running when teardown stopped. These are pids + /// for display only; they must never be signalled by number. + pub survivors: Vec, +} + +impl Teardown { + /// Nothing was running to begin with. + pub(crate) fn nothing() -> Self { + Self { + gone: true, + escalated: false, + survivors: Vec::new(), + } + } +} + +/// What to do with a leader that survives SIGKILL past the deadline. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub(crate) enum LastResort { + /// Keep waiting for it (a dedicated reaper thread). + Block, + /// Return without reaping (the caller's thread must not hang). + GiveUp, +} + +/// What the process table says a pid is right now. +#[derive(Clone, Copy, Debug)] +pub(crate) struct Identity { + pub sid: libc::pid_t, + pub pgid: libc::pid_t, + pub live: bool, +} + +/// A handle that names one process for its whole life. +pub(crate) trait ProcHandle { + /// Send `sig`; `false` when the process is gone. + fn send(&self, sig: libc::c_int) -> bool; +} + +/// The result of asking for a handle. +#[cfg_attr( + not(target_os = "linux"), + allow(dead_code, reason = "only Linux has a race-free handle to construct") +)] +pub(crate) enum Open { + Handle(Box), + /// No such process. + Gone, + /// This platform has no race-free handle for another process. + Unsupported, +} + +/// The process table as teardown sees it. The real one is [`OsTable`]; +/// tests substitute their own to control what a pid means at each step. +pub(crate) trait ProcTable { + fn scan(&self, leader: libc::pid_t) -> Vec; + fn open(&self, pid: libc::pid_t) -> Open; + fn identity(&self, pid: libc::pid_t) -> Option; + /// Runs after a member has been verified and before it is signalled. + /// The boundary a pid reuse would have to fall in; a no-op outside tests. + fn verified(&self, _pid: libc::pid_t) {} +} + +/// The operating system's process table. +pub(crate) struct OsTable; + +impl ProcTable for OsTable { + fn scan(&self, leader: libc::pid_t) -> Vec { + imp::members(leader) + } + + fn open(&self, pid: libc::pid_t) -> Open { + imp::open(pid) + } + + fn identity(&self, pid: libc::pid_t) -> Option { + imp::identity(pid) + } +} + +/// Signal the members of `leader`'s session that are outside its group, +/// each through its own handle, and classify every scanned pid. Members of +/// the leader's own group are only verified here; the caller signals that +/// group with `killpg` under the anchor. +/// +/// No pid number is ever passed to a signalling call: after `open`, the +/// only thing that can be signalled is the handle. +pub(crate) fn signal_members( + leader: libc::pid_t, + sig: libc::c_int, + table: &dyn ProcTable, +) -> SignalReport { + let mut report = SignalReport::default(); + for pid in table.scan(leader) { + if pid == leader { + continue; + } + // Open first: from here a handle, if there is one, pins the process + // that `pid` named at this instant. + let handle = match table.open(pid) { + Open::Gone => { + report.skipped.push(pid); + continue; + } + Open::Handle(h) => Some(h), + Open::Unsupported => None, + }; + let Some(id) = table.identity(pid).filter(|i| i.sid == leader && i.live) else { + report.skipped.push(pid); + continue; + }; + table.verified(pid); + if id.pgid == leader { + // The caller's killpg reaches it under the anchor. + report.signalled.push(pid); + } else { + match handle { + Some(h) if h.send(sig) => report.signalled.push(pid), + Some(_) => report.skipped.push(pid), + None => report.unreachable.push(pid), + } + } + } + report +} + +/// A session we started and are responsible for ending. See the module docs. +#[derive(Debug)] +pub(crate) struct OwnedSession { + leader: libc::pid_t, + /// `Some` until the leader is reaped. + child: Option, + /// The process table signalling goes through; the OS one outside tests. + table: Arc, +} + +impl std::fmt::Debug for dyn ProcTable + Send + Sync { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.write_str("ProcTable") + } +} + +impl OwnedSession { + /// Take ownership of a child that called `setsid()` before exec. + pub fn new(child: Child) -> io::Result { + match libc::pid_t::try_from(child.id()) { + Ok(leader) => Ok(Self { + leader, + child: Some(child), + table: Arc::new(OsTable), + }), + Err(_) => { + let mut child = child; + let _ = child.kill(); + let _ = child.wait(); + Err(io::Error::other("child pid out of range")) + } + } + } + + pub fn leader(&self) -> libc::pid_t { + self.leader + } + + /// The leader's exit, observed without reaping. + pub fn exit(&self) -> Option { + peek_exit(self.leader) + } + + /// Signal the leader's group, and every other verified session member + /// for which there is a race-free handle. + pub fn signal(&self, sig: libc::c_int) -> SignalReport { + self.signal_with(sig, &*self.table) + } + + /// Replace the process table, to run teardown as another platform would. + #[cfg(test)] + pub(crate) fn set_table(&mut self, table: Arc) { + self.table = table; + } + + /// [`Self::signal`] against a supplied process table. + pub(crate) fn signal_with(&self, sig: libc::c_int, table: &dyn ProcTable) -> SignalReport { + if self.child.is_none() { + // Reaped: the anchor is gone, so nothing may be signalled. + return SignalReport::default(); + } + // Members first, while they can still be verified. + let report = signal_members(self.leader, sig, table); + // SAFETY: the leader is unreaped, so this group id is still ours. + unsafe { + libc::killpg(self.leader, sig); + } + report + } + + /// End the session and reap the leader. Sends SIGKILL once `grace` has + /// passed (immediately when zero) and keeps re-sending it; runs + /// `before_reap` after the last signal; then reaps. + pub fn retire( + mut self, + grace: Duration, + deadline: Duration, + before_reap: impl FnOnce(), + last: LastResort, + ) -> Teardown { + self.finish(grace, deadline, before_reap, last) + } + + fn finish( + &mut self, + grace: Duration, + deadline: Duration, + before_reap: impl FnOnce(), + last: LastResort, + ) -> Teardown { + let start = Instant::now(); + let mut killed_at: Option = None; + // Set while everything still alive is beyond our reach. + let mut stuck_since: Option = None; + let gone = loop { + let exited = self.exit().is_some(); + let alive = members(self.leader); + if exited && alive.is_empty() { + break true; + } + let rekill = killed_at.is_some_and(|t| t.elapsed() >= REKILL_EVERY); + if (killed_at.is_none() && start.elapsed() >= grace) || rekill { + let report = self.signal(libc::SIGKILL); + killed_at = Some(Instant::now()); + // SIGKILL has gone to everything we can reach. If the leader + // is dead and all that is left has no handle, waiting longer + // changes nothing. + let stuck = exited && alive.iter().all(|p| report.unreachable.contains(p)); + stuck_since = stuck.then(|| stuck_since.unwrap_or_else(Instant::now)); + } + if stuck_since.is_some_and(|t| t.elapsed() >= UNREACHABLE_SETTLE) { + break false; + } + if start.elapsed() >= deadline { + break false; + } + std::thread::sleep(Duration::from_millis(10)); + }; + let survivors = if gone { + Vec::new() + } else { + members(self.leader) + }; + before_reap(); + // Every signal has been sent. Only now give up the pid. + if let Some(mut child) = self.child.take() + && (last == LastResort::Block || peek_exit(self.leader).is_some()) + { + let _ = child.wait(); + } + Teardown { + gone, + escalated: killed_at.is_some(), + survivors, + } + } + + /// Retire on a reaper thread. If the thread cannot be started, this + /// thread keeps the responsibility: it kills the session now and reaps. + /// The result is delivered to `report` either way. + pub fn retire_in_background( + self, + spawner: &ThreadSpawner, + grace: Duration, + before_reap: Box, + report: Option>, + ) { + type Parts = ( + OwnedSession, + Box, + Option>, + ); + type Slot = Arc>>; + let take = |slot: &Slot| slot.lock().unwrap_or_else(|e| e.into_inner()).take(); + let slot: Slot = Arc::new(Mutex::new(Some((self, before_reap, report)))); + let in_thread = slot.clone(); + let job: Job = Box::new(move || { + if let Some((mut session, before_reap, report)) = take(&in_thread) { + let done = + session.finish(grace, BACKGROUND_DEADLINE, before_reap, LastResort::Block); + if let Some(tx) = report { + let _ = tx.send(done); + } + } + }); + if spawner.spawn("hack-term-reaper", job).is_err() + && let Some((mut session, before_reap, report)) = take(&slot) + { + let done = session.finish( + Duration::ZERO, + SYNC_DEADLINE, + before_reap, + LastResort::GiveUp, + ); + if let Some(tx) = report { + let _ = tx.send(done); + } + } + } +} + +/// The result of a teardown that is running on a reaper thread. +#[derive(Debug)] +pub struct PendingTeardown(pub(crate) std::sync::mpsc::Receiver); + +impl PendingTeardown { + /// The result, once teardown has finished. Check + /// [`Teardown::survivors`] to tell the user about anything left running. + pub fn try_result(&self) -> Option { + self.0.try_recv().ok() + } + + /// Wait up to `timeout` for the result. + pub fn wait(&self, timeout: Duration) -> Option { + self.0.recv_timeout(timeout).ok() + } +} + +impl Drop for OwnedSession { + /// A session dropped without being retired (an error path between spawn + /// and hand-over) is killed and reaped here, on the caller's thread. + fn drop(&mut self) { + if self.child.is_some() { + self.finish(Duration::ZERO, SYNC_DEADLINE, || {}, LastResort::GiveUp); + } + } +} + +pub(crate) type Job = Box; + +type SpawnFn = dyn Fn(&str, Job) -> io::Result> + Send + Sync; + +/// How worker threads are started. Injectable so tests can make it fail. +#[derive(Clone)] +pub(crate) struct ThreadSpawner(Arc); + +impl std::fmt::Debug for ThreadSpawner { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.write_str("ThreadSpawner") + } +} + +impl ThreadSpawner { + /// Real OS threads. + pub fn os() -> Self { + Self(Arc::new(|name, job| { + std::thread::Builder::new().name(name.to_owned()).spawn(job) + })) + } + + pub fn spawn(&self, name: &str, job: Job) -> io::Result> { + (self.0)(name, job) + } + + /// Succeeds `ok` times, then fails every call. For tests. + #[cfg(test)] + pub fn failing_after(ok: usize) -> Self { + use std::sync::atomic::{AtomicUsize, Ordering}; + let calls = AtomicUsize::new(0); + let real = Self::os(); + Self(Arc::new(move |name, job| { + if calls.fetch_add(1, Ordering::SeqCst) < ok { + real.spawn(name, job) + } else { + Err(io::Error::other("injected thread-spawn failure")) + } + })) + } +} + +#[cfg(target_os = "linux")] +mod imp { + use super::{Identity, Open, ProcHandle}; + use std::os::fd::{AsRawFd, FromRawFd, OwnedFd}; + + /// State, process group and session from `/proc//stat`. + pub fn identity(pid: libc::pid_t) -> Option { + let text = std::fs::read_to_string(format!("/proc/{pid}/stat")).ok()?; + // comm may contain spaces and parentheses; fields resume after the last ')'. + let rest = &text[text.rfind(')')? + 1..]; + let mut fields = rest.split_ascii_whitespace(); + let state = fields.next()?.chars().next()?; + let _ppid = fields.next()?; + let pgid = fields.next()?.parse().ok()?; + let sid = fields.next()?.parse().ok()?; + Some(Identity { + sid, + pgid, + live: state != 'Z' && state != 'X', + }) + } + + pub fn members(leader: libc::pid_t) -> Vec { + let Ok(dir) = std::fs::read_dir("/proc") else { + return Vec::new(); + }; + dir.filter_map(|e| e.ok()?.file_name().to_str()?.parse::().ok()) + .filter(|&pid| identity(pid).is_some_and(|i| i.sid == leader && i.live)) + .collect() + } + + struct PidFd(OwnedFd); + + impl ProcHandle for PidFd { + fn send(&self, sig: libc::c_int) -> bool { + // SAFETY: a valid pidfd; a null siginfo means a kill-style signal. + let rc = unsafe { + libc::syscall( + libc::SYS_pidfd_send_signal, + self.0.as_raw_fd(), + sig, + std::ptr::null::(), + 0 as libc::c_uint, + ) + }; + rc == 0 + } + } + + /// A pidfd for `pid`. Kernels before 5.3 have none: `Unsupported`, and + /// the caller then signals nothing by number. + pub fn open(pid: libc::pid_t) -> Open { + // SAFETY: pidfd_open takes a pid and flags and returns a descriptor. + let raw = unsafe { libc::syscall(libc::SYS_pidfd_open, pid, 0 as libc::c_uint) }; + if raw >= 0 { + // SAFETY: a fresh descriptor we own. pidfds are close-on-exec. + return Open::Handle(Box::new(PidFd(unsafe { + OwnedFd::from_raw_fd(raw as libc::c_int) + }))); + } + match std::io::Error::last_os_error().raw_os_error() { + Some(libc::ENOSYS) => Open::Unsupported, + _ => Open::Gone, + } + } +} + +#[cfg(target_os = "macos")] +mod imp { + use super::{Identity, Open}; + + /// `SZOMB` from ``. + const SZOMB: u32 = 5; + + fn bsd_info(pid: libc::pid_t) -> Option { + // SAFETY: proc_bsdinfo is plain old data. + let mut info: libc::proc_bsdinfo = unsafe { std::mem::zeroed() }; + let size = libc::c_int::try_from(std::mem::size_of::()).ok()?; + // SAFETY: the buffer is one proc_bsdinfo of `size` bytes. + let n = unsafe { + libc::proc_pidinfo(pid, libc::PROC_PIDTBSDINFO, 0, (&raw mut info).cast(), size) + }; + (n == size).then_some(info) + } + + pub fn identity(pid: libc::pid_t) -> Option { + let info = bsd_info(pid)?; + // SAFETY: getsid only reads the process table. + let sid = unsafe { libc::getsid(pid) }; + Some(Identity { + sid, + pgid: libc::pid_t::try_from(info.pbi_pgid).ok()?, + live: info.pbi_status != SZOMB, + }) + } + + fn all_pids() -> Vec { + for _ in 0..4 { + // SAFETY: a null buffer asks for the current count. + let n = unsafe { libc::proc_listallpids(std::ptr::null_mut(), 0) }; + let Ok(n) = usize::try_from(n) else { + return Vec::new(); + }; + let mut buf: Vec = vec![0; n + 64]; + let Ok(bytes) = libc::c_int::try_from(buf.len() * std::mem::size_of::()) + else { + return Vec::new(); + }; + // SAFETY: the buffer holds `bytes` bytes; the result is a pid count. + let got = unsafe { libc::proc_listallpids(buf.as_mut_ptr().cast(), bytes) }; + let Ok(got) = usize::try_from(got) else { + return Vec::new(); + }; + if got < buf.len() { + buf.truncate(got); + return buf; + } + } + Vec::new() + } + + pub fn members(leader: libc::pid_t) -> Vec { + all_pids() + .into_iter() + .filter(|&pid| pid > 0) + .filter(|&pid| identity(pid).is_some_and(|i| i.sid == leader && i.live)) + .collect() + } + + /// macOS has no handle that pins another process, so nothing outside + /// the anchored leader group is signalled. See the module docs. + pub fn open(_pid: libc::pid_t) -> Open { + Open::Unsupported + } +} + +#[cfg(not(any(target_os = "linux", target_os = "macos")))] +mod imp { + use super::{Identity, Open}; + + pub fn identity(pid: libc::pid_t) -> Option { + // SAFETY: these calls only read the process table. + let (sid, pgid) = unsafe { (libc::getsid(pid), libc::getpgid(pid)) }; + (sid > 0).then_some(Identity { + sid, + pgid, + live: true, + }) + } + + pub fn members(_leader: libc::pid_t) -> Vec { + Vec::new() + } + + pub fn open(_pid: libc::pid_t) -> Open { + Open::Unsupported + } +} + +#[cfg(test)] +mod tests { + use super::*; + use std::cell::RefCell; + use std::collections::HashMap; + use std::rc::Rc; + + const LEADER: libc::pid_t = 1000; + + /// A process in the fake table. `generation` tells apart two processes + /// that held the same pid one after the other. + #[derive(Clone, Copy, Debug, PartialEq, Eq)] + struct FakeProc { + generation: u32, + sid: libc::pid_t, + pgid: libc::pid_t, + } + + /// A process table the test controls, with pid reuse on demand. + #[derive(Default)] + struct FakeTable { + procs: RefCell>, + scan: Vec, + handles: bool, + /// Pids whose process is replaced right after verification. + replace_after_verify: Vec, + /// Every signal delivered: (pid, generation that received it). + delivered: Rc>>, + /// Every send attempted through a handle: the generation it pins. + attempts: Rc>>, + } + + struct FakeHandle { + pid: libc::pid_t, + pinned: u32, + procs: *const RefCell>, + delivered: Rc>>, + attempts: Rc>>, + } + + impl ProcHandle for FakeHandle { + fn send(&self, _sig: libc::c_int) -> bool { + self.attempts.borrow_mut().push((self.pid, self.pinned)); + // SAFETY: the table outlives every handle in these tests. + let procs = unsafe { &*self.procs }.borrow(); + // Like a pidfd: the signal goes to the pinned process if it + // still exists, never to whatever holds the pid now. + let alive = procs + .get(&self.pid) + .is_some_and(|p| p.generation == self.pinned); + if alive { + self.delivered.borrow_mut().push((self.pid, self.pinned)); + } + alive + } + } + + impl ProcTable for FakeTable { + fn scan(&self, _leader: libc::pid_t) -> Vec { + self.scan.clone() + } + + fn open(&self, pid: libc::pid_t) -> Open { + if !self.handles { + return Open::Unsupported; + } + match self.procs.borrow().get(&pid) { + None => Open::Gone, + Some(p) => Open::Handle(Box::new(FakeHandle { + pid, + pinned: p.generation, + procs: &self.procs, + delivered: self.delivered.clone(), + attempts: self.attempts.clone(), + })), + } + } + + fn identity(&self, pid: libc::pid_t) -> Option { + self.procs.borrow().get(&pid).map(|p| Identity { + sid: p.sid, + pgid: p.pgid, + live: true, + }) + } + + fn verified(&self, pid: libc::pid_t) { + if self.replace_after_verify.contains(&pid) { + // The verified process exits and an unrelated one, in + // another session, takes over its pid number. + self.procs.borrow_mut().insert( + pid, + FakeProc { + generation: 2, + sid: 7777, + pgid: 7777, + }, + ); + } + } + } + + fn member(pgid: libc::pid_t) -> FakeProc { + FakeProc { + generation: 1, + sid: LEADER, + pgid, + } + } + + #[test] + fn a_pid_replaced_between_verification_and_signalling_receives_nothing() { + let table = FakeTable { + procs: RefCell::new(HashMap::from([ + (2001, member(2001)), // a job, replaced at the boundary + (2002, member(2002)), // a job that stays + (2003, member(LEADER)), // in the leader's group, replaced + ])), + scan: vec![2001, 2002, 2003], + handles: true, + replace_after_verify: vec![2001, 2003], + ..FakeTable::default() + }; + let report = signal_members(LEADER, libc::SIGTERM, &table); + // The send for 2001 went through the handle pinned to the verified + // process (generation 1), which no longer exists. + assert_eq!(*table.attempts.borrow(), [(2001, 1), (2002, 1)]); + // Only the member that is still itself received a signal. The + // replacement at pid 2001 (generation 2) received nothing. + assert_eq!(*table.delivered.borrow(), [(2002, 1)]); + assert!(!table.delivered.borrow().iter().any(|&(_, g)| g == 2)); + // 2003 is covered by the leader's group signal and is never + // signalled by number, so its replacement cannot be hit here either. + assert_eq!(report.signalled, [2002, 2003]); + assert_eq!(report.skipped, [2001]); + assert!(report.unreachable.is_empty()); + } + + #[test] + fn without_a_handle_nothing_outside_the_leader_group_is_signalled() { + // What macOS, and Linux without pidfd, look like. + let table = FakeTable { + procs: RefCell::new(HashMap::from([ + (2001, member(2001)), + (2002, member(LEADER)), + ( + 2003, + FakeProc { + generation: 1, + sid: 4242, + pgid: 4242, + }, + ), + ])), + scan: vec![2001, 2002, 2003, 2004], + handles: false, + replace_after_verify: vec![2001], + ..FakeTable::default() + }; + let report = signal_members(LEADER, libc::SIGKILL, &table); + assert!( + table.attempts.borrow().is_empty(), + "no per-process signal at all" + ); + assert!(table.delivered.borrow().is_empty()); + assert_eq!(report.unreachable, [2001], "reported, not signalled"); + assert_eq!(report.signalled, [2002], "the leader's group, by killpg"); + assert_eq!(report.skipped, [2003, 2004], "another session; no such pid"); + } + + #[test] + fn stale_scan_results_are_skipped() { + let table = FakeTable { + procs: RefCell::new(HashMap::from([( + 2001, + FakeProc { + generation: 1, + sid: 9, + pgid: 9, + }, + )])), + scan: vec![LEADER, 2001, 2002], + handles: true, + ..FakeTable::default() + }; + let report = signal_members(LEADER, libc::SIGTERM, &table); + assert_eq!(report.skipped, [2001, 2002]); + assert!(report.signalled.is_empty() && table.attempts.borrow().is_empty()); + } +} diff --git a/apps/desktop/term/src/sprite.rs b/apps/desktop/term/src/sprite.rs new file mode 100644 index 000000000..b59aacfa4 --- /dev/null +++ b/apps/desktop/term/src/sprite.rs @@ -0,0 +1,738 @@ +//! Glyphs the terminal draws itself, as geometry that fits the cell exactly. +//! +//! Three families are drawn here instead of taken from a font, the way +//! Ghostty's renderer does it: +//! +//! - box drawing, U+2500..U+257F; +//! - block elements, U+2580..U+259F; +//! - the geometric powerline separators, U+E0B0..U+E0BF. +//! +//! A font's versions of these rarely meet the edges of the cell, so lines +//! break between rows and prompt segments show seams. Drawn as geometry in +//! whole device pixels they join at any font size, with any font. +//! +//! This module only computes shapes; it has no GUI in it. The view paints +//! them. The pictographs next to the separators (branch U+E0A0, line number +//! U+E0A1, padlock U+E0A2, column U+E0A3) are icons, not cell geometry, and +//! come from the symbols font; Ghostty does the same. +//! +//! The box-drawing table is derived from the Unicode character names (each +//! name says which arms a character has and how heavy they are). The +//! geometry is written for this crate; it is not a port of Ghostty's code. + +/// One command of a path, in cell pixels from the cell's top-left corner. +#[derive(Clone, Copy, Debug, PartialEq)] +pub enum Seg { + Move(f32, f32), + Line(f32, f32), + /// A cubic Bézier: two control points, then the end point. + Cubic((f32, f32), (f32, f32), (f32, f32)), + /// Close the current contour. A path may hold several. + Close, +} + +/// One piece of a glyph. +#[derive(Clone, Debug, PartialEq)] +pub enum Shape { + /// A filled rectangle. `alpha` scales the colour (the shade blocks). + Rect { + x0: f32, + y0: f32, + x1: f32, + y1: f32, + alpha: f32, + }, + /// A filled, closed path. + Fill(Vec), + /// A stroked, open path. + Stroke { path: Vec, width: f32 }, +} + +/// Whether `ch` is drawn by this module. +pub fn covers(ch: char) -> bool { + matches!(ch, '\u{2500}'..='\u{259f}' | '\u{e0b0}'..='\u{e0bf}') +} + +/// The thickness of a light line for a cell `w` device pixels wide. +pub fn line_thickness(w: f32) -> f32 { + (w / 8.0).round().max(1.0) +} + +/// The shapes for `ch` in a cell of `w` by `h` device pixels, with light +/// lines `t` device pixels thick. All three are whole numbers, so every +/// rectangle lands on pixel boundaries. `None` when `ch` is not drawn here. +pub fn shapes(ch: char, w: f32, h: f32, t: f32) -> Option> { + let t = t.max(1.0); + let mut out = Vec::new(); + match ch { + '\u{2500}'..='\u{257f}' => box_drawing(BOX[ch as usize - 0x2500], w, h, t, &mut out), + '\u{2580}'..='\u{259f}' => block(ch, w, h, &mut out), + '\u{e0b0}'..='\u{e0bf}' => powerline(ch, w, h, t, &mut out), + _ => return None, + } + Some(out) +} + +fn rect(out: &mut Vec, x0: f32, y0: f32, x1: f32, y1: f32) { + if x1 > x0 && y1 > y0 { + out.push(Shape::Rect { + x0, + y0, + x1, + y1, + alpha: 1.0, + }); + } +} + +// --- box drawing ----------------------------------------------------------- + +/// Arms and kind for U+2500..U+257F. Two bits per arm (0 none, 1 light, +/// 2 heavy, 3 double) in the order up, right, down, left; the kind in bits +/// 8..11: 0 lines, 2/3/4 that many dashes, 5 arc, 6 "/", 7 "\\", 8 both. +#[rustfmt::skip] +const BOX: [u16; 128] = [ + 0x0044, 0x0088, 0x0011, 0x0022, 0x0344, 0x0388, 0x0311, 0x0322, // U+2500 + 0x0444, 0x0488, 0x0411, 0x0422, 0x0014, 0x0018, 0x0024, 0x0028, // U+2508 + 0x0050, 0x0090, 0x0060, 0x00a0, 0x0005, 0x0009, 0x0006, 0x000a, // U+2510 + 0x0041, 0x0081, 0x0042, 0x0082, 0x0015, 0x0019, 0x0016, 0x0025, // U+2518 + 0x0026, 0x001a, 0x0029, 0x002a, 0x0051, 0x0091, 0x0052, 0x0061, // U+2520 + 0x0062, 0x0092, 0x00a1, 0x00a2, 0x0054, 0x0094, 0x0058, 0x0098, // U+2528 + 0x0064, 0x00a4, 0x0068, 0x00a8, 0x0045, 0x0085, 0x0049, 0x0089, // U+2530 + 0x0046, 0x0086, 0x004a, 0x008a, 0x0055, 0x0095, 0x0059, 0x0099, // U+2538 + 0x0056, 0x0065, 0x0066, 0x0096, 0x005a, 0x00a5, 0x0069, 0x009a, // U+2540 + 0x00a9, 0x00a6, 0x006a, 0x00aa, 0x0244, 0x0288, 0x0211, 0x0222, // U+2548 + 0x00cc, 0x0033, 0x001c, 0x0034, 0x003c, 0x00d0, 0x0070, 0x00f0, // U+2550 + 0x000d, 0x0007, 0x000f, 0x00c1, 0x0043, 0x00c3, 0x001d, 0x0037, // U+2558 + 0x003f, 0x00d1, 0x0073, 0x00f3, 0x00dc, 0x0074, 0x00fc, 0x00cd, // U+2560 + 0x0047, 0x00cf, 0x00dd, 0x0077, 0x00ff, 0x0514, 0x0550, 0x0541, // U+2568 + 0x0505, 0x0600, 0x0700, 0x0800, 0x0040, 0x0001, 0x0004, 0x0010, // U+2570 + 0x0080, 0x0002, 0x0008, 0x0020, 0x0048, 0x0021, 0x0084, 0x0012, // U+2578 +]; + +const NONE: u8 = 0; +const HEAVY: u8 = 2; +const DOUBLE: u8 = 3; + +/// Where lines sit across one dimension of the cell. A light line occupies +/// `[l0, l1)`, a heavy one `[h0, h1)`, and the two lines of a double line +/// `[d0, l0)` and `[l1, d3)`: a light-line-wide gap between them. +#[derive(Clone, Copy)] +struct Axis { + n: f32, + l0: f32, + l1: f32, + h0: f32, + h1: f32, + d0: f32, + d3: f32, + mid: f32, +} + +impl Axis { + fn new(n: f32, t: f32) -> Self { + let l0 = ((n - t) / 2.0).floor(); + let h0 = ((n - 2.0 * t) / 2.0).floor().max(0.0); + Self { + n, + l0, + l1: l0 + t, + h0, + h1: (h0 + 2.0 * t).min(n), + d0: (l0 - t).max(0.0), + d3: (l0 + 2.0 * t).min(n), + mid: (n / 2.0).floor(), + } + } +} + +/// How an arm ends at the centre of the cell. +#[derive(Clone, Copy)] +enum Join { + /// Reach the far line of a perpendicular double line: an outer corner. + Outer, + /// Stop at the near line of a perpendicular double line. + Inner, + /// Cover a perpendicular single line, light or heavy. + Touch { heavy: bool }, + /// Nothing to meet: a half line. + Lone, +} + +/// The coordinate at which an arm ends at the centre. `forward` arms (right +/// and down) run from there to the far edge, the others from 0 to there. +fn join_at(a: Axis, join: Join, forward: bool) -> f32 { + match (join, forward) { + (Join::Outer, true) => a.d0, + (Join::Outer, false) => a.d3, + (Join::Inner, true) => a.l1, + (Join::Inner, false) => a.l0, + (Join::Touch { heavy: true }, true) => a.h0, + (Join::Touch { heavy: true }, false) => a.h1, + (Join::Touch { heavy: false }, true) => a.l0, + (Join::Touch { heavy: false }, false) => a.l1, + (Join::Lone, _) => a.mid, + } +} + +fn box_drawing(entry: u16, w: f32, h: f32, t: f32, out: &mut Vec) { + let arms = [ + (entry & 3) as u8, + (entry >> 2 & 3) as u8, + (entry >> 4 & 3) as u8, + (entry >> 6 & 3) as u8, + ]; + let (x, y) = (Axis::new(w, t), Axis::new(h, t)); + match entry >> 8 { + 0 => box_lines(arms, x, y, out), + dashes @ 2..=4 => box_dashes(arms, x, y, f32::from(dashes), out), + 5 => box_arc(arms, x, y, t, out), + kind => { + if kind == 6 || kind == 8 { + out.push(Shape::Stroke { + path: vec![Seg::Move(w, 0.0), Seg::Line(0.0, h)], + width: t, + }); + } + if kind == 7 || kind == 8 { + out.push(Shape::Stroke { + path: vec![Seg::Move(0.0, 0.0), Seg::Line(w, h)], + width: t, + }); + } + } + } +} + +fn box_lines(arms: [u8; 4], x: Axis, y: Axis, out: &mut Vec) { + for (i, &weight) in arms.iter().enumerate() { + if weight == NONE { + continue; + } + let horizontal = i % 2 == 1; + let forward = i == 1 || i == 2; + // The axis the arm runs along, and the one its thickness is on. + let (along, cross) = if horizontal { (x, y) } else { (y, x) }; + // The perpendicular arms, at the smaller and the larger cross + // coordinate: up and down for a horizontal arm, left and right for + // a vertical one. And the arm straight across the centre. + let (before, after) = if horizontal { + (arms[0], arms[2]) + } else { + (arms[3], arms[1]) + }; + let opposite = arms[(i + 2) % 4]; + let single_touch = || { + if before != NONE || after != NONE { + Join::Touch { + heavy: before == HEAVY || after == HEAVY, + } + } else if opposite != NONE { + Join::Touch { heavy: false } + } else { + Join::Lone + } + }; + let mut line = |c0: f32, c1: f32, join: Join| { + let at = join_at(along, join, forward); + let (a0, a1) = if forward { (at, along.n) } else { (0.0, at) }; + if horizontal { + rect(out, a0, c0, a1, c1); + } else { + rect(out, c0, a0, c1, a1); + } + }; + if weight == DOUBLE { + // One line on each side of the centre. Each joins the double + // line on its own side, or wraps around the one on the other. + for (c0, c1, side, other) in [ + (cross.d0, cross.l0, before, after), + (cross.l1, cross.d3, after, before), + ] { + let join = if side == DOUBLE { + Join::Inner + } else if other == DOUBLE { + Join::Outer + } else { + single_touch() + }; + line(c0, c1, join); + } + } else { + let (c0, c1) = if weight == HEAVY { + (cross.h0, cross.h1) + } else { + (cross.l0, cross.l1) + }; + let join = match (before == DOUBLE, after == DOUBLE) { + // Crossing a double line: pass through, or stop at it. + (true, true) if opposite != NONE => Join::Touch { heavy: false }, + (true, true) => Join::Inner, + // A corner with a double line: reach its outer line. + (true, false) | (false, true) => Join::Outer, + (false, false) => single_touch(), + }; + line(c0, c1, join); + } + } +} + +fn box_dashes(arms: [u8; 4], x: Axis, y: Axis, count: f32, out: &mut Vec) { + let horizontal = arms[1] != NONE; + let weight = if horizontal { arms[1] } else { arms[0] }; + let (along, cross) = if horizontal { (x, y) } else { (y, x) }; + let (c0, c1) = if weight == HEAVY { + (cross.h0, cross.h1) + } else { + (cross.l0, cross.l1) + }; + let step = along.n / count; + if step < 2.0 { + // Too small a cell for separate dashes: a solid line. + if horizontal { + rect(out, 0.0, c0, along.n, c1); + } else { + rect(out, c0, 0.0, c1, along.n); + } + return; + } + let gap = (step / 4.0).round().max(1.0); + for i in 0..count as usize { + let a0 = (i as f32 * step + gap / 2.0).round(); + let a1 = ((i + 1) as f32 * step - gap / 2.0).round(); + if horizontal { + rect(out, a0, c0, a1, c1); + } else { + rect(out, c0, a0, c1, a1); + } + } +} + +/// How far along a quarter circle's tangent a cubic's control point sits. +const KAPPA: f32 = 0.552_284_8; + +fn box_arc(arms: [u8; 4], x: Axis, y: Axis, t: f32, out: &mut Vec) { + let (cx, cy) = (x.l0 + t / 2.0, y.l0 + t / 2.0); + let r = (x.n.min(y.n) / 2.0).floor(); + // The vertical arm goes up (-1) or down (+1); the horizontal one left + // (-1) or right (+1). + let v = if arms[0] != NONE { -1.0 } else { 1.0 }; + let u = if arms[3] != NONE { -1.0 } else { 1.0 }; + let k = r * (1.0 - KAPPA); + out.push(Shape::Stroke { + path: vec![ + Seg::Move(cx, if v > 0.0 { y.n } else { 0.0 }), + Seg::Line(cx, cy + v * r), + Seg::Cubic((cx, cy + v * k), (cx + u * k, cy), (cx + u * r, cy)), + Seg::Line(if u > 0.0 { x.n } else { 0.0 }, cy), + ], + width: t, + }); +} + +// --- block elements -------------------------------------------------------- + +fn block(ch: char, w: f32, h: f32, out: &mut Vec) { + let eighth_y = |i: u32| (h * i as f32 / 8.0).round(); + let eighth_x = |i: u32| (w * i as f32 / 8.0).round(); + let (hx, hy) = (eighth_x(4), eighth_y(4)); + let shade = |out: &mut Vec, alpha: f32| { + out.push(Shape::Rect { + x0: 0.0, + y0: 0.0, + x1: w, + y1: h, + alpha, + }); + }; + // Quadrants as bits: upper left 1, upper right 2, lower left 4, lower right 8. + let quadrants = |out: &mut Vec, bits: u8| { + for (bit, x0, y0, x1, y1) in [ + (1, 0.0, 0.0, hx, hy), + (2, hx, 0.0, w, hy), + (4, 0.0, hy, hx, h), + (8, hx, hy, w, h), + ] { + if bits & bit != 0 { + rect(out, x0, y0, x1, y1); + } + } + }; + match ch as u32 { + 0x2580 => rect(out, 0.0, 0.0, w, hy), + // Lower one eighth up to the full block. + // Never thinner than one pixel, however small the cell. + cp @ 0x2581..=0x2588 => rect(out, 0.0, eighth_y(0x2588 - cp).min(h - 1.0), w, h), + // Left seven eighths down to left one eighth. + cp @ 0x2589..=0x258f => rect(out, 0.0, 0.0, eighth_x(0x2590 - cp).max(1.0), h), + 0x2590 => rect(out, hx, 0.0, w, h), + 0x2591 => shade(out, 0.25), + 0x2592 => shade(out, 0.5), + 0x2593 => shade(out, 0.75), + 0x2594 => rect(out, 0.0, 0.0, w, eighth_y(1).max(1.0)), + 0x2595 => rect(out, eighth_x(7).min(w - 1.0), 0.0, w, h), + 0x2596 => quadrants(out, 4), + 0x2597 => quadrants(out, 8), + 0x2598 => quadrants(out, 1), + 0x2599 => quadrants(out, 1 | 4 | 8), + 0x259a => quadrants(out, 1 | 8), + 0x259b => quadrants(out, 1 | 2 | 4), + 0x259c => quadrants(out, 1 | 2 | 8), + 0x259d => quadrants(out, 2), + 0x259e => quadrants(out, 2 | 4), + _ => quadrants(out, 2 | 4 | 8), + } +} + +// --- powerline ------------------------------------------------------------- + +fn powerline(ch: char, w: f32, h: f32, t: f32, out: &mut Vec) { + let mid = h / 2.0; + let triangle = |out: &mut Vec, p: [(f32, f32); 3]| { + out.push(Shape::Fill(vec![ + Seg::Move(p[0].0, p[0].1), + Seg::Line(p[1].0, p[1].1), + Seg::Line(p[2].0, p[2].1), + ])); + }; + let stroke = |out: &mut Vec, path: Vec| { + out.push(Shape::Stroke { path, width: t }); + }; + // Half an ellipse with its flat side on the cell's left edge (`flat` 0) + // or right edge (`flat` w), bulging to the other edge. + let half_round = |flat: f32| { + let far = w - flat; + let toward = |f: f32| flat + (far - flat) * f; + vec![ + Seg::Move(flat, 0.0), + Seg::Cubic((toward(KAPPA), 0.0), (far, mid - mid * KAPPA), (far, mid)), + Seg::Cubic((far, mid + mid * KAPPA), (toward(KAPPA), h), (flat, h)), + ] + }; + match ch as u32 { + 0xe0b0 => triangle(out, [(0.0, 0.0), (w, mid), (0.0, h)]), + 0xe0b1 => stroke( + out, + vec![Seg::Move(0.0, 0.0), Seg::Line(w, mid), Seg::Line(0.0, h)], + ), + 0xe0b2 => triangle(out, [(w, 0.0), (0.0, mid), (w, h)]), + 0xe0b3 => stroke( + out, + vec![Seg::Move(w, 0.0), Seg::Line(0.0, mid), Seg::Line(w, h)], + ), + 0xe0b4 => out.push(Shape::Fill(half_round(0.0))), + 0xe0b5 => stroke(out, half_round(0.0)), + 0xe0b6 => out.push(Shape::Fill(half_round(w))), + 0xe0b7 => stroke(out, half_round(w)), + 0xe0b8 => triangle(out, [(0.0, 0.0), (w, h), (0.0, h)]), + 0xe0ba => triangle(out, [(w, 0.0), (w, h), (0.0, h)]), + 0xe0bc => triangle(out, [(0.0, 0.0), (w, 0.0), (0.0, h)]), + 0xe0be => triangle(out, [(0.0, 0.0), (w, 0.0), (w, h)]), + // Thin separators: the diagonal of the triangle beside them. + 0xe0b9 | 0xe0bf => stroke(out, vec![Seg::Move(0.0, 0.0), Seg::Line(w, h)]), + _ => stroke(out, vec![Seg::Move(w, 0.0), Seg::Line(0.0, h)]), + } +} + +#[cfg(test)] +mod tests { + use super::*; + + const W: usize = 9; + const H: usize = 20; + + /// The pixels a glyph's rectangles cover. Glyphs made of paths (arcs, + /// diagonals, powerline) are not rasterised here. + fn raster(ch: char, t: f32) -> Vec> { + let mut grid = vec![vec![false; W]; H]; + for shape in shapes(ch, W as f32, H as f32, t).expect("covered") { + let Shape::Rect { x0, y0, x1, y1, .. } = shape else { + panic!("{ch:?} is not made of rectangles"); + }; + for (v, limit) in [(x0, W), (x1, W), (y0, H), (y1, H)] { + assert_eq!(v.fract(), 0.0, "{ch:?}: {v} is not a whole pixel"); + assert!( + v >= 0.0 && v <= limit as f32, + "{ch:?}: {v} outside the cell" + ); + } + for row in &mut grid[y0 as usize..y1 as usize] { + for cell in &mut row[x0 as usize..x1 as usize] { + *cell = true; + } + } + } + grid + } + + fn column(grid: &[Vec], x: usize) -> Vec { + grid.iter().map(|row| row[x]).collect() + } + + fn arms(ch: char) -> [u8; 4] { + let e = BOX[ch as usize - 0x2500]; + [0, 2, 4, 6].map(|shift| (e >> shift & 3) as u8) + } + + fn is_lines(ch: char) -> bool { + BOX[ch as usize - 0x2500] >> 8 == 0 + } + + #[test] + fn the_three_families_and_nothing_else_are_covered() { + for ch in ['─', '╿', '▀', '▟', '\u{e0b0}', '\u{e0bf}'] { + assert!(covers(ch) && shapes(ch, 9., 20., 1.).is_some(), "{ch:?}"); + } + // Letters, and the pictographs that come from the symbols font. + for ch in [ + 'a', '\u{24ff}', '\u{25a0}', '\u{e0a0}', '\u{e0a3}', '\u{e0c0}', '\u{f418}', + ] { + assert!(!covers(ch) && shapes(ch, 9., 20., 1.).is_none(), "{ch:?}"); + } + // Every covered character draws something, from a very small cell + // (a double line needs three pixels) to a large one. + for (w, h) in [(4., 8.), (7., 15.), (8., 17.), (9., 20.), (16., 35.)] { + for cp in (0x2500..=0x259f).chain(0xe0b0..=0xe0bf) { + let ch = char::from_u32(cp).unwrap(); + let drawn = shapes(ch, w, h, line_thickness(w)).unwrap(); + assert!(!drawn.is_empty(), "U+{cp:04X} at {w}x{h} draws nothing"); + } + } + } + + #[test] + fn lines_reach_exactly_the_edges_their_arms_point_at() { + for cp in 0x2500..=0x257f_u32 { + let ch = char::from_u32(cp).unwrap(); + if !is_lines(ch) { + continue; + } + for t in [1.0, 2.0] { + let g = raster(ch, t); + let a = arms(ch); + let touches = [ + g[0].iter().any(|&p| p), + column(&g, W - 1).iter().any(|&p| p), + g[H - 1].iter().any(|&p| p), + column(&g, 0).iter().any(|&p| p), + ]; + for (i, name) in ["top", "right", "bottom", "left"].iter().enumerate() { + assert_eq!( + touches[i], + a[i] != NONE, + "U+{cp:04X} {ch} t={t}: {name} edge" + ); + } + } + } + } + + #[test] + fn neighbouring_cells_join_without_a_step_or_a_gap() { + // What an arm of each weight looks like where it meets the cell's + // edge must not depend on the character: then any two characters + // whose arms face each other line up. + for t in [1.0, 2.0] { + // [weight][0 = horizontal edge pattern, 1 = vertical edge pattern] + let mut seen: [[Option>; 2]; 4] = Default::default(); + for cp in 0x2500..=0x257f_u32 { + let ch = char::from_u32(cp).unwrap(); + if !is_lines(ch) { + continue; + } + let g = raster(ch, t); + let a = arms(ch); + let edges = [ + (a[0], 1, g[0].clone()), + (a[1], 0, column(&g, W - 1)), + (a[2], 1, g[H - 1].clone()), + (a[3], 0, column(&g, 0)), + ]; + for (weight, axis, pattern) in edges { + if weight == NONE { + continue; + } + let slot = &mut seen[weight as usize][axis]; + match slot { + None => *slot = Some(pattern), + Some(first) => assert_eq!( + *first, pattern, + "U+{cp:04X} {ch} t={t}: weight {weight} meets the edge differently" + ), + } + } + } + // Light is one line, heavy is twice as thick, double is two + // lines with a gap. + let runs = |p: &Vec| { + let mut out = Vec::new(); + let mut n = 0; + for &on in p.iter().chain([&false]) { + if on { + n += 1; + } else if n > 0 { + out.push(n); + n = 0; + } + } + out + }; + let t = t as usize; + assert_eq!(runs(seen[1][0].as_ref().unwrap()), [t]); + assert_eq!(runs(seen[2][0].as_ref().unwrap()), [2 * t]); + assert_eq!(runs(seen[3][0].as_ref().unwrap()), [t, t]); + assert_eq!(runs(seen[3][1].as_ref().unwrap()), [t, t]); + } + } + + #[test] + fn corners_are_closed_and_double_crossings_are_hollow() { + let lit = |ch: char, x: usize, y: usize| raster(ch, 1.0)[y][x]; + // Light line position at this size: x 4, y 9. + // A light corner has its corner pixel. + for ch in ['┌', '┐', '└', '┘', '┼', '├', '┬'] { + assert!(lit(ch, 4, 9), "{ch}: the corner pixel is missing"); + } + assert!( + !lit('┌', 3, 9) && !lit('┌', 4, 8), + "┌ has no left or up arm" + ); + // A double corner: outer and inner corners are both closed, and + // the gap between the lines stays open. + assert!(lit('╔', 3, 8), "╔ outer corner"); + assert!(lit('╔', 5, 10), "╔ inner corner"); + assert!(!lit('╔', 4, 9), "╔ gap between the two lines"); + assert!( + !lit('╔', 2, 9) && !lit('╔', 4, 7), + "╔ has no left or up arm" + ); + // All four double arms: the centre and the gaps are hollow. + for (x, y) in [(4, 9), (4, 0), (0, 9), (8, 9), (4, 19)] { + assert!(!lit('╬', x, y), "╬ should be open at {x},{y}"); + } + assert!(lit('╬', 3, 0) && lit('╬', 5, 0) && lit('╬', 0, 8) && lit('╬', 0, 10)); + // A single line stops at a double line it does not cross, and + // passes through one it does. + assert!( + !lit('╟', 4, 9), + "╟: the single arm starts at the right-hand line" + ); + assert!(lit('╫', 4, 9), "╫: the single line passes through"); + assert!( + !lit('╤', 4, 9) && lit('╤', 4, 10), + "╤: the stem starts at the lower line" + ); + // Half lines stop at the middle. + assert!(lit('╴', 0, 9) && !lit('╴', 8, 9)); + assert!(lit('╷', 4, 19) && !lit('╷', 4, 0)); + } + + #[test] + fn dashes_blocks_and_shades_have_the_right_coverage() { + // Dashes: that many separate runs along the line. + for (ch, count) in [('╌', 2), ('┄', 3), ('┈', 4)] { + let row = &raster(ch, 1.0)[9]; + let runs = row.windows(2).filter(|p| !p[0] && p[1]).count() + usize::from(row[0]); + assert_eq!(runs, count, "{ch}"); + } + let area = |ch: char| raster(ch, 1.0).iter().flatten().filter(|&&p| p).count(); + assert_eq!(area('█'), W * H, "the full block fills the cell"); + assert_eq!(area('▀') + area('▄'), W * H, "upper and lower halves tile"); + assert_eq!(area('▌') + area('▐'), W * H, "left and right halves tile"); + assert_eq!(area('▚') + area('▞'), W * H, "the two diagonals tile"); + assert_eq!(area('▙') + area('▝'), W * H); + assert!(area('▁') < area('▂') && area('▂') < area('▇') && area('▇') < area('█')); + assert!(area('▏') < area('▎') && area('▎') < area('▉')); + let g = raster('▁', 1.0); + assert!(g[H - 1][0] && !g[0][0], "the lower eighth is at the bottom"); + // Shades are the whole cell at part strength. + for (ch, alpha) in [('░', 0.25), ('▒', 0.5), ('▓', 0.75)] { + assert_eq!( + shapes(ch, 9., 20., 1.).unwrap(), + [Shape::Rect { + x0: 0., + y0: 0., + x1: 9., + y1: 20., + alpha + }] + ); + } + } + + #[test] + fn powerline_separators_span_the_whole_cell() { + let (w, h) = (9.0, 20.0); + let fill = |ch: char| match shapes(ch, w, h, 1.).unwrap().as_slice() { + [Shape::Fill(path)] => path.clone(), + other => panic!("{ch:?}: {other:?}"), + }; + // The solid arrows: flat on one edge, their point on the other, the + // full height, so adjacent segments meet with no seam. + assert_eq!( + fill('\u{e0b0}'), + [Seg::Move(0., 0.), Seg::Line(w, h / 2.), Seg::Line(0., h)] + ); + assert_eq!( + fill('\u{e0b2}'), + [Seg::Move(w, 0.), Seg::Line(0., h / 2.), Seg::Line(w, h)] + ); + // The four corner triangles. + assert_eq!( + fill('\u{e0b8}'), + [Seg::Move(0., 0.), Seg::Line(w, h), Seg::Line(0., h)] + ); + assert_eq!( + fill('\u{e0ba}'), + [Seg::Move(w, 0.), Seg::Line(w, h), Seg::Line(0., h)] + ); + assert_eq!( + fill('\u{e0bc}'), + [Seg::Move(0., 0.), Seg::Line(w, 0.), Seg::Line(0., h)] + ); + assert_eq!( + fill('\u{e0be}'), + [Seg::Move(0., 0.), Seg::Line(w, 0.), Seg::Line(w, h)] + ); + // The round caps start and end on the flat edge and reach the other. + for (ch, flat, far) in [('\u{e0b4}', 0., w), ('\u{e0b6}', w, 0.)] { + let path = fill(ch); + assert_eq!(path[0], Seg::Move(flat, 0.)); + assert!( + matches!(path[1], Seg::Cubic(_, _, to) if to == (far, h / 2.)), + "{path:?}" + ); + assert!( + matches!(path[2], Seg::Cubic(_, _, to) if to == (flat, h)), + "{path:?}" + ); + } + // The thin variants are strokes along the same outlines. + for ch in [ + '\u{e0b1}', '\u{e0b3}', '\u{e0b5}', '\u{e0b7}', '\u{e0b9}', '\u{e0bb}', '\u{e0bd}', + '\u{e0bf}', + ] { + assert!( + matches!(shapes(ch, w, h, 1.).unwrap().as_slice(), [Shape::Stroke { width, .. }] if *width == 1.), + "{ch:?}" + ); + } + // Arcs and diagonals of the box-drawing block are strokes too. + for ch in ['╭', '╮', '╯', '╰', '╱', '╲'] { + assert!( + matches!( + shapes(ch, w, h, 1.).unwrap().as_slice(), + [Shape::Stroke { .. }] + ), + "{ch}" + ); + } + assert_eq!(shapes('╳', w, h, 1.).unwrap().len(), 2); + // An arc ends at the middles of the two edges its arms point at. + let Shape::Stroke { path, .. } = &shapes('╭', w, h, 1.).unwrap()[0] else { + unreachable!() + }; + assert_eq!(path[0], Seg::Move(4.5, h), "down"); + assert_eq!(path[3], Seg::Line(w, 9.5), "right"); + } +} diff --git a/apps/desktop/term/src/stream.rs b/apps/desktop/term/src/stream.rs new file mode 100644 index 000000000..a1310db7d --- /dev/null +++ b/apps/desktop/term/src/stream.rs @@ -0,0 +1,389 @@ +//! Read-only byte sources for the terminal surface: a file or a process. +//! +//! A process source runs in its own session (`setsid`, no controlling +//! terminal) with stdin closed and stdout and stderr merged into one pipe, +//! in the order the process wrote them. It is owned the same way a PTY +//! child is ([`crate::session`]): dropping the [`ByteStream`] sends SIGTERM +//! and SIGCONT to the session, then SIGKILL after +//! [`crate::pty::TERMINATE_GRACE`], and reaps on a background thread. +//! +//! Bytes go through a queue bounded at [`STREAM_QUEUE_BYTES`]. When the +//! consumer stalls, the reader stops reading and the process blocks on its +//! pipe. Dropping the stream cancels the reader. +//! +//! Bytes are passed through as printed. Nothing here logs them. + +use std::ffi::OsString; +use std::fs::File; +use std::io::{self, Read}; +use std::os::fd::{AsRawFd, OwnedFd}; +use std::os::unix::process::CommandExt; +use std::path::PathBuf; +use std::process::{Command, Stdio}; +use std::sync::Arc; +use std::thread::JoinHandle; +use std::time::{Duration, Instant}; + +use crate::fdio::{self, Cancel, CancelWatch, Wait}; +use crate::pty::TERMINATE_GRACE; +use crate::queue::ByteQueue; +use crate::session::{ + LastResort, OwnedSession, PendingTeardown, SYNC_DEADLINE, Teardown, ThreadSpawner, +}; + +/// Most stream bytes held in memory before the reader stops reading. +pub const STREAM_QUEUE_BYTES: usize = 1 << 20; + +/// A program to run as a byte source. +/// +/// `env` entries are added to the inherited environment, or to an empty one +/// when `env_clear` is set. Values are never logged. +#[derive(Clone, Debug)] +pub struct ProcessSpec { + pub program: PathBuf, + pub args: Vec, + pub cwd: Option, + pub env: Vec<(OsString, OsString)>, + pub env_clear: bool, +} + +impl ProcessSpec { + pub fn new(program: impl Into) -> Self { + Self { + program: program.into(), + args: Vec::new(), + cwd: None, + env: Vec::new(), + env_clear: false, + } + } + + pub fn arg(mut self, arg: impl Into) -> Self { + self.args.push(arg.into()); + self + } + + pub fn env(mut self, key: impl Into, value: impl Into) -> Self { + self.env.push((key.into(), value.into())); + self + } +} + +/// Where the bytes come from. +#[derive(Clone, Debug)] +pub enum StreamSource { + /// Read the file to its end. + File(PathBuf), + /// Run a program and read its merged stdout/stderr until it closes them. + Process(ProcessSpec), +} + +/// Why opening a stream failed, with the child's pid when one had started. +#[derive(Debug)] +pub(crate) struct OpenFailure { + pub error: io::Error, + #[cfg_attr(not(test), allow(dead_code, reason = "read by tests"))] + pub leader: Option, +} + +/// A running byte source. Poll it with [`ByteStream::drain`]. +#[derive(Debug)] +pub struct ByteStream { + queue: Arc, + session: Option, + cancel: Option, + threads: Vec>, + spawner: ThreadSpawner, + ended: bool, +} + +impl ByteStream { + pub fn open(source: &StreamSource) -> io::Result { + Self::open_with(source, ThreadSpawner::os()).map_err(|f| f.error) + } + + pub(crate) fn open_with( + source: &StreamSource, + spawner: ThreadSpawner, + ) -> Result { + let early = |error| OpenFailure { + error, + leader: None, + }; + let (cancel, watch) = fdio::cancel_pair().map_err(early)?; + let (reader, session): (File, Option) = match source { + StreamSource::File(path) => (File::open(path).map_err(early)?, None), + StreamSource::Process(spec) => { + let (r, w) = io::pipe().map_err(early)?; + let mut cmd = Command::new(&spec.program); + cmd.args(&spec.args) + .stdin(Stdio::null()) + .stdout(w.try_clone().map_err(early)?) + .stderr(w); + if let Some(cwd) = &spec.cwd { + cmd.current_dir(cwd); + } + if spec.env_clear { + cmd.env_clear(); + } + for (k, v) in &spec.env { + cmd.env(k, v); + } + // SAFETY: only async-signal-safe calls run between fork and exec. + unsafe { + cmd.pre_exec(|| { + if libc::setsid() == -1 { + return Err(io::Error::last_os_error()); + } + fdio::mark_inherited_fds_cloexec(); + Ok(()) + }); + } + let child = cmd.spawn().map_err(early)?; + // `cmd` holds the parent's copies of the write end; drop them + // so end of file arrives when the session closes its side. + drop(cmd); + // From here the child is owned: an early return ends and reaps it. + let session = OwnedSession::new(child).map_err(early)?; + (File::from(OwnedFd::from(r)), Some(session)) + } + }; + let leader = session.as_ref().map(OwnedSession::leader); + let mut stream = Self { + queue: ByteQueue::new(STREAM_QUEUE_BYTES), + session, + cancel: Some(cancel), + threads: Vec::new(), + spawner, + ended: false, + }; + let queue = stream.queue.clone(); + let job = Box::new(move || read_loop(reader, &watch, &queue)); + match stream.spawner.spawn("hack-term-stream-read", job) { + Ok(handle) => stream.threads.push(handle), + Err(error) => { + stream.abort(); + return Err(OpenFailure { error, leader }); + } + } + Ok(stream) + } + + /// Everything received so far, without blocking: whole chunks until at + /// least `max_bytes`. The second value is set once, at the end. + pub fn drain(&mut self, max_bytes: usize) -> (Vec, Option>) { + if self.ended { + return (Vec::new(), None); + } + let d = self.queue.drain(max_bytes); + self.ended = d.end.is_some(); + (d.bytes, d.end) + } + + /// Block until `n` bytes or the end arrive, or `deadline` passes. For tests. + pub fn read_blocking(&mut self, n: usize, deadline: Duration) -> Vec { + let start = Instant::now(); + let mut out = Vec::new(); + while out.len() < n && !self.ended && start.elapsed() < deadline { + self.queue.wait(deadline.saturating_sub(start.elapsed())); + out.extend_from_slice(&self.drain(n - out.len()).0); + } + out + } + + /// Whether the end of the source has been drained. + pub fn is_ended(&self) -> bool { + self.ended + } + + /// Bytes read from the source and not yet drained. + pub fn pending_bytes(&self) -> usize { + self.queue.pending_bytes() + } + + /// The most bytes ever pending at once. + pub fn high_water_bytes(&self) -> usize { + self.queue.high_water() + } + + /// The session (and process group) id of a process source. + pub fn pid(&self) -> Option { + self.session.as_ref().map(|s| s.leader().unsigned_abs()) + } + + fn stop_io(&mut self) -> Vec> { + self.queue.close(); + self.cancel.take(); + std::mem::take(&mut self.threads) + } + + /// End a process source on this thread: SIGTERM and SIGCONT, SIGKILL + /// after `grace`, and wait until the session is reaped or `deadline` + /// passes. A file source has nothing to end. + pub fn terminate_blocking_with(mut self, grace: Duration, deadline: Duration) -> Teardown { + let threads = self.stop_io(); + let Some(session) = self.session.take() else { + join_all(threads); + return Teardown::nothing(); + }; + session.signal(libc::SIGTERM); + session.signal(libc::SIGCONT); + session.retire( + grace, + deadline, + move || join_all(threads), + LastResort::GiveUp, + ) + } + + /// [`Self::terminate_blocking_with`] with the default grace. + pub fn terminate_blocking(self, deadline: Duration) -> Teardown { + self.terminate_blocking_with(TERMINATE_GRACE, deadline) + } + + /// End a process source on a reaper thread and get a handle to the result. + pub fn terminate_in_background(mut self) -> PendingTeardown { + let (tx, rx) = std::sync::mpsc::channel(); + let threads = self.stop_io(); + match self.session.take() { + None => { + let _ = tx.send(Teardown::nothing()); + } + Some(session) => { + session.signal(libc::SIGTERM); + session.signal(libc::SIGCONT); + session.retire_in_background( + &self.spawner, + TERMINATE_GRACE, + Box::new(move || join_all(threads)), + Some(tx), + ); + } + } + PendingTeardown(rx) + } + + /// Kill the process now and reap it, on this thread. + fn abort(mut self) { + let threads = self.stop_io(); + if let Some(session) = self.session.take() { + session.retire( + Duration::ZERO, + SYNC_DEADLINE, + move || join_all(threads), + LastResort::GiveUp, + ); + } + } + + #[cfg(test)] + pub(crate) fn set_spawner(&mut self, spawner: ThreadSpawner) { + self.spawner = spawner; + } +} + +impl Drop for ByteStream { + fn drop(&mut self) { + let threads = self.stop_io(); + let Some(session) = self.session.take() else { + return; + }; + session.signal(libc::SIGTERM); + session.signal(libc::SIGCONT); + session.retire_in_background( + &self.spawner, + TERMINATE_GRACE, + Box::new(move || join_all(threads)), + None, + ); + } +} + +fn join_all(threads: Vec>) { + for t in threads { + let _ = t.join(); + } +} + +fn read_loop(mut reader: File, cancel: &CancelWatch, queue: &ByteQueue) { + let mut buf = vec![0u8; 64 * 1024]; + let end = loop { + match fdio::wait_fd(reader.as_raw_fd(), libc::POLLIN, cancel, -1) { + Ok(Wait::Cancelled) => return, + Ok(Wait::Ready | Wait::TimedOut) => {} + Err(e) => break Err(e.kind().to_string()), + } + match reader.read(&mut buf) { + Ok(0) => break Ok(()), + Ok(n) => { + // Blocks while the queue is full; false once the consumer left. + if !queue.push(buf[..n].to_vec()) { + return; + } + } + Err(e) if e.kind() == io::ErrorKind::Interrupted => {} + Err(e) => break Err(e.kind().to_string()), + } + }; + queue.finish(end); +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::session::{is_alive, members}; + use crate::testutil::{DEADLINE, Recorded, TempHome, assert_no_trace, tagged, wait_gone}; + + fn sh_source(script: &str, home: &TempHome) -> StreamSource { + StreamSource::Process(ProcessSpec { + program: "/bin/sh".into(), + args: vec!["-c".into(), script.into()], + cwd: Some(home.path().to_owned()), + env: vec![ + ("PATH".into(), "/usr/bin:/bin".into()), + ("HOME".into(), home.path().into()), + ], + env_clear: true, + }) + } + + #[test] + fn a_reader_spawn_failure_leaves_no_child_and_no_zombie() { + let home = TempHome::new(); + let failure = ByteStream::open_with( + &sh_source("exec sleep 120", &home), + ThreadSpawner::failing_after(0), + ) + .expect_err("open must fail"); + let leader = failure.leader.expect("the child had started"); + assert!(failure.error.to_string().contains("injected")); + // If it was left running, this handle ends it when the test unwinds. + let mut rec = Recorded::session(leader.unsigned_abs()); + assert_no_trace(leader, "reader spawn failure"); + rec.done(); + } + + #[test] + fn a_reaper_spawn_failure_still_ends_and_reaps_the_process() { + let home = TempHome::new(); + let mut stream = + ByteStream::open(&sh_source("sleep 120 & echo \"kid=$!\"; wait", &home)).expect("open"); + let leader = libc::pid_t::try_from(stream.pid().unwrap()).unwrap(); + let mut rec = Recorded::session(leader.unsigned_abs()); + let mut out = String::new(); + let start = Instant::now(); + while !out.contains("kid=") && start.elapsed() < DEADLINE { + out.push_str(&String::from_utf8_lossy( + &stream.read_blocking(1, Duration::from_millis(200)), + )); + } + let kid = tagged(&out, "kid").expect("kid"); + assert!(is_alive(kid) && is_alive(leader)); + stream.set_spawner(ThreadSpawner::failing_after(0)); + drop(stream); + assert!(members(leader).is_empty()); + assert_no_trace(leader, "the leader was not reaped"); + assert!(wait_gone(&[kid]).is_empty()); + rec.done(); + } +} diff --git a/apps/desktop/term/src/terminal.rs b/apps/desktop/term/src/terminal.rs new file mode 100644 index 000000000..01006eba6 --- /dev/null +++ b/apps/desktop/term/src/terminal.rs @@ -0,0 +1,343 @@ +//! An emulator joined to its byte source: a read-only stream or an +//! app-owned PTY. This is the part of the terminal view that has no GUI in +//! it, so it can be driven and tested without a window. +//! +//! [`TerminalCore::pump`] moves pending output into the emulator. It never +//! blocks: output comes from a bounded queue and input goes to one. +//! +//! End of a PTY session is decided by the end of its **output**, not by the +//! child's exit. A child can exit with output still in flight; pumping +//! continues until the terminal reports end of file. If the child has +//! exited but something else still holds the terminal open (a background +//! job), the core waits [`EXIT_DRAIN_QUIET`] without new output, at most +//! [`EXIT_DRAIN_CAP`], then takes what has arrived and closes. +//! +//! A session started from a [`SessionPlan`] with several attempts (a +//! container shell: `bash`, then `sh`) moves to the next attempt when the +//! current one could not start its program: it ended with exit status 126 +//! or 127 within [`FALLBACK_WINDOW`], before the user typed anything. + +use std::time::{Duration, Instant}; + +use crate::emulator::{Emulator, EmulatorOptions}; +use crate::launch::{Launch, SessionKind, SessionPlan}; +use crate::pty::{Pty, PtyOptions, PtySize, WriteOutcome}; +use crate::session::{ExitInfo, PendingTeardown, Teardown}; +use crate::stream::{ByteStream, StreamSource}; + +/// Most bytes fed per pump, so a flood cannot starve input or painting. +pub const MAX_BYTES_PER_PUMP: usize = 1 << 20; +/// After the child exits: how long without output before closing anyway. +pub const EXIT_DRAIN_QUIET: Duration = Duration::from_millis(500); +/// After the child exits: the longest wait for end of output. +pub const EXIT_DRAIN_CAP: Duration = Duration::from_secs(5); +/// How soon a "command not found" exit must come to try the next attempt. +pub const FALLBACK_WINDOW: Duration = Duration::from_secs(10); + +enum Backend { + Stream(ByteStream), + Pty(Pty), + Closed, +} + +/// Whether the source is still producing. +#[derive(Clone, Debug, Default, PartialEq, Eq)] +pub enum Status { + #[default] + Running, + Ended(String), +} + +/// A session's process side, taken out of the core so the caller can end it +/// (on app quit) and wait for that off the UI thread. +pub enum Shutdown { + Pty(Pty), + Stream(ByteStream), +} + +impl Shutdown { + /// End the session on this thread. Blocks up to `deadline`. + pub fn terminate_blocking_with(self, grace: Duration, deadline: Duration) -> Teardown { + match self { + Self::Pty(pty) => pty.terminate_blocking_with(grace, deadline), + Self::Stream(stream) => stream.terminate_blocking_with(grace, deadline), + } + } + + /// End the session on a reaper thread and get a handle to the result. + pub fn terminate_in_background(self) -> PendingTeardown { + match self { + Self::Pty(pty) => pty.terminate_in_background(), + Self::Stream(stream) => stream.terminate_in_background(), + } + } +} + +pub struct TerminalCore { + emu: Emulator, + backend: Backend, + kind: SessionKind, + status: Status, + notice: Option, + /// When the child's exit was first seen, and the last output after it. + exit_seen: Option<(ExitInfo, Instant)>, + last_output: Instant, + /// Attempts not yet tried, the current start time, and whether the user + /// has typed: what decides a fallback. + remaining: Vec, + started: Instant, + input_sent: bool, +} + +impl TerminalCore { + /// A read-only terminal over a file or process (log stream). + pub fn stream(source: &StreamSource, scrollback_lines: usize) -> std::io::Result { + let stream = ByteStream::open(source)?; + let emu = Emulator::new(EmulatorOptions { + scrollback_lines, + newline_is_crlf: true, + ..EmulatorOptions::default() + }) + .map_err(std::io::Error::other)?; + Ok(Self::with( + emu, + Backend::Stream(stream), + SessionKind::Logs, + Vec::new(), + )) + } + + /// An interactive terminal for a session plan: a container shell, a + /// host shell, or a host shell with env. See [`crate::launch`]. + pub fn session(plan: SessionPlan) -> std::io::Result { + let mut attempts = plan.attempts.into_iter(); + let first = attempts + .next() + .ok_or_else(|| std::io::Error::other("a session plan needs one attempt"))?; + let emu = Emulator::new(EmulatorOptions { + cols: first.options.size.cols, + rows: first.options.size.rows, + ..EmulatorOptions::default() + }) + .map_err(std::io::Error::other)?; + let pty = Pty::spawn(&first.options)?; + Ok(Self::with( + emu, + Backend::Pty(pty), + first.kind, + attempts.collect(), + )) + } + + /// An interactive terminal over one program on an app-owned PTY, shown + /// as a host shell named after the program. + pub fn pty(opts: &PtyOptions) -> std::io::Result { + let shell = opts + .program + .file_name() + .map_or_else(|| "sh".into(), |n| n.to_string_lossy().into_owned()); + Self::session(SessionPlan { + attempts: vec![Launch { + kind: SessionKind::HostShell { shell }, + options: opts.clone(), + }], + }) + } + + fn with(emu: Emulator, backend: Backend, kind: SessionKind, remaining: Vec) -> Self { + Self { + emu, + backend, + kind, + status: Status::Running, + notice: None, + exit_seen: None, + last_output: Instant::now(), + remaining, + started: Instant::now(), + input_sent: false, + } + } + + pub fn emulator(&mut self) -> &mut Emulator { + &mut self.emu + } + + pub fn emu(&self) -> &Emulator { + &self.emu + } + + /// What this tab is: its label and badge come from here. A container + /// shell's kind changes if `bash` was missing and `sh` was started. + pub fn kind(&self) -> &SessionKind { + &self.kind + } + + pub fn status(&self) -> &Status { + &self.status + } + + /// A message for the user about input that could not be delivered. + pub fn notice(&self) -> Option<&str> { + self.notice.as_deref() + } + + pub fn read_only(&self) -> bool { + !self.kind.is_interactive() + } + + /// Move pending output into the emulator. Returns whether anything + /// changed. Never blocks. + pub fn pump(&mut self) -> bool { + let mut busy = false; + match &mut self.backend { + Backend::Stream(s) => { + let (bytes, end) = s.drain(MAX_BYTES_PER_PUMP); + if !bytes.is_empty() { + self.emu.feed(&bytes); + busy = true; + } + if let Some(end) = end { + self.status = Status::Ended(match end { + Ok(()) => "end of stream".into(), + Err(e) => format!("read failed: {e}"), + }); + self.backend = Backend::Closed; + busy = true; + } + } + Backend::Pty(pty) => { + let read = pty.read(MAX_BYTES_PER_PUMP); + if !read.bytes.is_empty() { + busy = true; + self.last_output = Instant::now(); + let reply = self.emu.feed(&read.bytes); + // Query replies are a few bytes; if the child is not + // reading there is nobody waiting for them. + let _ = pty.write(&reply); + } + let exit = pty.exit(); + if let (Some(info), None) = (exit, self.exit_seen) { + self.exit_seen = Some((info, Instant::now())); + } + // `last_output` was just refreshed if this pump read anything, + // so "quiet" is only true once the queue has stayed empty. + let (quiet, capped) = self.exit_seen.map_or((false, false), |(_, at)| { + ( + at.max(self.last_output).elapsed() >= EXIT_DRAIN_QUIET, + at.elapsed() >= EXIT_DRAIN_CAP, + ) + }); + if read.eof || quiet || capped { + let info = self.exit_seen.map(|(info, _)| info).or(exit); + busy = true; + if !self.try_next_attempt(info) { + self.status = Status::Ended(match info { + Some(info) => format!("process ended ({info})"), + None => "terminal closed".into(), + }); + // Dropping the PTY ends whatever is left of the session. + self.backend = Backend::Closed; + } + } + } + Backend::Closed => {} + } + busy + } + + /// Start the next attempt of the plan if the current one could not + /// start its program. Returns whether a new attempt is now running. + fn try_next_attempt(&mut self, exit: Option) -> bool { + let could_not_start = matches!(exit, Some(ExitInfo::Exited(126 | 127))) + && !self.input_sent + && self.started.elapsed() < FALLBACK_WINDOW; + if !could_not_start || self.remaining.is_empty() { + return false; + } + let next = self.remaining.remove(0); + let mut options = next.options; + options.size = PtySize::cells(self.emu.cols(), self.emu.rows()); + match Pty::spawn(&options) { + Ok(pty) => { + // A full reset: the failed attempt's error text is not part + // of the session the user asked for. + self.emu.feed(b"\x1bc"); + self.backend = Backend::Pty(pty); + self.kind = next.kind; + self.exit_seen = None; + self.last_output = Instant::now(); + self.started = Instant::now(); + true + } + Err(_) => false, + } + } + + /// Queue input for the PTY (no-op for a stream). Input that cannot be + /// queued is dropped whole and reported through [`Self::notice`]. + pub fn send(&mut self, bytes: &[u8]) { + let Backend::Pty(pty) = &self.backend else { + return; + }; + if bytes.is_empty() { + return; + } + match pty.write(bytes) { + WriteOutcome::Queued => { + self.notice = None; + self.input_sent = true; + } + WriteOutcome::Dropped { bytes } => { + self.notice = Some(format!( + "input not sent: {bytes} bytes (the program is not reading)" + )); + } + WriteOutcome::Closed => {} + } + } + + /// Resize the grid and tell the PTY. + pub fn resize(&mut self, cols: u16, rows: u16, cell_w_px: u16, cell_h_px: u16) { + if cols == self.emu.cols() && rows == self.emu.rows() { + return; + } + self.emu + .resize(cols, rows, u32::from(cell_w_px), u32::from(cell_h_px)); + if let Backend::Pty(pty) = &self.backend { + let _ = pty.resize(PtySize { + cols, + rows, + pixel_width: cols.saturating_mul(cell_w_px), + pixel_height: rows.saturating_mul(cell_h_px), + }); + } + } + + /// Give up the process side, for a caller that ends the session itself + /// and wants the result (app quit, or closing a tab and reporting + /// survivors). `None` for a file stream's core once it has ended, or + /// when already taken. + pub fn take_shutdown(&mut self) -> Option { + match std::mem::replace(&mut self.backend, Backend::Closed) { + Backend::Pty(pty) => Some(Shutdown::Pty(pty)), + Backend::Stream(stream) => Some(Shutdown::Stream(stream)), + Backend::Closed => None, + } + } + + /// Pump until the source ends, nothing arrives for `quiet`, or + /// `deadline` passes. For headless capture and tests. + pub fn settle(&mut self, quiet: Duration, deadline: Duration) { + let start = Instant::now(); + let mut last = Instant::now(); + while start.elapsed() < deadline && last.elapsed() < quiet && self.status == Status::Running + { + if self.pump() { + last = Instant::now(); + } else { + std::thread::sleep(Duration::from_millis(5)); + } + } + } +} diff --git a/apps/desktop/term/src/testutil.rs b/apps/desktop/term/src/testutil.rs new file mode 100644 index 000000000..5f4d2c262 --- /dev/null +++ b/apps/desktop/term/src/testutil.rs @@ -0,0 +1,130 @@ +//! Helpers for this crate's unit tests. +//! +//! Children get an emptied environment with a fixed `PATH` and a private +//! temporary `HOME`, so a test does not depend on the caller's environment. +//! Tests signal only pids they recorded; nothing here matches by name. + +use std::path::{Path, PathBuf}; +use std::sync::atomic::{AtomicU32, Ordering}; +use std::time::{Duration, Instant}; + +use crate::pty::PtyOptions; +use crate::session::{is_alive, members}; + +/// A generous scheduling bound, not a latency claim. +pub const DEADLINE: Duration = Duration::from_secs(60); + +/// A private directory, removed on drop. +pub struct TempHome(PathBuf); + +impl TempHome { + pub fn new() -> Self { + static NEXT: AtomicU32 = AtomicU32::new(0); + let dir = std::env::temp_dir().join(format!( + "hack-term-test-{}-{}", + std::process::id(), + NEXT.fetch_add(1, Ordering::SeqCst) + )); + std::fs::create_dir_all(&dir).expect("create temp home"); + Self(dir) + } + + pub fn path(&self) -> &Path { + &self.0 + } +} + +impl Drop for TempHome { + fn drop(&mut self) { + let _ = std::fs::remove_dir_all(&self.0); + } +} + +/// `/bin/sh -c script` on a PTY with a hermetic environment. +pub fn sh(script: &str) -> (PtyOptions, TempHome) { + let home = TempHome::new(); + let mut opts = PtyOptions::new("/bin/sh").arg("-c").arg(script); + opts.env_clear = true; + opts.env = vec![ + ("PATH".into(), "/usr/bin:/bin".into()), + ("HOME".into(), home.path().into()), + ("TERM".into(), "dumb".into()), + ]; + opts.cwd = Some(home.path().to_owned()); + (opts, home) +} + +/// The integer following `tag=` in `text`. +pub fn tagged(text: &str, tag: &str) -> Option { + let at = text.find(&format!("{tag}="))? + tag.len() + 1; + text[at..] + .chars() + .take_while(char::is_ascii_digit) + .collect::() + .parse() + .ok() +} + +/// Wait for every pid to stop running; returns the ones still alive. +pub fn wait_gone(pids: &[libc::pid_t]) -> Vec { + let start = Instant::now(); + loop { + let alive: Vec<_> = pids.iter().copied().filter(|&p| is_alive(p)).collect(); + if alive.is_empty() || start.elapsed() >= DEADLINE { + return alive; + } + std::thread::sleep(Duration::from_millis(20)); + } +} + +/// Assert that `pid`, a process this test recorded, is gone without +/// leaving a zombie. It only reports: it never signals a pid. A test that +/// may leave something running holds a handle to it (a [`Recorded`] +/// session, which cleans up through pidfds). +pub fn assert_no_trace(pid: libc::pid_t, what: &str) { + assert!( + !crate::session::exists(pid), + "{what}: pid {pid} was left behind" + ); +} + +/// A session this test started. If the test fails before its own teardown, +/// dropping this kills what is left of that session through pidfds, after +/// checking each one is still in that session. Where there is no pidfd +/// (macOS) nothing is signalled: the fixtures there sleep for at most two +/// minutes and end by themselves. +pub struct Recorded { + sid: libc::pid_t, + done: bool, +} + +impl Recorded { + pub fn session(leader: u32) -> Self { + Self { + sid: libc::pid_t::try_from(leader).expect("pid"), + done: false, + } + } + + pub fn done(&mut self) { + self.done = true; + } +} + +impl Drop for Recorded { + fn drop(&mut self) { + if self.done { + return; + } + use crate::session::{Open, OsTable, ProcTable}; + for pid in members(self.sid) { + if let Open::Handle(handle) = OsTable.open(pid) + && OsTable + .identity(pid) + .is_some_and(|i| i.sid == self.sid && i.live) + { + handle.send(libc::SIGKILL); + } + } + } +} diff --git a/apps/desktop/term/src/view.rs b/apps/desktop/term/src/view.rs new file mode 100644 index 000000000..6d53e11e8 --- /dev/null +++ b/apps/desktop/term/src/view.rs @@ -0,0 +1,1177 @@ +//! The GPUI terminal view: libghostty-vt state painted as GPUI quads and +//! shaped text, so it composes like any other element (clipping, z-order, +//! focus, overlays such as the search bar drawn above it). +//! +//! A [`TerminalView`] wraps a [`TerminalCore`]: a read-only stream (logs) or +//! an app-owned PTY (an interactive shell or `hack exec`). Dropping the view +//! drops the core, which ends the stream's or the PTY's session. On app quit +//! the PTY session is ended on the background executor, with a grace short +//! enough to finish inside GPUI's shutdown window. +//! +//! Nothing here blocks the UI thread on the child: output arrives through a +//! bounded queue, input is queued for a writer thread, and input that cannot +//! be queued is dropped whole and shown as a notice. +//! +//! Keys: the view forwards keys to the program on the PTY, encoded by +//! libghostty for the program's modes, and takes typed text for its find +//! field while that is open. It binds no shortcuts. Copy, paste, find, +//! find navigation and scrolling are actions ([`crate::actions`]) that the +//! app binds to keys; each is also a public method here. + +use std::collections::HashSet; +use std::time::Duration; + +use gpui_kit::component::ActiveTheme as _; +use gpui_kit::{ + App, Bounds, ClipboardItem, ContentMask, Context, FocusHandle, Focusable, Font, FontStyle, + FontWeight, Hsla, InteractiveElement as _, IntoElement, KeyDownEvent, MouseButton, + MouseDownEvent, MouseMoveEvent, MouseUpEvent, ParentElement as _, Pixels, Point, Render, + ScrollDelta, ScrollEvent, SharedString, StrikethroughStyle, Styled as _, TextAlign, TextRun, + UnderlineStyle, Window, canvas, div, fill, point, px, size, +}; + +use crate::actions; +use crate::emulator::{CursorShape, Emulator, Frame, KeyInput, Rgb, SearchState, TermTheme}; +use crate::ghostty_config::GhosttyLook; +use crate::glyphs::{self, BundledSymbols, SymbolFallback}; +use crate::launch::{SessionKind, SessionPlan}; +use crate::pty::{PtyOptions, QUIT_GRACE}; +use crate::sprite::{self, Seg, Shape}; +use crate::stream::StreamSource; +pub use crate::terminal::Status; +use crate::terminal::TerminalCore; + +/// Padding around the grid when the user's config sets none. +const DEFAULT_PAD: f32 = 8.0; +/// GPUI gives quit handlers 200 ms in total. +const QUIT_DEADLINE: Duration = Duration::from_millis(180); + +/// Ghostty counts font size and padding in points. On macOS a point is a +/// logical pixel; elsewhere Ghostty assumes 96 DPI, so 72 points are 96 px. +fn points(pt: f32) -> f32 { + if cfg!(target_os = "macos") { + pt + } else { + pt * 96.0 / 72.0 + } +} + +/// Padding in logical pixels. +#[derive(Clone, Copy, Debug, PartialEq)] +struct Pad { + left: f32, + right: f32, + top: f32, + bottom: f32, +} + +/// What the last `render` drew, for hosts and tests that need to know a +/// state change reached the screen. +#[derive(Clone, Debug, Default, PartialEq, Eq)] +pub struct Rendered { + /// How many times the view has rendered. + pub count: u64, + /// The notice shown in that render, if any. + pub notice: Option, + /// The match count and selection the find overlay showed in that + /// render; `None` when find was closed. + pub find: Option, + /// How many match highlights that frame drew: the matches on screen. + pub highlights: usize, +} + +/// Where the grid was laid out last, in logical pixels: for tests that +/// look at rendered cells. +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct GridMetrics { + /// The top-left corner of the first cell. + pub origin: (f32, f32), + /// One cell's width and height. + pub cell: (f32, f32), +} + +/// Font settings. Defaults to the theme's monospace family. +#[derive(Clone, Debug)] +pub struct TermFont { + pub family: Option, + pub size: Pixels, + pub line_height: f32, + /// When symbols come from the bundled symbols font. + pub symbols: BundledSymbols, +} + +impl Default for TermFont { + fn default() -> Self { + Self { + family: None, + size: px(13.), + line_height: 1.3, + symbols: BundledSymbols::default(), + } + } +} + +pub struct TerminalView { + core: TerminalCore, + focus: FocusHandle, + font: TermFont, + /// Cell size and canvas origin from the last layout. + cell: (Pixels, Pixels), + origin: gpui_kit::Point, + selecting: bool, + scroll_carry: f32, + search_open: bool, + query: String, + search: SearchState, + applied_theme: Option, + /// The user's terminal look, and whether it still has to be applied. + look: GhosttyLook, + look_pending: bool, + rendered: Rendered, + /// Which symbols the terminal's font lacks, remembered across frames. + symbols: SymbolFallback, + /// The symbols the last frame took from the symbols font. + fallback_symbols: Vec, +} + +impl TerminalView { + /// A read-only view over a file or process (log stream). + pub fn new_stream( + source: &StreamSource, + scrollback_lines: usize, + cx: &mut Context, + ) -> std::io::Result { + Ok(Self::with_core( + TerminalCore::stream(source, scrollback_lines)?, + cx, + )) + } + + /// An interactive view over a program on an app-owned PTY. + pub fn new_pty(opts: &PtyOptions, cx: &mut Context) -> std::io::Result { + Ok(Self::with_core(TerminalCore::pty(opts)?, cx)) + } + + /// An interactive view for a session plan: a container shell, a host + /// shell, or a host shell with env. See [`crate::launch`]. + pub fn new_session(plan: SessionPlan, cx: &mut Context) -> std::io::Result { + Ok(Self::with_core(TerminalCore::session(plan)?, cx)) + } + + /// A view over a core built beforehand. Starting a session can fail, and + /// GPUI builds entities infallibly, so build the core first: + /// `let core = TerminalCore::session(plan)?;` then + /// `cx.new(|cx| TerminalView::from_core(core, cx))`. + pub fn from_core(core: TerminalCore, cx: &mut Context) -> Self { + Self::with_core(core, cx) + } + + fn with_core(core: TerminalCore, cx: &mut Context) -> Self { + // End the session before the process exits; Drop alone may not run + // when the app quits. This covers both kinds of backend: a PTY and + // a process-backed stream. The signalling and the wait run on the + // background executor, never on the UI thread, and GPUI awaits the + // returned task inside its shutdown window. + cx.on_app_quit(|this, cx| { + let shutdown = this.core.take_shutdown(); + cx.background_executor().spawn(async move { + if let Some(shutdown) = shutdown { + shutdown.terminate_blocking_with(QUIT_GRACE, QUIT_DEADLINE); + } + }) + }) + .detach(); + cx.spawn(async move |this, cx| { + loop { + let Ok(busy) = this.update(cx, |v, cx| v.pump(cx)) else { + break; + }; + let wait = if busy { 8 } else { 33 }; + cx.background_executor() + .timer(Duration::from_millis(wait)) + .await; + } + }) + .detach(); + Self { + core, + focus: cx.focus_handle(), + font: TermFont::default(), + cell: (px(8.), px(17.)), + origin: point(px(0.), px(0.)), + selecting: false, + scroll_carry: 0., + search_open: false, + query: String::new(), + search: SearchState::default(), + applied_theme: None, + look: GhosttyLook::default(), + look_pending: false, + rendered: Rendered::default(), + symbols: SymbolFallback::default(), + fallback_symbols: Vec::new(), + } + } + + /// What this tab is; its label and badge come from the kind. + pub fn kind(&self) -> &SessionKind { + self.core.kind() + } + + /// Apply the user's terminal look (font, colours, cursor, padding). + /// Anything it leaves unset keeps following the app's theme. + pub fn set_look(&mut self, look: GhosttyLook, cx: &mut Context) { + self.look = look; + self.look_pending = true; + cx.notify(); + } + + /// The notice currently set, whether or not it has been drawn yet. + pub fn notice(&self) -> Option<&str> { + self.core.notice() + } + + /// Give up the session's process side so the caller can end it and + /// get the result (closing a tab). The view stays, showing what it has. + pub fn take_shutdown(&mut self) -> Option { + self.core.take_shutdown() + } + + /// What the last render drew. + pub fn rendered(&self) -> &Rendered { + &self.rendered + } + + /// Where the grid was laid out last. + pub fn grid_metrics(&self) -> GridMetrics { + GridMetrics { + origin: (f32::from(self.origin.x), f32::from(self.origin.y)), + cell: (f32::from(self.cell.0), f32::from(self.cell.1)), + } + } + + /// The symbols the last frame drew from the bundled symbols font, + /// because the terminal's own font has no glyph for them. Sorted. + pub fn fallback_symbols(&self) -> &[char] { + &self.fallback_symbols + } + + /// Open the find field. + pub fn open_find(&mut self, cx: &mut Context) { + self.search_open = true; + cx.notify(); + } + + /// Close the find field and clear its highlights. + pub fn close_find(&mut self, cx: &mut Context) { + self.search_open = false; + self.query.clear(); + self.search = self.core.emulator().search(None); + cx.notify(); + } + + /// Whether the find field is open. + pub fn find_open(&self) -> bool { + self.search_open + } + + /// Copy the selection to the clipboard, if there is one. + pub fn copy_selection(&self, cx: &mut App) { + if let Some(text) = self.core.emu().selection_text() { + cx.write_to_clipboard(ClipboardItem::new_string(text)); + } + } + + /// Paste the clipboard's text into the PTY. + pub fn paste_clipboard(&mut self, cx: &mut Context) { + if let Some(text) = cx.read_from_clipboard().and_then(|c| c.text()) { + self.paste_text(&text, cx); + } + } + + /// Scroll one screen toward older (`up`) or newer output. + pub fn scroll_page(&mut self, up: bool, cx: &mut Context) { + let rows = self.core.emu().rows() as isize; + self.core.emulator().scroll(if up { -rows } else { rows }); + cx.notify(); + } + + /// Scroll to the oldest output kept. Following stops. + pub fn scroll_to_top(&mut self, cx: &mut Context) { + self.core.emulator().scroll(isize::MIN / 2); + cx.notify(); + } + + /// Paste text into the PTY as the user would (bracketed when the + /// program asked for it). No-op for a stream view. + pub fn paste_text(&mut self, text: &str, cx: &mut Context) { + let bytes = self.core.emu().encode_paste(text); + self.send(&bytes, cx); + } + + pub fn set_font(&mut self, font: TermFont) { + self.font = font; + } + + pub fn status(&self) -> &Status { + self.core.status() + } + + pub fn follow(&self) -> bool { + self.core.emu().follow() + } + + pub fn set_follow(&mut self, follow: bool, cx: &mut Context) { + self.core.emulator().set_follow(follow); + cx.notify(); + } + + /// Search hook: set the query (empty clears) and jump to the newest match. + pub fn search(&mut self, query: &str, cx: &mut Context) -> SearchState { + self.query = query.to_owned(); + self.search_open = !query.is_empty(); + self.search = self.core.emulator().search(Some(query)); + cx.notify(); + self.search + } + + /// Search hook: step to an older or newer match. + pub fn search_step(&mut self, older: bool, cx: &mut Context) -> SearchState { + self.search = self.core.emulator().search_step(older); + cx.notify(); + self.search + } + + /// Pump synchronously until nothing arrives for `quiet` or `deadline` + /// passes. For headless capture and tests; the window uses its timer. + pub fn settle(&mut self, quiet: Duration, deadline: Duration, cx: &mut Context) { + self.core.settle(quiet, deadline); + cx.notify(); + } + + /// Queue input for the PTY as if typed (no-op for a stream view). + pub fn type_text(&mut self, text: &str, cx: &mut Context) { + self.send(text.as_bytes(), cx); + } + + /// The emulator, for hosts that need more than the hooks above. + pub fn emulator(&mut self) -> &mut Emulator { + self.core.emulator() + } + + /// Process pending output. Returns whether anything arrived. + fn pump(&mut self, cx: &mut Context) -> bool { + let busy = self.core.pump(); + if busy { + // The find count is brought up to date in `render`, in the + // same pass as the frame's highlights. + cx.notify(); + } + busy + } + + /// Queue input. Always notifies: sending can set or clear the notice + /// about undelivered input, and that has to reach the next frame even + /// when the child prints nothing in response. + fn send(&mut self, bytes: &[u8], cx: &mut Context) { + self.core.send(bytes); + cx.notify(); + } + + fn cell_at(&self, pos: gpui_kit::Point) -> (u16, u16) { + let x = ((pos.x - self.origin.x) / self.cell.0).max(0.) as u16; + let y = ((pos.y - self.origin.y) / self.cell.1).max(0.) as u16; + ( + x.min(self.core.emu().cols().saturating_sub(1)), + y.min(self.core.emu().rows().saturating_sub(1)), + ) + } + + /// Keys go to the find field while it is open, and otherwise to the + /// program on the PTY. Nothing here is a shortcut: a key the app has + /// bound to an action never reaches this handler. + fn on_key(&mut self, ev: &KeyDownEvent, _window: &mut Window, cx: &mut Context) { + let k = &ev.keystroke; + let m = k.modifiers; + if self.search_open { + // Text entry for the find field. + let typed = k + .key_char + .as_deref() + .filter(|t| !m.control && !m.platform && !t.chars().any(char::is_control)); + match (k.key.as_str(), typed) { + ("backspace", _) => { + self.query.pop(); + } + (_, Some(text)) => self.query.push_str(text), + _ => return, + } + self.search = self.core.emulator().search(Some(&self.query)); + cx.notify(); + return; + } + if self.core.read_only() { + return; + } + let bytes = self.core.emulator().encode_key(KeyInput { + key: &k.key, + text: k.key_char.as_deref(), + shift: m.shift, + ctrl: m.control, + alt: m.alt, + meta: m.platform, + }); + self.core.emulator().clear_selection(); + self.core.emulator().set_follow(true); + self.send(&bytes, cx); + } + + fn on_mouse_down(&mut self, ev: &MouseDownEvent, window: &mut Window, cx: &mut Context) { + window.focus(&self.focus, cx); + let (c, r) = self.cell_at(ev.position); + if (ev.modifiers.platform || ev.modifiers.control) + && let Some(url) = self.core.emulator().link_at(c, r) + { + cx.open_url(&url); + return; + } + if ev.click_count >= 3 { + self.core.emulator().select_all(); + } else { + self.core.emulator().select_start(c, r); + self.selecting = true; + } + cx.notify(); + } + + fn on_mouse_move(&mut self, ev: &MouseMoveEvent, _: &mut Window, cx: &mut Context) { + if self.selecting && ev.pressed_button == Some(MouseButton::Left) { + let (c, r) = self.cell_at(ev.position); + self.core.emulator().select_to(c, r); + cx.notify(); + } + } + + fn on_mouse_up(&mut self, _: &MouseUpEvent, _: &mut Window, _: &mut Context) { + self.selecting = false; + } + + fn on_scroll(&mut self, ev: &ScrollEvent, _: &mut Window, cx: &mut Context) { + let lines = match ev.delta { + ScrollDelta::Lines(p) => p.y * 3., + ScrollDelta::Pixels(p) => p.y / self.cell.1, + }; + self.scroll_carry -= lines; + let whole = self.scroll_carry.trunc(); + if whole != 0. { + self.scroll_carry -= whole; + self.core.emulator().scroll(whole as isize); + cx.notify(); + } + } + + /// Colours: the user's terminal config where it sets them, the app's + /// theme for the rest. + fn sync_theme(&mut self, cx: &App) { + let t = cx.theme(); + let mut theme = TermTheme { + foreground: rgb_of(t.foreground), + background: rgb_of(t.background), + cursor: rgb_of(t.foreground), + selection_bg: rgb_of(t.selection), + ..if t.is_dark() { + TermTheme::DARK + } else { + TermTheme::LIGHT + } + }; + if let Some(fg) = self.look.foreground { + theme.foreground = fg; + theme.cursor = fg; + } + if let Some(bg) = self.look.background { + theme.background = bg; + } + for (index, color) in self.look.palette.range(..16) { + theme.ansi[usize::from(*index)] = *color; + } + if self.applied_theme != Some(theme) || self.look_pending { + let extra: Vec<(u8, Rgb)> = self + .look + .palette + .range(16..) + .map(|(i, c)| (*i, *c)) + .collect(); + let emu = self.core.emulator(); + let _ = emu.set_theme(theme); + let _ = emu.set_palette_entries(&extra); + let _ = emu.set_default_cursor(self.look.cursor_style); + self.applied_theme = Some(theme); + self.look_pending = false; + } + } + + fn pad(&self) -> Pad { + let (left, right) = self + .look + .padding_x + .map_or((DEFAULT_PAD, DEFAULT_PAD), |(l, r)| (points(l), points(r))); + let (top, bottom) = self + .look + .padding_y + .map_or((DEFAULT_PAD, DEFAULT_PAD), |(t, b)| (points(t), points(b))); + Pad { + left, + right, + top, + bottom, + } + } + + /// Size the grid to the canvas (and tell the PTY), then build the frame. + fn layout( + &mut self, + bounds: Bounds, + cell: (Pixels, Pixels), + font: &Font, + font_size: Pixels, + window: &Window, + ) -> Option<(Frame, HashSet)> { + self.cell = cell; + let pad = self.pad(); + self.origin = point( + bounds.origin.x + px(pad.left), + bounds.origin.y + px(pad.top), + ); + let cols = ((bounds.size.width - px(pad.left + pad.right)) / cell.0) + .floor() + .max(1.) as u16; + let rows = ((bounds.size.height - px(pad.top + pad.bottom)) / cell.1) + .floor() + .max(1.) as u16; + let (cw, ch) = ( + f32::from(cell.0).round() as u16, + f32::from(cell.1).round() as u16, + ); + self.core.resize(cols, rows, cw, ch); + let frame = self.core.emulator().frame().ok()?; + self.rendered.highlights = frame.matches.len(); + // `render` read the find count before this resize. If the resize + // changed it (text reflowed), draw again with the count that goes + // with this frame's highlights. + if self.search_open && frame.search != self.search { + self.search = frame.search; + window.request_animation_frame(); + } + // Which symbols on screen the terminal's font lacks: those are + // drawn from the bundled symbols font. + let mut from_symbols = HashSet::new(); + for cell in frame.rows.iter().flatten() { + if let Some(ch) = single_char(&cell.text) + && !sprite::covers(ch) + && glyphs::in_symbols_font(ch) + && !from_symbols.contains(&ch) + && (self.font.symbols == BundledSymbols::Always + || self.symbols.uses_symbols_font(ch, font, font_size, window)) + { + from_symbols.insert(ch); + } + } + self.fallback_symbols = from_symbols.iter().copied().collect(); + self.fallback_symbols.sort_unstable(); + Some((frame, from_symbols)) + } +} + +fn rgb_of(h: Hsla) -> Rgb { + let c = h.to_rgb(); + let q = |v: f32| (v.clamp(0., 1.) * 255.).round() as u8; + Rgb::new(q(c.r), q(c.g), q(c.b)) +} + +fn hsla(c: Rgb) -> Hsla { + gpui_kit::rgb(c.to_u32()).into() +} + +impl Focusable for TerminalView { + fn focus_handle(&self, _: &App) -> FocusHandle { + self.focus.clone() + } +} + +impl Render for TerminalView { + fn render(&mut self, window: &mut Window, cx: &mut Context) -> impl IntoElement { + self.sync_theme(cx); + // Font: the user's terminal config, then an explicit `set_font`, + // then the app's monospace family. Later families in the config are + // fallbacks for glyphs the first one lacks (prompt symbols). + let mut families = self + .look + .font_families + .iter() + .cloned() + .map(SharedString::from); + let family = families + .next() + .or_else(|| self.font.family.clone()) + .unwrap_or_else(|| cx.theme().mono_font_family.clone()); + let mut base_font = gpui_kit::font(family); + let fallbacks: Vec = self.look.font_families.iter().skip(1).cloned().collect(); + if !fallbacks.is_empty() { + base_font.fallbacks = Some(gpui_kit::FontFallbacks::from_fonts(fallbacks)); + } + let font_size = self + .look + .font_size_pt + .map_or(self.font.size, |pt| px(points(pt))); + let pad = self.pad(); + let ts = window.text_system(); + let font_id = ts.resolve_font(&base_font); + let cell_w = ts + .advance(font_id, font_size, 'm') + .map(|s| s.width) + .unwrap_or(font_size * 0.6); + let cell_h = (font_size * self.font.line_height).round(); + let focused = self.focus.is_focused(window); + let theme = self.applied_theme.unwrap_or(TermTheme::DARK); + let entity = cx.entity(); + // A log stream has no input, so it has no cursor. + let show_cursor = !self.core.read_only(); + + let layout_font = base_font.clone(); + let surface = canvas( + move |bounds, window, cx| { + entity.update(cx, |v, _| { + v.layout(bounds, (cell_w, cell_h), &layout_font, font_size, window) + }) + }, + move |bounds, laid_out, window, cx| { + let Some((frame, symbols)) = laid_out else { + return; + }; + paint_frame( + &PaintArgs { + bounds, + frame: &frame, + font: &base_font, + font_size, + cell: (cell_w, cell_h), + focused, + show_cursor, + theme, + pad, + symbols: &symbols, + }, + window, + cx, + ); + }, + ) + .size_full(); + + // New output may have added or removed matches, and the search + // only learns of it when it is run. Bring it up to date before + // reading the count, so the overlay and the highlights `layout` + // takes below come from the same pass: the emulator runs the + // search once per change, and not at all for an unchanged redraw. + // Without this the count stays one batch of output behind, for as + // long as the source is then quiet. + if self.search_open { + self.search = self.core.emulator().search_refresh(); + } + let overlay = self.search_open.then(|| { + let t = cx.theme(); + let count = match (self.search.total, self.search.selected) { + (0, _) => "no matches".to_owned(), + (n, Some(i)) => format!("{} of {n}", i + 1), + (n, None) => format!("{n} matches"), + }; + div() + .absolute() + .top(px(6.)) + .right(px(14.)) + .px(px(10.)) + .py(px(4.)) + .rounded(px(6.)) + .bg(t.popover) + .border_1() + .border_color(t.border) + .text_color(t.foreground) + .text_size(px(12.)) + .flex() + .gap(px(10.)) + .child(format!("Find: {}▏", self.query)) + .child(div().text_color(t.muted_foreground).child(count)) + }); + + // A notice about undelivered input outranks the status line. + let status = match (self.core.notice(), self.core.status()) { + (Some(notice), _) => Some(notice.to_owned()), + (None, Status::Ended(s)) => Some(s.clone()), + (None, Status::Running) if self.core.read_only() && !self.core.emu().follow() => { + Some("paused".into()) + } + _ => None, + }; + // Contexts for the app's key bindings; see `crate::actions`. + let mut key_context = gpui_kit::KeyContext::default(); + key_context.add("Terminal"); + if self.core.read_only() { + key_context.add("TerminalLogs"); + } + if self.search_open { + key_context.add("TerminalFind"); + } + self.rendered = Rendered { + count: self.rendered.count + 1, + notice: self.core.notice().map(str::to_owned), + find: self.search_open.then_some(self.search), + highlights: self.rendered.highlights, + }; + let status = status.map(|s| { + let t = cx.theme(); + div() + .id("terminal-status") + .absolute() + .bottom(px(6.)) + .right(px(14.)) + .px(px(8.)) + .py(px(2.)) + .rounded(px(4.)) + .bg(t.secondary) + .text_color(t.muted_foreground) + .text_size(px(11.)) + .child(s) + }); + + div() + .id("terminal") + .track_focus(&self.focus) + .key_context(key_context) + .relative() + .size_full() + .overflow_hidden() + .bg(hsla(theme.background)) + .on_action(cx.listener(|this, _: &actions::Copy, _, cx| this.copy_selection(cx))) + .on_action(cx.listener(|this, _: &actions::Paste, _, cx| this.paste_clipboard(cx))) + .on_action(cx.listener(|this, _: &actions::Find, _, cx| this.open_find(cx))) + .on_action(cx.listener(|this, _: &actions::FindOlder, _, cx| { + this.search_step(true, cx); + })) + .on_action(cx.listener(|this, _: &actions::FindNewer, _, cx| { + this.search_step(false, cx); + })) + .on_action(cx.listener(|this, _: &actions::CloseFind, _, cx| this.close_find(cx))) + .on_action(cx.listener(|this, _: &actions::ScrollPageUp, _, cx| { + this.scroll_page(true, cx); + })) + .on_action(cx.listener(|this, _: &actions::ScrollPageDown, _, cx| { + this.scroll_page(false, cx); + })) + .on_action(cx.listener(|this, _: &actions::ScrollToTop, _, cx| { + this.scroll_to_top(cx); + })) + .on_action(cx.listener(|this, _: &actions::ScrollToBottom, _, cx| { + this.set_follow(true, cx); + })) + .on_action(cx.listener(|this, _: &actions::ToggleFollow, _, cx| { + let follow = !this.follow(); + this.set_follow(follow, cx); + })) + .on_key_down(cx.listener(Self::on_key)) + .on_mouse_down(MouseButton::Left, cx.listener(Self::on_mouse_down)) + .on_mouse_move(cx.listener(Self::on_mouse_move)) + .on_mouse_up(MouseButton::Left, cx.listener(Self::on_mouse_up)) + .on_scroll(cx.listener(Self::on_scroll)) + .child(surface) + .children(overlay) + .children(status) + } +} + +struct PaintArgs<'a> { + bounds: Bounds, + frame: &'a Frame, + font: &'a Font, + font_size: Pixels, + cell: (Pixels, Pixels), + focused: bool, + show_cursor: bool, + theme: TermTheme, + pad: Pad, + /// Symbols to draw from the bundled symbols font. + symbols: &'a HashSet, +} + +/// The cell's character, when it holds exactly one. +fn single_char(text: &str) -> Option { + let mut chars = text.chars(); + match (chars.next(), chars.next()) { + (Some(ch), None) => Some(ch), + _ => None, + } +} + +/// A cell that is not drawn as ordinary text. +#[derive(Clone, Copy)] +enum Special { + /// Drawn as geometry: box drawing, blocks, powerline separators. + Sprite(char), + /// Drawn from the bundled symbols font. + Symbol(char), +} + +/// Draw one self-drawn glyph into a cell given in whole device pixels. +/// Nothing is drawn outside the cell: a diagonal's stroke, for one, is +/// wider than the cell at its corners and is cut off at the cell's edges. +fn paint_sprite(ch: char, cell: [f32; 4], scale: f32, color: Hsla, window: &mut Window) { + let [x0, y0, x1, y1] = cell; + let (w, h) = (x1 - x0, y1 - y0); + let Some(shapes) = sprite::shapes(ch, w, h, sprite::line_thickness(w)) else { + return; + }; + let at = |x: f32, y: f32| point(px((x0 + x) / scale), px((y0 + y) / scale)); + let bounds = Bounds::from_corners(at(0., 0.), at(w, h)); + window.with_content_mask(Some(ContentMask { bounds }), |window| { + paint_shapes(shapes, &at, scale, color, window); + }); +} + +fn paint_shapes( + shapes: Vec, + at: &dyn Fn(f32, f32) -> Point, + scale: f32, + color: Hsla, + window: &mut Window, +) { + let trace = |builder: &mut gpui_kit::PathBuilder, path: &[Seg]| { + for seg in path { + match *seg { + Seg::Move(x, y) => builder.move_to(at(x, y)), + Seg::Line(x, y) => builder.line_to(at(x, y)), + Seg::Cubic(a, b, to) => { + builder.cubic_bezier_to(at(to.0, to.1), at(a.0, a.1), at(b.0, b.1)); + } + Seg::Close => builder.close(), + } + } + }; + for shape in shapes { + match shape { + Shape::Rect { + x0, + y0, + x1, + y1, + alpha, + } => window.paint_quad(fill( + Bounds::from_corners(at(x0, y0), at(x1, y1)), + color.opacity(alpha), + )), + Shape::Fill(path) => { + let mut builder = gpui_kit::PathBuilder::fill(); + trace(&mut builder, &path); + builder.close(); + if let Ok(path) = builder.build() { + window.paint_path(path, color); + } + } + Shape::Stroke { path, width } => { + let mut builder = gpui_kit::PathBuilder::stroke(px(width / scale)); + trace(&mut builder, &path); + if let Ok(path) = builder.build() { + window.paint_path(path, color); + } + } + } + } +} + +/// How many cells a symbol from the bundled font has to draw in, starting +/// at `row[x]`. +/// +/// A double-width character has the two cells the emulator gave it. A +/// narrow one has its own cell, and the next cell as well when that one is +/// blank: fitting an icon to a single narrow cell makes it small, prompt +/// themes put a space after their icons, and Ghostty and other terminals +/// treat that space the same way. A double-width symbol is not widened +/// further: it already has the two cells the blank-cell rule exists to give. +fn symbol_cells(row: &[crate::emulator::FrameCell], x: usize) -> u16 { + if row[x].wide { + return 2; + } + let next_blank = row + .get(x + 1) + .is_some_and(|n| !n.spacer && !n.wide && (n.text.is_empty() || n.text == " ")); + if next_blank { 2 } else { 1 } +} + +/// Draw one symbol from the bundled symbols font into its room (see +/// [`symbol_cells`]). The glyph's outline is placed so that all its ink is +/// inside the room (see [`glyphs::SymbolOutline::place`]) and filled as a +/// vector path; the room is also the clip, so nothing can land in a +/// neighbouring cell. +fn paint_symbol(ch: char, room: Bounds, color: Hsla, window: &mut Window) { + let Some(outline) = glyphs::outline(ch) else { + return; + }; + let place = outline.place(f32::from(room.size.width), f32::from(room.size.height)); + let left = f32::from(room.origin.x) + place.left; + let baseline = f32::from(room.origin.y) + place.baseline; + let k = place.scale; + // Font units have y pointing up. + let at = |(x, y): (f32, f32)| point(px(left + x * k), px(baseline - y * k)); + // TrueType outlines are filled with the non-zero rule. + let mut builder = gpui_kit::PathBuilder::fill().with_style(gpui_kit::PathStyle::Fill( + gpui_kit::FillOptions::default().with_fill_rule(gpui_kit::FillRule::NonZero), + )); + for seg in &outline.path { + match *seg { + Seg::Move(x, y) => builder.move_to(at((x, y))), + Seg::Line(x, y) => builder.line_to(at((x, y))), + Seg::Cubic(a, b, to) => builder.cubic_bezier_to(at(to), at(a), at(b)), + Seg::Close => builder.close(), + } + } + if let Ok(path) = builder.build() { + window.with_content_mask(Some(ContentMask { bounds: room }), |window| { + window.paint_path(path, color); + }); + } +} + +/// Paint backgrounds, highlights, the cursor, then text. Text is shaped in +/// runs of narrow cells with a forced per-glyph advance of one cell, so the +/// grid stays exact; wide characters are placed on their own two cells. +fn paint_frame(a: &PaintArgs<'_>, window: &mut Window, cx: &mut App) { + let (cw, ch) = a.cell; + let ox = a.bounds.origin.x + px(a.pad.left); + let oy = a.bounds.origin.y + px(a.pad.top); + let at = |col: u16, row: usize| point(ox + cw * f32::from(col), oy + ch * row as f32); + // Cell edges on whole device pixels. Backgrounds, highlights, the + // cursor and the self-drawn glyphs all use them, so neighbouring cells + // tile without seams at any font size. + let scale = window.scale_factor(); + let edge_x = |col: usize| ((f32::from(ox) + f32::from(cw) * col as f32) * scale).round(); + let edge_y = |row: usize| ((f32::from(oy) + f32::from(ch) * row as f32) * scale).round(); + let rect = |col: u16, row: usize, n: u16| { + let col = usize::from(col); + Bounds::from_corners( + point(px(edge_x(col) / scale), px(edge_y(row) / scale)), + point( + px(edge_x(col + usize::from(n)) / scale), + px(edge_y(row + 1) / scale), + ), + ) + }; + let special = |cell: &crate::emulator::FrameCell| { + let ch = single_char(&cell.text)?; + if sprite::covers(ch) { + Some(Special::Sprite(ch)) + } else if a.symbols.contains(&ch) { + Some(Special::Symbol(ch)) + } else { + None + } + }; + window.paint_quad(fill(a.bounds, hsla(a.frame.background))); + + for (y, row) in a.frame.rows.iter().enumerate() { + // Cell backgrounds, merged into spans. + let mut x = 0usize; + while x < row.len() { + let Some(bg) = row[x].bg else { + x += 1; + continue; + }; + let start = x; + while x < row.len() && row[x].bg == Some(bg) { + x += 1; + } + let n = u16::try_from(x - start).unwrap_or(u16::MAX); + window.paint_quad(fill(rect(start as u16, y, n), hsla(bg))); + } + if let Some(Some((s, e))) = a.frame.selection.get(y) { + let sel = hsla(a.theme.selection_bg).opacity(0.45); + window.paint_quad(fill(rect(*s, y, e - s + 1), sel)); + } + } + for &(row, s, e, active) in &a.frame.matches { + let c = if active { + a.theme.active_match_bg + } else { + a.theme.match_bg + }; + window.paint_quad(fill( + rect(s, usize::from(row), e - s + 1), + hsla(c).opacity(0.9), + )); + } + // A block cursor covers its cell, so the glyph under it is drawn in the + // background colour. Bar, underline and hollow cursors leave it as is. + let shape = a.frame.cursor_shape.unwrap_or(CursorShape::Block); + let block_cursor = a.show_cursor && a.focused && shape == CursorShape::Block; + if let Some((cx_, cy)) = a.frame.cursor.filter(|_| a.show_cursor) { + let r = rect(cx_, usize::from(cy), 1); + let c = hsla(a.frame.cursor_color); + let thin = px(2.); + match (a.focused, shape) { + (false, _) | (true, CursorShape::BlockHollow) => { + window.paint_quad(gpui_kit::outline(r, c, gpui_kit::BorderStyle::Solid)); + } + (true, CursorShape::Block) => window.paint_quad(fill(r, c)), + (true, CursorShape::Bar) => window.paint_quad(fill( + Bounds { + origin: r.origin, + size: size(thin, ch), + }, + c, + )), + (true, CursorShape::Underline) => window.paint_quad(fill( + Bounds { + origin: point(r.origin.x, r.origin.y + ch - thin), + size: size(cw, thin), + }, + c, + )), + } + } + + let ts = window.text_system().clone(); + for (y, row) in a.frame.rows.iter().enumerate() { + let mut x = 0usize; + while x < row.len() { + if row[x].spacer || row[x].text.is_empty() && row[x].bg.is_none() && !row[x].underline { + x += 1; + continue; + } + if let Some(kind) = special(&row[x]) { + let c = &row[x]; + let on_cursor = block_cursor && a.frame.cursor == Some((x as u16, y as u16)); + let mut color = hsla(if on_cursor { a.frame.background } else { c.fg }); + if c.faint { + color = color.opacity(0.6); + } + match kind { + Special::Sprite(ch) => paint_sprite( + ch, + [edge_x(x), edge_y(y), edge_x(x + 1), edge_y(y + 1)], + scale, + color, + window, + ), + Special::Symbol(ch) => { + let room = rect(x as u16, y, symbol_cells(row, x)); + paint_symbol(ch, room, color, window); + } + } + x += 1; + continue; + } + let start = x; + let mut text = String::new(); + let mut runs: Vec = Vec::new(); + let wide = row[x].wide; + loop { + let c = &row[x]; + let glyph = if c.text.is_empty() { + " " + } else { + c.text.as_str() + }; + let on_cursor = block_cursor && a.frame.cursor == Some((x as u16, y as u16)); + let fg = if on_cursor { a.frame.background } else { c.fg }; + let mut color = hsla(fg); + if c.faint { + color = color.opacity(0.6); + } + let mut font = a.font.clone(); + if c.bold { + font.weight = FontWeight::BOLD; + } + if c.italic { + font.style = FontStyle::Italic; + } + let run = TextRun { + len: glyph.len(), + font, + color, + background_color: None, + underline: c.underline.then_some(UnderlineStyle { + thickness: px(1.), + color: Some(color), + wavy: false, + }), + strikethrough: c.strikethrough.then_some(StrikethroughStyle { + thickness: px(1.), + color: Some(color), + }), + }; + match runs.last_mut() { + Some(last) + if last.font == run.font + && last.color == run.color + && last.underline == run.underline + && last.strikethrough == run.strikethrough => + { + last.len += run.len; + } + _ => runs.push(run), + } + text.push_str(glyph); + x += 1; + if wide + || x >= row.len() + || row[x].wide + || row[x].spacer + || special(&row[x]).is_some() + { + break; + } + } + let forced = (!wide).then_some(cw); + let line = ts.shape_line(text.into(), a.font_size, &runs, forced); + // Centre the font's line box in the cell. + let _ = line.paint(at(start as u16, y), ch, TextAlign::Left, None, window, cx); + } + } +} + +#[cfg(test)] +mod tests { + use super::symbol_cells; + use crate::emulator::FrameCell; + + fn cell(text: &str) -> FrameCell { + FrameCell { + text: text.into(), + ..FrameCell::default() + } + } + + #[test] + fn a_symbol_gets_the_cells_the_emulator_gave_it_then_a_blank_neighbour() { + let wide = FrameCell { + wide: true, + ..cell("\u{26a1}") + }; + let spacer = FrameCell { + spacer: true, + ..FrameCell::default() + }; + // A double-width symbol has two cells whatever follows its spacer. + for after in ["x", " ", ""] { + let row = [wide.clone(), spacer.clone(), cell(after)]; + assert_eq!(symbol_cells(&row, 0), 2, "followed by {after:?}"); + } + assert_eq!(symbol_cells(&[wide.clone(), spacer.clone()], 0), 2); + // A narrow symbol borrows a blank cell after it, and nothing else. + let icon = cell("\u{f07b}"); + assert_eq!(symbol_cells(&[icon.clone(), cell(" ")], 0), 2); + assert_eq!(symbol_cells(&[icon.clone(), cell("")], 0), 2); + assert_eq!(symbol_cells(&[icon.clone(), cell("x")], 0), 1); + assert_eq!(symbol_cells(&[icon.clone(), icon.clone()], 0), 1); + assert_eq!(symbol_cells(&[icon.clone(), wide, spacer], 0), 1); + assert_eq!(symbol_cells(&[icon], 0), 1, "at the end of the row"); + } +} diff --git a/apps/desktop/term/tests/common/mod.rs b/apps/desktop/term/tests/common/mod.rs new file mode 100644 index 000000000..1eb06b2fe --- /dev/null +++ b/apps/desktop/term/tests/common/mod.rs @@ -0,0 +1,245 @@ +//! Shared helpers for the integration tests. +//! +//! Children get an emptied environment with a fixed `PATH` and a private +//! temporary `HOME`, so a test does not depend on the caller's environment. +//! Tests signal only pids they recorded; nothing here matches by name. +#![allow(dead_code, reason = "each test file uses a subset")] + +use std::path::{Path, PathBuf}; +use std::sync::atomic::{AtomicU32, Ordering}; +use std::time::{Duration, Instant}; + +use hack_desktop_term::pty::{Pty, PtyOptions}; +use hack_desktop_term::session::is_alive; +#[cfg(target_os = "linux")] +use hack_desktop_term::session::members; +use hack_desktop_term::stream::{ProcessSpec, StreamSource}; + +/// A generous scheduling bound, not a latency claim. +pub const DEADLINE: Duration = Duration::from_secs(60); + +/// A private directory, removed on drop. +pub struct TempHome(PathBuf); + +impl TempHome { + pub fn new() -> Self { + static NEXT: AtomicU32 = AtomicU32::new(0); + let dir = std::env::temp_dir().join(format!( + "hack-term-itest-{}-{}", + std::process::id(), + NEXT.fetch_add(1, Ordering::SeqCst) + )); + std::fs::create_dir_all(&dir).expect("create temp home"); + Self(dir) + } + + pub fn path(&self) -> &Path { + &self.0 + } +} + +impl Drop for TempHome { + fn drop(&mut self) { + let _ = std::fs::remove_dir_all(&self.0); + } +} + +/// `/bin/sh -c script` on a PTY with a hermetic environment. +pub fn sh(script: &str, home: &TempHome) -> PtyOptions { + let mut opts = PtyOptions::new("/bin/sh").arg("-c").arg(script); + opts.env_clear = true; + opts.env = vec![ + ("PATH".into(), "/usr/bin:/bin".into()), + ("HOME".into(), home.path().into()), + ("TERM".into(), "dumb".into()), + ]; + opts.cwd = Some(home.path().to_owned()); + opts +} + +/// `/bin/sh -c script` as a stream source with a hermetic environment. +pub fn sh_source(script: &str, home: &TempHome) -> StreamSource { + StreamSource::Process(ProcessSpec { + program: "/bin/sh".into(), + args: vec!["-c".into(), script.into()], + cwd: Some(home.path().to_owned()), + env: vec![ + ("PATH".into(), "/usr/bin:/bin".into()), + ("HOME".into(), home.path().into()), + ], + env_clear: true, + }) +} + +/// A stand-in for the `hack` CLI, written into `home`. It checks the argv +/// the app passes and then starts a plain shell, so tests need no Docker, +/// daemon or project: +/// +/// - `exec -- ` prints `container::` and +/// runs `/bin/sh`. With `FAKE_HACK_NO_BASH` set, `bash` fails the way a +/// container without it does: a message and exit status 127. +/// - `host shell --env --scope ` prints +/// `host-shell env= scope=` and runs `$SHELL -l`, as the +/// real command does. +pub fn fake_hack(home: &TempHome) -> PathBuf { + use std::os::unix::fs::PermissionsExt; + let script = r#"#!/bin/sh +case "$1" in + exec) + [ "$#" -eq 4 ] && [ "$3" = "--" ] || { echo "fake hack: bad exec argv: $*" >&2; exit 2; } + if [ "$4" = bash ] && [ -n "$FAKE_HACK_NO_BASH" ]; then + echo 'exec: "bash": executable file not found in $PATH' >&2 + exit 127 + fi + echo "container:$2:$4" + PS1='container$ ' exec /bin/sh + ;; + host) + [ "$#" -eq 6 ] && [ "$2" = shell ] && [ "$3" = --env ] && [ "$5" = --scope ] \ + || { echo "fake hack: bad host argv: $*" >&2; exit 2; } + echo "host-shell env=$4 scope=$6" + exec "${SHELL:-/bin/sh}" -l + ;; + *) echo "fake hack: unknown command: $*" >&2; exit 2 ;; +esac +"#; + // Write, close, mark executable, then rename into place, so nothing can + // try to run a file that is still open for writing. + let pending = home.path().join("hack.pending"); + std::fs::write(&pending, script).expect("write fake hack"); + std::fs::set_permissions(&pending, std::fs::Permissions::from_mode(0o755)).expect("chmod"); + let path = home.path().join("hack"); + std::fs::rename(&pending, &path).expect("rename"); + path +} + +/// Make a session plan hermetic: an emptied environment with a fixed +/// `PATH`, the private `HOME`, `SHELL=/bin/sh`, the plan's own terminal +/// identity variables, and `extra`. +pub fn hermetic( + plan: &mut hack_desktop_term::launch::SessionPlan, + home: &TempHome, + extra: &[(&str, &str)], +) { + for attempt in &mut plan.attempts { + let o = &mut attempt.options; + o.env_clear = true; + o.env.push(("PATH".into(), "/usr/bin:/bin".into())); + o.env.push(("HOME".into(), home.path().into())); + o.env.push(("SHELL".into(), "/bin/sh".into())); + for (k, v) in extra { + o.env.push(((*k).into(), (*v).into())); + } + } +} + +/// Collect PTY output until `done` accepts it, the end, or the deadline. +pub fn read_until(pty: &Pty, done: impl Fn(&str) -> bool) -> String { + let start = Instant::now(); + let mut out = Vec::new(); + while start.elapsed() < DEADLINE { + let r = pty.read_timeout(usize::MAX, Duration::from_millis(100)); + out.extend_from_slice(&r.bytes); + if r.eof || done(&String::from_utf8_lossy(&out)) { + break; + } + } + String::from_utf8_lossy(&out).into_owned() +} + +/// The integer following `tag=` in `text`. +pub fn tagged(text: &str, tag: &str) -> Option { + let at = text.find(&format!("{tag}="))? + tag.len() + 1; + text[at..] + .chars() + .take_while(char::is_ascii_digit) + .collect::() + .parse() + .ok() +} + +/// Wait for every pid to stop running; returns the ones still alive. +pub fn wait_gone(pids: &[libc::pid_t]) -> Vec { + let start = Instant::now(); + loop { + let alive: Vec<_> = pids.iter().copied().filter(|&p| is_alive(p)).collect(); + if alive.is_empty() || start.elapsed() >= DEADLINE { + return alive; + } + std::thread::sleep(Duration::from_millis(20)); + } +} + +/// A session this test started. If the test fails before its own teardown, +/// dropping this kills what is left of that session, through pidfds only, +/// after checking each one is still in that session. No pid is signalled by +/// number. Where there is no pidfd (macOS) nothing is signalled: the +/// fixtures sleep for at most two minutes and end by themselves. +pub struct Recorded { + sid: libc::pid_t, + done: bool, +} + +impl Recorded { + pub fn session(leader: u32) -> Self { + Self { + sid: libc::pid_t::try_from(leader).expect("pid"), + done: false, + } + } + + pub fn done(&mut self) { + self.done = true; + } +} + +impl Drop for Recorded { + fn drop(&mut self) { + #[cfg(target_os = "linux")] + if !self.done { + for pid in members(self.sid) { + kill_through_pidfd(pid, self.sid); + } + } + // Elsewhere there is no race-free handle, so nothing is signalled. + #[cfg(not(target_os = "linux"))] + let _ = (self.sid, self.done); + } +} + +/// SIGKILL `pid` through a pidfd, only if that pidfd's process is still in +/// session `sid`. +#[cfg(target_os = "linux")] +fn kill_through_pidfd(pid: libc::pid_t, sid: libc::pid_t) { + use std::os::fd::{AsRawFd, FromRawFd, OwnedFd}; + // SAFETY: pidfd_open returns a new descriptor or -1. + let raw = unsafe { libc::syscall(libc::SYS_pidfd_open, pid, 0 as libc::c_uint) }; + if raw < 0 { + return; + } + // SAFETY: a fresh descriptor this function owns. + let fd = unsafe { OwnedFd::from_raw_fd(raw as libc::c_int) }; + // The pidfd pins one process; check what the pid names now. + let in_session = std::fs::read_to_string(format!("/proc/{pid}/stat")) + .ok() + .and_then(|t| { + let rest = t[t.rfind(')')? + 1..].to_owned(); + rest.split_ascii_whitespace() + .nth(3)? + .parse::() + .ok() + }) + == Some(sid); + if in_session { + // SAFETY: a valid pidfd; a null siginfo means a kill-style signal. + unsafe { + libc::syscall( + libc::SYS_pidfd_send_signal, + fd.as_raw_fd(), + libc::SIGKILL, + std::ptr::null::(), + 0 as libc::c_uint, + ); + } + } +} diff --git a/apps/desktop/term/tests/emulator.rs b/apps/desktop/term/tests/emulator.rs new file mode 100644 index 000000000..b2e69b53f --- /dev/null +++ b/apps/desktop/term/tests/emulator.rs @@ -0,0 +1,378 @@ +//! libghostty-vt behaviour the terminal surface relies on, read back +//! through the same `Frame` the GPUI element paints. +#![cfg(all(unix, feature = "ghostty"))] + +use hack_desktop_term::emulator::{Emulator, EmulatorOptions, Frame, Rgb, SearchState}; + +fn emu(cols: u16, rows: u16, crlf: bool) -> Emulator { + Emulator::new(EmulatorOptions { + cols, + rows, + scrollback_lines: 10_000, + newline_is_crlf: crlf, + ..EmulatorOptions::default() + }) + .expect("emulator") +} + +fn lines(range: std::ops::Range) -> Vec { + range + .flat_map(|i| format!("line {i}\n").into_bytes()) + .collect() +} + +#[test] +fn ansi_colours_and_attributes_reach_the_frame() { + let mut e = emu(40, 4, true); + e.feed(b"\x1b[31mred\x1b[0m \x1b[1;38;2;1;2;3mrgb\x1b[0m \x1b[7minv\x1b[0m\n"); + let f = e.frame().unwrap(); + let row = &f.rows[0]; + assert_eq!(f.row_text(0), "red rgb inv"); + let palette_red = row[0].fg; + assert_ne!(palette_red, f.foreground, "SGR 31 changes the foreground"); + assert_eq!(row[4].fg, Rgb::new(1, 2, 3)); + assert!(row[4].bold); + // Inverse swaps default colours. + assert_eq!(row[8].bg, Some(f.foreground)); + assert_eq!(row[8].fg, f.background); +} + +#[test] +fn bare_newlines_start_a_new_line_in_stream_mode() { + let mut e = emu(20, 3, true); + e.feed(b"ab\ncd\n"); + let f = e.frame().unwrap(); + assert_eq!(f.row_text(0), "ab"); + assert_eq!(f.row_text(1), "cd", "LNM: LF also returns the carriage"); +} + +#[test] +fn scrollback_is_bounded_and_follow_tracks_the_newest_line() { + let mut e = emu(40, 10, true); + e.feed(&lines(0..30_000)); + let held = e.scrollback_rows(); + // Bounded at 10,000 lines, pruned at page granularity. + assert!((9_000..=12_000).contains(&held), "scrollback rows: {held}"); + let f = e.frame().unwrap(); + assert_eq!(f.row_text(8), "line 29999", "follow shows the newest line"); + assert!(e.at_bottom()); + // The oldest lines were pruned. + assert_eq!(e.search(Some("line 5 ")).total, 0); +} + +#[test] +fn the_line_bound_holds_on_a_wide_grid() { + // libghostty's default byte limit would keep only about 5,500 lines at + // this width; the line bound is the one that must apply. + let mut e = emu(200, 60, true); + e.feed(&lines(0..30_000)); + let held = e.scrollback_rows(); + assert!((9_000..=12_000).contains(&held), "scrollback rows: {held}"); +} + +#[test] +fn ten_thousand_lines_with_follow_off_keep_the_viewport_still() { + let mut e = emu(40, 10, true); + e.feed(&lines(0..10_000)); + e.scroll(-50); + assert!(!e.follow(), "scrolling up turns follow off"); + let before = e.frame().unwrap(); + e.feed(&lines(10_000..10_100)); + let after = e.frame().unwrap(); + assert_eq!( + before.row_text(0), + after.row_text(0), + "viewport did not move" + ); + e.set_follow(true); + let f = e.frame().unwrap(); + assert_eq!(f.row_text(8), "line 10099"); +} + +#[test] +fn the_search_counts_new_output_once_it_is_refreshed() { + let mut e = emu(40, 6, true); + e.feed(b"needle one\n"); + assert_eq!(e.search(Some("needle")).total, 1); + e.feed(b"needle two\nneedle three\n"); + // Feeding the terminal does not run the search: this is why a count + // read with `search_state` alone is one batch of output behind. + assert_eq!(e.search_state().total, 1, "not run since the output"); + let refreshed = e.search_refresh(); + assert_eq!(refreshed.total, 3); + // The frame's highlights come from the same pass and agree. + assert_eq!(e.frame().unwrap().matches.len(), 3); + assert_eq!(e.search_state(), refreshed); + // With no search there is nothing to refresh. + e.search(None); + assert_eq!(e.search_refresh().total, 0); +} + +/// Ask for the count and then the frame, as a redraw does, after one +/// change: both come from one search pass, exactly one. +fn one_pass(e: &mut Emulator, what: &str, runs: &mut u64) -> (SearchState, Frame) { + let count = e.search_refresh(); + let frame = e.frame().unwrap(); + assert_eq!(frame.search, count, "{what}: one state for both"); + assert_eq!(e.search_refresh(), count, "{what}: and it is kept"); + *runs += 1; + assert_eq!(e.search_runs(), *runs, "{what}: exactly one pass"); + (count, frame) +} + +#[test] +fn the_search_runs_once_per_change_and_not_at_all_for_an_unchanged_redraw() { + let mut e = emu(40, 6, true); + e.feed(&lines(0..200)); + e.feed(b"needle one\nneedle two\n"); + let found = e.search(Some("needle")); + assert_eq!(found.total, 2); + let first = e.frame().unwrap(); + assert_eq!(first.search, found, "the frame carries the same state"); + + // Redrawing an unchanged terminal: the count and the highlights are + // asked for again and again, and the search does no work. + let settled = e.search_runs(); + for _ in 0..5 { + assert_eq!(e.search_refresh(), found); + assert_eq!(e.frame().unwrap(), first); + } + assert_eq!(e.search_runs(), settled, "an unchanged redraw runs nothing"); + // Things that change nothing do not count as changes. + e.feed(b""); + e.resize(40, 6, 8, 16); + e.scroll(0); + let _ = (e.search_refresh(), e.frame().unwrap()); + assert_eq!(e.search_runs(), settled, "no-op calls run nothing"); + + // Each change costs one pass, shared by the count and the frame. + let mut runs = settled; + e.feed(b"needle three\n"); + let (count, frame) = one_pass(&mut e, "output", &mut runs); + assert_eq!((count.total, frame.matches.len()), (3, 3)); + e.resize(30, 6, 8, 16); + assert_eq!(one_pass(&mut e, "resize", &mut runs).0.total, 3); + // A viewport move: the highlights are for the rows now on screen. + e.scroll(-100); + let (count, frame) = one_pass(&mut e, "scroll", &mut runs); + assert_eq!((count.total, frame.matches.len()), (3, 0)); + e.set_follow(true); + assert_eq!(one_pass(&mut e, "follow", &mut runs).1.matches.len(), 3); + // The frame alone, with no count asked for first, also runs it once. + e.feed(b"needle four\n"); + assert_eq!(e.frame().unwrap().search.total, 4); + runs += 1; + assert_eq!(e.search_runs(), runs, "the frame runs the pass itself"); + assert_eq!(e.search_refresh().total, 4); + assert_eq!(e.search_runs(), runs, "and the count reuses it"); + + // Stepping selects among up-to-date matches without a pass of its own + // here, then the next frame needs one for the new selection. + let stepped = e.search_step(true); + assert!( + stepped.selected.is_some() && stepped.total == 4, + "{stepped:?}" + ); + assert_eq!(e.search_runs(), runs, "nothing had changed before the step"); + let (count, frame) = one_pass(&mut e, "step", &mut runs); + assert_eq!(count, stepped); + assert_eq!(frame.matches.iter().filter(|m| m.3).count(), 1); + // Output between frames is seen by the step itself. + e.feed(b"needle five\n"); + assert_eq!(e.search_step(false).total, 5); + + // A new needle: one pass to search, one for the frame after the + // viewport moved to the selected match. + let before = e.search_runs(); + assert_eq!(e.search(Some("line 7")).total, 11); + let frame = e.frame().unwrap(); + assert_eq!(frame.search.total, 11); + assert_eq!(e.search_runs(), before + 2); + // With no search there is nothing to run. + let before = e.search_runs(); + e.search(None); + e.feed(b"more\n"); + assert_eq!(e.frame().unwrap().search, Default::default()); + assert_eq!(e.search_runs(), before); +} + +#[test] +fn search_counts_matches_in_scrollback_and_scrolls_to_them() { + let mut e = emu(40, 5, true); + let mut text = lines(0..2_000); + text.extend_from_slice(b"needle-one\n"); + text.extend_from_slice(&lines(2_000..2_500)); + text.extend_from_slice(b"needle-two\n"); + text.extend_from_slice(&lines(2_500..2_800)); + text.extend_from_slice(b"needle-three\n"); + text.extend_from_slice(&lines(2_800..3_000)); + e.feed(&text); + let s = e.search(Some("NEEDLE")); + assert_eq!(s.total, 3, "case-insensitive for ASCII"); + assert_eq!(s.selected, Some(0), "starts at the newest match"); + let f = e.frame().unwrap(); + assert_eq!( + f.row_text(0), + "needle-three", + "scrolled to the newest match" + ); + assert_eq!(f.matches, vec![(0, 0, 5, true)]); + assert!(!e.follow(), "a match above the live end pauses follow"); + assert_eq!(e.search_step(true).selected, Some(1)); + assert_eq!( + e.frame().unwrap().row_text(0), + "needle-two", + "one step older" + ); + assert_eq!(e.search_step(true).selected, Some(2)); + let f = e.frame().unwrap(); + assert_eq!(f.row_text(0), "needle-one", "oldest"); + assert_eq!(f.matches, vec![(0, 0, 5, true)]); + assert_eq!(e.search_step(false).selected, Some(1), "back toward newer"); + assert_eq!(e.search(None).total, 0); + assert!(e.frame().unwrap().matches.is_empty()); +} + +#[test] +fn selection_and_copy_return_plain_text() { + let mut e = emu(20, 4, true); + e.feed(b"\x1b[32mhello\x1b[0m world\nsecond line\n"); + e.select_start(0, 0); + e.select_to(4, 1); + let text = e.selection_text().expect("selection"); + assert_eq!(text, "hello world\nsecon"); + let f = e.frame().unwrap(); + assert_eq!(f.selection[0], Some((0, 19))); + assert_eq!(f.selection[1], Some((0, 4))); + e.clear_selection(); + assert!(e.selection_text().is_none()); +} + +#[test] +fn resize_reflows_soft_wrapped_lines() { + let mut e = emu(10, 6, true); + e.feed(b"0123456789abcdefghij\nnext\n"); + let f = e.frame().unwrap(); + assert_eq!(f.row_text(0), "0123456789"); + assert_eq!(f.row_text(1), "abcdefghij"); + e.resize(30, 6, 8, 16); + let f = e.frame().unwrap(); + assert_eq!(f.cols, 30); + assert_eq!(f.row_text(0), "0123456789abcdefghij", "rewrapped"); + assert_eq!(f.row_text(1), "next"); +} + +#[test] +fn wide_characters_take_two_cells() { + let mut e = emu(10, 2, true); + e.feed("日本\n".as_bytes()); + let f = e.frame().unwrap(); + let r = &f.rows[0]; + assert!(r[0].wide && r[1].spacer && r[2].wide && r[3].spacer); + assert_eq!(f.row_text(0), "日本"); +} + +#[test] +fn osc8_hyperlinks_are_readable_and_queries_get_replies() { + let mut e = emu(40, 2, true); + e.feed(b"\x1b]8;;https://example.invalid/x\x1b\\link\x1b]8;;\x1b\\ plain\n"); + assert_eq!( + e.link_at(1, 0).as_deref(), + Some("https://example.invalid/x") + ); + assert_eq!(e.link_at(6, 0), None); + // Device status report: a PTY needs the reply written back. + let reply = e.feed(b"\x1b[6n"); + assert!(reply.starts_with(b"\x1b["), "{reply:?}"); +} + +#[test] +fn theme_changes_the_default_colours() { + use hack_desktop_term::emulator::TermTheme; + let mut e = emu(10, 3, true); + e.feed(b"x\n"); + e.set_theme(TermTheme::LIGHT).unwrap(); + let f = e.frame().unwrap(); + assert_eq!(f.background, TermTheme::LIGHT.background); + assert_eq!(f.rows[0][0].fg, TermTheme::LIGHT.foreground); + e.feed(b"\x1b[32mg\x1b[0m\n"); + let f = e.frame().unwrap(); + assert_eq!( + f.rows[1][0].fg, + TermTheme::LIGHT.ansi[2], + "themed ANSI palette" + ); +} + +#[test] +fn keys_encode_for_the_program_and_follow_its_modes() { + use hack_desktop_term::emulator::KeyInput; + let mut e = emu(20, 2, false); + let k = |key, text| KeyInput { + key, + text, + ..KeyInput::default() + }; + assert_eq!(e.encode_key(k("a", Some("a"))), b"a"); + assert_eq!(e.encode_key(k("enter", None)), b"\r"); + assert_eq!(e.encode_key(k("backspace", None)), b"\x7f"); + assert_eq!( + e.encode_key(KeyInput { + ctrl: true, + ..k("c", Some("c")) + }), + b"\x03" + ); + assert_eq!(e.encode_key(k("up", None)), b"\x1b[A"); + // DECCKM (application cursor keys), as set by full-screen programs. + e.feed(b"\x1b[?1h"); + assert_eq!(e.encode_key(k("up", None)), b"\x1bOA"); + assert_eq!(e.encode_key(k("é", Some("é"))), "é".as_bytes()); +} + +#[test] +fn paste_is_bracketed_when_asked_and_cannot_escape_the_bracket() { + let mut e = emu(20, 2, false); + assert_eq!(e.encode_paste("a\nb"), b"a\rb"); + e.feed(b"\x1b[?2004h"); + assert_eq!( + e.encode_paste("x\x1b[201~rm"), + b"\x1b[200~x[201~rm\x1b[201~" + ); +} + +#[test] +fn the_users_palette_and_cursor_style_apply_until_a_program_overrides_them() { + use hack_desktop_term::emulator::{CursorShape, KeyInput}; + let mut e = emu(20, 3, true); + // Index 1 is an ANSI colour, 208 is in the 256-colour cube. + e.set_palette_entries(&[(1, Rgb::new(250, 1, 2)), (208, Rgb::new(3, 4, 5))]) + .unwrap(); + e.set_default_cursor(Some(CursorShape::Bar)).unwrap(); + e.feed(b"\x1b[31mr\x1b[38;5;208mo\x1b[0m"); + let f = e.frame().unwrap(); + assert_eq!(f.rows[0][0].fg, Rgb::new(250, 1, 2)); + assert_eq!(f.rows[0][1].fg, Rgb::new(3, 4, 5)); + assert_eq!( + f.cursor_shape, + Some(CursorShape::Bar), + "the configured default" + ); + // A program asks for an underline cursor (DECSCUSR 4), then resets it. + e.feed(b"\x1b[4 q"); + assert_eq!( + e.frame().unwrap().cursor_shape, + Some(CursorShape::Underline) + ); + e.feed(b"\x1b[0 q"); + assert_eq!(e.frame().unwrap().cursor_shape, Some(CursorShape::Bar)); + // Unrelated: keys still encode. + assert_eq!( + e.encode_key(KeyInput { + key: "a", + text: Some("a"), + ..KeyInput::default() + }), + b"a" + ); +} diff --git a/apps/desktop/term/tests/process_cleanup.rs b/apps/desktop/term/tests/process_cleanup.rs new file mode 100644 index 000000000..386f1d8b9 --- /dev/null +++ b/apps/desktop/term/tests/process_cleanup.rs @@ -0,0 +1,287 @@ +//! Dropping a PTY or a process stream leaves no process behind; queues stay +//! bounded; output written just before exit is not lost. +//! +//! Hermetic: `/bin/sh` and a few POSIX tools from `/usr/bin:/bin`, an +//! emptied environment, a private temporary HOME, no network, no daemon, no +//! Docker. Deadlines are generous (60 s) scheduling bounds, not latency +//! claims; each test first proves its processes were alive, and signals +//! only pids it recorded. +#![cfg(unix)] + +mod common; + +use std::time::{Duration, Instant}; + +use common::{DEADLINE, Recorded, TempHome, read_until, sh, sh_source, tagged, wait_gone}; +use hack_desktop_term::pty::{OUTPUT_QUEUE_BYTES, Pty, PtySize}; +use hack_desktop_term::session::{ExitInfo, exists, is_alive}; +use hack_desktop_term::stream::{ByteStream, STREAM_QUEUE_BYTES, StreamSource}; + +#[test] +fn dropping_a_pty_ends_the_shell_and_everything_in_its_group() { + // A grandchild in the shell's group, and one that ignores SIGHUP and + // SIGTERM, so SIGKILL escalation has to run. Both are reached by the + // group signal on every platform. + let script = r#" + sleep 120 & echo "same=$!" + ( trap '' HUP TERM; exec sleep 120 ) & echo "deaf=$!" + echo "shell=$$" + wait + "#; + let home = TempHome::new(); + let pty = Pty::spawn(&sh(script, &home)).expect("spawn"); + let mut rec = Recorded::session(pty.pid()); + let leader = libc::pid_t::try_from(pty.pid()).unwrap(); + let out = read_until(&pty, |t| t.contains("shell=")); + let pids: Vec = ["same", "deaf", "shell"] + .iter() + .map(|t| tagged(&out, t).unwrap_or_else(|| panic!("no {t}= in {out:?}"))) + .collect(); + assert_eq!(pids[2], leader, "the shell is the session leader"); + for &p in &pids { + assert!(is_alive(p), "pid {p} should be running before drop"); + } + drop(pty); + let alive = wait_gone(&pids); + assert!(alive.is_empty(), "still running after drop: {alive:?}"); + // The reaper thread reaps the leader after its last signal. + let start = Instant::now(); + while exists(leader) && start.elapsed() < DEADLINE { + std::thread::sleep(Duration::from_millis(20)); + } + assert!(!exists(leader), "the leader was left as a zombie"); + rec.done(); +} + +#[test] +fn a_job_in_another_process_group_is_ended_or_reported() { + // `set -m` puts the job in its own process group inside the session. A + // non-interactive shell does not pass SIGHUP on to it. + // + // Linux reaches it through a pidfd. macOS has no race-free handle for + // it, so it is left alone and named in `survivors`; there the job + // sleeps only a few seconds and ends by itself. + let job_seconds = if cfg!(target_os = "linux") { 120 } else { 4 }; + let script = format!( + "set -m; sleep {job_seconds} & echo \"job=$!\"; set +m; sleep 120 & echo \"same=$!\"; wait" + ); + let home = TempHome::new(); + let pty = Pty::spawn(&sh(&script, &home)).expect("spawn"); + let mut rec = Recorded::session(pty.pid()); + let leader = libc::pid_t::try_from(pty.pid()).unwrap(); + let out = read_until(&pty, |t| t.contains("same=")); + let job = tagged(&out, "job").expect("job"); + let same = tagged(&out, "same").expect("same"); + assert!(is_alive(job) && is_alive(same)); + assert_ne!( + unsafe { libc::getpgid(job) }, + leader, + "its own process group" + ); + assert_eq!(unsafe { libc::getsid(job) }, leader, "the same session"); + + let done = pty + .terminate_in_background() + .wait(DEADLINE) + .expect("teardown result"); + assert!(wait_gone(&[same, leader]).is_empty()); + if cfg!(target_os = "linux") { + assert!(done.gone && done.survivors.is_empty(), "{done:?}"); + assert!(!is_alive(job)); + } else { + assert!(!done.gone, "{done:?}"); + assert_eq!(done.survivors, vec![job], "reported, not signalled"); + assert!(wait_gone(&[job]).is_empty(), "it ends by itself"); + } + assert!(!exists(leader)); + rec.done(); +} + +#[test] +fn terminate_blocking_reports_the_session_gone() { + let home = TempHome::new(); + let pty = Pty::spawn(&sh("sleep 120 & echo \"kid=$!\"; wait", &home)).expect("spawn"); + let mut rec = Recorded::session(pty.pid()); + let out = read_until(&pty, |t| t.contains("kid=")); + let kid = tagged(&out, "kid").expect("kid pid"); + let leader = libc::pid_t::try_from(pty.pid()).unwrap(); + assert!(is_alive(kid) && is_alive(leader)); + assert!( + pty.terminate_blocking(DEADLINE).gone, + "session should be gone" + ); + assert!(!is_alive(kid) && !exists(leader)); + rec.done(); +} + +#[test] +fn resize_reaches_the_child() { + let home = TempHome::new(); + let mut opts = sh("read _; stty size; read _", &home); + opts.size = PtySize::cells(80, 24); + let pty = Pty::spawn(&opts).expect("spawn"); + let mut rec = Recorded::session(pty.pid()); + pty.resize(PtySize::cells(101, 33)).expect("resize"); + pty.write(b"\n"); + let out = read_until(&pty, |t| t.contains("33 101")); + assert!(out.contains("33 101"), "stty size after resize: {out:?}"); + assert!(pty.terminate_blocking(DEADLINE).gone); + rec.done(); +} + +#[test] +fn the_child_leads_a_session_with_a_controlling_terminal() { + let home = TempHome::new(); + let pty = Pty::spawn(&sh("tty >/dev/null && echo has-tty; read _", &home)).expect("spawn"); + let mut rec = Recorded::session(pty.pid()); + let leader = libc::pid_t::try_from(pty.pid()).unwrap(); + let out = read_until(&pty, |t| t.contains("has-tty")); + assert!(out.contains("has-tty"), "{out:?}"); + assert_eq!(unsafe { libc::getsid(leader) }, leader); + assert_eq!(unsafe { libc::getpgid(leader) }, leader); + assert!(pty.terminate_blocking(DEADLINE).gone); + rec.done(); +} + +#[test] +fn the_child_sees_only_the_environment_it_was_given() { + let home = TempHome::new(); + let pty = Pty::spawn(&sh("env; echo env-done", &home)).expect("spawn"); + let mut rec = Recorded::session(pty.pid()); + let out = read_until(&pty, |t| t.contains("env-done")); + let names: Vec<&str> = out + .lines() + .filter_map(|l| l.split_once('=').map(|(k, _)| k.trim())) + .filter(|k| !matches!(*k, "PWD" | "OLDPWD" | "SHLVL" | "_")) + .collect(); + let mut sorted = names.clone(); + sorted.sort_unstable(); + assert_eq!(sorted, ["HOME", "PATH", "TERM"], "{out:?}"); + assert!(out.contains(&format!("HOME={}", home.path().display()))); + assert!(pty.terminate_blocking(DEADLINE).gone); + rec.done(); +} + +#[test] +fn output_printed_just_before_exit_is_delivered_before_end_of_file() { + // Small output, then exit at once: the child is gone long before the + // first read. Nothing may be lost, and the end comes after the bytes. + let home = TempHome::new(); + let pty = Pty::spawn(&sh("printf 'tiny-final-suffix\\n'", &home)).expect("spawn"); + let mut rec = Recorded::session(pty.pid()); + let start = Instant::now(); + while pty.exit().is_none() && start.elapsed() < DEADLINE { + std::thread::sleep(Duration::from_millis(10)); + } + assert_eq!(pty.exit(), Some(ExitInfo::Exited(0))); + std::thread::sleep(Duration::from_millis(300)); + let out = read_until(&pty, |_| false); + assert!(out.contains("tiny-final-suffix"), "{out:?}"); + assert!(pty.read(1).eof, "end of file after the last byte"); + assert!(pty.terminate_blocking(DEADLINE).gone); + rec.done(); + + // Large output: more than the queue and the kernel hold, so the child + // must wait for the reader (backpressure) and then exits right after + // its last line. + let pty = Pty::spawn(&sh("seq 1 300000; echo large-final-suffix", &home)).expect("spawn"); + let mut rec = Recorded::session(pty.pid()); + // Stall first: the reader fills the queue and stops reading. + let start = Instant::now(); + while pty.pending_output_bytes() + 64 * 1024 < OUTPUT_QUEUE_BYTES && start.elapsed() < DEADLINE + { + std::thread::sleep(Duration::from_millis(5)); + } + std::thread::sleep(Duration::from_millis(200)); + assert!(pty.pending_output_bytes() <= OUTPUT_QUEUE_BYTES); + assert!( + pty.exit().is_none(), + "the child is blocked on its own write" + ); + let out = read_until(&pty, |_| false); + let lines: Vec<&str> = out.lines().map(str::trim_end).collect(); + assert_eq!(lines.len(), 300_001, "every line arrived"); + assert_eq!(lines[0], "1"); + assert_eq!(lines[299_999], "300000"); + assert_eq!(lines[300_000], "large-final-suffix"); + let start = Instant::now(); + while pty.exit().is_none() && start.elapsed() < DEADLINE { + std::thread::sleep(Duration::from_millis(10)); + } + assert_eq!(pty.exit(), Some(ExitInfo::Exited(0))); + assert!(pty.terminate_blocking(DEADLINE).gone); + rec.done(); +} + +#[test] +fn a_fast_stream_with_a_stalled_consumer_stays_under_the_byte_bound() { + let home = TempHome::new(); + // `yes` writes as fast as the pipe accepts, forever. + let mut stream = ByteStream::open(&sh_source( + "echo \"sh=$$\"; exec yes 0123456789abcdef", + &home, + )) + .expect("open"); + let leader = libc::pid_t::try_from(stream.pid().unwrap()).unwrap(); + let mut rec = Recorded::session(leader.unsigned_abs()); + // Do not drain. The reader fills the queue to the bound and then blocks. + let start = Instant::now(); + while stream.pending_bytes() + 64 * 1024 < STREAM_QUEUE_BYTES && start.elapsed() < DEADLINE { + std::thread::sleep(Duration::from_millis(5)); + } + let filled = stream.pending_bytes(); + assert!(filled + 64 * 1024 >= STREAM_QUEUE_BYTES, "filled {filled}"); + std::thread::sleep(Duration::from_millis(500)); + assert_eq!(stream.pending_bytes(), filled, "no growth while stalled"); + assert!(stream.high_water_bytes() <= STREAM_QUEUE_BYTES); + assert!(is_alive(leader), "the producer is blocked, not killed"); + // Draining lets it continue; the bound still holds. + let mut total = 0; + while total < 8 * STREAM_QUEUE_BYTES && start.elapsed() < DEADLINE { + total += stream.read_blocking(STREAM_QUEUE_BYTES, DEADLINE).len(); + assert!(stream.pending_bytes() <= STREAM_QUEUE_BYTES); + } + assert!(total >= 8 * STREAM_QUEUE_BYTES, "read {total}"); + assert!(stream.high_water_bytes() <= STREAM_QUEUE_BYTES); + // Closing cancels the blocked reader and ends the producer. + drop(stream); + assert!(wait_gone(&[leader]).is_empty()); + rec.done(); +} + +#[test] +fn dropping_a_process_stream_ends_its_session() { + let home = TempHome::new(); + let source = sh_source("sleep 120 & echo \"kid=$!\"; echo \"sh=$$\"; wait", &home); + let mut stream = ByteStream::open(&source).expect("open"); + let mut rec = Recorded::session(stream.pid().unwrap()); + let mut out = String::new(); + let start = Instant::now(); + while !out.contains("sh=") && start.elapsed() < DEADLINE { + out.push_str(&String::from_utf8_lossy( + &stream.read_blocking(1, Duration::from_millis(200)), + )); + } + let pids = [tagged(&out, "kid").unwrap(), tagged(&out, "sh").unwrap()]; + for &p in &pids { + assert!(is_alive(p), "pid {p} should be running before drop"); + } + drop(stream); + let alive = wait_gone(&pids); + assert!(alive.is_empty(), "still running after drop: {alive:?}"); + rec.done(); +} + +#[test] +fn a_file_stream_reads_to_the_end() { + let home = TempHome::new(); + let path = home.path().join("log.txt"); + let body: String = (0..10_000).map(|i| format!("line {i}\n")).collect(); + std::fs::write(&path, &body).unwrap(); + let mut stream = ByteStream::open(&StreamSource::File(path)).expect("open"); + let got = stream.read_blocking(body.len(), DEADLINE); + assert_eq!(got, body.as_bytes()); + // Nothing more arrives, and the end is reported after the last byte. + assert!(stream.read_blocking(1, DEADLINE).is_empty()); + assert!(stream.is_ended(), "the end is reported"); +} diff --git a/apps/desktop/term/tests/render.rs b/apps/desktop/term/tests/render.rs new file mode 100644 index 000000000..57f0deaf4 --- /dev/null +++ b/apps/desktop/term/tests/render.rs @@ -0,0 +1,529 @@ +//! Pixels: prompt symbols, powerline separators and box drawing must +//! render as real glyphs, not as the missing-glyph box. +//! +//! This draws a row of code points offscreen and looks at each cell. It +//! needs GPUI's offscreen renderer: a GPU, or a software Vulkan driver. +//! Without one the test reports that it could not run and passes, unless +//! `HACK_TERM_REQUIRE_RENDERER=1` is set, as it is in CI. +#![cfg(all(unix, feature = "screenshots"))] + +mod common; + +use std::sync::Arc; +use std::time::Duration; + +use common::TempHome; +use gpui_kit::test::TestWindowExt as _; +use gpui_kit::{ + AppContext as _, Bounds, HeadlessAppContext, WindowBounds, WindowOptions, px, size, +}; +use hack_desktop_term::glyphs::{self, BundledSymbols}; +use hack_desktop_term::stream::StreamSource; +use hack_desktop_term::view::{GridMetrics, TermFont, TerminalView}; + +/// A block of rendered pixels, row by row, as RGB. +struct Cell { + w: usize, + h: usize, + px: Vec<[u8; 3]>, +} + +impl Cell { + fn at(&self, x: usize, y: usize) -> [u8; 3] { + self.px[y * self.w + x] + } + + fn is_ink(&self, x: usize, y: usize, background: [u8; 3]) -> bool { + let p = self.at(x, y); + (0..3).any(|i| p[i].abs_diff(background[i]) > 40) + } + + /// The share of pixels, in a part of the cell, that are not background. + fn ink(&self, xs: std::ops::Range, ys: std::ops::Range, bg: [u8; 3]) -> f32 { + let total = (xs.len() * ys.len()).max(1); + let mut n = 0; + for y in ys { + for x in xs.clone() { + n += usize::from(self.is_ink(x, y, bg)); + } + } + n as f32 / total as f32 + } + + fn all_ink(&self, bg: [u8; 3]) -> f32 { + self.ink(0..self.w, 0..self.h, bg) + } + + /// The box around the ink: left, top, right, bottom, in pixels from the + /// top left corner. `None` when there is no ink. + fn ink_box(&self, bg: [u8; 3]) -> Option<[f32; 4]> { + let mut found: Option<[usize; 4]> = None; + for y in 0..self.h { + for x in 0..self.w { + if self.is_ink(x, y, bg) { + let b = found.get_or_insert([x, y, x, y]); + *b = [b[0].min(x), b[1].min(y), b[2].max(x), b[3].max(y)]; + } + } + } + found.map(|b| { + [ + b[0] as f32, + b[1] as f32, + (b[2] + 1) as f32, + (b[3] + 1) as f32, + ] + }) + } + + /// The share of pixels that differ between two cells of the same size. + fn difference(&self, other: &Self) -> f32 { + assert_eq!((self.w, self.h), (other.w, other.h)); + let n = self + .px + .iter() + .zip(&other.px) + .filter(|(a, b)| (0..3).any(|i| a[i].abs_diff(b[i]) > 40)) + .count(); + n as f32 / self.px.len() as f32 + } +} + +/// One offscreen render of a terminal view and where its grid is. +struct Capture { + image: image::RgbaImage, + scale: f32, + metrics: GridMetrics, + /// The symbols the view drew from the bundled font. + fallback: Vec, +} + +impl Capture { + /// The device pixel a column's left edge is on: the edge the view paints to. + fn edge_x(&self, col: usize) -> u32 { + ((self.metrics.origin.0 + self.metrics.cell.0 * col as f32) * self.scale).round() as u32 + } + + fn edge_y(&self, row: usize) -> u32 { + ((self.metrics.origin.1 + self.metrics.cell.1 * row as f32) * self.scale).round() as u32 + } + + fn crop(&self, xs: std::ops::Range, ys: std::ops::Range) -> Cell { + let mut px = Vec::new(); + for y in ys.clone() { + for x in xs.clone() { + let p = self.image.get_pixel(x, y).0; + px.push([p[0], p[1], p[2]]); + } + } + Cell { + w: xs.len(), + h: ys.len(), + px, + } + } + + /// Exactly `n` cells of a row, on the edges the view paints to. + fn cells(&self, col: usize, row: usize, n: usize) -> Cell { + self.crop( + self.edge_x(col)..self.edge_x(col + n), + self.edge_y(row)..self.edge_y(row + 1), + ) + } + + /// A cell and the one after it, always the same width, so two of them + /// can be compared pixel by pixel. + fn pair(&self, col: usize, row: usize) -> Cell { + let width = (2.0 * self.metrics.cell.0 * self.scale).floor() as u32; + let x0 = self.edge_x(col); + self.crop(x0..x0 + width, self.edge_y(row)..self.edge_y(row + 1)) + } + + /// The top left cell is blank in every render here. + fn background(&self) -> [u8; 3] { + self.cells(0, 0, 1).at(0, 0) + } + + /// How many pixels just outside a room are not the background. The + /// room is `n` cells of `row` from `col`; "just outside" is the row + /// above, the row below and the cell before it, and the cell after it + /// too when `after` is set. Those cells must be blank in the render. + /// Any visible difference counts, however faint. + fn stray(&self, col: usize, row: usize, n: usize, after: bool) -> usize { + let bg = self.background(); + let room_x = self.edge_x(col)..self.edge_x(col + n); + let room_y = self.edge_y(row)..self.edge_y(row + 1); + let xs = self.edge_x(col - 1)..self.edge_x(col + n + usize::from(after)); + let ys = self.edge_y(row - 1)..self.edge_y(row + 2); + let mut stray = 0; + for y in ys { + for x in xs.clone() { + if room_x.contains(&x) && room_y.contains(&y) { + continue; + } + let p = self.image.get_pixel(x, y).0; + stray += usize::from((0..3).any(|i| p[i].abs_diff(bg[i]) > 8)); + } + } + stray + } +} + +/// Render `text` as a log in an offscreen window. `None` when this machine +/// has no offscreen renderer and one is not required. +fn capture(text: &str, font: Option, dump: &str) -> Option { + let required = std::env::var_os("HACK_TERM_REQUIRE_RENDERER").is_some_and(|v| v == "1"); + let home = TempHome::new(); + let log = home.path().join("glyphs.log"); + std::fs::write(&log, text).unwrap(); + + let mut cx = HeadlessAppContext::with_platform( + gpui_kit::platform::current_platform(true).text_system(), + Arc::new(gpui_kit::assets::Assets), + gpui_kit::platform::current_headless_renderer, + ); + cx.update(gpui_kit::init); + let options = WindowOptions { + focus: false, + show: false, + window_bounds: Some(WindowBounds::Windowed(Bounds { + origin: Default::default(), + size: size(px(560.), px(120.)), + })), + ..WindowOptions::default() + }; + let (window, view) = cx + .update(|cx| { + gpui_kit::open_window(options, cx, |_, cx| { + cx.new(|cx| { + let mut view = TerminalView::new_stream(&StreamSource::File(log), 1_000, cx) + .expect("open"); + if let Some(font) = font { + view.set_font(font); + } + view + }) + }) + }) + .expect("open an offscreen window"); + cx.update_window(window, |_, w, cx| w.render_frame(cx)) + .unwrap(); + cx.update(|cx| { + view.update(cx, |v, cx| { + v.settle(Duration::from_millis(100), Duration::from_secs(20), cx); + }); + }); + cx.update_window(window, |_, w, cx| w.render_frame(cx)) + .unwrap(); + let image = match cx.capture_screenshot(window) { + Ok(image) => image, + Err(error) if !required => { + eprintln!("NOT RUN: no offscreen renderer on this machine ({error})"); + return None; + } + Err(error) => panic!("an offscreen renderer is required here: {error}"), + }; + // For a person to look at when an assertion needs explaining: set + // HACK_TERM_RENDER_DUMP to a directory. + if let Some(dir) = std::env::var_os("HACK_TERM_RENDER_DUMP") { + image + .save(std::path::Path::new(&dir).join(dump)) + .expect("write the capture"); + } + let scale = cx + .update_window(window, |_, w, _| w.scale_factor()) + .unwrap(); + let (metrics, fallback) = cx.update(|cx| { + let v = view.read(cx); + (v.grid_metrics(), v.fallback_symbols().to_vec()) + }); + Some(Capture { + image, + scale, + metrics, + fallback, + }) +} + +/// How a sample is expected to be drawn. +#[derive(Clone, Copy, PartialEq)] +enum Drawn { + /// As geometry, in exactly its own cell. + Sprite, + /// From the bundled font, in its cell and the blank one after it. + Symbol, + /// By the text system, as whatever this machine shows for no glyph. + Missing, +} + +/// The glyphs under test. Each has two blank cells after it, and the rows +/// above and below are blank, so the pixels around each one can be checked. +const SAMPLES: &[(&str, char, Drawn)] = &[ + ("powerline right arrow", '\u{e0b0}', Drawn::Sprite), + ("powerline left arrow", '\u{e0b2}', Drawn::Sprite), + ("branch", '\u{e0a0}', Drawn::Symbol), + ("octicon", '\u{f418}', Drawn::Symbol), + ("devicon", '\u{e718}', Drawn::Symbol), + ("folder", '\u{f07b}', Drawn::Symbol), + // Two glyphs whose ink rises above the font's ascender. + ("sort", '\u{f0dc}', Drawn::Symbol), + ("sort up", '\u{f0de}', Drawn::Symbol), + ("box horizontal", '─', Drawn::Sprite), + ("box vertical", '│', Drawn::Sprite), + ("box corner", '┌', Drawn::Sprite), + ("box double cross", '╬', Drawn::Sprite), + // Diagonals: a stroke from corner to corner is wider than the cell + // at its ends. + ("box diagonal", '╱', Drawn::Sprite), + ("box diagonal cross", '╳', Drawn::Sprite), + ("full block", '█', Drawn::Sprite), + ("lower half block", '▄', Drawn::Sprite), + // A private-use code point no font has, the symbols font included: + // whatever this cell shows is this machine's missing-glyph rendering. + ("known-missing", '\u{e050}', Drawn::Missing), +]; +/// The row the samples are on, and the column of the first one. +const ROW: usize = 1; +const FIRST: usize = 1; +const STEP: usize = 3; + +/// Assert that what is in a room is the bundled glyph, where the view's +/// own placement rule puts it: the ink's box matches to within a pixel and +/// a half on each side. +fn assert_placed(shot: &Capture, ch: char, col: usize, row: usize, n: usize, report: &str) { + let outline = glyphs::outline(ch).expect("in the bundled font"); + let room = shot.cells(col, row, n); + let want = outline.place(room.w as f32, room.h as f32).ink(&outline); + let got = room + .ink_box(shot.background()) + .unwrap_or_else(|| panic!("U+{:04X} is blank{report}", ch as u32)); + for side in 0..4 { + assert!( + (got[side] - want[side]).abs() <= 1.5, + "U+{:04X}: ink box {got:?}, the bundled glyph placed in {}x{} is {want:?}{report}", + ch as u32, + room.w, + room.h + ); + } +} + +#[test] +fn prompt_symbols_separators_and_box_drawing_are_real_glyphs() { + let row: String = SAMPLES.iter().map(|(_, ch, _)| format!("{ch} ")).collect(); + let Some(shot) = capture(&format!("\n {row}\n\n"), None, "glyphs.png") else { + return; + }; + let background = shot.background(); + let col_of = |ch: char| FIRST + STEP * SAMPLES.iter().position(|s| s.1 == ch).expect("sample"); + let cells: Vec<(&str, char, Cell)> = SAMPLES + .iter() + .map(|(name, ch, _)| (*name, *ch, shot.pair(col_of(*ch), ROW))) + .collect(); + let wide = |ch: char| &cells.iter().find(|c| c.1 == ch).expect("sample").2; + let by = |ch: char| shot.cells(col_of(ch), ROW, 1); + let report: String = cells + .iter() + .map(|(name, ch, cell)| { + format!( + "\n U+{:04X} {name}: {}x{} ink {:.2}", + *ch as u32, + cell.w, + cell.h, + cell.all_ink(background) + ) + }) + .collect(); + let report = format!( + "\nscale {}, from the symbols font: {:04X?}{report}", + shot.scale, shot.fallback + ); + eprintln!("{report}"); + + // Every glyph draws something. + let missing = wide('\u{e050}'); + for (name, ch, cell) in &cells { + if *ch == '\u{e050}' { + continue; + } + assert!( + cell.all_ink(background) > 0.02, + "U+{:04X} {name} is blank{report}", + *ch as u32 + ); + // And it is not this machine's missing-glyph box. + assert!( + cell.difference(missing) > 0.03, + "U+{:04X} {name} renders like a missing glyph{report}", + *ch as u32 + ); + } + // Missing glyphs all look alike; real icons do not. + let icons = ['\u{e0a0}', '\u{f418}', '\u{e718}', '\u{f07b}']; + for (i, a) in icons.iter().enumerate() { + for b in &icons[i + 1..] { + assert!( + wide(*a).difference(wide(*b)) > 0.03, + "U+{:04X} and U+{:04X} render the same: a missing-glyph box?{report}", + *a as u32, + *b as u32 + ); + } + } + // An icon fills a good part of its cell: it is not a speck. + for ch in icons { + let cell = wide(ch); + let ink_rows = (0..cell.h) + .filter(|&y| (0..cell.w).any(|x| cell.is_ink(x, y, background))) + .count(); + assert!( + ink_rows as f32 >= cell.h as f32 * 0.5, + "U+{:04X} is only {ink_rows} of {} rows tall{report}", + ch as u32, + cell.h + ); + } + // The view's default font is not a Nerd Font, so every symbol comes from + // the bundled font and none from whatever fonts this machine has. A + // machine font may define one of these code points as something else. + let mut expected: Vec = SAMPLES + .iter() + .filter(|s| s.2 == Drawn::Symbol) + .map(|s| s.1) + .collect(); + expected.sort_unstable(); + assert_eq!( + shot.fallback, expected, + "the icons are drawn from the bundled symbols font{report}" + ); + // And what is on screen is the bundled glyph, placed by its ink: no + // part of it is cut off and it is the size its room allows. + for (_, ch, drawn) in SAMPLES { + if *drawn == Drawn::Symbol { + assert_placed(&shot, *ch, col_of(*ch), ROW, 2, &report); + } + } + // Nothing is drawn outside a glyph's room: not in the rows above and + // below it, nor in the blank cells before and after it. + for (name, ch, drawn) in SAMPLES { + let n = match drawn { + Drawn::Sprite => 1, + Drawn::Symbol => 2, + Drawn::Missing => continue, + }; + let stray = shot.stray(col_of(*ch), ROW, n, true); + assert_eq!( + stray, 0, + "U+{:04X} {name} paints {stray} pixels outside its {n}-cell room{report}", + *ch as u32 + ); + } + + // The glyphs drawn as geometry fit the cell exactly. + let (c, bg) = (by('█'), background); + assert!( + c.all_ink(bg) > 0.99, + "the full block fills its cell{report}" + ); + let c = by('▄'); + assert!( + c.ink(0..c.w, c.h / 2 + 1..c.h, bg) > 0.99, + "lower half is solid{report}" + ); + assert!( + c.ink(0..c.w, 0..c.h / 2 - 1, bg) < 0.01, + "upper half is empty{report}" + ); + // A horizontal line touches both side edges and is a thin band. + let c = by('─'); + assert!( + c.ink(0..1, 0..c.h, bg) > 0.0 && c.ink(c.w - 1..c.w, 0..c.h, bg) > 0.0, + "─ reaches both edges{report}" + ); + assert!(c.all_ink(bg) < 0.25, "─ is a thin line{report}"); + assert!( + c.ink(0..c.w, 0..c.h / 4, bg) < 0.01, + "─ is in the middle{report}" + ); + // A vertical line touches the top and bottom edges. + let c = by('│'); + assert!( + c.ink(0..c.w, 0..1, bg) > 0.0 && c.ink(0..c.w, c.h - 1..c.h, bg) > 0.0, + "│ reaches both edges{report}" + ); + // A corner reaches the right and bottom edges only. + let c = by('┌'); + assert!( + c.ink(c.w - 1..c.w, 0..c.h, bg) > 0.0 && c.ink(0..c.w, c.h - 1..c.h, bg) > 0.0, + "┌ right and down{report}" + ); + assert!( + c.ink(0..1, 0..c.h, bg) < 0.01 && c.ink(0..c.w, 0..1, bg) < 0.01, + "┌ not left or up{report}" + ); + // The solid arrows: flat and full height on one side, a point on the other. + let c = by('\u{e0b0}'); + assert!( + c.ink(0..1, 0..c.h, bg) > 0.9, + "the right arrow is flat on its left edge{report}" + ); + assert!( + c.ink(c.w - 1..c.w, 0..c.h, bg) < 0.25, + "and comes to a point{report}" + ); + let c = by('\u{e0b2}'); + assert!( + c.ink(c.w - 1..c.w, 0..c.h, bg) > 0.9, + "the left arrow is flat on its right edge{report}" + ); + assert!( + c.ink(0..1, 0..c.h, bg) < 0.25, + "and comes to a point{report}" + ); +} + +#[test] +fn a_double_width_symbol_is_drawn_in_both_of_its_cells() { + // U+26A1 is double width and in the bundled font. Many fonts have it + // too, so the bundled font is asked for outright. The "x" straight + // after it shows that the two cells are the symbol's own and not a + // blank neighbour borrowed. + let bolt = '\u{26a1}'; + let font = TermFont { + symbols: BundledSymbols::Always, + ..TermFont::default() + }; + let Some(shot) = capture(&format!("\n {bolt}x\n\n"), Some(font), "wide.png") else { + return; + }; + let bg = shot.background(); + let report = format!( + "\nscale {}, from the symbols font: {:04X?}", + shot.scale, shot.fallback + ); + assert_eq!(shot.fallback, [bolt], "drawn from the bundled font{report}"); + assert!( + shot.cells(3, ROW, 1).all_ink(bg) > 0.02 && shot.cells(4, ROW, 1).all_ink(bg) == 0.0, + "the symbol takes two cells: the x is in the fourth column{report}" + ); + // The glyph is placed in the two cells together. + assert_placed(&shot, bolt, 1, ROW, 2, &report); + // Which is larger than it would be in one cell, and reaches the second. + let outline = glyphs::outline(bolt).unwrap(); + let (one, two) = (shot.cells(1, ROW, 1), shot.cells(1, ROW, 2)); + let [_, top, _, bottom] = two.ink_box(bg).expect("ink"); + let [_, one_top, _, one_bottom] = outline.place(one.w as f32, one.h as f32).ink(&outline); + assert!( + bottom - top > (one_bottom - one_top) + 2.0, + "the symbol is {} pixels tall; fitted to one cell it would be {}{report}", + bottom - top, + one_bottom - one_top + ); + assert!( + shot.cells(2, ROW, 1).all_ink(bg) > 0.02, + "the symbol reaches into its second cell{report}" + ); + // And it stays in them. The cell after is the x, so it is not checked. + let stray = shot.stray(1, ROW, 2, false); + assert_eq!(stray, 0, "{stray} pixels outside the two cells{report}"); +} diff --git a/apps/desktop/term/tests/sessions.rs b/apps/desktop/term/tests/sessions.rs new file mode 100644 index 000000000..944c965ae --- /dev/null +++ b/apps/desktop/term/tests/sessions.rs @@ -0,0 +1,169 @@ +//! The session kinds, run for real on a PTY: the user's login shell with +//! its profile, and the two CLI-backed kinds through a stand-in for `hack`. +//! +//! Hermetic: `/bin/sh` and POSIX tools, an emptied environment, a private +//! temporary HOME. No Docker, no daemon, no real project. +#![cfg(unix)] + +mod common; + +use std::path::Path; + +use common::{DEADLINE, Recorded, TempHome, fake_hack, hermetic, read_until}; +use hack_desktop_term::launch::{ + BadgeTone, HackCli, LoginShell, Name, SessionKind, TermChoice, container_shell, host_shell, + host_shell_with_env, +}; +use hack_desktop_term::pty::Pty; + +fn cli(home: &TempHome) -> HackCli { + HackCli { + program: fake_hack(home), + project_root: home.path().to_owned(), + } +} + +/// Type a line and read its output. The terminal echoes what is typed, so +/// the end marker is computed by the shell (`fin-42`) and never appears in +/// the typed text itself. +fn ask(pty: &Pty, line: &str) -> String { + pty.write(format!("{line}; echo fin-$((6 * 7))\n").as_bytes()); + read_until(pty, |t| t.contains("fin-42")) +} + +#[test] +fn a_host_shell_is_an_interactive_login_shell_that_loads_the_users_profile() { + let home = TempHome::new(); + // What a user's prompt theming looks like to us: the profile sets the + // prompt and the environment, and an `ENV` file runs for interactive + // shells. A real setup (zsh with a prompt theme) rides the same path. + std::fs::write( + home.path().join(".profile"), + "echo profile-loaded\nFROM_PROFILE=yes; export FROM_PROFILE\n\ + ENV=\"$HOME/.shrc\"; export ENV\nPS1='themed-prompt> '\n", + ) + .unwrap(); + std::fs::write(home.path().join(".shrc"), "FROM_RC=yes\n").unwrap(); + let shell = LoginShell { + path: "/bin/sh".into(), + }; + let mut plan = host_shell(home.path(), &shell, TermChoice::default()); + assert_eq!(plan.kind().label(), "host · sh"); + hermetic(&mut plan, &home, &[]); + let pty = Pty::spawn(&plan.attempts[0].options).expect("spawn"); + let mut rec = Recorded::session(pty.pid()); + + let start = read_until(&pty, |t| t.contains("themed-prompt> ")); + assert!(start.contains("profile-loaded"), "login shell: {start:?}"); + assert!( + start.contains("themed-prompt> "), + "the user's prompt: {start:?}" + ); + let out = ask( + &pty, + "echo \"zero=[$0] term=[$TERM] color=[$COLORTERM] prog=[$TERM_PROGRAM] profile=[$FROM_PROFILE] rc=[$FROM_RC]\"; case $- in *i*) echo is-interactive-$((1 + 1));; esac", + ); + assert!( + out.contains("zero=[-sh]"), + "argv[0] marks a login shell: {out:?}" + ); + assert!(out.contains("is-interactive-2"), "interactive: {out:?}"); + assert!( + out.contains("term=[xterm-256color] color=[truecolor] prog=[hack] profile=[yes] rc=[yes]"), + "{out:?}" + ); + // Where it starts: the project root. + let out = ask(&pty, "echo \"cwd=[$(pwd -P)]\""); + let root = std::fs::canonicalize(home.path()).unwrap(); + assert!( + out.contains(&format!("cwd=[{}]", root.display())), + "{out:?}" + ); + assert!(pty.terminate_blocking(DEADLINE).gone); + rec.done(); +} + +#[test] +fn zsh_loads_its_login_and_interactive_files_when_it_is_installed() { + let Some(zsh) = ["/bin/zsh", "/usr/bin/zsh"] + .into_iter() + .find(|p| Path::new(p).is_file()) + else { + eprintln!("zsh is not installed; skipping"); + return; + }; + let home = TempHome::new(); + std::fs::write(home.path().join(".zprofile"), "echo zprofile-loaded\n").unwrap(); + std::fs::write( + home.path().join(".zshrc"), + "echo zshrc-loaded\nPROMPT='zsh-themed %# '\nalias hello='echo alias-works'\n", + ) + .unwrap(); + let shell = LoginShell { path: zsh.into() }; + let mut plan = host_shell(home.path(), &shell, TermChoice::default()); + assert_eq!(plan.kind().label(), "host · zsh"); + hermetic(&mut plan, &home, &[]); + let pty = Pty::spawn(&plan.attempts[0].options).expect("spawn"); + let mut rec = Recorded::session(pty.pid()); + let start = read_until(&pty, |t| t.contains("zsh-themed")); + assert!(start.contains("zprofile-loaded"), "login: {start:?}"); + assert!(start.contains("zshrc-loaded"), "interactive: {start:?}"); + let out = ask(&pty, "hello"); + assert!(out.contains("alias-works"), "aliases from .zshrc: {out:?}"); + assert!(pty.terminate_blocking(DEADLINE).gone); + rec.done(); +} + +#[test] +fn a_container_shell_runs_through_the_cli_and_is_interactive() { + let home = TempHome::new(); + let mut plan = container_shell(&cli(&home), &Name::new("api").unwrap()); + hermetic(&mut plan, &home, &[]); + assert_eq!(plan.kind().label(), "api · bash"); + let pty = Pty::spawn(&plan.attempts[0].options).expect("spawn"); + let mut rec = Recorded::session(pty.pid()); + let start = read_until(&pty, |t| t.contains("container$ ")); + assert!(start.contains("container:api:bash"), "{start:?}"); + let out = ask(&pty, "echo \"sum=[$((40 + 2))]\""); + assert!(out.contains("sum=[42]"), "{out:?}"); + assert!(pty.terminate_blocking(DEADLINE).gone); + rec.done(); +} + +#[test] +fn a_host_shell_with_env_is_started_by_the_cli_from_names_only() { + let home = TempHome::new(); + std::fs::write(home.path().join(".profile"), "PS1='env-shell> '\n").unwrap(); + let shell = LoginShell { + path: "/bin/sh".into(), + }; + let mut plan = host_shell_with_env( + &cli(&home), + &shell, + &Name::new("staging").unwrap(), + &Name::new("api").unwrap(), + false, + TermChoice::default(), + ); + hermetic(&mut plan, &home, &[]); + let kind = plan.kind().clone(); + assert_eq!(kind.label(), "host · sh"); + let badge = kind.badge().expect("badge"); + assert_eq!( + (badge.text.as_str(), badge.tone), + ("env staging · api", BadgeTone::Warning) + ); + assert!(matches!(kind, SessionKind::HostShellWithEnv { .. })); + let pty = Pty::spawn(&plan.attempts[0].options).expect("spawn"); + let mut rec = Recorded::session(pty.pid()); + let start = read_until(&pty, |t| t.contains("env-shell> ")); + // The CLI received the names and started the login shell itself. + assert!( + start.contains("host-shell env=staging scope=api"), + "{start:?}" + ); + let out = ask(&pty, "echo \"prog=[$TERM_PROGRAM]\""); + assert!(out.contains("prog=[hack]"), "{out:?}"); + assert!(pty.terminate_blocking(DEADLINE).gone); + rec.done(); +} diff --git a/apps/desktop/term/tests/terminal.rs b/apps/desktop/term/tests/terminal.rs new file mode 100644 index 000000000..7639aceac --- /dev/null +++ b/apps/desktop/term/tests/terminal.rs @@ -0,0 +1,203 @@ +//! The terminal core end to end: a real child on a PTY, its output in the +//! emulator's grid and scrollback. +#![cfg(all(unix, feature = "ghostty"))] + +mod common; + +use std::time::{Duration, Instant}; + +use common::{DEADLINE, TempHome, fake_hack, hermetic, sh, sh_source}; +use hack_desktop_term::launch::{HackCli, Name, SessionKind, container_shell}; +use hack_desktop_term::stream::StreamSource; +use hack_desktop_term::terminal::{Status, TerminalCore}; + +fn pump_to_end(core: &mut TerminalCore) { + let start = Instant::now(); + while *core.status() == Status::Running && start.elapsed() < DEADLINE { + if !core.pump() { + std::thread::sleep(Duration::from_millis(2)); + } + } + assert!( + matches!(core.status(), Status::Ended(_)), + "{:?}", + core.status() + ); +} + +/// The last non-empty row of the viewport. +fn last_line(core: &mut TerminalCore) -> String { + let frame = core.emulator().frame().unwrap(); + (0..frame.rows.len()) + .rev() + .map(|r| frame.row_text(r)) + .find(|t| !t.is_empty()) + .unwrap_or_default() +} + +#[test] +fn a_short_command_keeps_its_final_output_in_the_grid() { + let home = TempHome::new(); + // The child exits right after a large output. Its exit is visible well + // before the output has been pumped. + let mut core = + TerminalCore::pty(&sh("seq 1 200000; echo final-suffix-7f3a", &home)).expect("spawn"); + pump_to_end(&mut core); + assert_eq!( + *core.status(), + Status::Ended("process ended (exit status 0)".into()) + ); + assert_eq!(last_line(&mut core), "final-suffix-7f3a"); + let emu = core.emulator(); + assert_eq!(emu.search(Some("final-suffix-7f3a")).total, 1); + // The line before it is the last number, so nothing was cut off. + assert_eq!(emu.search(Some("200000")).total, 1); + assert!(emu.scrollback_rows() >= 9_000, "scrollback kept the tail"); + + // The same with a tiny output and a consumer that starts late. + let mut core = TerminalCore::pty(&sh("echo tiny-suffix-91c2", &home)).expect("spawn"); + std::thread::sleep(Duration::from_millis(300)); + pump_to_end(&mut core); + assert_eq!(last_line(&mut core), "tiny-suffix-91c2"); +} + +#[test] +fn a_descendant_holding_the_terminal_does_not_keep_it_open_for_ever() { + let home = TempHome::new(); + // The shell exits; a background job keeps the terminal open and silent. + let mut core = TerminalCore::pty(&sh("sleep 120 & echo shell-done", &home)).expect("spawn"); + let start = Instant::now(); + pump_to_end(&mut core); + assert!( + start.elapsed() < Duration::from_secs(30), + "bounded wait for end of output" + ); + assert_eq!(core.emulator().search(Some("shell-done")).total, 1); + assert!(matches!(core.status(), Status::Ended(s) if s.contains("exit status 0"))); +} + +#[test] +fn a_stream_ends_with_its_last_line_in_the_grid() { + let home = TempHome::new(); + let mut core = TerminalCore::stream( + &sh_source("seq 1 50000; echo stream-suffix-4d", &home), + 10_000, + ) + .expect("open"); + pump_to_end(&mut core); + assert_eq!(*core.status(), Status::Ended("end of stream".into())); + assert_eq!(last_line(&mut core), "stream-suffix-4d"); +} + +/// Pump until the screen shows `needle`, the session ends, or the deadline. +fn pump_until(core: &mut TerminalCore, needle: &str) -> String { + let start = Instant::now(); + loop { + if !core.pump() { + std::thread::sleep(Duration::from_millis(2)); + } + let frame = core.emulator().frame().unwrap(); + let text = (0..frame.rows.len()) + .map(|r| frame.row_text(r)) + .collect::>() + .join("\n"); + if text.contains(needle) || *core.status() != Status::Running || start.elapsed() >= DEADLINE + { + return text; + } + } +} + +fn container_plan( + home: &TempHome, + extra: &[(&str, &str)], +) -> hack_desktop_term::launch::SessionPlan { + let cli = HackCli { + program: fake_hack(home), + project_root: home.path().to_owned(), + }; + let mut plan = container_shell(&cli, &Name::new("api").unwrap()); + hermetic(&mut plan, home, extra); + plan +} + +#[test] +fn a_container_without_bash_falls_back_to_sh_and_the_tab_says_so() { + let home = TempHome::new(); + let mut core = + TerminalCore::session(container_plan(&home, &[("FAKE_HACK_NO_BASH", "1")])).expect("spawn"); + assert_eq!(core.kind().label(), "api · bash", "bash is tried first"); + let screen = pump_until(&mut core, "container:api:sh"); + assert!(screen.contains("container:api:sh"), "{screen:?}"); + assert_eq!( + core.kind().label(), + "api · sh", + "the label follows the shell" + ); + assert_eq!(*core.status(), Status::Running); + assert!( + !screen.contains("not found"), + "the failed attempt's message is cleared: {screen:?}" + ); + // The shell that started is usable. + core.send(b"echo \"sum=[$((40 + 2))]\"\n"); + assert!(pump_until(&mut core, "sum=[42]").contains("sum=[42]")); +} + +#[test] +fn a_container_with_bash_keeps_it_and_a_later_exit_127_is_not_a_fallback() { + let home = TempHome::new(); + let mut core = TerminalCore::session(container_plan(&home, &[])).expect("spawn"); + let screen = pump_until(&mut core, "container$"); + assert!(screen.contains("container:api:bash"), "{screen:?}"); + assert_eq!( + *core.kind(), + SessionKind::ContainerShell { + service: "api".into(), + shell: "bash".into() + } + ); + // The user runs something and the shell exits 127: that is their + // session ending, not a missing shell. No second attempt. + core.send(b"exit 127\n"); + pump_to_end(&mut core); + assert_eq!( + *core.status(), + Status::Ended("process ended (exit status 127)".into()) + ); + assert_eq!(core.kind().label(), "api · bash"); +} + +#[test] +fn logs_keep_their_ansi_colours_and_prompt_glyphs_exactly() { + let home = TempHome::new(); + // A log line with SGR colours, a truecolour value, an OSC 8 link and + // the private-use glyphs a Nerd Font prompt uses. + let line = "\x1b[1;31mERROR\x1b[0m \x1b[38;2;10;200;30mok\x1b[0m \u{e0b0}\u{f418} \x1b]8;;https://example.invalid/a\x1b\\link\x1b]8;;\x1b\\\n"; + let path = home.path().join("app.log"); + std::fs::write(&path, line).unwrap(); + let mut core = TerminalCore::stream(&StreamSource::File(path), 10_000).expect("open"); + pump_to_end(&mut core); + assert_eq!(core.kind().label(), "logs"); + assert!(core.read_only()); + let frame = core.emulator().frame().unwrap(); + let row = &frame.rows[0]; + assert_eq!(frame.row_text(0), "ERROR ok \u{e0b0}\u{f418} link"); + // Colours are the ones the service printed, not ours. + assert!(row[0].bold); + assert_ne!(row[0].fg, frame.foreground, "SGR 31"); + assert_eq!( + row[6].fg, + hack_desktop_term::emulator::Rgb::new(10, 200, 30) + ); + // Each prompt glyph is one cell holding exactly that character. + assert_eq!((row[9].text.as_str(), row[9].wide), ("\u{e0b0}", false)); + assert_eq!(row[10].text, "\u{f418}"); + assert_eq!( + core.emu().link_at(12, 0).as_deref(), + Some("https://example.invalid/a") + ); + // Input is never sent to a log stream. + core.send(b"anything\n"); + assert!(core.notice().is_none()); +} diff --git a/apps/desktop/term/tests/view.rs b/apps/desktop/term/tests/view.rs new file mode 100644 index 000000000..7d46cc29d --- /dev/null +++ b/apps/desktop/term/tests/view.rs @@ -0,0 +1,328 @@ +//! The GPUI view, driven through a headless app: state changes reach the +//! next frame, and quitting the app ends every kind of session. +//! +//! The headless app draws a window when, and only when, something marked it +//! dirty, as the real app does on its next frame. No renderer or display +//! is needed. +#![cfg(all(unix, feature = "screenshots"))] + +mod common; + +use std::sync::Arc; +use std::time::{Duration, Instant}; + +use common::{DEADLINE, Recorded, TempHome, sh, sh_source, tagged, wait_gone}; +use gpui_kit::test::TestWindowExt as _; +use gpui_kit::{ + AnyWindowHandle, AppContext as _, ClipboardItem, Entity, Focusable as _, HeadlessAppContext, + KeyBinding, WindowOptions, +}; +use hack_desktop_term::actions::{CloseFind, Copy, Find, Paste}; +use hack_desktop_term::pty::INPUT_QUEUE_BYTES; +use hack_desktop_term::session::{exists, is_alive}; +use hack_desktop_term::view::TerminalView; + +fn app() -> HeadlessAppContext { + let mut cx = HeadlessAppContext::with_platform( + gpui_kit::platform::current_platform(true).text_system(), + Arc::new(gpui_kit::assets::Assets), + || Ok(None), + ); + cx.update(gpui_kit::init); + cx +} + +fn open( + cx: &mut HeadlessAppContext, + build: impl FnOnce(&mut gpui_kit::Context) -> TerminalView + 'static, +) -> (AnyWindowHandle, Entity) { + let options = WindowOptions { + focus: false, + show: false, + ..WindowOptions::default() + }; + cx.update(|cx| gpui_kit::open_window(options, cx, |_, cx| cx.new(build))) + .expect("open a headless window") +} + +/// Everything the terminal shows, row by row. +fn screen(cx: &mut HeadlessAppContext, view: &Entity) -> String { + cx.update(|cx| { + view.update(cx, |v, _| { + let frame = v.emulator().frame().expect("frame"); + (0..frame.rows.len()) + .map(|r| frame.row_text(r)) + .collect::>() + .join("\n") + }) + }) +} + +fn settle_until( + cx: &mut HeadlessAppContext, + view: &Entity, + done: impl Fn(&str) -> bool, +) -> String { + let start = Instant::now(); + loop { + cx.update(|cx| { + view.update(cx, |v, cx| { + v.settle(Duration::from_millis(50), Duration::from_secs(2), cx); + }); + }); + let text = screen(cx, view); + if done(&text) || start.elapsed() >= DEADLINE { + return text; + } + } +} + +#[test] +fn a_rejected_paste_shows_its_notice_in_the_next_frame_without_other_events() { + let home = TempHome::new(); + let mut cx = app(); + // Raw mode and no echo: the child prints nothing in response to input, + // and never reads it. + let opts = sh("stty raw -echo; echo ready; exec sleep 120", &home); + let (_window, view) = open(&mut cx, move |cx| { + TerminalView::new_pty(&opts, cx).expect("spawn") + }); + assert!(settle_until(&mut cx, &view, |t| t.contains("ready")).contains("ready")); + let before = cx.update(|cx| view.read(cx).rendered().clone()); + assert!(before.count > 0 && before.notice.is_none(), "{before:?}"); + + // Larger than the input queue: refused whole. + let paste = "x".repeat(INPUT_QUEUE_BYTES + 1); + cx.update(|cx| view.update(cx, |v, cx| v.paste_text(&paste, cx))); + + // No output arrived, nothing was pumped, no key was pressed. The one + // thing that can have drawn a frame is the paste itself. + let after = cx.update(|cx| view.read(cx).rendered().clone()); + assert_eq!(after.count, before.count + 1, "exactly one new frame"); + let notice = after.notice.expect("the notice is in that frame"); + assert!(notice.contains("input not sent"), "{notice}"); + assert!( + notice.contains(&(INPUT_QUEUE_BYTES + 1).to_string()), + "{notice}" + ); + + // Input that fits clears the notice, again in the very next frame. + cx.update(|cx| view.update(cx, |v, cx| v.type_text("ok", cx))); + let cleared = cx.update(|cx| view.read(cx).rendered().clone()); + assert_eq!((cleared.count, cleared.notice), (after.count + 1, None)); +} + +#[test] +fn the_find_count_matches_the_highlights_when_output_arrives_and_then_stops() { + let home = TempHome::new(); + let mut cx = app(); + // One match; two more when the test says so; then silence. + let source = sh_source( + "echo 'needle one'; while [ ! -e go ]; do sleep 0.02; done; \ + echo 'needle two'; echo 'needle three'; exec sleep 120", + &home, + ); + let (_window, view) = open(&mut cx, move |cx| { + TerminalView::new_stream(&source, 1_000, cx).expect("spawn") + }); + assert!(settle_until(&mut cx, &view, |t| t.contains("needle one")).contains("needle one")); + let found = cx.update(|cx| view.update(cx, |v, cx| v.search("needle", cx))); + assert_eq!(found.total, 1); + let before = cx.update(|cx| view.read(cx).rendered().clone()); + assert_eq!( + (before.find.map(|f| f.total), before.highlights), + (Some(1), 1), + "{before:?}" + ); + + // Find is open. Output arrives that adds two matches. + std::fs::write(home.path().join("go"), "").unwrap(); + assert!(settle_until(&mut cx, &view, |t| t.contains("needle three")).contains("needle three")); + + // The source is quiet now: nothing more is pumped, typed or clicked. + // The last frame is the one drawn for the output that arrived, and its + // count has to be right already, because no later frame is coming. + let after = cx.update(|cx| view.read(cx).rendered().clone()); + assert_eq!(after.highlights, 3, "all three are on screen: {after:?}"); + assert_eq!( + after.find.map(|f| f.total), + Some(after.highlights), + "the count shown is the number of highlighted matches: {after:?}" + ); + let later = cx.update(|cx| view.read(cx).rendered().clone()); + assert_eq!(later, after, "and no further frame was drawn to get there"); +} + +#[test] +fn redrawing_an_unchanged_view_with_find_open_does_not_run_the_search() { + let home = TempHome::new(); + let mut cx = app(); + let source = sh_source( + "echo 'needle one'; echo 'needle two'; exec sleep 120", + &home, + ); + let (_window, view) = open(&mut cx, move |cx| { + TerminalView::new_stream(&source, 1_000, cx).expect("spawn") + }); + assert!(settle_until(&mut cx, &view, |t| t.contains("needle two")).contains("needle two")); + let found = cx.update(|cx| view.update(cx, |v, cx| v.search("needle", cx))); + assert_eq!(found.total, 2); + // The frame for the search has been drawn. The source is quiet. + let runs = |cx: &mut HeadlessAppContext| { + cx.update(|cx| view.update(cx, |v, _| v.emulator().search_runs())) + }; + let (searched, before) = ( + runs(&mut cx), + cx.update(|cx| view.read(cx).rendered().clone()), + ); + assert_eq!((before.find, before.highlights), (Some(found), 2)); + + // Redraw three times with nothing changed, as a hover, a blink or a + // neighbouring view's update would. + for _ in 0..3 { + cx.update(|cx| view.update(cx, |_, cx| cx.notify())); + } + let after = cx.update(|cx| view.read(cx).rendered().clone()); + assert_eq!(after.count, before.count + 3, "three more frames"); + assert_eq!( + (after.find, after.highlights), + (Some(found), 2), + "showing the same count and highlights" + ); + assert_eq!(runs(&mut cx), searched, "and the search did no work"); +} + +#[test] +fn quitting_the_app_ends_a_stream_process_that_ignores_sigterm_and_its_descendant() { + let home = TempHome::new(); + let mut cx = app(); + // Both the process and its child ignore SIGTERM, so only the awaited + // quit teardown's SIGKILL can end them. + let script = + "trap '' TERM; ( trap '' TERM; exec sleep 120 ) & echo \"kid=$!\"; echo \"sh=$$\"; wait"; + let source = sh_source(script, &home); + let (_window, view) = open(&mut cx, move |cx| { + TerminalView::new_stream(&source, 10_000, cx).expect("open") + }); + let text = settle_until(&mut cx, &view, |t| t.contains("sh=")); + let kid = tagged(&text, "kid").expect("kid"); + let leader = tagged(&text, "sh").expect("sh"); + let mut rec = Recorded::session(leader.unsigned_abs()); + assert!(is_alive(kid) && is_alive(leader)); + + cx.update(|cx| cx.shutdown()); + + // The quit handler was awaited: by the time shutdown returns the + // session has been killed and reaped. + assert!( + !is_alive(kid) && !is_alive(leader), + "left running after quit" + ); + assert!(!exists(leader), "the process was not reaped"); + assert!(wait_gone(&[kid, leader]).is_empty()); + rec.done(); +} + +#[test] +fn quitting_the_app_ends_a_pty_session_that_ignores_hangup() { + let home = TempHome::new(); + let mut cx = app(); + let opts = sh( + "trap '' HUP TERM; ( trap '' HUP TERM; exec sleep 120 ) & echo \"kid=$!\"; echo \"sh=$$\"; wait", + &home, + ); + let (_window, view) = open(&mut cx, move |cx| { + TerminalView::new_pty(&opts, cx).expect("spawn") + }); + let text = settle_until(&mut cx, &view, |t| t.contains("sh=")); + let kid = tagged(&text, "kid").expect("kid"); + let leader = tagged(&text, "sh").expect("sh"); + let mut rec = Recorded::session(leader.unsigned_abs()); + assert!(is_alive(kid) && is_alive(leader)); + + cx.update(|cx| cx.shutdown()); + + assert!( + !is_alive(kid) && !is_alive(leader), + "left running after quit" + ); + assert!(!exists(leader), "the shell was not reaped"); + rec.done(); +} + +/// Focus the terminal and draw, so key events and actions are routed to it. +fn focus(cx: &mut HeadlessAppContext, window: AnyWindowHandle, view: &Entity) { + cx.update_window(window, |_, window, cx| { + let handle = view.read(cx).focus_handle(cx); + window.focus(&handle, cx); + window.render_frame(cx); + }) + .expect("focus"); +} + +fn press(cx: &mut HeadlessAppContext, window: AnyWindowHandle, key: &str) { + cx.update_window(window, |_, window, cx| window.press(key, cx)) + .expect("press"); +} + +fn find_open(cx: &mut HeadlessAppContext, view: &Entity) -> bool { + cx.update(|cx| view.read(cx).find_open()) +} + +#[test] +fn the_crate_binds_no_keys_and_copy_paste_and_find_are_actions() { + let home = TempHome::new(); + let mut cx = app(); + let opts = sh("read line; echo \"got=[$line]\"; exec sleep 120", &home); + let (window, view) = open(&mut cx, move |cx| { + TerminalView::new_pty(&opts, cx).expect("spawn") + }); + focus(&mut cx, window, &view); + + // With nothing bound, the usual chords are only keys: they do not open + // find, and nothing in the crate treats them as shortcuts. + for chord in ["ctrl-shift-f", "cmd-f"] { + press(&mut cx, window, chord); + assert!(!find_open(&mut cx, &view), "{chord} is not a shortcut here"); + } + + // The actions do the work. + cx.update_window(window, |_, window, cx| { + window.dispatch_action(Box::new(Find), cx); + }) + .unwrap(); + assert!(find_open(&mut cx, &view), "Find opens the find field"); + cx.update_window(window, |_, window, cx| { + window.dispatch_action(Box::new(CloseFind), cx); + }) + .unwrap(); + assert!(!find_open(&mut cx, &view)); + + // Paste: the clipboard goes to the program. + cx.update(|cx| cx.write_to_clipboard(ClipboardItem::new_string("hello-paste\n".into()))); + cx.update_window(window, |_, window, cx| { + window.dispatch_action(Box::new(Paste), cx); + }) + .unwrap(); + let text = settle_until(&mut cx, &view, |t| t.contains("got=[hello-paste]")); + assert!(text.contains("got=[hello-paste]"), "{text:?}"); + + // Copy: the selection goes to the clipboard. + cx.update(|cx| { + view.update(cx, |v, _| v.emulator().select_all()); + cx.write_to_clipboard(ClipboardItem::new_string(String::new())); + }); + cx.update_window(window, |_, window, cx| { + window.dispatch_action(Box::new(Copy), cx); + }) + .unwrap(); + let copied = cx + .update(|cx| cx.read_from_clipboard().and_then(|c| c.text())) + .unwrap_or_default(); + assert!(copied.contains("got=[hello-paste]"), "{copied:?}"); + + // A key becomes a shortcut only when the app binds it. + cx.update(|cx| cx.bind_keys([KeyBinding::new("ctrl-shift-f", Find, Some("Terminal"))])); + press(&mut cx, window, "ctrl-shift-f"); + assert!(find_open(&mut cx, &view), "bound by the app, it opens find"); +}