diff --git a/.github/workflows/build-ghostty.yml b/.github/workflows/build-ghostty.yml deleted file mode 100644 index 0745eab..0000000 --- a/.github/workflows/build-ghostty.yml +++ /dev/null @@ -1,97 +0,0 @@ -name: Build Ghostty - -on: - workflow_dispatch: - push: - paths: - - "native/ghostty/**" - - ".github/workflows/build-ghostty.yml" - - "!native/ghostty/**/*.dll" - - "!native/ghostty/**/*.so" - - "!native/ghostty/**/*.dylib" - - "!native/ghostty/**/*.sha256" - pull_request: - paths: - - "native/ghostty/**" - - ".github/workflows/build-ghostty.yml" - - "!native/ghostty/**/*.dll" - - "!native/ghostty/**/*.so" - - "!native/ghostty/**/*.dylib" - - "!native/ghostty/**/*.sha256" - -permissions: - contents: read - -concurrency: - group: build-ghostty-${{ github.ref }} - cancel-in-progress: true - -jobs: - native: - name: libghostty-vt ${{ matrix.rid }} - runs-on: ${{ matrix.os }} - strategy: - fail-fast: false - matrix: - include: - - rid: win-x64 - os: windows-latest - - rid: win-arm64 - os: windows-latest - - rid: linux-x64 - os: ubuntu-24.04 - - rid: linux-arm64 - os: ubuntu-24.04 - - rid: osx-arm64 - os: macos-14 - - rid: osx-x64 - os: macos-14 - steps: - - name: Checkout - uses: actions/checkout@v4 - - - name: Install llvm-strip - if: runner.os == 'Linux' - run: | - sudo apt-get update - sudo apt-get install --no-install-recommends llvm - - - name: Build libghostty-vt - shell: pwsh - run: > - ./native/ghostty/Build-Ghostty.ps1 - -Rid ${{ matrix.rid }} - -InstallZig - -SkipHashCheck - - - name: Upload ${{ matrix.rid }} - uses: actions/upload-artifact@v4 - with: - name: libghostty-vt-${{ matrix.rid }} - path: native/ghostty/${{ matrix.rid }} - if-no-files-found: error - - package: - name: Collect libghostty-vt artifacts - needs: native - runs-on: ubuntu-24.04 - steps: - - name: Download RID artifacts - uses: actions/download-artifact@v4 - with: - pattern: libghostty-vt-* - path: artifacts/libghostty-vt - merge-multiple: false - - - name: Write SHA-256 manifest - run: | - find artifacts/libghostty-vt -type f ! -name '*.sha256' -print0 | - sort -z | - xargs -0 sha256sum > artifacts/libghostty-vt/SHA256SUMS - - - name: Upload combined natives - uses: actions/upload-artifact@v4 - with: - name: libghostty-vt - path: artifacts/libghostty-vt - if-no-files-found: error diff --git a/.github/workflows/build-terminal.yml b/.github/workflows/build-terminal.yml index c1b750a..facdde3 100644 --- a/.github/workflows/build-terminal.yml +++ b/.github/workflows/build-terminal.yml @@ -6,11 +6,7 @@ on: - "**" tags: - "v*" - paths-ignore: - - ".github/workflows/build-ghostty.yml" pull_request: - paths-ignore: - - ".github/workflows/build-ghostty.yml" workflow_dispatch: inputs: version: @@ -142,12 +138,10 @@ jobs: with: path: | artifacts/tools - artifacts/ghostty-src - native/ghostty/*/* native/linux-pty/*/dt-pty-host native/noto-emoji/NotoColorEmoji.ttf # Never restore native sources or reuse binaries built from different inputs. - key: native-v2-${{ runner.os }}-${{ runner.arch }}-${{ hashFiles('native/**/*.ps1', 'native/ghostty/ghostty-upstream.json', 'native/linux-pty/dt-pty-host.c', 'native/noto-emoji/noto-emoji.json') }} + key: native-v3-${{ runner.os }}-${{ runner.arch }}-${{ hashFiles('native/**/*.ps1', 'native/zig.json', 'native/linux-pty/dt-pty-host.c', 'native/noto-emoji/noto-emoji.json') }} - name: Set up .NET uses: actions/setup-dotnet@v4 @@ -199,7 +193,7 @@ jobs: uses: actions/cache@v4 with: path: artifacts/tools - key: zig-${{ runner.os }}-${{ runner.arch }}-${{ hashFiles('native/Zig.ps1', 'native/ghostty/ghostty-upstream.json') }} + key: zig-${{ runner.os }}-${{ runner.arch }}-${{ hashFiles('native/Zig.ps1', 'native/zig.json') }} - name: Build dt-pty-host for every Unix RID shell: pwsh @@ -233,7 +227,7 @@ jobs: with: path: | native/noto-emoji/NotoColorEmoji.ttf - key: native-v2-${{ runner.os }}-${{ runner.arch }}-${{ hashFiles('native/**/*.ps1', 'native/ghostty/ghostty-upstream.json', 'native/linux-pty/dt-pty-host.c', 'native/noto-emoji/noto-emoji.json') }} + key: native-v3-${{ runner.os }}-${{ runner.arch }}-${{ hashFiles('native/**/*.ps1', 'native/zig.json', 'native/linux-pty/dt-pty-host.c', 'native/noto-emoji/noto-emoji.json') }} - name: Set up .NET uses: actions/setup-dotnet@v4 @@ -337,11 +331,9 @@ jobs: with: path: | artifacts/tools - artifacts/ghostty-src - native/ghostty/*/* native/linux-pty/*/dt-pty-host native/noto-emoji/NotoColorEmoji.ttf - key: native-v2-${{ runner.os }}-${{ runner.arch }}-${{ matrix.rid }}-${{ hashFiles('native/**/*.ps1', 'native/ghostty/ghostty-upstream.json', 'native/linux-pty/dt-pty-host.c', 'native/noto-emoji/noto-emoji.json') }} + key: native-v3-${{ runner.os }}-${{ runner.arch }}-${{ matrix.rid }}-${{ hashFiles('native/**/*.ps1', 'native/zig.json', 'native/linux-pty/dt-pty-host.c', 'native/noto-emoji/noto-emoji.json') }} - name: Set up .NET uses: actions/setup-dotnet@v4 @@ -378,11 +370,9 @@ jobs: with: path: | artifacts/tools - artifacts/ghostty-src - native/ghostty/*/* native/linux-pty/*/dt-pty-host native/noto-emoji/NotoColorEmoji.ttf - key: native-v2-${{ runner.os }}-${{ runner.arch }}-${{ hashFiles('native/**/*.ps1', 'native/ghostty/ghostty-upstream.json', 'native/linux-pty/dt-pty-host.c', 'native/noto-emoji/noto-emoji.json') }} + key: native-v3-${{ runner.os }}-${{ runner.arch }}-${{ hashFiles('native/**/*.ps1', 'native/zig.json', 'native/linux-pty/dt-pty-host.c', 'native/noto-emoji/noto-emoji.json') }} - name: Set up .NET uses: actions/setup-dotnet@v4 @@ -411,11 +401,9 @@ jobs: with: path: | artifacts/tools - artifacts/ghostty-src - native/ghostty/*/* native/linux-pty/*/dt-pty-host native/noto-emoji/NotoColorEmoji.ttf - key: native-v2-${{ runner.os }}-${{ runner.arch }}-${{ hashFiles('native/**/*.ps1', 'native/ghostty/ghostty-upstream.json', 'native/linux-pty/dt-pty-host.c', 'native/noto-emoji/noto-emoji.json') }} + key: native-v3-${{ runner.os }}-${{ runner.arch }}-${{ hashFiles('native/**/*.ps1', 'native/zig.json', 'native/linux-pty/dt-pty-host.c', 'native/noto-emoji/noto-emoji.json') }} - name: Set up .NET uses: actions/setup-dotnet@v4 @@ -460,11 +448,9 @@ jobs: with: path: | artifacts/tools - artifacts/ghostty-src - native/ghostty/*/* native/linux-pty/*/dt-pty-host native/noto-emoji/NotoColorEmoji.ttf - key: native-v2-${{ runner.os }}-${{ runner.arch }}-${{ hashFiles('native/**/*.ps1', 'native/ghostty/ghostty-upstream.json', 'native/linux-pty/dt-pty-host.c', 'native/noto-emoji/noto-emoji.json') }} + key: native-v3-${{ runner.os }}-${{ runner.arch }}-${{ hashFiles('native/**/*.ps1', 'native/zig.json', 'native/linux-pty/dt-pty-host.c', 'native/noto-emoji/noto-emoji.json') }} - name: Set up .NET uses: actions/setup-dotnet@v4 diff --git a/.github/workflows/test-iseberg.yml b/.github/workflows/test-iseberg.yml index 478dd89..ee67666 100644 --- a/.github/workflows/test-iseberg.yml +++ b/.github/workflows/test-iseberg.yml @@ -57,11 +57,9 @@ jobs: with: path: | artifacts/tools - artifacts/ghostty-src - native/ghostty/*/* native/linux-pty/*/dt-pty-host native/noto-emoji/NotoColorEmoji.ttf - key: native-v2-${{ runner.os }}-${{ runner.arch }}-${{ hashFiles('native/**/*.ps1', 'native/ghostty/ghostty-upstream.json', 'native/linux-pty/dt-pty-host.c', 'native/noto-emoji/noto-emoji.json') }} + key: native-v3-${{ runner.os }}-${{ runner.arch }}-${{ hashFiles('native/**/*.ps1', 'native/zig.json', 'native/linux-pty/dt-pty-host.c', 'native/noto-emoji/noto-emoji.json') }} - name: Set up .NET uses: actions/setup-dotnet@v4 @@ -72,7 +70,7 @@ jobs: shell: pwsh run: ./scripts/Install-IsebergTestPowerShell.ps1 - # A native cache miss downloads Zig and compiles Ghostty; keep that out of the test budget. + # A native cache miss downloads Zig and compiles dt-pty-host; keep that out of the test budget. - name: Restore native libraries timeout-minutes: 30 shell: pwsh diff --git a/.gitignore b/.gitignore index ed49128..3467c3f 100644 --- a/.gitignore +++ b/.gitignore @@ -27,10 +27,6 @@ Thumbs.db .DS_Store ## Native libraries built from source (see native/Restore-NativeLibraries.ps1) -native/ghostty/**/*.dll -native/ghostty/**/*.so -native/ghostty/**/*.dylib -native/ghostty/**/*.sha256 native/linux-pty/**/dt-pty-host native/linux-pty/**/dt-pty-host.exe native/noto-emoji/NotoColorEmoji.ttf diff --git a/AGENTS.md b/AGENTS.md index 4883235..e316ace 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -47,11 +47,11 @@ already the primary scripting language for Windows packaging in this repo ## Native helpers - Linux/macOS PTY host: `native/linux-pty` (`dt-pty-host.c`) -- Ghostty VT library: `native/ghostty` - Windows Explorer/toast helpers: `native/windows-shell` -`dotnet build` restores these for the host RID automatically -(`-p:SkipNativeRestore=true` to skip). +`dotnet build` restores `dt-pty-host` for Linux/macOS host RIDs automatically +(Zig version pinned in `native/zig.json`; `-p:SkipNativeRestore=true` to +skip). Windows builds have nothing to restore. ## Platform packaging entry points diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 911523e..3ffd6bb 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -69,13 +69,13 @@ Do not point that tool at this repository. ## Native helpers - Linux/macOS PTY host: `native/linux-pty` (`dt-pty-host.c`; Zig `cc` on Linux, Apple clang on macOS) -- Ghostty VT library: `native/ghostty` (Zig build of pinned Ghostty) - Windows Explorer/toast helpers: `native/windows-shell` (MSVC, gitignored `bin/`) -`dotnet build` restores Ghostty and `dt-pty-host` for the host RID (Zig is -downloaded into `artifacts/tools` on first use; a system `zig` of a different -minor version than the pinned one is ignored, so it does not break the build). See -[`native/ghostty/README.md`](native/ghostty/README.md). Pass +`dotnet build` restores `dt-pty-host` for Linux/macOS host RIDs; Windows builds +have nothing to restore. The Zig version is pinned in +[`native/zig.json`](native/zig.json); Zig is downloaded into `artifacts/tools` +on first use, and a system `zig` of a different minor version than the pinned +one is ignored, so it does not break the build. Pass `-p:SkipNativeRestore=true` to skip. ## macOS @@ -88,7 +88,7 @@ dotnet test Devolutions.Terminal.slnx pwsh scripts/Build-MacOsPackage.ps1 osx-arm64 2026.3.0 artifacts/packages ``` -`dotnet build` restores Ghostty and `dt-pty-host` for `osx-arm64` / `osx-x64`. +`dotnet build` restores `dt-pty-host` for `osx-arm64` / `osx-x64`. The PTY host is compiled with Apple clang against the macOS 13 SDK. App-bundle packaging is Darwin-only; release builds are signed and notarized with a Developer ID identity (see [docs/macos.md](docs/macos.md) and diff --git a/Devolutions.Terminal.slnx b/Devolutions.Terminal.slnx index 1e87ed0..e92445e 100644 --- a/Devolutions.Terminal.slnx +++ b/Devolutions.Terminal.slnx @@ -3,7 +3,6 @@ - @@ -27,7 +26,6 @@ - diff --git a/NOTICE.md b/NOTICE.md index b699ac4..5e18580 100644 --- a/NOTICE.md +++ b/NOTICE.md @@ -17,11 +17,6 @@ See the license in the Cascadia Code repository. Vendored under `native/noto-emoji/`. Licensed under the SIL Open Font License 1.1. See `native/noto-emoji/LICENSE`. -## Ghostty (`libghostty-vt`) - -Pinned native VT engine binaries under `native/ghostty/`. See -`native/ghostty/LICENSE` and `docs/ghostty-engine.md`. - ## AppImage type-2 runtime Linux AppImage packages embed a pinned type-2 runtime. See diff --git a/README.md b/README.md index 13b6c9f..9085b83 100644 --- a/README.md +++ b/README.md @@ -5,7 +5,7 @@ as a self-contained **NativeAOT** desktop app, and rendered with **Avalonia 12** - **ConPTY** on Windows and a real **forkpty** transport on Linux and macOS - Azure Cloud Shell for remote Azure sessions -- selectable built-in or **Ghostty** VT engine +- a built-in VT engine written in C# - Windows Terminal-compatible `settings.json`, actions, keybindings, and `dt` CLI - source-integrated **Iseberg** PowerShell ISE profiles, with execution in an owned PowerShell subprocess and a NativeAOT desktop @@ -104,10 +104,6 @@ windows is unavailable, and persisted layouts are neither restored nor saved. It is the per-launch equivalent of Windows Terminal's `compatibility.isolatedMode` setting, which Windows Terminal 1.23 removed. -Set `"experimental.terminalEngine": "ghostty"` to use the pinned -`libghostty-vt` engine globally. A profile can override it with `"builtin"` or -`"ghostty"`. ConPTY remains the Windows process transport for both engines. - The compiled-XAML settings editor is documented in [docs/settings-editor.md](docs/settings-editor.md). @@ -115,7 +111,6 @@ The compiled-XAML settings editor is documented in ``` Devolutions.Terminal.Core VT parser + text buffer + terminal engine -Devolutions.Terminal.Ghostty NativeAOT-safe libghostty-vt engine adapter Devolutions.Terminal.Render Immutable plans + HarfBuzz/Skia glyph renderer Devolutions.Terminal.Connection ConPTY + Linux PTY + Azure Cloud Shell + browser shell Devolutions.Terminal.Settings Layered Windows Terminal-compatible JSON settings diff --git a/docs/advanced-vt-protocols.md b/docs/advanced-vt-protocols.md index 3668da8..b8cc369 100644 --- a/docs/advanced-vt-protocols.md +++ b/docs/advanced-vt-protocols.md @@ -3,9 +3,8 @@ > [!NOTE] > Windows local sessions use the bundled Microsoft ConPTY runtime, including > `OpenConsole.exe`, rather than the OS copy that filters Sixel on some Windows -> builds. With the built-in engine, Sixel producers such as `Get-XKCD -Show` -> can render inline images. Other transports must likewise preserve graphics -> payloads; the optional Ghostty engine does not expose image resources. +> builds. Sixel producers such as `Get-XKCD -Show` can therefore render inline +> images. Other transports must likewise preserve graphics payloads. `Devolutions.Terminal.Core` parses advanced string protocols without depending on Avalonia, Skia, Win32, or an image codec. It exposes decoded Sixel pixels and bounded @@ -140,7 +139,6 @@ delete or reset does not paint stale cached pixels. - kitty cell/z-index/number-targeted deletes (`d=c/x/y/z/n`) — silent no-op; the cursor for `f=100` images without `r` stays put because Core retains codec bytes without decoding dimensions -- Ghostty image projection (the pinned C ABI exposes no image resources) These gaps avoid remote I/O and renderer/input dependencies while keeping every implemented parser and downloadable resource path bounded. diff --git a/docs/browser.md b/docs/browser.md index 53b9a4f..04cb641 100644 --- a/docs/browser.md +++ b/docs/browser.md @@ -21,7 +21,6 @@ Devolutions Terminal can run as a static Avalonia WebAssembly app. This is a | Desktop capability | Why it is out of scope | | --- | --- | | ConPTY / `forkpty` inside the page | No process or PTY APIs in the WASM sandbox. A loopback host process can own the PTY and bridge it over WebSocket. | -| Ghostty (`libghostty-vt`) | Native library, not compiled to WASM | | `dt` CLI, broker, multi-window | Separate OS processes and named endpoints | | Package identity, toasts, jump lists, global hotkeys | Windows / desktop shell | | Native file dialogs, unrestricted filesystem | Browser sandbox (MEMFS only) | diff --git a/docs/ghostty-engine.md b/docs/ghostty-engine.md deleted file mode 100644 index 988e111..0000000 --- a/docs/ghostty-engine.md +++ /dev/null @@ -1,69 +0,0 @@ -# Ghostty terminal engine - -The Windows host always uses ConPTY for local processes. The optional Ghostty -mode replaces the VT parser/state engine, not the Windows pseudoconsole or the -Avalonia renderer. - -Select it globally: - -```json -{ - "experimental.terminalEngine": "ghostty" -} -``` - -Or override a profile: - -```json -{ - "profiles": { - "list": [ - { - "name": "PowerShell", - "experimental.terminalEngine": "ghostty" - } - ] - } -} -``` - -`builtin` remains the default. New and restarted terminal sessions resolve the -setting; an already running session keeps its current engine. - -## Native integration - -`Devolutions.Terminal.Ghostty` uses source-generated `LibraryImport` calls and safe handles. -RID-specific `libghostty-vt` binaries are **built from source** on restore and -copied into JIT, NativeAOT, and MSIX outputs. The ABI type manifest is validated -at startup. See `native/ghostty/ghostty-upstream.json` and -`native/Restore-NativeLibraries.ps1`. - -## Stack headroom - -The engine's P/Invoke calls execute on whatever thread the host is using — -including the ConPTY read loop's thread-pool thread with the default ~1 MB stack -reserve. Full libghostty (renderer included) has overflowed a 1 MB stack in -other hosts; the VT-only `libghostty-vt` has a much smaller surface. A test -(`EngineSurvivesAdversarialCorpusOnSmallStackThread`) feeds an adversarial -corpus on a 256 KB-stack thread — 4x below the host default — to pin that -headroom. If the pinned upstream changes and that test fails, route engine calls -onto a dedicated thread with an explicit `maxStackSize`. - -## Current rendering boundary - -Ghostty owns VT parsing, modes, viewport state, resize/reflow, scrollback, -terminal replies, titles, and bells. Its render-state API is projected into the -shared immutable cell model consumed by the existing Skia renderer. - -Projection is lazy. A feed only marks the projection stale. The next read of -`Buffer`, the cursor or a snapshot (normally once per frame) re-projects the -rows that Ghostty's render state reports dirty, then cleans that state. Title -and working-directory changes are still raised during the feed. See -[performance benchmarks](performance-benchmarks.md) for the measured effect. This keeps -Avalonia tabs, panes, accessibility, clipboard, selection, search, and window -behavior common across engines. - -The pinned C ABI exposes no Sixel, OSC 1337, or ConEmu image resources. The -engine excludes those image capability flags and emits deterministic unsupported -diagnostics instead of pretending to project graphics or silently switching the -selected engine. diff --git a/docs/history/ghostty-comparison-benchmarks.md b/docs/history/ghostty-comparison-benchmarks.md new file mode 100644 index 0000000..5f0295f --- /dev/null +++ b/docs/history/ghostty-comparison-benchmarks.md @@ -0,0 +1,491 @@ +# Performance benchmarks vs Ghostty + +> **Historical record.** The optional Ghostty engine has been removed from +> Devolutions Terminal; the builtin engine is now the only one. The numbers +> below were measured against `libghostty-vt` pinned at commit +> `3c1ef5b32fc5ea6b93d28493fabf193f595139cf`. The `ghostty-vt` and `ghostty` +> bench targets, the equivalence gate, `--no-gate`, `-RequireBeatGhostty`, +> `-GhosttyBench` and `native/ghostty/` no longer exist, so the commands on +> this page will not run as written. For the current benchmark, see +> [performance benchmarks](../performance-benchmarks.md). + +This page explains how Ghostty backs up its "fastest terminal" positioning, +what its own benchmarks do and do not measure, and how DT benchmarks its +**builtin** engine and renderer against it. The goal is a claim that holds up +under review: "with the default engine, DT matches or beats Ghostty at +workload X, measured this way, on this machine." + +For renderer-only details (caches, pixel verification, historical numbers) +see [renderer benchmarks](../renderer-benchmarks.md). + +## How Ghostty supports its performance claims + +Ghostty's public claims are mostly qualitative. Few of them come with a method +someone else could rerun. + +| Claim | Where | Evidence | +| --- | --- | --- | +| "Same performance category" as the fastest terminals; "~100x faster than Terminal.app and iTerm" | README | None. Ghostty's own devlog measured about 6x against iTerm2 and under 2x against Terminal.app | +| `cat` of plain text 4x faster than iTerm/Kitty, ~60 fps under load | v1.0 README | No method given. The README itself says "we need better benchmarks" | +| SIMD parser: 7.3x faster ASCII parse, 16.6x faster UTF-8 decode; `time cat` of a 5.4 MB Japanese text: Ghostty 73 ms, Alacritty 66 ms, Kitty 392 ms, iTerm2 470 ms | [Devlog 006](https://mitchellh.com/writing/ghostty-devlog-006) | **The only write-up with a real method**: hyperfine microbenchmarks with noop/memcpy floors, plus 10-run `time cat` averages. Alacritty beat Ghostty in that table | +| 480–500 fps in DOOM-fire | [Zig patterns talk](https://mitchellh.com/writing/ghostty-and-useful-zig-patterns) | Anecdote. The same talk says input latency was never measured | +| 1.3: IO 20–30% faster on a 4 GB asciinema replay; frame builds 2–5x faster | [1.3.0 notes](https://ghostty.org/docs/install/release-notes/1-3-0) | Real-world replay, but the corpus is not public | +| `cat` of 150 MB ASCII in 575 ms (nightly) | Social post, second-hand | One run, no hardware listed. Unverified | + +The [about page](https://ghostty.org/docs/about) itself is careful: Ghostty is +"in some benchmarks faster, in others slower." + +### What Ghostty's in-tree benchmarks measure + +Ghostty's benchmarks live in `src/benchmark/` at the pinned commit (see +`native/ghostty/ghostty-upstream.json`). They are built with +`zig build -Demit-bench`, which produces two binaries: + +- `ghostty-bench +` runs one component microbenchmark. It prints + nothing; the documented method is to time the **whole process** with + `hyperfine --warmup N`. That time includes process startup and setup. This is + why many actions have a `noop` mode to subtract. +- `ghostty-gen ` writes synthetic input: `ascii`, `utf8`, `osc`, + `kitty` and `styled`. Seeds are time-based by default, so you save the output + once and reuse the file. + +The actions that matter for a comparison with DT: + +| Action | What it times | Closest DT measurement | +| --- | --- | --- | +| `+terminal-stream --data=F` | Full VT parse and terminal state update, read in 64 KiB chunks | `vt` (`ghostty-vt` target) | +| `+terminal-parser` | State machine only, actions discarded | none (DT's parser is not separable) | +| `+screen-clone --mode=render\|render-clean\|render-partial` | Updates to the CPU `RenderState` while holding the lock | `render` snapshot/plan | +| `+codepoint-width`, `+grapheme-break` | Unicode tables | none | +| `+terminal-resize` | Reflow | none yet | + +**CI only builds these benchmarks.** Nothing runs them, Ghostty publishes no +result history, and nothing gates regressions. Ghostty does **not** measure: + +- input latency +- frame presentation or dropped frames +- PTY-to-screen throughput outside of hand runs of `vtebench` or `time cat` +- memory per line of scrollback +- startup time + +On Windows its IO path is a serial `ReadFile` loop, and no Ghostty GUI exists +there. + +### Why Ghostty is fast (what the builtin engine has to match) + +- **SIMD ground state**: Ghostty scans for ESC and decodes UTF-8 to UTF-32 in + bulk (simdutf/Highway). Each printable run goes to the screen as one slice + (`stream.zig`), not one byte at a time. +- **Bulk cell writes**: `printSlice` writes same-width runs against a template + cell. Widths come from a 3-level lookup table, with a shortcut for + codepoints up to 0xFF. +- **8-byte cells in pooled, offset-addressed pages**: when scrollback is full, + the oldest page is reused. Steady-state scrolling allocates nothing. +- **Separate IO and render threads**: the renderer rebuilds only dirty rows, + once per frame, never once per write. + +## DT's benchmark layers + +All modes live in `tools/Devolutions.Terminal.Bench`. That tool runs on the +JIT with tiered compilation disabled. It is **not** the NativeAOT shipping +build, so treat its numbers as relative. + +| Layer | Mode | Ghostty counterpart | Comparable on Windows? | +| --- | --- | --- | --- | +| 1. VT ingest | `vt` | `ghostty-bench +terminal-stream` | **Yes, in-process on identical bytes** | +| 2. Frame preparation | `render` (snapshot, plan) | `+screen-clone --mode=render*` | Diagnostic only; the two do different work | +| 3. CPU paint | `render` (paint-*) | none (GPU renderer) | No | +| 4. PTY to UI coalescing | `control` | termio/renderer wakeups | No | +| 5. End-to-end | manual: `vtebench`, `time cat` | same | Linux/macOS only, same machine | + +### Layer 1: `vt`, same-process ingest A/B + +```powershell +dotnet run --project tools\Devolutions.Terminal.Bench -c Release -- vt --mb 8 --runs 7 --json artifacts\bench\vt.json +``` + +Three targets consume the same bytes in the same chunks (16 KiB by default, +the ConPTY read size; `--chunk-kb 64` matches `+terminal-stream`): + +- `builtin`: DT's `TerminalEngine`, the engine under test. +- `ghostty-vt`: raw `ghostty_terminal_vt_write` from the pinned + `libghostty-vt`, with only the scrollback limit set to the builtin's 9,001 + lines. This is the bar to beat. It is the same work as `+terminal-stream`, + without process startup. +- `ghostty`: DT's `GhosttyTerminalEngine`, which adds the projection into DT's + cell model. This is what the opt-in Ghostty mode actually costs. Projection + is lazy, so measure it with `--frame-kb`. + +`--frame-kb N` reads one frame every N KiB fed, inside the timed region: + +- builtin and `ghostty` take a `CreateSnapshot`; +- `ghostty-vt` does what Ghostty's renderer does per frame, a render-state + update followed by a clean. + +The default of `0` measures pure ingest. `256` approximates a busy screen at a +frame rate the renderer can sustain. + +Methodology: + +- **Identical, deterministic bytes.** Each workload is seeded, and its + SHA-256 is printed. `--export ` writes the `.vt` files so + `ghostty-bench` can consume exactly the same input. `--data ` replays + a capture, for example an asciinema or vim session. +- **Equivalence gate.** Before timing, both engines consume the workload, and + the visible text, cursor and history are compared. A throughput number only + counts when the screens are identical. The `utf8` workload enables DEC mode + 2027 because, without it, Ghostty measures ZWJ emoji per codepoint while + builtin clusters them. Ghostty trims history by page, so its history count + can be a few hundred lines below 9,001; that difference is expected. +- **Fair timing.** + - Engines are built outside the timed region. + - Runs interleave the targets in rotating order and include a discarded + warmup. + - A sample replays the corpus into the same engine until at least `--min-ms` + (500 ms by default) has elapsed, so fast targets are not dominated by + timer noise. + - Laptop CPUs leave turbo a few seconds into sustained load: on the machine + below, throughput falls about 3x after ~2.7 s and stays there, whatever + the engine does. With 500 ms samples, runs land on both sides of that drop + and spreads reach 50–180%. Samples of ~3 s (`--min-ms 3000`, or the + script's `-MinMs 3000`) all run at the sustained clock. +- **Conservative verdict.** + - `faster` only when builtin's *slowest* run beats Ghostty's *fastest*. + - `slower` when the reverse holds. + - `overlap` otherwise. + - Without `--no-gate`, the exit code is non-zero unless every workload is + `faster`. +- **Cost metrics**: managed bytes allocated per MiB fed, GCs per MiB, and + retained private memory after the run. Retained memory is a process-wide + delta, so read it as an order of magnitude. Native allocations by Ghostty + appear only in that column. + +Workloads (`--workloads a,b,...`): + +| Workload | Shape | Source of the idea | +| --- | --- | --- | +| `ascii` | unbroken 0x21–0x7E stream relying on autowrap | `ghostty-gen ascii` | +| `ascii-lines` | printable lines with CRLF | `cat` of a log | +| `utf8` | Latin, Greek, Cyrillic, CJK, emoji, ZWJ, combining marks | `ghostty-gen utf8`, restricted to assigned characters | +| `cjk` | lines of CJK ideographs only (wide, 3-byte UTF-8) | `time cat` of CJK text | +| `emoji` | lines of emoji only (wide, 4-byte UTF-8) | emoji-heavy logs and prompts | +| `styled` | short SGR runs, OSC 8, wide text | `ghostty-gen styled` | +| `dense-cells` | every cell gets its own truecolor/256 SGR | vtebench `dense_cells` | +| `light-cells` | full-screen plain text from home | vtebench `light_cells` | +| `cursor-motion` | random CUP, short writes, EL | vtebench `cursor_motion`, TUIs | +| `scrolling` | `y\n` | vtebench `scrolling` | +| `scroll-region` | DECSTBM region scrolling | vtebench `scrolling_*_region` | +| `osc` | titles, OSC 133 marks, OSC 8 links | `ghostty-gen osc` (valid sequences only) | +| `mixed` | the legacy `engine`/`render` corpus | DT history | + +### Running the whole suite + +```powershell +pwsh scripts/Invoke-TerminalBenchmarks.ps1 # vt + render, results in artifacts/bench/ +pwsh scripts/Invoke-TerminalBenchmarks.ps1 -Quick # fast local signal +pwsh scripts/Invoke-TerminalBenchmarks.ps1 -Suite vt -MinMs 3000 # ~3 s samples: stable on CPUs that leave turbo under load +pwsh scripts/Invoke-TerminalBenchmarks.ps1 -Baseline artifacts/bench//vt.json # fails if builtin/ghostty-vt drops >15% on identical inputs +pwsh scripts/Invoke-TerminalBenchmarks.ps1 -RequireBeatGhostty # fails unless builtin wins every vt workload +pwsh scripts/Invoke-TerminalBenchmarks.ps1 -GhosttyBench \ghostty-bench.exe # also replays the corpora through upstream +terminal-stream +``` + +`-GhosttyBench` times whole processes, as Ghostty does, so its figures include +process startup. It cross-checks the in-process `ghostty-vt` target; it does +not replace it. Build `ghostty-bench` as described in +[Ghostty reference measurements](#ghostty-reference-measurements) below. + +### End-to-end (layer 5) on Linux/macOS + +Ghostty's only cross-terminal numbers come from `time cat` and vtebench. To +reproduce them, run on one native Linux or macOS machine, with both apps in +Release and matching font, size, grid and scrollback: + +- `time cat` of a large ASCII file and of a large CJK file, 10 runs each. +- vtebench's 12 workloads, run inside each terminal. vtebench measures how fast + the terminal drains the PTY (backpressure), not paint time. + +Never run both terminals at the same time; alternate their order and report +the spread. The checklist in +[renderer comparison against Ghostty](#renderer-comparison-against-ghostty-linuxmacos) +lists the full set of gates. + +## Results at the time of removal + +Setup for these numbers: + +- One Windows 11 x64 laptop (Intel Core Ultra 9 285HX, 24 cores, AVX2), + .NET 10.0.14 (JIT, tiered compilation off), and the pinned `libghostty-vt` + (ReleaseFast). +- 120x30 grid, 16 KiB chunks, 8 MiB per workload, 7 timed runs plus 1 warmup, + samples of at least 3 s. +- Each workload ran in its own process, via: + + ```powershell + pwsh scripts/Invoke-TerminalBenchmarks.ps1 -Suite vt -MinMs 3000 + ``` + +This machine's absolute speed drifts by up to 2.5x between invocations, +mostly with the CPU's turbo state (see *Fair timing* above). So read the +**builtin / ghostty-vt ratio**, which is measured within each run, and treat +anything within about ±10% of 1.0 as a tie. Throughput is the median MB/s. + +### Ingest: builtin engine vs raw `libghostty-vt` + +| Workload | builtin | ghostty-vt | ghostty (DT) | ratio | verdict | builtin alloc / MiB | screens | +| --- | ---: | ---: | ---: | ---: | --- | ---: | --- | +| osc | 147 | 25.3 | 23.5 | **5.82x** | **faster** | 6.7 MB | identical | +| styled | 45.9 | 18.0 | 13.2 | **2.54x** | **faster** | 1.4 MB | 1 row differs | +| mixed | 787 | 366 | 229 | **2.15x** | **faster** | 8 KB | identical | +| dense-cells | 262 | 138 | 51 | **1.90x** | **faster** | 0 | identical | +| scrolling | 56.5 | 32.6 | 31.9 | **1.73x** | **faster** | 104 KB | identical | +| utf8 | 80.1 | 59.0 | 69.3 | **1.36x** | overlap | 92 KB | identical | +| light-cells | 2,265 | 1,737 | 62 | **1.30x** | **faster** | 0 | identical | +| emoji* | 816 | 672 | 301 | **1.21x** | **faster** | 7 KB | identical | +| ascii | 1,145 | 971 | 448 | **1.18x** | **faster** | 5 KB | identical | +| ascii-lines | 716 | 626 | 286 | **1.14x** | overlap | 9 KB | identical | +| cjk* | 583 | 522 | 237 | **1.12x** | **faster** | 10 KB | identical | +| scroll-region | 865 | 782 | 58 | **1.11x** | **faster** | 0 | identical | +| cursor-motion | 188 | 172 | 95 | **1.09x** | **faster** | 32 KB | identical | + +\* From a later invocation of those two workloads alone. In the full run they +landed in a noisy stretch (spreads over 50%) and measured 0.98x and 1.22x. +Other quiet runs of the same code measured 1.07–1.21x on cjk and 1.18x on +emoji. + +`ghostty (DT)` skips projection without `--frame-kb`. The one-row difference in +`styled` is a behavior difference, not a measurement artifact: builtin drops a +combining mark that follows a space, and Ghostty keeps it. + +How builtin got here, as the median builtin/ghostty-vt ratio per round (each +round measured against its own in-run Ghostty control): + +| Workload | `dc1cd26` | round 1 | round 2 (packed cells) | round 3 | round 4 (`f45b95b`) | round 5 | +| --- | ---: | ---: | ---: | ---: | ---: | ---: | +| ascii | 0.009 | 0.20 | 0.55 | 1.21 | 1.24 | 1.18 | +| ascii-lines | 0.009 | 0.20 | 0.50 | 1.13 | 1.19 | 1.14 | +| light-cells | 0.009 | 0.32 | 0.48 | 1.20 | 1.23 | 1.30 | +| scrolling | 0.015 | 0.14 | 0.40 | 1.11 | 1.33 | 1.73 | +| dense-cells | 0.42 | 0.63 | 0.62 | 1.57 | 1.44 | 1.90 | +| styled | 0.30 | 1.33 | 1.14 | 2.82 | 2.91 | 2.54 | +| osc | 0.29 | 2.60 | 4.52 | 4.57 | 4.59 | 5.82 | +| mixed | 0.03 | 0.39 | 0.45 | 1.56 | 1.84 | 2.15 | +| scroll-region | 0.018 | 0.22 | 0.34 | 0.90 | 0.96 | 1.11 | +| cursor-motion | 0.11 | 0.23 | 0.17 | 0.94 | 0.90 | 1.09 | +| utf8 | 0.10 | 0.36 | 0.27 | 0.57 | 0.77 | 1.36 | +| cjk | — | — | — | 0.53 | 0.77 | 1.12 | +| emoji | — | — | — | 0.50 | 0.80 | 1.21 | + +Round 5 was measured with 3 s samples; earlier rounds used 500 ms samples. + +The `-Baseline` gate flagged real regressions twice: + +- **Round 2:** utf8 and cursor-motion. +- **Round 4:** dense-cells and cursor-motion, from caching packed templates on + the short-ASCII path. + +Each was confirmed with an interleaved A/B build and fixed before moving on. +Round 5 passed the gate after each change. Moves of about ±10% between rounds +(ascii and ascii-lines into round 5, for example) are within this machine's +noise. styled's 2.54x comes from a run with a 136% spread; single-workload +runs of the same code measured 3.39–3.61x. + +### DT's Ghostty mode vs raw `libghostty-vt`, with frames + +These numbers use `--frame-kb 256`, so a projection is taken every 256 KiB. +They were measured in round 4; round 5 did not change the Ghostty engine. + +| Workload | ghostty (DT) before | after | ghostty-vt | ratio before → after | +| --- | ---: | ---: | ---: | ---: | +| ascii | 49.8 | 214.4 | 960 | 0.05 → 0.22 | +| ascii-lines | 46.5 | 220.0 | 660 | 0.07 → 0.33 | +| cursor-motion | 36.9 | 79.9 | 146 | 0.25 → 0.55 | +| mixed | 41.1 | 146.5 | 295 | 0.12 → 0.50 | +| light-cells | 45.6 | 57.5 | 1,580 | 0.03 → 0.04 | + +`light-cells` rewrites every row between frames, so every row is dirty and the +per-cell P/Invoke projection still dominates. + +### Frame preparation and CPU paint + +`render`, 120x30, median p50 / p95, measured in round 4: + +| Stage | before packed cells | now | +| --- | --- | --- | +| snapshot | 0.009 / 0.011 ms | 0.040 / 0.044 ms | +| snapshot + plan | 0.074 / 0.082 ms | 0.113 / 0.129 ms | +| static paint | 0.69 / 1.08 ms | 0.75 / 1.00 ms | +| one-row update | 0.69 / 1.70 ms | 0.76 / 1.06 ms | +| scrolling paint | 3.29 / 9.72 ms | 2.90 / 5.54 ms | + +Snapshots now unpack each packed cell into a public `Cell`, which costs about +0.03 ms per 120x30 frame. Every stage still fits well inside a 16.7 ms frame. +No Ghostty counterpart exists on Windows. + +### What changed in the builtin engine + +Every fast path is checked against the per-character path by differential +tests (`AsciiFastPathTests`, `CsiFastPathTests`, `CellRowModelTests`, +`PackedCellStorageTests`, `CombiningTableTests`). Each new branch was also +checked by deliberately breaking it and confirming a test fails. + +- **Packed, reference-free storage** (`CellRow`, `PackedCell`). + - A cell is 16 bytes with no GC references, down from about 40 with two + `string?` fields. + - Hyperlinks live in a per-line side array that exists only while needed. + - Combining text is interned per buffer (`CombiningTable`), and lines store + int IDs. A cluster seen before costs no allocation, a recycled line keeps + its ID array, and the table compacts so ever-new clusters cannot grow it + without bound. This took utf8 from 9.4 MB to under 0.1 MB allocated per + MiB. + - The public `Cell` and `TermColor` are unchanged, and every value round-trips + losslessly. +- **Logically blank cells are never written.** + - A line stores only its written range; everything outside it reads as the + blank it was cleared with. + - Recycling a scrollback line, the coldest memory in the buffer, is O(1). + - Erasing to the end of a line is O(1) for left-aligned text. + - Writes fill only the gap between the stored range and the new cells. +- **Bulk printing.** + - **ASCII:** the parser hands whole printable-ASCII runs to + `IVtDispatch.PrintAscii`. They are stored a row segment at a time, two + 64-bit (or, with AVX2, two 256-bit per four cells) stores per cell batch. + - **UTF-8 runs:** well-formed UTF-8 runs go to `IVtDispatch.PrintRunes`. + Runs of 3- or 4-byte sequences (CJK, emoji) are decoded four at a time + from 16-byte `Vector128` loads, with the scalar decoder's validity rules + (no overlong forms, surrogates or values past U+10FFFF); anything else + falls back to the scalar decoder. On CJK this decodes about 3x faster. + - **Grapheme joins:** precomputed tables for planes 0 and 1 rule out joins, + so runes that cannot join are stored a segment at a time. That includes + emoji after a cell without a trailing ZWJ. A zero-width rune with no join + property (a combining accent, ZWJ) goes straight into the previous cell's + combining text. + - **Rows without wide glyphs:** a conservative per-row "may hold wide cells" + flag means classifying and storing happen in one pass, with no clearing + around wide glyphs. Every freshly recycled scrollback line qualifies. That + pass ORs each rune into precomputed template bits and writes one 16-byte + store per cell, with widths from a table where ambiguous widths are already + resolved. + - **Short writes:** the packed print templates are cached and rebuilt only + when the attributes, protection or shell-integration state change, and a + single printable byte between escape sequences is one store. +- **CSI fast path.** A complete plain CSI sequence is parsed in one pass, + straight into the arrays the dispatcher receives, and the parser calls the + engine directly instead of through `IVtDispatch`. Sequences with no private + marker or intermediate byte skip the special cases and go straight to the + switch on the final byte. +- **C0 fast path.** CR, LF and other controls in ground state go straight to + `ExecuteC0` instead of through the string-state checks. +- **Scroll regions** rotate line references with `Array.Copy`, not one + indexer call per line. + +### What still separates builtin from Ghostty + +Builtin now leads on every workload. The closest leads, `cursor-motion`, +`scroll-region` and `cjk` (1.09–1.12x), sit near the ±10% band of this +machine's noise, and `ascii-lines` and `utf8` still get `overlap` verdicts +from run-to-run spread. Timed stage by stage on CJK after round 5 (one engine +reused with history full, per scalar, all stages in one process): + +| Stage | Cost | +| --- | --- | +| UTF-8 decode, block path | ~0.5–1.0 ns | +| Parser per-line work (CR/LF dispatch, run setup) | ~0.7–1 ns | +| Cell stores (two 16-byte cells per wide character) | ~2 ns | +| Buffer CR/LF, scrolling and line reuse | ~0.7 ns | + +- **Parser per-line overhead** now costs about as much as the decode. Each + CR and LF still goes through an interface call, and each line's run of + characters pays its setup once. +- **Cell size.** Packed cells are 16 bytes against Ghostty's 8, so storing, + clearing or copying a row moves twice the memory. Getting to 8 bytes means + interning styles, which is a larger change. +- **What did not help.** Prefetching the scrollback lines about to be reused + measured slower: with history full, steady-state throughput is the same at + 100 and 9,000 lines, so cold recycled lines are not a bottleneck. + +**Variance.** Within isolated processes the ratio repeats to about ±10% on +this machine with 3 s samples. With 500 ms samples, runs straddle the CPU's +turbo drop and single workloads swing by 50% or more. In one long process +running every workload, ratios also drifted by up to 50%, because heap state +carried over between workloads. For that reason the script runs one process +per workload, and `-Baseline` only compares workloads with the same SHA-256 +and gates on the ratio, not on absolute MB/s. + +## Definition of "matches or beats Ghostty" + +A claim needs every row below. A missing or failed row means the claim has +not been established. + +1. `vt`: `faster` (or, for "matches", `overlap` with a median ratio ≥ 0.95) + on every workload, with identical screens, on at least one x64 and one + arm64 host. +2. The same corpora through upstream `ghostty-bench +terminal-stream` do not + contradict layer 1. This rules out a P/Invoke artifact. +3. Scrolling allocation is bounded. Steady-state `scrolling` should allocate + close to 0 B/MiB once history is full, as Ghostty's page reuse does. +4. On Linux/macOS, `time cat` and vtebench place DT at or above Ghostty, run + under the conditions described in the end-to-end section. +5. Frame latency and dropped frames: measure these even though Ghostty does + not publish them. Input latency (key to PTY echo to presented frame) is + unmeasured on both sides, and a DT measurement would be new evidence rather + than a comparison. + +Run `scripts/Invoke-TerminalBenchmarks.ps1 -Baseline` on every engine change +so wins are kept, and record the commit, machine and `vt.json` with each +published number. + +## Ghostty reference measurements + +These notes were moved here from +[renderer benchmarks](../renderer-benchmarks.md) when the Ghostty engine was +removed. + +This repository pinned Ghostty at the commit above (then recorded in +`native/ghostty/ghostty-upstream.json`). Build **that** source with Zig 0.16.0 +using `zig build -Demit-bench -Doptimize=ReleaseFast` (on macOS add +`-Demit-macos-app=false`). With the pinned revision on Windows, build the +benchmark-only/lib-vt targets instead; the default full target failed to +link because of a duplicate `memset` symbol: + +```powershell +zig build -Demit-bench=true -Demit-lib-vt=true -Doptimize=ReleaseFast -j1 +``` + +The upstream `zig-out/bin/ghostty-bench` (`.exe` on Windows) can consume the +exported corpus: + +```text +ghostty-bench +screen-clone --mode=render-partial --terminal-rows=30 --terminal-cols=120 --data= +``` + +Run it repeatedly with warmups (e.g. `hyperfine`) rather than timing +`ghostty-gen | ghostty-bench`: keep generation out of the measurement and use +enough internal operations to amortize process startup and input loading. +Ghostty's `screen-clone` `render`, `render-clean`, and +`render-partial` modes time updates to its CPU `RenderState`, **not** GPU +painting. Their steps respectively perform 50,000 full, 3,000,000 clean, or +2,000,000 partial updates at `--loops=1`. External wall times also include +process startup and corpus loading. They use different semantics and +iteration counts from this project's snapshot/plan stage; record them as +separate diagnostics, not a numeric speed ratio. The `libghostty-vt` binary +used by this project had no Ghostty graphics renderer. + +## Renderer comparison against Ghostty (Linux/macOS) + +Use the general procedure in +[renderer benchmarks](../renderer-benchmarks.md#full-renderer-comparison-linuxmacos). +For Ghostty specifically, instrument upstream Ghostty at `RenderState.update` +and `renderer/generic.zig:drawFrame`. Ghostty's native GUI is available on +Linux/macOS, not Windows; its Windows `libghostty-vt` support cannot stand in +for a native renderer comparison. + +For a claim of beating Ghostty **at every measured point**, establish separate +equivalent baselines for VT ingest, CPU frame preparation, idle/static paint, +localized updates, first-pass and replayed scrolling, and presented frame +latency. Compare p50, p95, p99, allocation/peak resident memory, CPU/GPU +utilization, and dropped frames for each workload on the same native host; +require pixel or terminal-state equivalence first. Alternate the applications +across repeated runs and report the run-to-run spread as well as medians. A +failure or unmeasured metric means the all-points claim has **not** passed. +The Windows offscreen raster and upstream `screen-clone` results measure +different work and cannot satisfy the presented-frame gate. diff --git a/docs/iseberg.md b/docs/iseberg.md index 08ffdf0..3ac05ca 100644 --- a/docs/iseberg.md +++ b/docs/iseberg.md @@ -296,7 +296,8 @@ Iseberg CI runs test projects sequentially (`-m:1`) to avoid concurrent test-hos startup starving short broker deadlines. It retains TRX results and per-test hang diagnostics and a 20-minute test-step budget for the slower hosted Windows runner. Native libraries are restored in a preceding step, so a native-cache miss -(Zig download and Ghostty build) does not consume that budget. A failed App test +(on Linux and macOS, the Zig download and `dt-pty-host` build) does not +consume that budget. A failed App test project stops the step before starting the UI suite. Run the same core locally after installing the test runtime: diff --git a/docs/macos.md b/docs/macos.md index d32b959..a48986b 100644 --- a/docs/macos.md +++ b/docs/macos.md @@ -1,7 +1,7 @@ # macOS support macOS is a first-class host for the managed app, Unix PTY transport, built-in -and Ghostty engines, and self-contained `.app` packaging (including signed, +engine, and self-contained `.app` packaging (including signed, notarized `.dmg` release artifacts). Global hotkeys, default terminal registration, and Homebrew remain out of scope. @@ -9,7 +9,7 @@ registration, and Homebrew remain out of scope. - Avalonia desktop host (`osx-arm64` / `osx-x64`) - Local shells through `dt-pty-host` (`forkpty`, same framing as Linux) -- Selectable built-in and Ghostty engines (`libghostty-vt.dylib`) +- Built-in terminal engine - Settings at `~/Library/Application Support/Devolutions/Terminal/` - Native Command-key defaults for clipboard, tabs, windows, search, settings, selection, font sizing, full screen, and quit actions @@ -39,7 +39,7 @@ registration, and Homebrew remain out of scope. - Global hotkeys (broker / `dt -w` still work) - Default-terminal registration -Ghostty dylibs and `dt-pty-host` are built on restore for `osx-arm64` / +`dt-pty-host` is built on restore for `osx-arm64` / `osx-x64` (macOS 13+). `dt-pty-host` is compiled with Apple clang so it can link `libutil` from the SDK. diff --git a/docs/parity-status.md b/docs/parity-status.md index 7bc4015..d941150 100644 --- a/docs/parity-status.md +++ b/docs/parity-status.md @@ -2,7 +2,7 @@ This document is the authoritative measured parity record for the .NET 10, NativeAOT, and Avalonia port. [history/porting.md](history/porting.md) records the historical roadmap; -this file records measured behavior after the Windows, Ghostty, and Linux +this file records measured behavior after the Windows and Linux milestones. Implementation, unit, headless Avalonia, protocol, process, package-structure, @@ -15,7 +15,7 @@ and NativeAOT gates completed before the final live UI matrix. | Windows process transport | ConPTY with input, resize, cancellation, restart, exit metadata, and x64/ARM64 NativeAOT | | Linux process transport | Bundled `forkpty` relay with input, resize, cancellation, restart, exit metadata, and x64/ARM64 NativeAOT | | macOS process transport | Same Unix `forkpty` host (`osx-arm64`/`osx-x64`); Apple clang `dt-pty-host` plus NativeAOT `.app` packaging on Darwin | -| Terminal engines | Selectable built-in and pinned Ghostty engines on Windows, Linux, and macOS | +| Terminal engine | Built-in engine (`Devolutions.Terminal.Core.TerminalEngine`) on Windows, Linux, and macOS | | Application shell | Multi-window broker, tabs, panes, settings editor, palettes, tray behavior, accessibility, clipboard, and notifications | | Settings | Layering, dynamic profiles, fragments, source-generated JSON, Windows paths, XDG paths, macOS Application Support, and state persistence | | Distribution | Windows x64/ARM64 MSIX and bundle; reproducible Linux x64/ARM64 tar, DEB, RPM, and AppImage packages with canonical freedesktop assets, licenses, checksums, inventory/SPDX SBOM, and DESTDIR-aware helpers; macOS NativeAOT `.app` and zip on Darwin, plus signed, notarized `.dmg` release artifacts from CI | @@ -101,17 +101,17 @@ process environment block. | Area | Status | Remaining acceptance | | --- | --- | --- | -| Search | Implemented across both engines; Core canonicalizes equivalent Unicode spellings and maps ZWJ/wide grapheme matches to cell ranges; built-in search anchors the selected match by logical-line ID and cell offset across reflow, duplicate lines, viewport scrolling, and bounded scrollback eviction, selecting the next surviving match if the selected line is lost | Ghostty projection regenerates line IDs, so selection uses a text/neighbor heuristic; its pinned render ABI does not expose equivalent cluster geometry | -| Grapheme/emoji | Built-in supports Hangul, Indic conjuncts, prepend, spacing marks, RI pairs, emoji modifiers/selectors, and emoji ZWJ sequences at arbitrary UTF-8 feed boundaries; bundled Noto Color Emoji provides deterministic Linux fallback | Unicode 16.0.0 `GraphemeBreakTest.txt` is pinned: all 369 terminal-representable rows pass ordered-cell and chunk-invariance checks. The other 724 rows are classified as unsuitable for terminal-cell assertions, not claimed as pure-segmenter passes; full UAX #29 conformance outside the tested subset and equivalent Ghostty cluster geometry remain unavailable | -| Row rendition | Built-in DECDWL/DECDHL parser, snapshots, logical cursor clipping, reflow preservation, and render transforms implemented | The pinned Ghostty C ABI does not expose row rendition; capability is explicitly unavailable there | -| Sixel | Built-in decode/render, DECSDM scrolling/display behavior, retained cell geometry, and stable ownership implemented | The pinned Ghostty C ABI exposes no image resources and reports the capability unavailable | -| OSC 1337 | Built-in bounded inline decode/render and stable ownership implemented; non-inline transfer is explicitly rejected without I/O | The pinned Ghostty C ABI exposes no image resources and reports the capability unavailable | -| ConEmu images | Bounded single-part `st=0;sz=` payloads decode into shared overlay metadata and render safely | Multipart payloads are explicitly rejected; the pinned Ghostty C ABI exposes no image resources | -| Kitty graphics | Direct-medium RGBA/RGB/PNG transmissions with chunking, zlib, placements, crop, z-order, deletes, and query responses implemented; unsupported media and animation are rejected deterministically | File/shared-memory media and animation frames are intentional gaps; the pinned Ghostty C ABI exposes no image resources and reports the capability unavailable | -| Image ownership | Stable logical-line anchors survive scrollback and reflow in main and alternate buffers and are removed on owning-line eviction | Ghostty image projection is unavailable in the pinned C ABI and produces deterministic unsupported diagnostics | -| VT52 | Output plus host cursor/PF/application-keypad encoding implemented, with built-in/Ghostty differential mode coverage | No remaining shared subset work | -| DRCS | Built-in parse/resource mapping, bounded snapshot masks, render planning, and downloaded-pixel rendering implemented | The pinned Ghostty C ABI does not expose DRCS resources; capability is explicitly unavailable there | -| Extended keyboard | Built-in Kitty set/query/push/pop flags, CSI-u event bytes, `modifyOtherKeys`, Win32-input mode, and press/repeat/release encoding implemented | Kitty alternate-key reporting and associated-text reporting are not advertised; the pinned Ghostty C ABI exposes no keyboard protocol state and reports these capabilities unavailable | +| Search | Implemented in the built-in engine; Core canonicalizes equivalent Unicode spellings and maps ZWJ/wide grapheme matches to cell ranges; built-in search anchors the selected match by logical-line ID and cell offset across reflow, duplicate lines, viewport scrolling, and bounded scrollback eviction, selecting the next surviving match if the selected line is lost | None | +| Grapheme/emoji | Built-in supports Hangul, Indic conjuncts, prepend, spacing marks, RI pairs, emoji modifiers/selectors, and emoji ZWJ sequences at arbitrary UTF-8 feed boundaries; bundled Noto Color Emoji provides deterministic Linux fallback | Unicode 16.0.0 `GraphemeBreakTest.txt` is pinned: all 369 terminal-representable rows pass ordered-cell and chunk-invariance checks. The other 724 rows are classified as unsuitable for terminal-cell assertions, not claimed as pure-segmenter passes; full UAX #29 conformance outside the tested subset remains unavailable | +| Row rendition | Built-in DECDWL/DECDHL parser, snapshots, logical cursor clipping, reflow preservation, and render transforms implemented | None | +| Sixel | Built-in decode/render, DECSDM scrolling/display behavior, retained cell geometry, and stable ownership implemented | None | +| OSC 1337 | Built-in bounded inline decode/render and stable ownership implemented; non-inline transfer is explicitly rejected without I/O | None | +| ConEmu images | Bounded single-part `st=0;sz=` payloads decode into shared overlay metadata and render safely | Multipart payloads are explicitly rejected | +| Kitty graphics | Direct-medium RGBA/RGB/PNG transmissions with chunking, zlib, placements, crop, z-order, deletes, and query responses implemented; unsupported media and animation are rejected deterministically | File/shared-memory media and animation frames are intentional gaps | +| Image ownership | Stable logical-line anchors survive scrollback and reflow in main and alternate buffers and are removed on owning-line eviction | None | +| VT52 | Output plus host cursor/PF/application-keypad encoding implemented | None | +| DRCS | Built-in parse/resource mapping, bounded snapshot masks, render planning, and downloaded-pixel rendering implemented | None | +| Extended keyboard | Built-in Kitty set/query/push/pop flags, CSI-u event bytes, `modifyOtherKeys`, Win32-input mode, and press/repeat/release encoding implemented | Kitty alternate-key reporting and associated-text reporting are not advertised | | Shader effects | Optional deterministic, bounded Skia retro/scanline pass, toggleable per active terminal | Custom arbitrary HLSL/pixel-shader files are not loaded or advertised | The image regression matrix covers split protocol feeds, exact/over-limit @@ -146,7 +146,7 @@ The `linux-arm64-hardware` CI job runs on GitHub's native not `aarch64` or `arm64`; emulation is not an accepted substitute. The job downloads the cross-built ARM64 tar, DEB, RPM, and AppImage artifacts and runs `scripts/Test-LinuxArm64Runtime.sh`. The gate executes the NativeAOT `wt` -help/error paths from every extracted format, Ghostty ABI and engine feeds, the +help/error paths from every extracted format, the built-in parser/engine, real `forkpty` input/resize/exit/cancellation/restart, broker concurrency, Linux shell profile and XDG paths, and disposable-root package install/upgrade/uninstall. @@ -161,8 +161,8 @@ install/upgrade/uninstall verification for Windows x64/ARM64 packages. ## Final UI results -Windows x64 and packaged Linux x64 were validated with built-in and Ghostty -engines after the full non-UI matrix. The runs covered three independent tabs, +Windows x64 and packaged Linux x64 were validated with the built-in +engine after the full non-UI matrix. The runs covered three independent tabs, split panes, focus/resize/close isolation, clipboard, fullscreen, maximize/restore, settings, workspace recreation, marks, summon/quake, truecolor, Unicode/CJK/emoji, row rendition, images, and shader effects. @@ -170,9 +170,7 @@ truecolor, Unicode/CJK/emoji, row rendition, images, and shader effects. - PowerShell 7 loaded PSReadLine normally. Windows PowerShell 5.1 on the test machine rejected its PSReadLine script module because of the machine's execution policy; ConPTY handles were confirmed as non-redirected. -- Built-in Linux rendered Sixel and inline OSC 1337 images. Ghostty displayed - explicit limitation notifications for image protocols and row rendition that - its pinned C ABI does not expose. +- Built-in Linux rendered Sixel and inline OSC 1337 images. - Bundled Noto Color Emoji fixed Linux emoji-ZWJ tofu. Portable settings icons and narrower content padding removed Linux settings-page clipping. - Mark actions display notifications and colored scrollbar ticks. @@ -180,7 +178,7 @@ truecolor, Unicode/CJK/emoji, row rendition, images, and shader effects. provide a visible file-manager handler. - Native Windows ARM64 and Linux ARM64 remain non-UI hardware/package gates; no ARM64 desktop was available in this session. -- macOS ARM64 NativeAOT `.app` packaging, `dt` parser, Ghostty ABI, real +- macOS ARM64 NativeAOT `.app` packaging, `dt` parser, real `forkpty`, broker named-pipe length, and a live GUI-host process start were validated on Darwin. Signed, notarized `.dmg` release artifacts are produced in CI by `scripts/Release-MacOsPackage.ps1` (see [macos.md](macos.md)). diff --git a/docs/performance-benchmarks.md b/docs/performance-benchmarks.md index 421aac8..4cb5222 100644 --- a/docs/performance-benchmarks.md +++ b/docs/performance-benchmarks.md @@ -1,79 +1,13 @@ -# Performance benchmarks vs Ghostty +# Performance benchmarks -This page explains how Ghostty backs up its "fastest terminal" positioning, -what its own benchmarks do and do not measure, and how DT benchmarks its -**builtin** engine and renderer against it. The goal is a claim that holds up -under review: "with the default engine, DT matches or beats Ghostty at -workload X, measured this way, on this machine." +This page explains how DT benchmarks VT ingest in its terminal engine +(`Devolutions.Terminal.Core.TerminalEngine`) and how to read the results +across sessions and machines. For renderer-only details (caches, pixel verification, historical numbers) -see [renderer benchmarks](renderer-benchmarks.md). - -## How Ghostty supports its performance claims - -Ghostty's public claims are mostly qualitative. Few of them come with a method -someone else could rerun. - -| Claim | Where | Evidence | -| --- | --- | --- | -| "Same performance category" as the fastest terminals; "~100x faster than Terminal.app and iTerm" | README | None. Ghostty's own devlog measured about 6x against iTerm2 and under 2x against Terminal.app | -| `cat` of plain text 4x faster than iTerm/Kitty, ~60 fps under load | v1.0 README | No method given. The README itself says "we need better benchmarks" | -| SIMD parser: 7.3x faster ASCII parse, 16.6x faster UTF-8 decode; `time cat` of a 5.4 MB Japanese text: Ghostty 73 ms, Alacritty 66 ms, Kitty 392 ms, iTerm2 470 ms | [Devlog 006](https://mitchellh.com/writing/ghostty-devlog-006) | **The only write-up with a real method**: hyperfine microbenchmarks with noop/memcpy floors, plus 10-run `time cat` averages. Alacritty beat Ghostty in that table | -| 480–500 fps in DOOM-fire | [Zig patterns talk](https://mitchellh.com/writing/ghostty-and-useful-zig-patterns) | Anecdote. The same talk says input latency was never measured | -| 1.3: IO 20–30% faster on a 4 GB asciinema replay; frame builds 2–5x faster | [1.3.0 notes](https://ghostty.org/docs/install/release-notes/1-3-0) | Real-world replay, but the corpus is not public | -| `cat` of 150 MB ASCII in 575 ms (nightly) | Social post, second-hand | One run, no hardware listed. Unverified | - -The [about page](https://ghostty.org/docs/about) itself is careful: Ghostty is -"in some benchmarks faster, in others slower." - -### What Ghostty's in-tree benchmarks measure - -Ghostty's benchmarks live in `src/benchmark/` at the pinned commit (see -`native/ghostty/ghostty-upstream.json`). They are built with -`zig build -Demit-bench`, which produces two binaries: - -- `ghostty-bench +` runs one component microbenchmark. It prints - nothing; the documented method is to time the **whole process** with - `hyperfine --warmup N`. That time includes process startup and setup. This is - why many actions have a `noop` mode to subtract. -- `ghostty-gen ` writes synthetic input: `ascii`, `utf8`, `osc`, - `kitty` and `styled`. Seeds are time-based by default, so you save the output - once and reuse the file. - -The actions that matter for a comparison with DT: - -| Action | What it times | Closest DT measurement | -| --- | --- | --- | -| `+terminal-stream --data=F` | Full VT parse and terminal state update, read in 64 KiB chunks | `vt` (`ghostty-vt` target) | -| `+terminal-parser` | State machine only, actions discarded | none (DT's parser is not separable) | -| `+screen-clone --mode=render\|render-clean\|render-partial` | Updates to the CPU `RenderState` while holding the lock | `render` snapshot/plan | -| `+codepoint-width`, `+grapheme-break` | Unicode tables | none | -| `+terminal-resize` | Reflow | none yet | - -**CI only builds these benchmarks.** Nothing runs them, Ghostty publishes no -result history, and nothing gates regressions. Ghostty does **not** measure: - -- input latency -- frame presentation or dropped frames -- PTY-to-screen throughput outside of hand runs of `vtebench` or `time cat` -- memory per line of scrollback -- startup time - -On Windows its IO path is a serial `ReadFile` loop, and no Ghostty GUI exists -there. - -### Why Ghostty is fast (what the builtin engine has to match) - -- **SIMD ground state**: Ghostty scans for ESC and decodes UTF-8 to UTF-32 in - bulk (simdutf/Highway). Each printable run goes to the screen as one slice - (`stream.zig`), not one byte at a time. -- **Bulk cell writes**: `printSlice` writes same-width runs against a template - cell. Widths come from a 3-level lookup table, with a shortcut for - codepoints up to 0xFF. -- **8-byte cells in pooled, offset-addressed pages**: when scrollback is full, - the oldest page is reused. Steady-state scrolling allocates nothing. -- **Separate IO and render threads**: the renderer rebuilds only dirty rows, - once per frame, never once per write. +see [renderer benchmarks](renderer-benchmarks.md). The earlier comparison +against `libghostty-vt`, from before the Ghostty engine was removed, is kept in +[Ghostty comparison benchmarks](history/ghostty-comparison-benchmarks.md). ## DT's benchmark layers @@ -81,345 +15,91 @@ All modes live in `tools/Devolutions.Terminal.Bench`. That tool runs on the JIT with tiered compilation disabled. It is **not** the NativeAOT shipping build, so treat its numbers as relative. -| Layer | Mode | Ghostty counterpart | Comparable on Windows? | -| --- | --- | --- | --- | -| 1. VT ingest | `vt` | `ghostty-bench +terminal-stream` | **Yes, in-process on identical bytes** | -| 2. Frame preparation | `render` (snapshot, plan) | `+screen-clone --mode=render*` | Diagnostic only; the two do different work | -| 3. CPU paint | `render` (paint-*) | none (GPU renderer) | No | -| 4. PTY to UI coalescing | `control` | termio/renderer wakeups | No | -| 5. End-to-end | manual: `vtebench`, `time cat` | same | Linux/macOS only, same machine | +| Layer | Mode | +| --- | --- | +| 1. VT ingest | `vt` | +| 2. Frame preparation | `render` (snapshot, plan) | +| 3. CPU paint | `render` (paint-*) | +| 4. PTY to UI coalescing | `control` | -### Layer 1: `vt`, same-process ingest A/B +## `vt`: same-process ingest ```powershell dotnet run --project tools\Devolutions.Terminal.Bench -c Release -- vt --mb 8 --runs 7 --json artifacts\bench\vt.json ``` -Three targets consume the same bytes in the same chunks (16 KiB by default, -the ConPTY read size; `--chunk-kb 64` matches `+terminal-stream`): +Two targets consume the same bytes in the same chunks (16 KiB by default, +the ConPTY read size; change it with `--chunk-kb`): - `builtin`: DT's `TerminalEngine`, the engine under test. -- `ghostty-vt`: raw `ghostty_terminal_vt_write` from the pinned - `libghostty-vt`, with only the scrollback limit set to the builtin's 9,001 - lines. This is the bar to beat. It is the same work as `+terminal-stream`, - without process startup. -- `ghostty`: DT's `GhosttyTerminalEngine`, which adds the projection into DT's - cell model. This is what the opt-in Ghostty mode actually costs. Projection - is lazy, so measure it with `--frame-kb`. +- `control`: an FNV-1a hash over the fed bytes. Each step depends on the + previous one, so it is a serial loop that does no terminal work, and its + speed tracks only the core clock and load. -`--frame-kb N` reads one frame every N KiB fed, inside the timed region: +The control exists because absolute MB/s is not stable. On laptop CPUs it +drifts by up to about 2.5x between invocations, mostly with the CPU's turbo +state. Both targets see the same clock within a run, so the +**builtin / control ratio** (`builtinVsControl` in the JSON) is the number to +compare across sessions. Absolute MB/s is only meaningful within one run. -- builtin and `ghostty` take a `CreateSnapshot`; -- `ghostty-vt` does what Ghostty's renderer does per frame, a render-state - update followed by a clean. - -The default of `0` measures pure ingest. `256` approximates a busy screen at a -frame rate the renderer can sustain. +`--frame-kb N` makes `builtin` take a `CreateSnapshot` every N KiB fed, +inside the timed region. The default of `0` measures pure ingest. `256` +approximates a busy screen at a frame rate the renderer can sustain. Methodology: - **Identical, deterministic bytes.** Each workload is seeded, and its - SHA-256 is printed. `--export ` writes the `.vt` files so - `ghostty-bench` can consume exactly the same input. `--data ` replays - a capture, for example an asciinema or vim session. -- **Equivalence gate.** Before timing, both engines consume the workload, and - the visible text, cursor and history are compared. A throughput number only - counts when the screens are identical. The `utf8` workload enables DEC mode - 2027 because, without it, Ghostty measures ZWJ emoji per codepoint while - builtin clusters them. Ghostty trims history by page, so its history count - can be a few hundred lines below 9,001; that difference is expected. + SHA-256 is printed. `--export ` writes the `.vt` files. `--data ` + replays a capture, for example an asciinema or vim session. - **Fair timing.** - Engines are built outside the timed region. - Runs interleave the targets in rotating order and include a discarded - warmup. + warmup, so thermal drift and background load hit both alike. - A sample replays the corpus into the same engine until at least `--min-ms` - (500 ms by default) has elapsed, so fast targets are not dominated by + (500 ms by default) has elapsed, so fast workloads are not dominated by timer noise. - - Laptop CPUs leave turbo a few seconds into sustained load: on the machine - below, throughput falls about 3x after ~2.7 s and stays there, whatever - the engine does. With 500 ms samples, runs land on both sides of that drop - and spreads reach 50–180%. Samples of ~3 s (`--min-ms 3000`, or the - script's `-MinMs 3000`) all run at the sustained clock. -- **Conservative verdict.** - - `faster` only when builtin's *slowest* run beats Ghostty's *fastest*. - - `slower` when the reverse holds. - - `overlap` otherwise. - - Without `--no-gate`, the exit code is non-zero unless every workload is - `faster`. + - Laptop CPUs leave turbo a few seconds into sustained load, and throughput + can fall about 3x and stay there. With 500 ms samples, runs land on both + sides of that drop and spreads become large. Samples of ~3 s + (`--min-ms 3000`, or the script's `-MinMs 3000`) all run at the sustained + clock. - **Cost metrics**: managed bytes allocated per MiB fed, GCs per MiB, and retained private memory after the run. Retained memory is a process-wide - delta, so read it as an order of magnitude. Native allocations by Ghostty - appear only in that column. - -Workloads (`--workloads a,b,...`): + delta, so read it as an order of magnitude. -| Workload | Shape | Source of the idea | -| --- | --- | --- | -| `ascii` | unbroken 0x21–0x7E stream relying on autowrap | `ghostty-gen ascii` | -| `ascii-lines` | printable lines with CRLF | `cat` of a log | -| `utf8` | Latin, Greek, Cyrillic, CJK, emoji, ZWJ, combining marks | `ghostty-gen utf8`, restricted to assigned characters | -| `cjk` | lines of CJK ideographs only (wide, 3-byte UTF-8) | `time cat` of CJK text | -| `emoji` | lines of emoji only (wide, 4-byte UTF-8) | emoji-heavy logs and prompts | -| `styled` | short SGR runs, OSC 8, wide text | `ghostty-gen styled` | -| `dense-cells` | every cell gets its own truecolor/256 SGR | vtebench `dense_cells` | -| `light-cells` | full-screen plain text from home | vtebench `light_cells` | -| `cursor-motion` | random CUP, short writes, EL | vtebench `cursor_motion`, TUIs | -| `scrolling` | `y\n` | vtebench `scrolling` | -| `scroll-region` | DECSTBM region scrolling | vtebench `scrolling_*_region` | -| `osc` | titles, OSC 133 marks, OSC 8 links | `ghostty-gen osc` (valid sequences only) | -| `mixed` | the legacy `engine`/`render` corpus | DT history | +Workloads (`--workloads a,b,...`; `vt --list` prints them): -### Running the whole suite +| Workload | Shape | +| --- | --- | +| `ascii` | unbroken 0x21–0x7E stream relying on autowrap | +| `ascii-lines` | printable lines with CRLF (`cat` of a log) | +| `utf8` | Latin, Greek, Cyrillic, CJK, emoji, ZWJ, combining marks (DEC mode 2027 on) | +| `cjk` | lines of CJK ideographs only (wide, 3-byte UTF-8) | +| `emoji` | lines of emoji only (wide, 4-byte UTF-8) | +| `styled` | short SGR runs, OSC 8, wide text | +| `dense-cells` | every cell gets its own truecolor/256 SGR (vtebench `dense_cells`) | +| `light-cells` | full-screen plain text from home (vtebench `light_cells`) | +| `cursor-motion` | random CUP, short writes, EL (vtebench `cursor_motion`, TUIs) | +| `scrolling` | `y\n` (vtebench `scrolling`) | +| `scroll-region` | DECSTBM region scrolling (vtebench `scrolling_*_region`) | +| `osc` | titles, OSC 133 marks, OSC 8 links | +| `mixed` | the legacy `engine`/`render` corpus | + +## Running the whole suite ```powershell -pwsh scripts/Invoke-TerminalBenchmarks.ps1 # vt + render, results in artifacts/bench/ -pwsh scripts/Invoke-TerminalBenchmarks.ps1 -Quick # fast local signal -pwsh scripts/Invoke-TerminalBenchmarks.ps1 -Suite vt -MinMs 3000 # ~3 s samples: stable on CPUs that leave turbo under load -pwsh scripts/Invoke-TerminalBenchmarks.ps1 -Baseline artifacts/bench//vt.json # fails if builtin/ghostty-vt drops >15% on identical inputs -pwsh scripts/Invoke-TerminalBenchmarks.ps1 -RequireBeatGhostty # fails unless builtin wins every vt workload -pwsh scripts/Invoke-TerminalBenchmarks.ps1 -GhosttyBench \ghostty-bench.exe # also replays the corpora through upstream +terminal-stream +pwsh scripts/Invoke-TerminalBenchmarks.ps1 # vt + render, results in artifacts/bench/ +pwsh scripts/Invoke-TerminalBenchmarks.ps1 -Quick # fast local signal +pwsh scripts/Invoke-TerminalBenchmarks.ps1 -Suite vt -MinMs 3000 # ~3 s samples: stable on CPUs that leave turbo under load +pwsh scripts/Invoke-TerminalBenchmarks.ps1 -Baseline artifacts/bench//vt.json # fails if builtin/control drops >15% on identical inputs ``` -`-GhosttyBench` times whole processes, as Ghostty does, so its figures include -process startup. It cross-checks the in-process `ghostty-vt` target; it does -not replace it. Build `ghostty-bench` as described in -[renderer benchmarks](renderer-benchmarks.md#ghostty-reference-measurements). - -### End-to-end (layer 5) on Linux/macOS - -Ghostty's only cross-terminal numbers come from `time cat` and vtebench. To -reproduce them, run on one native Linux or macOS machine, with both apps in -Release and matching font, size, grid and scrollback: - -- `time cat` of a large ASCII file and of a large CJK file, 10 runs each. -- vtebench's 12 workloads, run inside each terminal. vtebench measures how fast - the terminal drains the PTY (backpressure), not paint time. - -Never run both terminals at the same time; alternate their order and report -the spread. The checklist in -[renderer benchmarks](renderer-benchmarks.md#full-renderer-comparison-linuxmacos) -lists the full set of gates. - -## Current results - -Setup for these numbers: - -- One Windows 11 x64 laptop (Intel Core Ultra 9 285HX, 24 cores, AVX2), - .NET 10.0.14 (JIT, tiered compilation off), and the pinned `libghostty-vt` - (ReleaseFast). -- 120x30 grid, 16 KiB chunks, 8 MiB per workload, 7 timed runs plus 1 warmup, - samples of at least 3 s. -- Each workload ran in its own process, via: - - ```powershell - pwsh scripts/Invoke-TerminalBenchmarks.ps1 -Suite vt -MinMs 3000 - ``` - -This machine's absolute speed drifts by up to 2.5x between invocations, -mostly with the CPU's turbo state (see *Fair timing* above). So read the -**builtin / ghostty-vt ratio**, which is measured within each run, and treat -anything within about ±10% of 1.0 as a tie. Throughput is the median MB/s. - -### Ingest: builtin engine vs raw `libghostty-vt` - -| Workload | builtin | ghostty-vt | ghostty (DT) | ratio | verdict | builtin alloc / MiB | screens | -| --- | ---: | ---: | ---: | ---: | --- | ---: | --- | -| osc | 147 | 25.3 | 23.5 | **5.82x** | **faster** | 6.7 MB | identical | -| styled | 45.9 | 18.0 | 13.2 | **2.54x** | **faster** | 1.4 MB | 1 row differs | -| mixed | 787 | 366 | 229 | **2.15x** | **faster** | 8 KB | identical | -| dense-cells | 262 | 138 | 51 | **1.90x** | **faster** | 0 | identical | -| scrolling | 56.5 | 32.6 | 31.9 | **1.73x** | **faster** | 104 KB | identical | -| utf8 | 80.1 | 59.0 | 69.3 | **1.36x** | overlap | 92 KB | identical | -| light-cells | 2,265 | 1,737 | 62 | **1.30x** | **faster** | 0 | identical | -| emoji* | 816 | 672 | 301 | **1.21x** | **faster** | 7 KB | identical | -| ascii | 1,145 | 971 | 448 | **1.18x** | **faster** | 5 KB | identical | -| ascii-lines | 716 | 626 | 286 | **1.14x** | overlap | 9 KB | identical | -| cjk* | 583 | 522 | 237 | **1.12x** | **faster** | 10 KB | identical | -| scroll-region | 865 | 782 | 58 | **1.11x** | **faster** | 0 | identical | -| cursor-motion | 188 | 172 | 95 | **1.09x** | **faster** | 32 KB | identical | - -\* From a later invocation of those two workloads alone. In the full run they -landed in a noisy stretch (spreads over 50%) and measured 0.98x and 1.22x. -Other quiet runs of the same code measured 1.07–1.21x on cjk and 1.18x on -emoji. - -`ghostty (DT)` skips projection without `--frame-kb`. The one-row difference in -`styled` is a behavior difference, not a measurement artifact: builtin drops a -combining mark that follows a space, and Ghostty keeps it. - -How builtin got here, as the median builtin/ghostty-vt ratio per round (each -round measured against its own in-run Ghostty control): - -| Workload | `dc1cd26` | round 1 | round 2 (packed cells) | round 3 | round 4 (`f45b95b`) | round 5 | -| --- | ---: | ---: | ---: | ---: | ---: | ---: | -| ascii | 0.009 | 0.20 | 0.55 | 1.21 | 1.24 | 1.18 | -| ascii-lines | 0.009 | 0.20 | 0.50 | 1.13 | 1.19 | 1.14 | -| light-cells | 0.009 | 0.32 | 0.48 | 1.20 | 1.23 | 1.30 | -| scrolling | 0.015 | 0.14 | 0.40 | 1.11 | 1.33 | 1.73 | -| dense-cells | 0.42 | 0.63 | 0.62 | 1.57 | 1.44 | 1.90 | -| styled | 0.30 | 1.33 | 1.14 | 2.82 | 2.91 | 2.54 | -| osc | 0.29 | 2.60 | 4.52 | 4.57 | 4.59 | 5.82 | -| mixed | 0.03 | 0.39 | 0.45 | 1.56 | 1.84 | 2.15 | -| scroll-region | 0.018 | 0.22 | 0.34 | 0.90 | 0.96 | 1.11 | -| cursor-motion | 0.11 | 0.23 | 0.17 | 0.94 | 0.90 | 1.09 | -| utf8 | 0.10 | 0.36 | 0.27 | 0.57 | 0.77 | 1.36 | -| cjk | — | — | — | 0.53 | 0.77 | 1.12 | -| emoji | — | — | — | 0.50 | 0.80 | 1.21 | - -Round 5 was measured with 3 s samples; earlier rounds used 500 ms samples. - -The `-Baseline` gate flagged real regressions twice: - -- **Round 2:** utf8 and cursor-motion. -- **Round 4:** dense-cells and cursor-motion, from caching packed templates on - the short-ASCII path. - -Each was confirmed with an interleaved A/B build and fixed before moving on. -Round 5 passed the gate after each change. Moves of about ±10% between rounds -(ascii and ascii-lines into round 5, for example) are within this machine's -noise. styled's 2.54x comes from a run with a 136% spread; single-workload -runs of the same code measured 3.39–3.61x. - -### DT's Ghostty mode vs raw `libghostty-vt`, with frames - -These numbers use `--frame-kb 256`, so a projection is taken every 256 KiB. -They were measured in round 4; round 5 did not change the Ghostty engine. - -| Workload | ghostty (DT) before | after | ghostty-vt | ratio before → after | -| --- | ---: | ---: | ---: | ---: | -| ascii | 49.8 | 214.4 | 960 | 0.05 → 0.22 | -| ascii-lines | 46.5 | 220.0 | 660 | 0.07 → 0.33 | -| cursor-motion | 36.9 | 79.9 | 146 | 0.25 → 0.55 | -| mixed | 41.1 | 146.5 | 295 | 0.12 → 0.50 | -| light-cells | 45.6 | 57.5 | 1,580 | 0.03 → 0.04 | - -`light-cells` rewrites every row between frames, so every row is dirty and the -per-cell P/Invoke projection still dominates. - -### Frame preparation and CPU paint - -`render`, 120x30, median p50 / p95, measured in round 4: - -| Stage | before packed cells | now | -| --- | --- | --- | -| snapshot | 0.009 / 0.011 ms | 0.040 / 0.044 ms | -| snapshot + plan | 0.074 / 0.082 ms | 0.113 / 0.129 ms | -| static paint | 0.69 / 1.08 ms | 0.75 / 1.00 ms | -| one-row update | 0.69 / 1.70 ms | 0.76 / 1.06 ms | -| scrolling paint | 3.29 / 9.72 ms | 2.90 / 5.54 ms | - -Snapshots now unpack each packed cell into a public `Cell`, which costs about -0.03 ms per 120x30 frame. Every stage still fits well inside a 16.7 ms frame. -No Ghostty counterpart exists on Windows. - -### What changed in the builtin engine - -Every fast path is checked against the per-character path by differential -tests (`AsciiFastPathTests`, `CsiFastPathTests`, `CellRowModelTests`, -`PackedCellStorageTests`, `CombiningTableTests`). Each new branch was also -checked by deliberately breaking it and confirming a test fails. - -- **Packed, reference-free storage** (`CellRow`, `PackedCell`). - - A cell is 16 bytes with no GC references, down from about 40 with two - `string?` fields. - - Hyperlinks live in a per-line side array that exists only while needed. - - Combining text is interned per buffer (`CombiningTable`), and lines store - int IDs. A cluster seen before costs no allocation, a recycled line keeps - its ID array, and the table compacts so ever-new clusters cannot grow it - without bound. This took utf8 from 9.4 MB to under 0.1 MB allocated per - MiB. - - The public `Cell` and `TermColor` are unchanged, and every value round-trips - losslessly. -- **Logically blank cells are never written.** - - A line stores only its written range; everything outside it reads as the - blank it was cleared with. - - Recycling a scrollback line, the coldest memory in the buffer, is O(1). - - Erasing to the end of a line is O(1) for left-aligned text. - - Writes fill only the gap between the stored range and the new cells. -- **Bulk printing.** - - **ASCII:** the parser hands whole printable-ASCII runs to - `IVtDispatch.PrintAscii`. They are stored a row segment at a time, two - 64-bit (or, with AVX2, two 256-bit per four cells) stores per cell batch. - - **UTF-8 runs:** well-formed UTF-8 runs go to `IVtDispatch.PrintRunes`. - Runs of 3- or 4-byte sequences (CJK, emoji) are decoded four at a time - from 16-byte `Vector128` loads, with the scalar decoder's validity rules - (no overlong forms, surrogates or values past U+10FFFF); anything else - falls back to the scalar decoder. On CJK this decodes about 3x faster. - - **Grapheme joins:** precomputed tables for planes 0 and 1 rule out joins, - so runes that cannot join are stored a segment at a time. That includes - emoji after a cell without a trailing ZWJ. A zero-width rune with no join - property (a combining accent, ZWJ) goes straight into the previous cell's - combining text. - - **Rows without wide glyphs:** a conservative per-row "may hold wide cells" - flag means classifying and storing happen in one pass, with no clearing - around wide glyphs. Every freshly recycled scrollback line qualifies. That - pass ORs each rune into precomputed template bits and writes one 16-byte - store per cell, with widths from a table where ambiguous widths are already - resolved. - - **Short writes:** the packed print templates are cached and rebuilt only - when the attributes, protection or shell-integration state change, and a - single printable byte between escape sequences is one store. -- **CSI fast path.** A complete plain CSI sequence is parsed in one pass, - straight into the arrays the dispatcher receives, and the parser calls the - engine directly instead of through `IVtDispatch`. Sequences with no private - marker or intermediate byte skip the special cases and go straight to the - switch on the final byte. -- **C0 fast path.** CR, LF and other controls in ground state go straight to - `ExecuteC0` instead of through the string-state checks. -- **Scroll regions** rotate line references with `Array.Copy`, not one - indexer call per line. - -### What still separates builtin from Ghostty - -Builtin now leads on every workload. The closest leads, `cursor-motion`, -`scroll-region` and `cjk` (1.09–1.12x), sit near the ±10% band of this -machine's noise, and `ascii-lines` and `utf8` still get `overlap` verdicts -from run-to-run spread. Timed stage by stage on CJK after round 5 (one engine -reused with history full, per scalar, all stages in one process): - -| Stage | Cost | -| --- | --- | -| UTF-8 decode, block path | ~0.5–1.0 ns | -| Parser per-line work (CR/LF dispatch, run setup) | ~0.7–1 ns | -| Cell stores (two 16-byte cells per wide character) | ~2 ns | -| Buffer CR/LF, scrolling and line reuse | ~0.7 ns | - -- **Parser per-line overhead** now costs about as much as the decode. Each - CR and LF still goes through an interface call, and each line's run of - characters pays its setup once. -- **Cell size.** Packed cells are 16 bytes against Ghostty's 8, so storing, - clearing or copying a row moves twice the memory. Getting to 8 bytes means - interning styles, which is a larger change. -- **What did not help.** Prefetching the scrollback lines about to be reused - measured slower: with history full, steady-state throughput is the same at - 100 and 9,000 lines, so cold recycled lines are not a bottleneck. - -**Variance.** Within isolated processes the ratio repeats to about ±10% on -this machine with 3 s samples. With 500 ms samples, runs straddle the CPU's -turbo drop and single workloads swing by 50% or more. In one long process -running every workload, ratios also drifted by up to 50%, because heap state -carried over between workloads. For that reason the script runs one process -per workload, and `-Baseline` only compares workloads with the same SHA-256 -and gates on the ratio, not on absolute MB/s. - -## Definition of "matches or beats Ghostty" - -A claim needs every row below. A missing or failed row means the claim has -not been established. - -1. `vt`: `faster` (or, for "matches", `overlap` with a median ratio ≥ 0.95) - on every workload, with identical screens, on at least one x64 and one - arm64 host. -2. The same corpora through upstream `ghostty-bench +terminal-stream` do not - contradict layer 1. This rules out a P/Invoke artifact. -3. Scrolling allocation is bounded. Steady-state `scrolling` should allocate - close to 0 B/MiB once history is full, as Ghostty's page reuse does. -4. On Linux/macOS, `time cat` and vtebench place DT at or above Ghostty, run - under the conditions described in the end-to-end section. -5. Frame latency and dropped frames: measure these even though Ghostty does - not publish them. Input latency (key to PTY echo to presented frame) is - unmeasured on both sides, and a DT measurement would be new evidence rather - than a comparison. +The script runs one process per `vt` workload: in one long process running +every workload, ratios drifted by up to 50% because heap state carried over +between workloads. `-Baseline` only compares workloads with the same SHA-256 +(use the same `-Quick` and `-Workloads` settings as the baseline) and gates on +the builtin/control ratio, not on absolute MB/s. `-RegressionPercent` changes +the 15% threshold. Run `scripts/Invoke-TerminalBenchmarks.ps1 -Baseline` on every engine change so wins are kept, and record the commit, machine and `vt.json` with each diff --git a/docs/release.md b/docs/release.md index 412a0c1..55245fe 100644 --- a/docs/release.md +++ b/docs/release.md @@ -26,12 +26,10 @@ Unix RID-specific publishes remove the Windows-only `OpenConsole.exe` hosts from ConPTY uses asynchronous host-side pipes and registered process-exit waits, with synchronous pipe endpoints for the console host, so idle sessions and blocked input do not exhaust the worker pool. Linux and macOS local sessions use the bundled `forkpty` relay. The Avalonia shell, settings, renderer, and terminal -engines are shared. +engine are shared. CI workflows: -- `build-ghostty.yml` — compile `libghostty-vt` for every RID and upload - artifacts (optional cache; not required to develop). - `build-terminal.yml` — restore natives from source, test, NativeAOT desktop, Linux packages, macOS `.app`/zip, MSIX, the `Devolutions.Terminal.Control` NuGet package, and the browser WASM host. NativeAOT desktop releases include @@ -110,7 +108,7 @@ pwsh scripts/Test-MacOsRuntime.ps1 artifacts/packages ``` The staged `Devolutions Terminal.app` contains `Devolutions.Terminal`, `dt`, -`dt-pty-host`, `libghostty-vt.dylib`, Skia, HarfBuzz, `Info.plist`, and an +`dt-pty-host`, Skia, HarfBuzz, `Info.plist`, and an ad-hoc signed icon. This gate (`Build-MacOsPackage.ps1`) produces an unsigned, unnotarized zip for fast local iteration; the CI release pipeline additionally runs `scripts/Release-MacOsPackage.ps1` to sign every Mach-O with a Developer ID @@ -128,7 +126,7 @@ scripts/Build-LinuxPackage.sh linux-arm64 2026.3.0 artifacts/packages all These commands publish the default self-contained desktop and create reproducible tar, DEB, RPM, and AppImage artifacts plus a SHA-256 manifest. Every format is staged from one canonical filesystem root and includes `Devolutions.Terminal`, `dt`, -`dt-pty-host`, Ghostty, Skia, HarfBuzz, licenses, freedesktop metadata, icons, +`dt-pty-host`, Skia, HarfBuzz, licenses, freedesktop metadata, icons, the reversible integration helper, a sorted inventory, and an SPDX 2.3 SBOM. Set `SOURCE_DATE_EPOCH` for release reproducibility. To reuse a separately validated publish, set `LINUX_PUBLISH_DIR`. @@ -159,7 +157,7 @@ uninstall removes every owned file. When installed, `desktop-file-validate` and `Test-LinuxArm64Runtime.sh` must run on native Linux ARM64 (`uname -m` equal to `aarch64` or `arm64`) and deliberately rejects x64 and QEMU-based cross-execution. It runs only non-UI gates: NativeAOT `dt` startup/parser, -Ghostty ABI and feed, the built-in engine, real `forkpty` lifecycle, broker +the built-in engine, real `forkpty` lifecycle, broker concurrency, Linux profile/XDG discovery, and package lifecycle under a disposable root. CI uses the exact GitHub-hosted runner label `ubuntu-24.04-arm` in job `linux-arm64-hardware`. If that label is unavailable diff --git a/docs/renderer-benchmarks.md b/docs/renderer-benchmarks.md index 77dda02..7542dd5 100644 --- a/docs/renderer-benchmarks.md +++ b/docs/renderer-benchmarks.md @@ -4,8 +4,7 @@ The `render` mode of `tools/Devolutions.Terminal.Bench` measures the CPU work of preparing and rasterizing terminal frames. It is not a GPU or presentation benchmark. The existing `engine` and `control` modes measure different parts of the pipeline; `control` uses Avalonia headless and does **not** paint. -For the engine-level comparison against Ghostty (`vt` mode), the full suite -script and the "beats Ghostty" criteria, see +For engine-level VT ingest (`vt` mode) and the full suite script, see [performance benchmarks](performance-benchmarks.md). ## Reproducible input @@ -79,8 +78,8 @@ and three Release runs, the median-run CPU-raster results were: | Scrolling p50 / p95 | 35.512 / 52.286 ms | 3.435 / 5.932 ms | | Scrolling managed allocation/frame | 3,428,490 B | 157,920 B | -These are separate runs on one machine, not a speed ratio against Ghostty. -In this workload 16 KiB updates replace most visible rows; the row cache +These are separate runs on one machine, not a speed ratio against another +terminal. In this workload 16 KiB updates replace most visible rows; the row cache therefore helps static/localized paints more than scrolling. Replaying only eight warmed frames gives artificially high row-cache hit rates and is not a substitute for the longer scrolling case. @@ -152,38 +151,6 @@ at the default 4,096-glyph bound. Median-run p50 changed from 3.388 to from these runs. For fully warmed static and localized paints, use at least `--warmup 2` so both the candidate and picture passes finish before timing. -## Ghostty reference measurements - -This repository pins Ghostty at the commit in -`native/ghostty/ghostty-upstream.json`. Build **that** source with Zig 0.16.0 -using `zig build -Demit-bench -Doptimize=ReleaseFast` (on macOS add -`-Demit-macos-app=false`). With the pinned revision on Windows, build the -benchmark-only/lib-vt targets instead; the default full target currently -fails to link because of a duplicate `memset` symbol: - -```powershell -zig build -Demit-bench=true -Demit-lib-vt=true -Doptimize=ReleaseFast -j1 -``` - -The upstream `zig-out/bin/ghostty-bench` (`.exe` on Windows) can consume the -exported corpus: - -```text -ghostty-bench +screen-clone --mode=render-partial --terminal-rows=30 --terminal-cols=120 --data= -``` - -Run it repeatedly with warmups (e.g. `hyperfine`) rather than timing -`ghostty-gen | ghostty-bench`: keep generation out of the measurement and use -enough internal operations to amortize process startup and input loading. -Ghostty's `screen-clone` `render`, `render-clean`, and -`render-partial` modes time updates to its CPU `RenderState`, **not** GPU -painting. Their steps respectively perform 50,000 full, 3,000,000 clean, or -2,000,000 partial updates at `--loops=1`. External wall times also include -process startup and corpus loading. They use different semantics and -iteration counts from this project's snapshot/plan stage; record them as -separate diagnostics, not a numeric speed ratio. The `libghostty-vt` binary -used by this project has no Ghostty graphics renderer. - ## Full renderer comparison (Linux/macOS) For a user-visible comparison, benchmark the full GUI applications on the @@ -196,8 +163,8 @@ scrolling separately; verify the rendered screen after each run, and exclude unsupported image protocols from paired workloads. Instrument Avalonia at `TermControl.Render` (snapshot/plan), then at -`TerminalSkiaDrawOperation.Render` (Skia submission). Instrument upstream -Ghostty at `RenderState.update` and `renderer/generic.zig:drawFrame`. Both draw +`TerminalSkiaDrawOperation.Render` (Skia submission), and instrument the other +terminal at its equivalent frame-preparation and draw entry points. Both draw paths may return before presentation: use platform GPU/compositor tracing or presentation timestamps for end-to-end frame latency and dropped frames. Report CPU preparation, render submission, GPU completion/presentation, p50/ @@ -205,11 +172,9 @@ p95/p99 frame intervals, and CPU/GPU utilization **as distinct metrics**. Full-app elapsed time also includes each app's engine, transport, and scheduling; it is not a render-only speed ratio. Warm up caches, alternate run order, collect repeated runs, and never run both -apps' measurements concurrently. Ghostty's native GUI is available on -Linux/macOS, not Windows; its Windows `libghostty-vt` support cannot stand in -for a native renderer comparison. +apps' measurements concurrently. -For a claim of beating Ghostty **at every measured point**, establish separate +For a claim of beating another terminal **at every measured point**, establish separate equivalent baselines for VT ingest, CPU frame preparation, idle/static paint, localized updates, first-pass and replayed scrolling, and presented frame latency. Compare p50, p95, p99, allocation/peak resident memory, CPU/GPU @@ -217,5 +182,9 @@ utilization, and dropped frames for each workload on the same native host; require pixel or terminal-state equivalence first. Alternate the applications across repeated runs and report the run-to-run spread as well as medians. A failure or unmeasured metric means the all-points claim has **not** passed. -The Windows offscreen raster and upstream `screen-clone` results above measure -different work and cannot satisfy the presented-frame gate. +The Windows offscreen raster results above measure different work and cannot +satisfy the presented-frame gate. + +Earlier comparison measurements against another engine, from before this +project had a single engine, are kept in the history record linked from +[performance benchmarks](performance-benchmarks.md). diff --git a/docs/renderer.md b/docs/renderer.md index af349bb..b8f3597 100644 --- a/docs/renderer.md +++ b/docs/renderer.md @@ -69,7 +69,7 @@ Coalescing takes an 8 MiB burst from 514 drains to roughly one per frame. The separate `render` mode measures snapshot/planning and CPU Skia rasterization of static, one-row-update, and scrolling screens. See [renderer benchmarks](renderer-benchmarks.md) for reproducible inputs, results -and the limits of comparing those measurements with Ghostty. +and the limits of comparing those measurements with other terminals. ## Performance contract diff --git a/linux/com.devolutions.Terminal.metainfo.xml b/linux/com.devolutions.Terminal.metainfo.xml index e217615..8965b68 100644 --- a/linux/com.devolutions.Terminal.metainfo.xml +++ b/linux/com.devolutions.Terminal.metainfo.xml @@ -8,7 +8,7 @@

Devolutions Terminal is a cross-platform Avalonia terminal emulator - with tabs, panes, configurable profiles, and built-in or Ghostty terminal + with tabs, panes, configurable profiles, and built-in terminal engines.

diff --git a/macos/entitlements.plist b/macos/entitlements.plist index 0198393..7665810 100644 --- a/macos/entitlements.plist +++ b/macos/entitlements.plist @@ -7,7 +7,7 @@ com.apple.security.cs.allow-unsigned-executable-memory - com.apple.security.cs.disable-library-validation diff --git a/macos/package.env b/macos/package.env index 7e9021f..8de9cb1 100644 --- a/macos/package.env +++ b/macos/package.env @@ -8,7 +8,6 @@ BUNDLE_NAME="Devolutions Terminal.app" EXECUTABLE_NAME=Devolutions.Terminal CLI_NAME=dt PTY_HOST_NAME=dt-pty-host -GHOSTTY_LIBRARY=libghostty-vt.dylib ICON_NAME=DevolutionsTerminal MACOS_DEPLOYMENT_TARGET=13.0 LICENSE_ID="MIT AND OFL-1.1" diff --git a/native/NativeLibraries.targets b/native/NativeLibraries.targets index 2b71476..61beb2d 100644 --- a/native/NativeLibraries.targets +++ b/native/NativeLibraries.targets @@ -1,60 +1,46 @@ $([System.IO.Path]::GetFullPath('$(MSBuildThisFileDirectory)')) - $(RuntimeIdentifier) - $(NETCoreSdkPortableRuntimeIdentifier) - libghostty-vt.so - libghostty-vt.dylib - ghostty-vt.dll $(RuntimeIdentifier) - $(NETCoreSdkPortableRuntimeIdentifier) - all + $(NETCoreSdkPortableRuntimeIdentifier) + pty powershell.exe pwsh pwsh + + Condition="'$(SkipNativeRestore)' != 'true' and '$(DesignTimeBuild)' != 'true' and '$(NativeRestore)' != 'none' and '$(UnixPtyRid)' != ''"> - + Condition="'$(NativeRestore)' != 'none' and '$(UnixPtyRid)' != ''"> + Condition="Exists('$(NativeRoot)linux-pty\$(UnixPtyRid)\dt-pty-host')" /> + Condition="Exists('$(TargetDir)dt-pty-host') and !$([MSBuild]::IsOSPlatform('Windows'))" /> - + Condition="'$(NativeRestore)' != 'none' and '$(PublishDir)' != '' and '$(UnixPtyRid)' != ''"> + Condition="Exists('$(NativeRoot)linux-pty\$(UnixPtyRid)\dt-pty-host')" /> + Condition="Exists('$(PublishDir)dt-pty-host') and !$([MSBuild]::IsOSPlatform('Windows'))" /> diff --git a/native/Restore-NativeLibraries.ps1 b/native/Restore-NativeLibraries.ps1 index 5542778..e3a8b37 100644 --- a/native/Restore-NativeLibraries.ps1 +++ b/native/Restore-NativeLibraries.ps1 @@ -1,8 +1,6 @@ [CmdletBinding()] param( [string] $Rid, - [ValidateSet("all", "ghostty", "pty")] - [string] $Component = "all", [string] $ZigPath, [switch] $Force ) @@ -14,20 +12,17 @@ $ErrorActionPreference = "Stop" $nativeRoot = $PSScriptRoot $dotnetRoot = [IO.Path]::GetFullPath((Join-Path $nativeRoot "..")) -$manifest = Get-Content -LiteralPath (Join-Path $nativeRoot "ghostty\ghostty-upstream.json") -Raw | - ConvertFrom-Json -$zigVersion = [string]$manifest.zig +$zigVersion = Get-PinnedZigVersion if ([string]::IsNullOrWhiteSpace($Rid)) { $Rid = Get-HostRid } -$buildGhostty = $Component -in @("all", "ghostty") +# dt-pty-host is the only native helper built from source, and only Unix RIDs use it. $isUnixRid = $Rid.StartsWith("linux-", [StringComparison]::Ordinal) -or $Rid.StartsWith("osx-", [StringComparison]::Ordinal) -$buildPty = ($Component -in @("all", "pty")) -and $isUnixRid -if (-not $buildGhostty -and -not $buildPty) { - Write-Host "No native libraries required for $Rid ($Component)." +if (-not $isUnixRid) { + Write-Host "No native libraries required for $Rid." return } @@ -35,64 +30,24 @@ $mutex = [System.Threading.Mutex]::new($false, "Devolutions.Terminal.NativeResto $acquired = $false try { $acquired = $mutex.WaitOne() - $needsBuild = $false - if ($buildGhostty) { - $fileName = [string]$manifest.targets.$Rid.file - if ($Force -or -not (Test-Path -LiteralPath (Join-Path $nativeRoot "ghostty\$Rid\$fileName"))) { - $needsBuild = $true + $output = Join-Path $nativeRoot "linux-pty\$Rid\dt-pty-host" + if ($Force -or -not (Test-Path -LiteralPath $output)) { + $ptyArgs = @{ + Rid = $Rid + ZigPath = Resolve-ZigPath -ZigPath $ZigPath -Version $zigVersion -DotnetRoot $dotnetRoot -Install } - } - - if ($buildPty -and ($Force -or -not (Test-Path -LiteralPath (Join-Path $nativeRoot "linux-pty\$Rid\dt-pty-host")))) { - $needsBuild = $true - } - - if ($needsBuild) { - $ZigPath = Resolve-ZigPath -ZigPath $ZigPath -Version $zigVersion -DotnetRoot $dotnetRoot -Install - } - - if ($buildGhostty) { - $fileName = [string]$manifest.targets.$Rid.file - $output = Join-Path $nativeRoot "ghostty\$Rid\$fileName" - if ($Force -or -not (Test-Path -LiteralPath $output)) { - $ghosttyArgs = @{ - Rid = $Rid - ZigPath = $ZigPath - } - if ($Force) { - $ghosttyArgs.Force = $true - } - - & (Join-Path (Join-Path $nativeRoot "ghostty") "Build-Ghostty.ps1") @ghosttyArgs - if ($LASTEXITCODE -ne 0) { - throw "Build-Ghostty.ps1 failed for $Rid." - } - } - else { - Write-Host "Using existing $output" + if ($Force) { + $ptyArgs.Force = $true } - } - if ($buildPty) { - $output = Join-Path $nativeRoot "linux-pty\$Rid\dt-pty-host" - if ($Force -or -not (Test-Path -LiteralPath $output)) { - $ptyArgs = @{ - Rid = $Rid - ZigPath = $ZigPath - } - if ($Force) { - $ptyArgs.Force = $true - } - - & (Join-Path (Join-Path $nativeRoot "linux-pty") "Build-LinuxPtyHost.ps1") @ptyArgs - if ($LASTEXITCODE -ne 0) { - throw "Build-LinuxPtyHost.ps1 failed for $Rid." - } - } - else { - Write-Host "Using existing $output" + & (Join-Path (Join-Path $nativeRoot "linux-pty") "Build-LinuxPtyHost.ps1") @ptyArgs + if ($LASTEXITCODE -ne 0) { + throw "Build-LinuxPtyHost.ps1 failed for $Rid." } } + else { + Write-Host "Using existing $output" + } } finally { if ($acquired) { diff --git a/native/Zig.ps1 b/native/Zig.ps1 index a8db016..38efa9b 100644 --- a/native/Zig.ps1 +++ b/native/Zig.ps1 @@ -1,6 +1,12 @@ Set-StrictMode -Version Latest $ErrorActionPreference = "Stop" +# The Zig release that builds the native helpers, pinned in native/zig.json. +function Get-PinnedZigVersion { + $manifest = Get-Content -LiteralPath (Join-Path $PSScriptRoot "zig.json") -Raw | ConvertFrom-Json + return [string]$manifest.zig +} + function Test-HostWindows { return [System.Runtime.InteropServices.RuntimeInformation]::IsOSPlatform( [System.Runtime.InteropServices.OSPlatform]::Windows) diff --git a/native/ghostty/Build-Ghostty.ps1 b/native/ghostty/Build-Ghostty.ps1 deleted file mode 100644 index 7e9476f..0000000 --- a/native/ghostty/Build-Ghostty.ps1 +++ /dev/null @@ -1,265 +0,0 @@ -[CmdletBinding()] -param( - [string[]] $Rid = @(), - [string] $ZigPath, - [string] $SourceDirectory, - [string] $LlvmStripPath, - [switch] $InstallZig, - [switch] $SkipHashCheck, - [switch] $VerifyHash, - [switch] $SkipCopy, - [switch] $Force -) - -Set-StrictMode -Version Latest -$ErrorActionPreference = "Stop" - -. (Join-Path $PSScriptRoot "..\Zig.ps1") - -$nativeRoot = $PSScriptRoot -$manifestPath = Join-Path $nativeRoot "ghostty-upstream.json" -$manifest = Get-Content -LiteralPath $manifestPath -Raw | ConvertFrom-Json -$commit = [string]$manifest.commit -$zigVersion = [string]$manifest.zig -$dotnetRoot = [IO.Path]::GetFullPath((Join-Path $nativeRoot "..\..")) -$artifacts = Join-Path $dotnetRoot "artifacts" -$SkipHashCheck = $SkipHashCheck -or -not $VerifyHash - -function Resolve-LlvmStrip { - param([string] $Explicit) - - if (-not [string]::IsNullOrWhiteSpace($Explicit) -and (Test-Path -LiteralPath $Explicit)) { - return $Explicit - } - - $command = Get-Command llvm-strip -ErrorAction SilentlyContinue | Select-Object -First 1 - if ($null -ne $command) { - return $command.Source - } - - if ((Test-HostWindows) -and $null -ne $env:ProgramFiles) { - $vsStrip = Get-ChildItem -File -ErrorAction SilentlyContinue -Path ( - Join-Path $env:ProgramFiles "Microsoft Visual Studio\*\*\VC\Tools\Llvm\x64\bin\llvm-strip.exe" - ) | Select-Object -First 1 - if ($null -ne $vsStrip) { - return $vsStrip.FullName - } - } - - if (Test-HostMacOS) { - $xcrun = Get-Command xcrun -ErrorAction SilentlyContinue - if ($null -ne $xcrun) { - $fromXcode = & xcrun --find llvm-strip 2>$null - if ($LASTEXITCODE -eq 0 -and -not [string]::IsNullOrWhiteSpace($fromXcode)) { - return $fromXcode.Trim() - } - } - } - - $strip = Get-Command strip -ErrorAction SilentlyContinue | Select-Object -First 1 - if ($null -ne $strip) { - return $strip.Source - } - - return $null -} - -function Find-BuiltLibrary { - param( - [string] $Prefix, - [string] $FileName - ) - - $candidates = @( - (Join-Path $Prefix "bin\$FileName"), - (Join-Path $Prefix "lib\$FileName"), - (Join-Path $Prefix "lib\$FileName.0.1.0") - ) - foreach ($candidate in $candidates) { - if (Test-Path -LiteralPath $candidate) { - return $candidate - } - } - - $search = Get-ChildItem -LiteralPath (Join-Path $Prefix "lib") -ErrorAction SilentlyContinue | - Where-Object { $_.Name -like "$FileName*" } | - Select-Object -First 1 - if ($null -ne $search) { - return $search.FullName - } - - throw "Zig build finished but '$FileName' was not found under '$Prefix'." -} - -if ([string]::IsNullOrWhiteSpace($SourceDirectory)) { - $SourceDirectory = Join-Path $artifacts "ghostty-src" -} - -$ZigPath = Resolve-ZigPath -ZigPath $ZigPath -Version $zigVersion -DotnetRoot $dotnetRoot -Install - -$selected = @() -if ($Rid.Count -eq 0) { - $selected = @(Get-HostRid) -} -else { - foreach ($value in $Rid) { - if (-not (@($manifest.targets.PSObject.Properties.Name) -contains $value)) { - throw "Unknown RID '$value'. Known: $($manifest.targets.PSObject.Properties.Name -join ', ')." - } - - $selected += $value - } -} - -$pending = @() -foreach ($currentRid in $selected) { - $fileName = [string]$manifest.targets.$currentRid.file - $destination = Join-Path $nativeRoot "$currentRid\$fileName" - if (-not $Force -and -not $SkipCopy -and (Test-Path -LiteralPath $destination)) { - Write-Host "Skipping $currentRid (already built)" - continue - } - - $pending += $currentRid -} - -if ($pending.Count -eq 0) { - return -} - -$selected = $pending - -if (-not (Test-Path -LiteralPath (Join-Path $SourceDirectory ".git"))) { - New-Item -ItemType Directory -Force -Path (Split-Path -Parent $SourceDirectory) | Out-Null - & git clone --filter=blob:none $manifest.repository $SourceDirectory - if ($LASTEXITCODE -ne 0) { - throw "Failed to clone Ghostty." - } -} - -& git -C $SourceDirectory fetch origin $commit -& git -C $SourceDirectory checkout --detach $commit -if ($LASTEXITCODE -ne 0) { - throw "Failed to check out Ghostty commit $commit." -} - -$hashes = [ordered]@{} -foreach ($currentRid in $selected) { - $target = $manifest.targets.$currentRid - $zigTarget = [string]$target.zigTarget - $fileName = [string]$target.file - $prefix = Join-Path $artifacts "ghostty-native\$currentRid" - - if ($currentRid.StartsWith("osx-", [StringComparison]::Ordinal) -and (Test-HostMacOS)) { - $sdk = & xcrun --show-sdk-path - if ($LASTEXITCODE -ne 0 -or [string]::IsNullOrWhiteSpace($sdk)) { - throw "xcrun --show-sdk-path failed; Xcode CLT is required for macOS libghostty-vt." - } - - $env:SDKROOT = $sdk.Trim() - } - - if (Test-Path -LiteralPath $prefix) { - Remove-Item -LiteralPath $prefix -Recurse -Force - } - - Push-Location $SourceDirectory - try { - & $ZigPath build ` - -Demit-lib-vt=true ` - "-Dtarget=$zigTarget" ` - "-Doptimize=$($manifest.optimize)" ` - -Dstrip=true ` - -Dsimd=true ` - --prefix $prefix - if ($LASTEXITCODE -ne 0) { - throw "Ghostty build failed for $currentRid." - } - } - finally { - Pop-Location - } - - $built = Find-BuiltLibrary -Prefix $prefix -FileName $fileName - $linux = $currentRid.StartsWith("linux-", [StringComparison]::Ordinal) - if ($linux -and $manifest.strip) { - $stripTool = Resolve-LlvmStrip -Explicit $LlvmStripPath - if ($currentRid -eq "linux-arm64" -and - [System.Runtime.InteropServices.RuntimeInformation]::OSArchitecture -ne "Arm64") { - $crossStrip = Get-Command aarch64-linux-gnu-strip -ErrorAction SilentlyContinue | - Select-Object -First 1 - if ($null -ne $crossStrip) { - $stripTool = $crossStrip.Source - } - elseif ($stripTool -and - [IO.Path]::GetFileNameWithoutExtension($stripTool) -eq "strip") { - $stripTool = $null - } - } - if ([string]::IsNullOrWhiteSpace($stripTool)) { - $requiresHash = @($target.PSObject.Properties.Name) -contains "sha256" -and - -not [string]::IsNullOrWhiteSpace([string]$target.sha256) - if (-not $SkipHashCheck -and $requiresHash) { - throw "llvm-strip/strip was not found. Pass -LlvmStripPath or -SkipHashCheck." - } - } - else { - $stripped = "$built.stripped" - & $stripTool --strip-debug -o $stripped $built - if ($LASTEXITCODE -ne 0) { - throw "Failed to strip debug data from $currentRid." - } - - $built = $stripped - } - } - - $sha = [System.Security.Cryptography.SHA256]::Create() - try { - $stream = [IO.File]::OpenRead($built) - try { - $hash = [BitConverter]::ToString($sha.ComputeHash($stream)).Replace("-", "") - } - finally { - $stream.Dispose() - } - } - finally { - $sha.Dispose() - } - $expected = $null - if (@($target.PSObject.Properties.Name) -contains "sha256") { - $expected = [string]$target.sha256 - } - - if (-not $SkipHashCheck -and -not [string]::IsNullOrWhiteSpace($expected) -and $hash -ne $expected) { - throw "Unexpected $currentRid hash $hash. Expected $expected." - } - - $hashes[$currentRid] = $hash - Write-Host "$currentRid SHA-256 $hash" - - if (-not $SkipCopy) { - $destinationDir = Join-Path $nativeRoot $currentRid - New-Item -ItemType Directory -Force -Path $destinationDir | Out-Null - $destination = Join-Path $destinationDir $fileName - Copy-Item -LiteralPath $built -Destination $destination -Force - Set-Content -LiteralPath "$destination.sha256" -Value $hash - } -} - -if ($env:GITHUB_STEP_SUMMARY) { - $lines = @( - "## libghostty-vt", - "", - "Commit ``$commit`` built with Zig $zigVersion.", - "", - "| RID | SHA-256 |", - "| --- | --- |" - ) - foreach ($entry in $hashes.GetEnumerator()) { - $lines += "| ``$($entry.Key)`` | ``$($entry.Value)`` |" - } - - Add-Content -LiteralPath $env:GITHUB_STEP_SUMMARY -Value ($lines -join "`n") -} diff --git a/native/ghostty/LICENSE b/native/ghostty/LICENSE deleted file mode 100644 index 0a07a66..0000000 --- a/native/ghostty/LICENSE +++ /dev/null @@ -1,21 +0,0 @@ -MIT License - -Copyright (c) 2024 Mitchell Hashimoto, Ghostty contributors - -Permission is hereby granted, free of charge, to any person obtaining a copy -of this software and associated documentation files (the "Software"), to deal -in the Software without restriction, including without limitation the rights -to use, copy, modify, merge, publish, distribute, sublicense, and/or sell -copies of the Software, and to permit persons to whom the Software is -furnished to do so, subject to the following conditions: - -The above copyright notice and this permission notice shall be included in all -copies or substantial portions of the Software. - -THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, -OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE -SOFTWARE. diff --git a/native/ghostty/README.md b/native/ghostty/README.md deleted file mode 100644 index 7bdf0bd..0000000 --- a/native/ghostty/README.md +++ /dev/null @@ -1,37 +0,0 @@ -# libghostty-vt native assets - -The optional Ghostty terminal engine uses `libghostty-vt` built from -[`ghostty-upstream.json`](ghostty-upstream.json): - -- repository: `https://github.com/ghostty-org/ghostty` -- commit: `3c1ef5b32fc5ea6b93d28493fabf193f595139cf` -- Zig: `0.16.0` -- configuration: `ReleaseFast`, SIMD enabled -- license: MIT (see `LICENSE`) - -Binaries are **not** committed. `dotnet build` / `dotnet test` restore the host -RID via [`../Restore-NativeLibraries.ps1`](../Restore-NativeLibraries.ps1), -which downloads Zig 0.16.0 if needed and compiles `libghostty-vt` into -`native/ghostty//` (gitignored). A `zig` on `PATH` is used only when it is -Zig 0.16.x; any other version (for example a newer system install) is ignored -and the pinned Zig is downloaded into `artifacts/tools` instead. ABI checks use the type manifest, not a -file hash. - -Linux: glibc **2.31+**. macOS: **13.0+** (`aarch64-macos.13.0` / -`x86_64-macos.13.0`). Windows: MSVC. - -## Rebuild locally - -```powershell -# Host RID (also run automatically by MSBuild) -./native/Restore-NativeLibraries.ps1 - -# One RID -./native/ghostty/Build-Ghostty.ps1 -Rid win-x64 -``` - -The script clones the pinned commit into `artifacts/ghostty-src`. - -`build-ghostty.yml` still builds every RID and uploads `libghostty-vt` artifacts. -Optional fast path: download that artifact and run -`Restore-GhosttyNative.ps1 -SourceDirectory artifacts/libghostty-vt`. diff --git a/native/ghostty/Restore-GhosttyNative.ps1 b/native/ghostty/Restore-GhosttyNative.ps1 deleted file mode 100644 index 7a9938e..0000000 --- a/native/ghostty/Restore-GhosttyNative.ps1 +++ /dev/null @@ -1,62 +0,0 @@ -[CmdletBinding()] -param( - [Parameter(Mandatory)] - [string] $SourceDirectory, - [string] $Rid, - [string] $NativeRoot = $PSScriptRoot -) - -Set-StrictMode -Version Latest -$ErrorActionPreference = "Stop" - -$manifest = Get-Content -LiteralPath (Join-Path $NativeRoot "ghostty-upstream.json") -Raw | - ConvertFrom-Json - -function Copy-Rid { - param( - [string] $CurrentRid, - [string] $From - ) - - $fileName = [string]$manifest.targets.$CurrentRid.file - if ([string]::IsNullOrWhiteSpace($fileName)) { - throw "Unknown Ghostty RID '$CurrentRid'." - } - - $source = Join-Path $From $fileName - if (-not (Test-Path -LiteralPath $source)) { - $nested = Join-Path $From "$CurrentRid\$fileName" - if (Test-Path -LiteralPath $nested) { - $source = $nested - } - else { - throw "Missing '$fileName' under '$From'." - } - } - - $destinationDir = Join-Path $NativeRoot $CurrentRid - New-Item -ItemType Directory -Force -Path $destinationDir | Out-Null - Copy-Item -LiteralPath $source -Destination (Join-Path $destinationDir $fileName) -Force - $hashFile = "$source.sha256" - if (Test-Path -LiteralPath $hashFile) { - Copy-Item -LiteralPath $hashFile -Destination (Join-Path $destinationDir "$fileName.sha256") -Force - } -} - -if (-not [string]::IsNullOrWhiteSpace($Rid)) { - Copy-Rid -CurrentRid $Rid -From $SourceDirectory - return -} - -$copied = 0 -foreach ($property in $manifest.targets.PSObject.Properties) { - $candidate = Join-Path $SourceDirectory $property.Name - if (Test-Path -LiteralPath $candidate) { - Copy-Rid -CurrentRid $property.Name -From $candidate - $copied++ - } -} - -if ($copied -eq 0) { - throw "No libghostty-vt RID folders were found under '$SourceDirectory'." -} diff --git a/native/ghostty/ghostty-upstream.json b/native/ghostty/ghostty-upstream.json deleted file mode 100644 index 7165d60..0000000 --- a/native/ghostty/ghostty-upstream.json +++ /dev/null @@ -1,34 +0,0 @@ -{ - "repository": "https://github.com/ghostty-org/ghostty.git", - "commit": "3c1ef5b32fc5ea6b93d28493fabf193f595139cf", - "zig": "0.16.0", - "optimize": "ReleaseFast", - "simd": true, - "strip": true, - "targets": { - "win-x64": { - "zigTarget": "x86_64-windows-msvc", - "file": "ghostty-vt.dll" - }, - "win-arm64": { - "zigTarget": "aarch64-windows-msvc", - "file": "ghostty-vt.dll" - }, - "linux-x64": { - "zigTarget": "x86_64-linux-gnu.2.31", - "file": "libghostty-vt.so" - }, - "linux-arm64": { - "zigTarget": "aarch64-linux-gnu.2.31", - "file": "libghostty-vt.so" - }, - "osx-arm64": { - "zigTarget": "aarch64-macos.13.0", - "file": "libghostty-vt.dylib" - }, - "osx-x64": { - "zigTarget": "x86_64-macos.13.0", - "file": "libghostty-vt.dylib" - } - } -} diff --git a/native/linux-pty/Build-LinuxPtyHost.ps1 b/native/linux-pty/Build-LinuxPtyHost.ps1 index 218c120..2c807b0 100644 --- a/native/linux-pty/Build-LinuxPtyHost.ps1 +++ b/native/linux-pty/Build-LinuxPtyHost.ps1 @@ -12,9 +12,7 @@ $ErrorActionPreference = "Stop" $nativeRoot = $PSScriptRoot $dotnetRoot = [IO.Path]::GetFullPath((Join-Path $nativeRoot "..\..")) -$manifest = Get-Content -LiteralPath (Join-Path $nativeRoot "..\ghostty\ghostty-upstream.json") -Raw | - ConvertFrom-Json -$ZigPath = Resolve-ZigPath -ZigPath $ZigPath -Version ([string]$manifest.zig) -DotnetRoot $dotnetRoot -Install +$ZigPath = Resolve-ZigPath -ZigPath $ZigPath -Version (Get-PinnedZigVersion) -DotnetRoot $dotnetRoot -Install $targets = [ordered]@{ "linux-x64" = "x86_64-linux-gnu.2.31" diff --git a/native/zig.json b/native/zig.json new file mode 100644 index 0000000..4a03ce1 --- /dev/null +++ b/native/zig.json @@ -0,0 +1,3 @@ +{ + "zig": "0.16.0" +} diff --git a/scripts/Build-LinuxPackage.sh b/scripts/Build-LinuxPackage.sh index 11310c4..ec832e6 100644 --- a/scripts/Build-LinuxPackage.sh +++ b/scripts/Build-LinuxPackage.sh @@ -115,7 +115,7 @@ else fi find "$publish_dir" -type f \( -name '*.dbg' -o -name '*.pdb' \) -delete -for artifact in Devolutions.Terminal dt dt-pty-host libghostty-vt.so libSkiaSharp.so libHarfBuzzSharp.so; do +for artifact in Devolutions.Terminal dt dt-pty-host libSkiaSharp.so libHarfBuzzSharp.so; do [[ -f "$publish_dir/$artifact" ]] || { echo "Publish output is missing $artifact." >&2; exit 70; } if [[ "$artifact" == *.so || "$artifact" != "lib"* ]]; then diff --git a/scripts/Build-MacOsPackage.ps1 b/scripts/Build-MacOsPackage.ps1 index da1aada..da56479 100644 --- a/scripts/Build-MacOsPackage.ps1 +++ b/scripts/Build-MacOsPackage.ps1 @@ -86,7 +86,7 @@ try { Remove-Item -Force $artifacts = @( - $metadata.EXECUTABLE_NAME, $metadata.CLI_NAME, $metadata.PTY_HOST_NAME, $metadata.GHOSTTY_LIBRARY, + $metadata.EXECUTABLE_NAME, $metadata.CLI_NAME, $metadata.PTY_HOST_NAME, 'libSkiaSharp.dylib', 'libHarfBuzzSharp.dylib' ) foreach ($artifact in $artifacts) { @@ -126,8 +126,7 @@ try { "$base.zip", "$($metadata.BUNDLE_NAME)/Contents/MacOS/$($metadata.EXECUTABLE_NAME)", "$($metadata.BUNDLE_NAME)/Contents/MacOS/$($metadata.CLI_NAME)", - "$($metadata.BUNDLE_NAME)/Contents/MacOS/$($metadata.PTY_HOST_NAME)", - "$($metadata.BUNDLE_NAME)/Contents/MacOS/$($metadata.GHOSTTY_LIBRARY)" + "$($metadata.BUNDLE_NAME)/Contents/MacOS/$($metadata.PTY_HOST_NAME)" ) $manifest = Get-Sha256Manifest -WorkingDirectory $OutputDir -Path $manifestPaths Set-Content -LiteralPath (Join-Path $OutputDir "$base.sha256") -Value $manifest -Encoding utf8 diff --git a/scripts/Invoke-TerminalBenchmarks.ps1 b/scripts/Invoke-TerminalBenchmarks.ps1 index f3d0d7f..91c4b39 100644 --- a/scripts/Invoke-TerminalBenchmarks.ps1 +++ b/scripts/Invoke-TerminalBenchmarks.ps1 @@ -1,6 +1,6 @@ [CmdletBinding()] param( - # vt: builtin vs Ghostty ingest; render: frame prep + CPU raster; control: PTY->UI coalescing. + # vt: builtin ingest vs an in-run clock control; render: frame prep + CPU raster; control: PTY->UI coalescing. [string[]] $Suite = @("vt", "render"), # Smaller corpora and fewer runs for a fast local signal (not for published numbers). @@ -25,14 +25,7 @@ param( # Laptop CPUs drop out of turbo a few seconds into sustained load; samples of # ~3000 ms all run at the sustained clock, so medians and spreads stay stable. [ValidateRange(0, 600000)] - [int] $MinMs = 0, - - # Upstream ghostty-bench built from the pinned commit (see docs/performance-benchmarks.md). - # When given, every vt workload is also replayed through `+terminal-stream`. - [string] $GhosttyBench, - - # Fail when builtin is not faster than raw libghostty-vt on every workload. - [switch] $RequireBeatGhostty + [int] $MinMs = 0 ) Set-StrictMode -Version Latest @@ -111,12 +104,8 @@ if ($Suite -contains "vt") { $vtArgs += @("--min-ms", $MinMs) } - if (-not $RequireBeatGhostty) { - $vtArgs += "--no-gate" - } - if ((Invoke-Bench -Name "vt-$name" -Arguments $vtArgs) -ne 0) { - $failures.Add("builtin did not beat ghostty-vt on vt workload '$name'") + $failures.Add("vt benchmark failed on workload '$name'") } $report = Get-Content -LiteralPath $partial -Raw | ConvertFrom-Json @@ -130,33 +119,6 @@ if ($Suite -contains "vt") { $merged | ConvertTo-Json -Depth 8 | Set-Content -LiteralPath $vtJson - if ($GhosttyBench) { - $corpora = Join-Path $OutputDirectory "corpora" - Invoke-Bench -Name "vt-export" -Arguments @("vt", "--mb", $megabytes, "--workloads", $Workloads, "--export", $corpora) | Out-Null - $upstream = foreach ($file in Get-ChildItem -LiteralPath $corpora -Filter "*.vt") { - $times = foreach ($run in 1..$runs) { - $watch = [System.Diagnostics.Stopwatch]::StartNew() - & $GhosttyBench "+terminal-stream" "--data=$($file.FullName)" "--terminal-rows=30" "--terminal-cols=120" | Out-Null - $watch.Stop() - if ($LASTEXITCODE -ne 0) { - throw "ghostty-bench failed on $($file.Name)." - } - - $watch.Elapsed.TotalSeconds - } - - $sorted = @($times | Sort-Object) - $median = $sorted[[int][Math]::Floor($sorted.Count / 2)] - [pscustomobject]@{ - Workload = $file.BaseName - MedianMBps = [Math]::Round($file.Length / 1MB / $median, 1) - Note = "process wall time, includes startup and file load" - } - } - - $upstream | Format-Table -AutoSize | Out-String | Tee-Object -FilePath (Join-Path $OutputDirectory "ghostty-bench.txt") - $upstream | ConvertTo-Json | Set-Content -LiteralPath (Join-Path $OutputDirectory "ghostty-bench.json") - } if ($Baseline) { $previous = Get-Content -LiteralPath $Baseline -Raw | ConvertFrom-Json @@ -183,13 +145,12 @@ if ($Suite -contains "vt") { } # Absolute MB/s drifts with clocks and background load between sessions, so - # gate on builtin relative to ghostty-vt measured in the same run: Ghostty is - # the control, and only the engine under our control can regress. - $oldRatio = $before.PSObject.Properties["builtinVsGhosttyVt"] - $newRatio = $workload.PSObject.Properties["builtinVsGhosttyVt"] + # gate on builtin relative to the clock control measured in the same run. + $oldRatio = $before.PSObject.Properties["builtinVsControl"] + $newRatio = $workload.PSObject.Properties["builtinVsControl"] if ($oldRatio -and $newRatio -and $oldRatio.Value -gt 0) { $change = ($newRatio.Value - $oldRatio.Value) / $oldRatio.Value * 100 - $line = "{0,-14} builtin/ghostty-vt {1:F3} -> {2:F3} ({3:+0.0;-0.0}%)" -f $workload.name, $oldRatio.Value, $newRatio.Value, $change + $line = "{0,-14} builtin/control {1:F4} -> {2:F4} ({3:+0.0;-0.0}%)" -f $workload.name, $oldRatio.Value, $newRatio.Value, $change if ($change -lt -$RegressionPercent) { $failures.Add("regression: $line") Write-Host "$line REGRESSION" diff --git a/scripts/Release-MacOsPackage.ps1 b/scripts/Release-MacOsPackage.ps1 index 08782aa..1b352b3 100644 --- a/scripts/Release-MacOsPackage.ps1 +++ b/scripts/Release-MacOsPackage.ps1 @@ -131,8 +131,7 @@ try { "$base.dmg", "$($metadata.BUNDLE_NAME)/Contents/MacOS/$($metadata.EXECUTABLE_NAME)", "$($metadata.BUNDLE_NAME)/Contents/MacOS/$($metadata.CLI_NAME)", - "$($metadata.BUNDLE_NAME)/Contents/MacOS/$($metadata.PTY_HOST_NAME)", - "$($metadata.BUNDLE_NAME)/Contents/MacOS/$($metadata.GHOSTTY_LIBRARY)" + "$($metadata.BUNDLE_NAME)/Contents/MacOS/$($metadata.PTY_HOST_NAME)" ) $manifest = Get-Sha256Manifest -WorkingDirectory $OutputDir -Path $manifestPaths Set-Content -LiteralPath (Join-Path $OutputDir "$base.sha256") -Value $manifest -Encoding utf8 diff --git a/scripts/Stage-LinuxPackage.sh b/scripts/Stage-LinuxPackage.sh index 4980f6e..5e6c849 100644 --- a/scripts/Stage-LinuxPackage.sh +++ b/scripts/Stage-LinuxPackage.sh @@ -41,10 +41,8 @@ required=( Devolutions.Terminal dt dt-pty-host - libghostty-vt.so libSkiaSharp.so libHarfBuzzSharp.so - THIRD-PARTY-NOTICES-GHOSTTY.txt THIRD-PARTY-NOTICES-NOTO-EMOJI.txt ) for path in "${required[@]}"; do @@ -78,8 +76,6 @@ install -d "$package_root/usr/bin" "$package_root/usr/share/doc/$PACKAGE_NAME" ln -s "../../${INSTALL_DIR#/}/Devolutions.Terminal" "$package_root/usr/bin/Devolutions.Terminal" ln -s "../../${INSTALL_DIR#/}/dt" "$package_root/usr/bin/dt" install -m 0644 "$repo_root/LICENSE" "$package_root/usr/share/doc/$PACKAGE_NAME/LICENSE" -install -m 0644 "$publish_dir/THIRD-PARTY-NOTICES-GHOSTTY.txt" \ - "$package_root/usr/share/doc/$PACKAGE_NAME/THIRD-PARTY-NOTICES-GHOSTTY.txt" find "$package_root" -type d -exec chmod 0755 {} + find "$package_root" -type f -exec chmod 0644 {} + diff --git a/scripts/Stage-MacOsApp.ps1 b/scripts/Stage-MacOsApp.ps1 index 29305b5..848bb77 100644 --- a/scripts/Stage-MacOsApp.ps1 +++ b/scripts/Stage-MacOsApp.ps1 @@ -59,10 +59,8 @@ $required = @( $metadata.EXECUTABLE_NAME, $metadata.CLI_NAME, $metadata.PTY_HOST_NAME, - $metadata.GHOSTTY_LIBRARY, 'libSkiaSharp.dylib', 'libHarfBuzzSharp.dylib', - 'THIRD-PARTY-NOTICES-GHOSTTY.txt', 'THIRD-PARTY-NOTICES-NOTO-EMOJI.txt' ) foreach ($path in $required) { diff --git a/scripts/Stage-MacOsNuGetPayload.ps1 b/scripts/Stage-MacOsNuGetPayload.ps1 index 363318c..dae2ebf 100644 --- a/scripts/Stage-MacOsNuGetPayload.ps1 +++ b/scripts/Stage-MacOsNuGetPayload.ps1 @@ -34,7 +34,7 @@ New-Item -ItemType Directory -Path $OutputDirectory -Force | Out-Null # Info.plist/resource seals cannot follow an executable out of its bundle. $binaries = @( $metadata.EXECUTABLE_NAME, $metadata.CLI_NAME, $metadata.PTY_HOST_NAME, - $metadata.GHOSTTY_LIBRARY, 'libAvaloniaNative.dylib', 'libSkiaSharp.dylib', 'libHarfBuzzSharp.dylib' + 'libAvaloniaNative.dylib', 'libSkiaSharp.dylib', 'libHarfBuzzSharp.dylib' ) foreach ($name in $binaries) { $source = Join-Path $AppPath "Contents/MacOS/$name" diff --git a/scripts/Test-ConPtyPublishLayout.ps1 b/scripts/Test-ConPtyPublishLayout.ps1 index d6bb46c..1b98088 100644 --- a/scripts/Test-ConPtyPublishLayout.ps1 +++ b/scripts/Test-ConPtyPublishLayout.ps1 @@ -16,7 +16,7 @@ try { - + @@ -34,7 +34,7 @@ try { if ($hosts.Count -ne $expectedHosts -or $items.Count -ne $expectedHosts + 3) { throw "Unexpected publish layout for '$rid': expected $expectedHosts Windows hosts and 3 retained files, got $($hosts.Count) hosts and $($items.Count) files." } - foreach ($retained in @('THIRD-PARTY-NOTICES-CONPTY.txt', 'dt', 'libghostty-vt.dylib')) { + foreach ($retained in @('THIRD-PARTY-NOTICES-CONPTY.txt', 'dt', 'dt-pty-host')) { if (@($items | Where-Object { "$($_.Filename)$($_.Extension)" -ceq $retained }).Count -ne 1) { throw "Publish layout for '$rid' lost $retained." } diff --git a/scripts/Test-LinuxArm64Runtime.sh b/scripts/Test-LinuxArm64Runtime.sh index 6ad7a55..b2b13f4 100644 --- a/scripts/Test-LinuxArm64Runtime.sh +++ b/scripts/Test-LinuxArm64Runtime.sh @@ -203,9 +203,7 @@ run_tests Devolutions.Terminal.Cli.Tests \ 'FullyQualifiedName~Devolutions.Terminal.Cli.Tests.CliParserTests' run_tests Devolutions.Terminal.Core.Tests \ 'FullyQualifiedName~Devolutions.Terminal.Core.Tests.VtParserTests' -run_packaged_native_tests Devolutions.Terminal.Ghostty.Tests libghostty-vt.so \ - "$artifact_root$INSTALL_DIR/libghostty-vt.so" \ - 'FullyQualifiedName~Devolutions.Terminal.Ghostty.Tests.GhosttyTerminalEngineTests' + run_packaged_native_tests Devolutions.Terminal.Connection.Tests dt-pty-host \ "$artifact_root$INSTALL_DIR/dt-pty-host" \ 'FullyQualifiedName~Devolutions.Terminal.Connection.Tests.LinuxPtyConnectionTests' diff --git a/scripts/Test-LinuxPackage.sh b/scripts/Test-LinuxPackage.sh index 920a2b0..f0ece9e 100644 --- a/scripts/Test-LinuxPackage.sh +++ b/scripts/Test-LinuxPackage.sh @@ -103,7 +103,7 @@ validate_rpm_metadata() { grep -E -- "^-rwxr-xr-x .* /opt/devolutions-terminal/$executable$" \ <<<"$listing" >/dev/null done - for library in libSkiaSharp.so libHarfBuzzSharp.so libghostty-vt.so; do + for library in libSkiaSharp.so libHarfBuzzSharp.so; do grep -E -- "^-rw-r--r-- .* /opt/devolutions-terminal/$library$" \ <<<"$listing" >/dev/null done @@ -145,7 +145,7 @@ PY local listing="$work/appimage-listing" unsquashfs -lln -o "$(cat "$work/appimage-offset")" "$package" > "$listing" grep -E -- '^-rwxr-xr-x .* squashfs-root/opt/devolutions-terminal/(Devolutions.Terminal|dt|dt-pty-host)$' "$listing" >/dev/null - grep -E -- '^-rw-r--r-- .* squashfs-root/opt/devolutions-terminal/lib(SkiaSharp|HarfBuzzSharp|ghostty-vt)\.so$' "$listing" >/dev/null + grep -E -- '^-rw-r--r-- .* squashfs-root/opt/devolutions-terminal/lib(SkiaSharp|HarfBuzzSharp)\.so$' "$listing" >/dev/null unsquashfs -no-progress -o "$(cat "$work/appimage-offset")" -d "$root" "$package" >/dev/null local runtime_size runtime_size="$(cat "$work/appimage-offset")" @@ -281,8 +281,7 @@ validate_root() { for path in \ "$app/Devolutions.Terminal" "$app/dt" "$app/dt-pty-host" \ - "$app/libghostty-vt.so" "$app/libSkiaSharp.so" "$app/libHarfBuzzSharp.so" \ - "$app/THIRD-PARTY-NOTICES-GHOSTTY.txt" \ + "$app/libSkiaSharp.so" "$app/libHarfBuzzSharp.so" \ "$root/usr/share/doc/$PACKAGE_NAME/LICENSE" \ "$root/usr/share/doc/$PACKAGE_NAME/inventory.json" \ "$root/usr/share/doc/$PACKAGE_NAME/sbom.spdx.json" \ diff --git a/scripts/Test-MacOsLegalNotices.ps1 b/scripts/Test-MacOsLegalNotices.ps1 index b928a37..a675873 100644 --- a/scripts/Test-MacOsLegalNotices.ps1 +++ b/scripts/Test-MacOsLegalNotices.ps1 @@ -19,7 +19,6 @@ try { [IO.File]::WriteAllBytes($codePath, [byte[]](0xCF, 0xFA, 0xED, 0xFE)) $codeHash = (Get-FileHash -LiteralPath $codePath).Hash $notices = @( - 'THIRD-PARTY-NOTICES-GHOSTTY.txt', 'THIRD-PARTY-NOTICES-NOTO-EMOJI.txt', 'THIRD-PARTY-NOTICES-CONPTY.txt', 'THIRD-PARTY-NOTICES-FUTURE-DEPENDENCY.txt' diff --git a/scripts/Test-MacOsNuGetPayload.ps1 b/scripts/Test-MacOsNuGetPayload.ps1 index 5a1b283..7ee3dbc 100644 --- a/scripts/Test-MacOsNuGetPayload.ps1 +++ b/scripts/Test-MacOsNuGetPayload.ps1 @@ -21,7 +21,7 @@ if ($RequireDeveloperId) { } $binaries = @( $metadata.EXECUTABLE_NAME, $metadata.CLI_NAME, $metadata.PTY_HOST_NAME, - $metadata.GHOSTTY_LIBRARY, 'libAvaloniaNative.dylib', 'libSkiaSharp.dylib', 'libHarfBuzzSharp.dylib' + 'libAvaloniaNative.dylib', 'libSkiaSharp.dylib', 'libHarfBuzzSharp.dylib' ) foreach ($name in $binaries) { $path = Join-Path $PayloadDirectory $name @@ -49,7 +49,7 @@ foreach ($name in $binaries) { } } } -foreach ($name in @('LICENSE', 'THIRD-PARTY-NOTICES-CONPTY.txt', 'THIRD-PARTY-NOTICES-GHOSTTY.txt', 'THIRD-PARTY-NOTICES-NOTO-EMOJI.txt')) { +foreach ($name in @('LICENSE', 'THIRD-PARTY-NOTICES-CONPTY.txt', 'THIRD-PARTY-NOTICES-NOTO-EMOJI.txt')) { if (-not (Test-Path -LiteralPath (Join-Path $PayloadDirectory $name) -PathType Leaf)) { throw "NuGet payload is missing $name." } diff --git a/scripts/Test-MacOsNuGetSigning.ps1 b/scripts/Test-MacOsNuGetSigning.ps1 index b96c867..7b96902 100644 --- a/scripts/Test-MacOsNuGetSigning.ps1 +++ b/scripts/Test-MacOsNuGetSigning.ps1 @@ -22,7 +22,7 @@ try { Invoke-Native -FilePath clang -ArgumentList '-arch', (Get-MacOsExpectedArch -Rid $rid), $fixtureSource, '-o', $fixtureBinary foreach ($name in @( $metadata.EXECUTABLE_NAME, $metadata.CLI_NAME, $metadata.PTY_HOST_NAME, - $metadata.GHOSTTY_LIBRARY, 'libAvaloniaNative.dylib', 'libSkiaSharp.dylib', 'libHarfBuzzSharp.dylib' + 'libAvaloniaNative.dylib', 'libSkiaSharp.dylib', 'libHarfBuzzSharp.dylib' )) { Invoke-Native -FilePath cp -ArgumentList $fixtureBinary, (Join-Path $code $name) } @@ -34,7 +34,7 @@ try { CFBundlePackageTypeAPPL "@ | Set-Content (Join-Path $app 'Contents/Info.plist') -Encoding utf8 - foreach ($name in @('LICENSE', 'THIRD-PARTY-NOTICES-CONPTY.txt', 'THIRD-PARTY-NOTICES-GHOSTTY.txt', 'THIRD-PARTY-NOTICES-NOTO-EMOJI.txt')) { + foreach ($name in @('LICENSE', 'THIRD-PARTY-NOTICES-CONPTY.txt', 'THIRD-PARTY-NOTICES-NOTO-EMOJI.txt')) { Set-Content (Join-Path $resources $name) "fixture license $name" -NoNewline } & (Join-Path $PSScriptRoot 'Sign-MacOsPackage.ps1') $app '-' diff --git a/scripts/Test-MacOsPackage.ps1 b/scripts/Test-MacOsPackage.ps1 index 629caf7..7f942da 100644 --- a/scripts/Test-MacOsPackage.ps1 +++ b/scripts/Test-MacOsPackage.ps1 @@ -61,10 +61,9 @@ function Test-MacOsAppBundle { (Join-Path $macosDir $metadata.EXECUTABLE_NAME), (Join-Path $macosDir $metadata.CLI_NAME), (Join-Path $macosDir $metadata.PTY_HOST_NAME), - (Join-Path $macosDir $metadata.GHOSTTY_LIBRARY), (Join-Path $macosDir 'libSkiaSharp.dylib'), (Join-Path $macosDir 'libHarfBuzzSharp.dylib'), - (Join-Path $App 'Contents/Resources/THIRD-PARTY-NOTICES-GHOSTTY.txt'), + (Join-Path $App 'Contents/Resources/THIRD-PARTY-NOTICES-NOTO-EMOJI.txt'), (Join-Path $App 'Contents/Resources/LICENSE'), $plist, @@ -136,7 +135,7 @@ function Test-MacOsAppBundle { } foreach ($binary in @( - $metadata.EXECUTABLE_NAME, $metadata.CLI_NAME, $metadata.PTY_HOST_NAME, $metadata.GHOSTTY_LIBRARY, + $metadata.EXECUTABLE_NAME, $metadata.CLI_NAME, $metadata.PTY_HOST_NAME, 'libSkiaSharp.dylib', 'libHarfBuzzSharp.dylib' )) { $binaryPath = Join-Path $macosDir $binary diff --git a/scripts/Test-MacOsPackagingMetadata.ps1 b/scripts/Test-MacOsPackagingMetadata.ps1 index 228169d..f7b154a 100644 --- a/scripts/Test-MacOsPackagingMetadata.ps1 +++ b/scripts/Test-MacOsPackagingMetadata.ps1 @@ -63,7 +63,6 @@ Assert-Equal -Actual $metadata.APPLE_TEAM_ID -Expected 'N592S9ASDB' -Name 'APPLE Assert-Equal -Actual $metadata.EXECUTABLE_NAME -Expected 'Devolutions.Terminal' -Name 'EXECUTABLE_NAME' Assert-Equal -Actual $metadata.CLI_NAME -Expected 'dt' -Name 'CLI_NAME' Assert-Equal -Actual $metadata.PTY_HOST_NAME -Expected 'dt-pty-host' -Name 'PTY_HOST_NAME' -Assert-Equal -Actual $metadata.GHOSTTY_LIBRARY -Expected 'libghostty-vt.dylib' -Name 'GHOSTTY_LIBRARY' Assert-Equal -Actual $metadata.ICON_NAME -Expected 'DevolutionsTerminal' -Name 'ICON_NAME' Assert-Equal -Actual $metadata.MACOS_DEPLOYMENT_TARGET -Expected '13.0' -Name 'MACOS_DEPLOYMENT_TARGET' Assert-Equal -Actual $metadata.LICENSE_ID -Expected 'MIT AND OFL-1.1' -Name 'LICENSE_ID' diff --git a/scripts/Test-MacOsRuntime.ps1 b/scripts/Test-MacOsRuntime.ps1 index 8832bf8..4869593 100644 --- a/scripts/Test-MacOsRuntime.ps1 +++ b/scripts/Test-MacOsRuntime.ps1 @@ -4,7 +4,7 @@ .SYNOPSIS Runs native, non-UI runtime gates for the host macOS architecture: a packaged `dt`/`dt-pty-host` smoke test plus targeted unit tests against - the packaged Ghostty and PTY-host native libraries. + the packaged PTY host. .PARAMETER PackageDir Directory containing the packaged zip(s) (defaults to artifacts/packages). @@ -171,9 +171,7 @@ try { -Filter 'FullyQualifiedName~Devolutions.Terminal.Cli.Tests.CliParserTests' Invoke-DotnetTest -Project 'Devolutions.Terminal.Core.Tests' ` -Filter 'FullyQualifiedName~Devolutions.Terminal.Core.Tests.VtParserTests' - Invoke-PackagedNativeTest -Project 'Devolutions.Terminal.Ghostty.Tests' -NativeName 'libghostty-vt.dylib' ` - -PackagedNative (Join-Path $macosDir $metadata.GHOSTTY_LIBRARY) ` - -Filter 'FullyQualifiedName~Devolutions.Terminal.Ghostty.Tests.GhosttyTerminalEngineTests' + Invoke-PackagedNativeTest -Project 'Devolutions.Terminal.Connection.Tests' -NativeName 'dt-pty-host' ` -PackagedNative (Join-Path $macosDir $metadata.PTY_HOST_NAME) ` -Filter 'FullyQualifiedName~Devolutions.Terminal.Connection.Tests.LinuxPtyConnectionTests' diff --git a/src/Devolutions.Terminal.App/Connections/TerminalEngineFactory.cs b/src/Devolutions.Terminal.App/Connections/TerminalEngineFactory.cs index 0d19fc8..3ac9554 100644 --- a/src/Devolutions.Terminal.App/Connections/TerminalEngineFactory.cs +++ b/src/Devolutions.Terminal.App/Connections/TerminalEngineFactory.cs @@ -1,5 +1,4 @@ using Devolutions.Terminal.Core; -using Devolutions.Terminal.Ghostty; using Devolutions.Terminal.Settings; namespace Devolutions.Terminal.App.Connections; @@ -10,18 +9,6 @@ public static ITerminalEngine Create(AppSettings settings, ProfileSettings profi { ArgumentNullException.ThrowIfNull(settings); ArgumentNullException.ThrowIfNull(profile); - if (OperatingSystem.IsBrowser()) - { - return new TerminalEngine(historySize: profile.HistorySize); - } - - return (profile.TerminalEngine ?? - settings.ProfileDefaults.TerminalEngine ?? - settings.TerminalEngine) switch - { - TerminalEngineKind.Ghostty => new GhosttyTerminalEngine( - historySize: profile.HistorySize), - _ => new TerminalEngine(historySize: profile.HistorySize), - }; + return new TerminalEngine(historySize: profile.HistorySize); } } diff --git a/src/Devolutions.Terminal.App/Devolutions.Terminal.App.csproj b/src/Devolutions.Terminal.App/Devolutions.Terminal.App.csproj index a58d617..3cea5df 100644 --- a/src/Devolutions.Terminal.App/Devolutions.Terminal.App.csproj +++ b/src/Devolutions.Terminal.App/Devolutions.Terminal.App.csproj @@ -27,7 +27,6 @@ - diff --git a/src/Devolutions.Terminal.App/Platform/PlatformLauncher.cs b/src/Devolutions.Terminal.App/Platform/PlatformLauncher.cs index dd7c8a7..6626a8d 100644 --- a/src/Devolutions.Terminal.App/Platform/PlatformLauncher.cs +++ b/src/Devolutions.Terminal.App/Platform/PlatformLauncher.cs @@ -143,8 +143,7 @@ public string GetCapabilityReport() "Open URI/file/directory: available through open(1)\n" + "System notifications: available through osascript display notification\n" + "Global summon hotkeys: unsupported; use the broker or dt -w\n" + - "Unix PTY: dt-pty-host (forkpty)\n" + - GetMacOsGhosttyCapability(), + "Unix PTY: dt-pty-host (forkpty)", _ => "Desktop platform: unsupported\nOpen and notification integrations are unavailable.", }; } @@ -161,13 +160,6 @@ private string GetWindowsCapabilityReport() $"Default terminal: {defaultTerminal.Status} ({defaultTerminal.Diagnostic})"; } - private static string GetMacOsGhosttyCapability() - { - var library = Path.Combine(AppContext.BaseDirectory, "libghostty-vt.dylib"); - return File.Exists(library) - ? "Ghostty engine: available (libghostty-vt.dylib)" - : "Ghostty engine: not restored for this build"; - } private static string Availability( ShellIntegrationResult result, diff --git a/src/Devolutions.Terminal.Connection/BrowserShellConnection.cs b/src/Devolutions.Terminal.Connection/BrowserShellConnection.cs index c2107b6..9d62fe4 100644 --- a/src/Devolutions.Terminal.Connection/BrowserShellConnection.cs +++ b/src/Devolutions.Terminal.Connection/BrowserShellConnection.cs @@ -27,8 +27,8 @@ public sealed class BrowserShellConnection : IRestartableTerminalConnection { ["README.md"] = "Devolutions Terminal in the browser.\n" + - "This session is an in-process shell. ConPTY, Unix PTY, Ghostty,\n" + - "and the desktop window chrome are not available under WebAssembly.\n", + "This session is an in-process shell. ConPTY, Unix PTY, and the\n" + + "desktop window chrome are not available under WebAssembly.\n", ["help.txt"] = "help, echo, cat, ls, clear, date, uname, whoami, env, history, color, about, exit\n", }; @@ -249,7 +249,7 @@ private void StartCore(TerminalLaunchOptions options) Emit( "\u001b[1;36mDevolutions Terminal\u001b[0m \u001b[90m· browser wasm\u001b[0m\r\n" + - "\u001b[90mBuilt-in VT engine. Local PTY, Ghostty, and desktop chrome are unavailable.\u001b[0m\r\n" + + "\u001b[90mBuilt-in VT engine. Local PTY and desktop chrome are unavailable.\u001b[0m\r\n" + "\r\nType \u001b[1mhelp\u001b[0m for commands.\r\n\r\n"); Emit(Prompt); } diff --git a/src/Devolutions.Terminal.Control/TerminalSearchSession.cs b/src/Devolutions.Terminal.Control/TerminalSearchSession.cs index 3b51f7e..9e9b578 100644 --- a/src/Devolutions.Terminal.Control/TerminalSearchSession.cs +++ b/src/Devolutions.Terminal.Control/TerminalSearchSession.cs @@ -174,7 +174,7 @@ private int FindAnchor(SearchAnchor anchor, int fallbackIndex) return 0; } - // Ghostty rebuilds projected rows (and their IDs) for each snapshot. + // Only the builtin engine keeps logical-line IDs stable across snapshots. if (_engine is not TerminalEngine || anchor.LogicalLineId == 0) { return FindTextAnchor(anchor, fallbackIndex); diff --git a/src/Devolutions.Terminal.Ghostty/Devolutions.Terminal.Ghostty.csproj b/src/Devolutions.Terminal.Ghostty/Devolutions.Terminal.Ghostty.csproj deleted file mode 100644 index b5a1473..0000000 --- a/src/Devolutions.Terminal.Ghostty/Devolutions.Terminal.Ghostty.csproj +++ /dev/null @@ -1,9 +0,0 @@ - - - true - - - - - - diff --git a/src/Devolutions.Terminal.Ghostty/GhosttyAbi.cs b/src/Devolutions.Terminal.Ghostty/GhosttyAbi.cs deleted file mode 100644 index 3491080..0000000 --- a/src/Devolutions.Terminal.Ghostty/GhosttyAbi.cs +++ /dev/null @@ -1,107 +0,0 @@ -using System.Runtime.InteropServices; -using System.Text.Json; - -namespace Devolutions.Terminal.Ghostty; - -public static class GhosttyAbi -{ - public const string Commit = "3c1ef5b32fc5ea6b93d28493fabf193f595139cf"; - public const int SchemaVersion = 1; - - private static readonly Lazy ValidatedManifest = new(ValidateCore); - - public static string TypeManifest => ValidatedManifest.Value; - - public static string NativeLibraryFileName => OperatingSystem.IsWindows() - ? "ghostty-vt.dll" - : OperatingSystem.IsMacOS() - ? "libghostty-vt.dylib" - : "libghostty-vt.so"; - - public static bool IsNativeLibraryPresent => - File.Exists(Path.Combine(AppContext.BaseDirectory, NativeLibraryFileName)); - - public static void Validate() => _ = ValidatedManifest.Value; - - private static string ValidateCore() - { - if (!OperatingSystem.IsWindows() && !OperatingSystem.IsLinux() && !OperatingSystem.IsMacOS()) - { - throw new PlatformNotSupportedException( - $"The Ghostty engine does not support {RuntimeInformation.OSDescription} {RuntimeInformation.ProcessArchitecture}."); - } - - var libraryPath = Path.Combine(AppContext.BaseDirectory, NativeLibraryFileName); - if (!File.Exists(libraryPath)) - { - throw new FileNotFoundException( - $"libghostty-vt was not restored for this RID. Build it with native/Restore-NativeLibraries.ps1 (commit {Commit}).", - libraryPath); - } - - var pointer = GhosttyNative.TypeJson(); - var manifest = Marshal.PtrToStringUTF8(pointer) - ?? throw new InvalidOperationException("libghostty-vt returned no ABI type manifest."); - using var document = JsonDocument.Parse(manifest); - var root = document.RootElement; - if (root.GetProperty("schema").GetInt32() != SchemaVersion) - { - throw new InvalidOperationException( - $"Unsupported libghostty-vt ABI schema. Expected schema {SchemaVersion} from {Commit}."); - } - - var abi = root.GetProperty("abi"); - var expectedTarget = RuntimeInformation.ProcessArchitecture == Architecture.Arm64 - ? "aarch64" - : "x86_64"; - var expectedOs = OperatingSystem.IsWindows() - ? "windows" - : OperatingSystem.IsMacOS() - ? "macos" - : "linux"; - var expectedEnvironment = OperatingSystem.IsWindows() - ? "msvc" - : OperatingSystem.IsMacOS() - ? "none" - : "gnu"; - if (abi.GetProperty("target").GetString() != expectedTarget || - abi.GetProperty("os").GetString() != expectedOs || - abi.GetProperty("environment").GetString() != expectedEnvironment || - abi.GetProperty("pointer_size").GetInt32() != IntPtr.Size) - { - throw new InvalidOperationException("ghostty-vt.dll ABI target does not match this process."); - } - - var types = root.GetProperty("types"); - ValidateSize(types, "GhosttyStyle", 72); - ValidateSize(types, "GhosttyRenderStateCursor", 24); - ValidateSize(types, "GhosttyClipboardWrite", 72); - ValidateSize(types, "GhosttyPoint", 24); - ValidateSize(types, "GhosttyGridRef", 24); - ValidateEnum(types, "GhosttyTerminalOption", "SCROLLBACK_MAX_LINES", 28); - ValidateEnum(types, "GhosttyTerminalData", "MODE", 37); - ValidateEnum(types, "GhosttyRenderStateData", "CURSOR", 18); - ValidateEnum(types, "GhosttyRenderStateRowCellsData", "GRAPHEMES_UTF8", 9); - return manifest; - } - - private static void ValidateSize(JsonElement types, string name, int expected) - { - if (types.GetProperty(name).GetProperty("size").GetInt32() != expected) - { - throw new InvalidOperationException($"Unexpected {name} ABI size."); - } - } - - private static void ValidateEnum( - JsonElement types, - string type, - string name, - int expected) - { - if (types.GetProperty(type).GetProperty("values").GetProperty(name).GetInt32() != expected) - { - throw new InvalidOperationException($"Unexpected {type}.{name} ABI value."); - } - } -} diff --git a/src/Devolutions.Terminal.Ghostty/GhosttyNative.cs b/src/Devolutions.Terminal.Ghostty/GhosttyNative.cs deleted file mode 100644 index a55f906..0000000 --- a/src/Devolutions.Terminal.Ghostty/GhosttyNative.cs +++ /dev/null @@ -1,253 +0,0 @@ -using System.Runtime.CompilerServices; -using System.Runtime.InteropServices; -using Microsoft.Win32.SafeHandles; - -namespace Devolutions.Terminal.Ghostty; - -internal static unsafe partial class GhosttyNative -{ - private const string LibraryName = "ghostty-vt"; - - [LibraryImport(LibraryName, EntryPoint = "ghostty_terminal_new")] - [UnmanagedCallConv(CallConvs = [typeof(CallConvCdecl)])] - internal static partial GhosttyResult TerminalNew( - nint allocator, - out nint terminal, - ushort columns, - ushort rows); - - [LibraryImport(LibraryName, EntryPoint = "ghostty_terminal_free")] - [UnmanagedCallConv(CallConvs = [typeof(CallConvCdecl)])] - internal static partial void TerminalFree(nint terminal); - - [LibraryImport(LibraryName, EntryPoint = "ghostty_terminal_vt_write")] - [UnmanagedCallConv(CallConvs = [typeof(CallConvCdecl)])] - internal static partial void TerminalWrite( - nint terminal, - byte* bytes, - nuint length); - - [LibraryImport(LibraryName, EntryPoint = "ghostty_terminal_resize")] - [UnmanagedCallConv(CallConvs = [typeof(CallConvCdecl)])] - internal static partial GhosttyResult TerminalResize( - nint terminal, - ushort columns, - ushort rows, - uint cellWidth, - uint cellHeight); - - [LibraryImport(LibraryName, EntryPoint = "ghostty_terminal_reset")] - [UnmanagedCallConv(CallConvs = [typeof(CallConvCdecl)])] - internal static partial void TerminalReset(nint terminal); - - [LibraryImport(LibraryName, EntryPoint = "ghostty_terminal_get")] - [UnmanagedCallConv(CallConvs = [typeof(CallConvCdecl)])] - internal static partial GhosttyResult TerminalGet( - nint terminal, - int data, - void* output); - - [LibraryImport(LibraryName, EntryPoint = "ghostty_terminal_set")] - [UnmanagedCallConv(CallConvs = [typeof(CallConvCdecl)])] - internal static partial GhosttyResult TerminalSet( - nint terminal, - int option, - void* value); - - [LibraryImport(LibraryName, EntryPoint = "ghostty_terminal_scroll_viewport")] - [UnmanagedCallConv(CallConvs = [typeof(CallConvCdecl)])] - internal static partial void TerminalScrollViewport( - nint terminal, - GhosttyScrollViewport viewport); - - [LibraryImport(LibraryName, EntryPoint = "ghostty_terminal_grid_ref")] - [UnmanagedCallConv(CallConvs = [typeof(CallConvCdecl)])] - internal static partial GhosttyResult TerminalGridRef( - nint terminal, - GhosttyPoint point, - GhosttyGridRef* gridRef); - - [LibraryImport(LibraryName, EntryPoint = "ghostty_terminal_grid_ref_track")] - [UnmanagedCallConv(CallConvs = [typeof(CallConvCdecl)])] - internal static partial GhosttyResult TerminalGridRefTrack( - nint terminal, - GhosttyPoint point, - out nint trackedGridRef); - - [LibraryImport(LibraryName, EntryPoint = "ghostty_tracked_grid_ref_free")] - [UnmanagedCallConv(CallConvs = [typeof(CallConvCdecl)])] - internal static partial void TrackedGridRefFree(nint trackedGridRef); - - [LibraryImport(LibraryName, EntryPoint = "ghostty_tracked_grid_ref_has_value")] - [UnmanagedCallConv(CallConvs = [typeof(CallConvCdecl)])] - [return: MarshalAs(UnmanagedType.I1)] - internal static partial bool TrackedGridRefHasValue(nint trackedGridRef); - - [LibraryImport(LibraryName, EntryPoint = "ghostty_grid_ref_hyperlink_uri")] - [UnmanagedCallConv(CallConvs = [typeof(CallConvCdecl)])] - internal static partial GhosttyResult GridRefHyperlinkUri( - GhosttyGridRef* gridRef, - byte* buffer, - nuint bufferLength, - nuint* written); - - - [LibraryImport(LibraryName, EntryPoint = "ghostty_render_state_new")] - [UnmanagedCallConv(CallConvs = [typeof(CallConvCdecl)])] - internal static partial GhosttyResult RenderStateNew( - nint allocator, - out nint state); - - [LibraryImport(LibraryName, EntryPoint = "ghostty_render_state_free")] - [UnmanagedCallConv(CallConvs = [typeof(CallConvCdecl)])] - internal static partial void RenderStateFree(nint state); - - [LibraryImport(LibraryName, EntryPoint = "ghostty_render_state_update")] - [UnmanagedCallConv(CallConvs = [typeof(CallConvCdecl)])] - internal static partial GhosttyResult RenderStateUpdate( - nint state, - nint terminal); - - [LibraryImport(LibraryName, EntryPoint = "ghostty_render_state_clean")] - [UnmanagedCallConv(CallConvs = [typeof(CallConvCdecl)])] - internal static partial GhosttyResult RenderStateClean(nint state); - - [LibraryImport(LibraryName, EntryPoint = "ghostty_render_state_get")] - [UnmanagedCallConv(CallConvs = [typeof(CallConvCdecl)])] - internal static partial GhosttyResult RenderStateGet( - nint state, - int data, - void* output); - - [LibraryImport(LibraryName, EntryPoint = "ghostty_render_state_row_iterator_new")] - [UnmanagedCallConv(CallConvs = [typeof(CallConvCdecl)])] - internal static partial GhosttyResult RowIteratorNew( - nint allocator, - out nint iterator); - - [LibraryImport(LibraryName, EntryPoint = "ghostty_render_state_row_iterator_free")] - [UnmanagedCallConv(CallConvs = [typeof(CallConvCdecl)])] - internal static partial void RowIteratorFree(nint iterator); - - [LibraryImport(LibraryName, EntryPoint = "ghostty_render_state_row_iterator_next")] - [UnmanagedCallConv(CallConvs = [typeof(CallConvCdecl)])] - [return: MarshalAs(UnmanagedType.I1)] - internal static partial bool RowIteratorNext(nint iterator); - - [LibraryImport(LibraryName, EntryPoint = "ghostty_render_state_row_get")] - [UnmanagedCallConv(CallConvs = [typeof(CallConvCdecl)])] - internal static partial GhosttyResult RowGet( - nint iterator, - int data, - void* output); - - [LibraryImport(LibraryName, EntryPoint = "ghostty_render_state_row_cells_new")] - [UnmanagedCallConv(CallConvs = [typeof(CallConvCdecl)])] - internal static partial GhosttyResult RowCellsNew( - nint allocator, - out nint cells); - - [LibraryImport(LibraryName, EntryPoint = "ghostty_render_state_row_cells_free")] - [UnmanagedCallConv(CallConvs = [typeof(CallConvCdecl)])] - internal static partial void RowCellsFree(nint cells); - - [LibraryImport(LibraryName, EntryPoint = "ghostty_render_state_row_cells_next")] - [UnmanagedCallConv(CallConvs = [typeof(CallConvCdecl)])] - [return: MarshalAs(UnmanagedType.I1)] - internal static partial bool RowCellsNext(nint cells); - - [LibraryImport(LibraryName, EntryPoint = "ghostty_render_state_row_cells_get")] - [UnmanagedCallConv(CallConvs = [typeof(CallConvCdecl)])] - internal static partial GhosttyResult RowCellsGet( - nint cells, - int data, - void* output); - - [LibraryImport(LibraryName, EntryPoint = "ghostty_cell_get")] - [UnmanagedCallConv(CallConvs = [typeof(CallConvCdecl)])] - internal static partial GhosttyResult CellGet( - ulong cell, - int data, - void* output); - - [LibraryImport(LibraryName, EntryPoint = "ghostty_row_get")] - [UnmanagedCallConv(CallConvs = [typeof(CallConvCdecl)])] - internal static partial GhosttyResult RawRowGet( - ulong row, - int data, - void* output); - - [LibraryImport(LibraryName, EntryPoint = "ghostty_type_json")] - [UnmanagedCallConv(CallConvs = [typeof(CallConvCdecl)])] - internal static partial nint TypeJson(); -} - -internal sealed class SafeGhosttyTerminalHandle : SafeHandleZeroOrMinusOneIsInvalid -{ - internal SafeGhosttyTerminalHandle(nint value) : base(true) - { - SetHandle(value); - } - - protected override bool ReleaseHandle() - { - GhosttyNative.TerminalFree(handle); - return true; - } -} - -internal sealed class SafeGhosttyRenderStateHandle : SafeHandleZeroOrMinusOneIsInvalid -{ - internal SafeGhosttyRenderStateHandle(nint value) : base(true) - { - SetHandle(value); - } - - protected override bool ReleaseHandle() - { - GhosttyNative.RenderStateFree(handle); - return true; - } -} - -internal sealed class SafeGhosttyRowIteratorHandle : SafeHandleZeroOrMinusOneIsInvalid -{ - internal SafeGhosttyRowIteratorHandle(nint value) : base(true) - { - SetHandle(value); - } - - protected override bool ReleaseHandle() - { - GhosttyNative.RowIteratorFree(handle); - return true; - } -} - -internal sealed class SafeGhosttyRowCellsHandle : SafeHandleZeroOrMinusOneIsInvalid -{ - internal SafeGhosttyRowCellsHandle(nint value) : base(true) - { - SetHandle(value); - } - - protected override bool ReleaseHandle() - { - GhosttyNative.RowCellsFree(handle); - return true; - } -} - -internal sealed class SafeGhosttyTrackedGridRefHandle : SafeHandleZeroOrMinusOneIsInvalid -{ - internal SafeGhosttyTrackedGridRefHandle(nint value) : base(true) - { - SetHandle(value); - } - - protected override bool ReleaseHandle() - { - GhosttyNative.TrackedGridRefFree(handle); - return true; - } -} diff --git a/src/Devolutions.Terminal.Ghostty/GhosttyNativeTypes.cs b/src/Devolutions.Terminal.Ghostty/GhosttyNativeTypes.cs deleted file mode 100644 index de9f996..0000000 --- a/src/Devolutions.Terminal.Ghostty/GhosttyNativeTypes.cs +++ /dev/null @@ -1,238 +0,0 @@ -using System.Runtime.InteropServices; - -namespace Devolutions.Terminal.Ghostty; - -internal enum GhosttyResult -{ - Success = 0, - OutOfMemory = -1, - InvalidValue = -2, - OutOfSpace = -3, - NoValue = -4, - IoError = -5, - LimitExceeded = -6, - Rejected = -7, -} - -[StructLayout(LayoutKind.Sequential, Pack = 1)] -internal struct GhosttyColorRgb -{ - public byte R; - public byte G; - public byte B; -} - -[StructLayout(LayoutKind.Explicit, Size = 16)] -internal struct GhosttyStyleColor -{ - [FieldOffset(0)] - public int Tag; - - [FieldOffset(8)] - public byte Palette; - - [FieldOffset(8)] - public GhosttyColorRgb Rgb; -} - -[StructLayout(LayoutKind.Explicit, Size = 72)] -internal struct GhosttyStyle -{ - [FieldOffset(0)] - public nuint Size; - - [FieldOffset(8)] - public GhosttyStyleColor Foreground; - - [FieldOffset(24)] - public GhosttyStyleColor Background; - - [FieldOffset(40)] - public GhosttyStyleColor UnderlineColor; - - [FieldOffset(56)] - public byte Bold; - - [FieldOffset(57)] - public byte Italic; - - [FieldOffset(58)] - public byte Faint; - - [FieldOffset(59)] - public byte Blink; - - [FieldOffset(60)] - public byte Inverse; - - [FieldOffset(61)] - public byte Invisible; - - [FieldOffset(62)] - public byte Strikethrough; - - [FieldOffset(63)] - public byte Overline; - - [FieldOffset(64)] - public int Underline; -} - -[StructLayout(LayoutKind.Explicit, Size = 24)] -internal struct GhosttyRenderCursor -{ - [FieldOffset(0)] - public nuint Size; - - [FieldOffset(8)] - public byte ViewportHasValue; - - [FieldOffset(10)] - public ushort ViewportX; - - [FieldOffset(12)] - public ushort ViewportY; - - [FieldOffset(14)] - public byte WideTail; - - [FieldOffset(15)] - public byte Visible; - - [FieldOffset(16)] - public byte Blinking; - - [FieldOffset(17)] - public byte PasswordInput; - - [FieldOffset(20)] - public int VisualStyle; -} - -[StructLayout(LayoutKind.Sequential)] -internal struct GhosttyString -{ - public nint Pointer; - public nuint Length; -} - -[StructLayout(LayoutKind.Explicit, Size = 4)] -internal struct GhosttyModeConfig -{ - [FieldOffset(0)] - public ushort Mode; - - [FieldOffset(2)] - public byte Value; -} - -[StructLayout(LayoutKind.Explicit, Size = 24)] -internal struct GhosttyScrollViewport -{ - [FieldOffset(0)] - public int Tag; - - [FieldOffset(8)] - public nuint Row; -} - -[StructLayout(LayoutKind.Sequential)] -internal struct GhosttyScrollbar -{ - public ulong Total; - public ulong Offset; - public ulong Length; -} - -[StructLayout(LayoutKind.Sequential)] -internal struct GhosttyClipboardContent -{ - public GhosttyString Mime; - public GhosttyString Data; -} - -[StructLayout(LayoutKind.Explicit, Size = 72)] -internal unsafe struct GhosttyClipboardWrite -{ - [FieldOffset(0)] - public nuint Size; - - [FieldOffset(16)] - public GhosttyClipboardContent* Contents; - - [FieldOffset(24)] - public nuint ContentsLength; - - [FieldOffset(64)] - public nint Reply; -} - -[StructLayout(LayoutKind.Explicit, Size = 16)] -internal struct GhosttyClipboardWriteReply -{ - [FieldOffset(0)] - public nuint Size; - - [FieldOffset(8)] - public int Result; - - [FieldOffset(12)] - public byte Remember; -} - -[StructLayout(LayoutKind.Explicit, Size = 40)] -internal struct GhosttyDesktopNotification -{ - [FieldOffset(0)] - public nuint Size; - - [FieldOffset(8)] - public GhosttyString Title; - - [FieldOffset(24)] - public GhosttyString Body; -} - -[StructLayout(LayoutKind.Explicit, Size = 24)] -internal struct GhosttyPoint -{ - [FieldOffset(0)] - public int Tag; - - [FieldOffset(8)] - public ushort X; - - [FieldOffset(12)] - public uint Y; -} - -[StructLayout(LayoutKind.Explicit, Size = 24)] -internal struct GhosttyGridRef -{ - [FieldOffset(0)] - public nuint Size; - - [FieldOffset(8)] - public nint Node; - - [FieldOffset(16)] - public ushort X; - - [FieldOffset(18)] - public ushort Y; -} - -[StructLayout(LayoutKind.Sequential)] -internal struct GhosttySizeReport -{ - public ushort Rows; - public ushort Columns; - public uint CellWidth; - public uint CellHeight; -} - -internal sealed class GhosttyException(string operation, GhosttyResult result) - : InvalidOperationException($"{operation} failed with Ghostty result {result} ({(int)result}).") -{ - public GhosttyResult Result { get; } = result; -} diff --git a/src/Devolutions.Terminal.Ghostty/GhosttyTerminalEngine.cs b/src/Devolutions.Terminal.Ghostty/GhosttyTerminalEngine.cs deleted file mode 100644 index 3e3c926..0000000 --- a/src/Devolutions.Terminal.Ghostty/GhosttyTerminalEngine.cs +++ /dev/null @@ -1,1609 +0,0 @@ -using System.Runtime.CompilerServices; -using System.Runtime.InteropServices; -using System.Text; -using Devolutions.Terminal.Core; - -namespace Devolutions.Terminal.Ghostty; - -public sealed unsafe class GhosttyTerminalEngine : ITerminalEngine -{ - private const int TerminalOptionUserData = 0; - private const int TerminalOptionWritePty = 1; - private const int TerminalOptionBell = 2; - private const int TerminalOptionTitleChanged = 5; - private const int TerminalOptionSize = 6; - private const int TerminalOptionPwdChanged = 25; - private const int TerminalOptionClipboardWrite = 26; - private const int TerminalOptionDesktopNotification = 29; - - private const int TerminalDataActiveScreen = 6; - private const int TerminalDataTitle = 12; - private const int TerminalDataPwd = 13; - private const int TerminalDataMode = 37; - - private const int RenderDataCols = 1; - private const int RenderDataRows = 2; - private const int RenderDataDirty = 3; - private const int RenderDataRowIterator = 4; - private const int RenderDataCursor = 18; - private const int RenderStateDirtyFull = 2; - private const int RenderRowDataDirty = 1; - private const int RenderRowDataRaw = 2; - private const int RenderRowDataCells = 3; - private const int RenderCellDataStyle = 2; - private const int RenderCellDataRaw = 1; - private const int RenderCellDataGraphemeLength = 3; - private const int RenderCellDataGraphemeBuffer = 4; - private const int RenderCellDataBackground = 5; - private const int RenderCellDataForeground = 6; - - private readonly object _gate = new(); - private readonly SafeGhosttyTerminalHandle _terminal; - private readonly SafeGhosttyRenderStateHandle _renderState; - private readonly SafeGhosttyRowIteratorHandle _rowIterator; - private readonly SafeGhosttyRowCellsHandle _rowCells; - private readonly GCHandle _selfHandle; - private ColorScheme _scheme = ColorScheme.Campbell; - private bool _disposed; - private bool _allowClipboardWrite; - private bool _allowNotifications; - private bool _allowKittyKeyboard = true; - private KittyKeyboardFlags _kittyKeyboardFlags; - private readonly Stack _kittyKeyboardStack = new(16); - private string _title = "Devolutions Terminal"; - private string? _workingDirectory; - private GhosttyRenderCursor _cursor; - private readonly TextBuffer _buffer; - // Feeds only mark the projection stale; readers of the projected buffer or - // cursor rebuild it on demand, once per frame rather than once per PTY read. - private bool _projectionStale; - // The last projection of each viewport row, reused while Ghostty's render - // state reports the row clean. - private (Cell[] Cells, int? PromptStart)?[] _projectedRows = []; - private uint[] _codepointBuffer = new uint[16]; - private byte[] _hyperlinkBuffer = new byte[256]; - private Exception? _callbackException; - private SafeGhosttyTrackedGridRefHandle? _historyAnchor; - private readonly int _historyLimit; - private uint _cellWidthPixels = 1; - private uint _cellHeightPixels = 1; - private readonly List _imageProbe = new(16); - private ImageProbeState _imageProbeState; - private bool _dcsImageCandidate; - private bool _ansiMode = true; - private int _modifyOtherKeys; - private KeyboardModeScanState _keyboardModeScan; - private bool _kittyImageReported; - private byte _keyboardCsiPrivate; - private int _keyboardCsiValue; - private bool _keyboardCsiHasValue; - private readonly int[] _keyboardCsiParams = new int[8]; - private int _keyboardCsiCount; - - private enum ImageProbeState : byte - { - Ground, - Escape, - DcsHeader, - DcsBody, - DcsEscape, - Osc, - OscEscape, - OscIgnored, - OscIgnoredEscape, - Apc, - ApcIgnored, - ApcIgnoredEscape, - } - - private enum KeyboardModeScanState : byte - { - Ground, - Escape, - Csi, - } - - public GhosttyTerminalEngine(int columns = 120, int rows = 30, int historySize = 9001) - { - GhosttyAbi.Validate(); - _historyLimit = Math.Max(0, historySize); - _buffer = new TextBuffer(columns, rows, historySize, hasHistory: true); - nint terminal = 0; - nint renderState = 0; - nint rowIterator = 0; - nint rowCells = 0; - try - { - ThrowIfFailed( - GhosttyNative.TerminalNew( - 0, - out terminal, - checked((ushort)columns), - checked((ushort)rows)), - "ghostty_terminal_new"); - ThrowIfFailed(GhosttyNative.RenderStateNew(0, out renderState), "ghostty_render_state_new"); - ThrowIfFailed(GhosttyNative.RowIteratorNew(0, out rowIterator), "ghostty_render_state_row_iterator_new"); - ThrowIfFailed(GhosttyNative.RowCellsNew(0, out rowCells), "ghostty_render_state_row_cells_new"); - } - catch - { - if (rowCells != 0) GhosttyNative.RowCellsFree(rowCells); - if (rowIterator != 0) GhosttyNative.RowIteratorFree(rowIterator); - if (renderState != 0) GhosttyNative.RenderStateFree(renderState); - if (terminal != 0) GhosttyNative.TerminalFree(terminal); - throw; - } - - _terminal = new SafeGhosttyTerminalHandle(terminal); - _renderState = new SafeGhosttyRenderStateHandle(renderState); - _rowIterator = new SafeGhosttyRowIteratorHandle(rowIterator); - _rowCells = new SafeGhosttyRowCellsHandle(rowCells); - _selfHandle = GCHandle.Alloc(this); - try - { - RegisterEffects(); - var scrollbackLines = (nuint)Math.Max(0, historySize); - if (historySize == 0) - { - var scrollbackBytes = (nuint)0; - ThrowIfFailed( - GhosttyNative.TerminalSet( - _terminal.DangerousGetHandle(), - 27, - &scrollbackBytes), - "disable scrollback bytes"); - } - else - { - ThrowIfFailed( - GhosttyNative.TerminalSet( - _terminal.DangerousGetHandle(), - 27, - null), - "remove scrollback byte limit"); - } - - ThrowIfFailed( - GhosttyNative.TerminalSet( - _terminal.DangerousGetHandle(), - 28, - &scrollbackLines), - "set scrollback line limit"); - ApplyScheme(); - SynchronizeProjection(); - ResetHistoryAnchor(); - } - catch - { - Dispose(); - throw; - } - } - - public TextBuffer Buffer - { - get - { - lock (_gate) - { - EnsureProjection(); - } - - return _buffer; - } - } - - private GhosttyRenderCursor ProjectedCursor - { - get - { - lock (_gate) - { - EnsureProjection(); - return _cursor; - } - } - } - - public ColorScheme Scheme - { - get => _scheme; - set - { - ArgumentNullException.ThrowIfNull(value); - lock (_gate) - { - ObjectDisposedException.ThrowIf(_disposed, this); - _scheme = value; - ApplyScheme(); - SynchronizeProjection(); - } - - RaiseInvalidated(); - } - } - - public string Title => _title; - public TerminalEngineCapabilities Capabilities => TerminalEngineCapabilities.Vt52Keyboard; - public string? WorkingDirectory => _workingDirectory; - public bool AlternateBufferActive => QueryInt(TerminalDataActiveScreen) == 1; - public bool CursorVisible => ProjectedCursor is { ViewportHasValue: not 0, Visible: not 0 }; - public bool CursorBlinking => ProjectedCursor.Blinking != 0; - public int CursorStyle => - ProjectedCursor.VisualStyle is var style and >= 1 and <= 6 ? style : 0; - public bool ApplicationCursorKeys => QueryMode(1); - public bool BracketedPaste => QueryMode(2004); - public bool MouseTracking => QueryMode(9) || QueryMode(1000) || QueryMode(1002) || QueryMode(1003); - public TerminalMouseTrackingMode MouseTrackingMode => - QueryMode(1003) ? TerminalMouseTrackingMode.AllMotion : - QueryMode(1002) ? TerminalMouseTrackingMode.ButtonEvent : - QueryMode(1000) ? TerminalMouseTrackingMode.Button : - QueryMode(9) ? TerminalMouseTrackingMode.Button : - TerminalMouseTrackingMode.None; - public bool SgrMouse => QueryMode(1006); - public bool FocusTracking => QueryMode(1004); - public bool AutoWrap => QueryMode(7); - public bool InsertMode => QueryMode(4, ansi: true); - public bool ReverseVideo => QueryMode(5); - public TerminalInputMode InputMode => new( - AnsiMode: _ansiMode, - ApplicationCursorKeys, - ApplicationKeypad: QueryMode(66), - KittyFlags: _allowKittyKeyboard ? _kittyKeyboardFlags : KittyKeyboardFlags.None, - ModifyOtherKeys: _modifyOtherKeys, - Win32InputMode: false); - public int Columns => Buffer.Columns; - public int Rows => Buffer.Rows; - public int CursorX => Buffer.CursorX; - public int CursorY => Buffer.CursorY; - public uint PixelWidth => QueryUInt32(16); - public uint PixelHeight => QueryUInt32(17); - public int HistoryCount => ReadScrollbar().HistoryCount; - public int ScrollOffset => ReadScrollbar().ScrollOffset; - - public event EventHandler? Invalidated; - public event EventHandler? TitleChanged; - public event EventHandler? WorkingDirectoryChanged; - public event EventHandler? ShellIntegrationChanged; - public event EventHandler? ClipboardWriteRequested; - public event EventHandler? NotificationRequested; - public event EventHandler? Diagnostic; - public event EventHandler? Bell; - public event EventHandler? ResponseReady; - - public void Feed(ReadOnlySpan data) - { - lock (_gate) - { - ObjectDisposedException.ThrowIf(_disposed, this); - ProbeUnsupportedImages(data); - TrackHostKeyboardModes(data); - var trackedHistory = _historyAnchor is { IsInvalid: false, IsClosed: false }; - var historyBefore = ReadScrollbarCore().HistoryCount; - fixed (byte* bytes = data) - { - _callbackException = null; - GhosttyNative.TerminalWrite(_terminal.DangerousGetHandle(), bytes, (nuint)data.Length); - } - - if (_callbackException is { } callbackException) - { - throw new InvalidOperationException( - "A libghostty-vt effect callback failed.", - callbackException); - } - - // Projecting the grid costs several native calls per cell, so it waits - // for a reader (normally one snapshot per frame). Title and directory - // changes still raise their events from the feed. - _projectionStale = true; - SynchronizeMetadata(); - var historyAfter = ReadScrollbarCore().HistoryCount; - var initialEvictionPossible = historyBefore == 0 && - EstimateRows(data, Columns) >= historyAfter + Rows; - var trackedHistoryEvicted = trackedHistory && - !GhosttyNative.TrackedGridRefHasValue( - _historyAnchor!.DangerousGetHandle()); - if (trackedHistoryEvicted || - initialEvictionPossible || - !trackedHistory) - { - _buffer.AdvanceCoordinateVersion(); - } - if (!trackedHistory || trackedHistoryEvicted) - { - ResetHistoryAnchor(); - } - } - - RaiseInvalidated(); - } - - public void Feed(string text) - { - ArgumentNullException.ThrowIfNull(text); - Feed(Encoding.UTF8.GetBytes(text)); - } - - public void Resize(int columns, int rows, double cellWidth = 1, double cellHeight = 1) - { - columns = Math.Clamp(columns, 1, ushort.MaxValue); - rows = Math.Clamp(rows, 1, ushort.MaxValue); - _cellWidthPixels = checked((uint)Math.Max(1, Math.Round(cellWidth))); - _cellHeightPixels = checked((uint)Math.Max(1, Math.Round(cellHeight))); - lock (_gate) - { - ObjectDisposedException.ThrowIf(_disposed, this); - ThrowIfFailed( - GhosttyNative.TerminalResize( - _terminal.DangerousGetHandle(), - (ushort)columns, - (ushort)rows, - _cellWidthPixels, - _cellHeightPixels), - "ghostty_terminal_resize"); - _buffer.Resize(columns, rows); - SynchronizeProjection(); - } - - RaiseInvalidated(); - } - - public void Reset() - { - lock (_gate) - { - ObjectDisposedException.ThrowIf(_disposed, this); - GhosttyNative.TerminalReset(_terminal.DangerousGetHandle()); - ResetHostKeyboardModes(); - SynchronizeProjection(); - ResetHistoryAnchor(); - } - - RaiseInvalidated(); - } - - public void SetScrollOffset(int offset) - { - lock (_gate) - { - ObjectDisposedException.ThrowIf(_disposed, this); - var scrollbar = ReadScrollbarCore(); - var normalized = Math.Clamp(offset, 0, scrollbar.HistoryCount); - GhosttyNative.TerminalScrollViewport( - _terminal.DangerousGetHandle(), - new GhosttyScrollViewport - { - Tag = 3, - Row = (nuint)(scrollbar.HistoryCount - normalized), - }); - SynchronizeProjection(); - } - - RaiseInvalidated(); - } - - public void ConfigureOptionalFeatures( - bool allowClipboardWrite, - bool allowNotifications, - bool allowKittyKeyboard = true) - { - _allowClipboardWrite = allowClipboardWrite; - _allowNotifications = allowNotifications; - _allowKittyKeyboard = allowKittyKeyboard; - if (!allowKittyKeyboard) - { - _kittyKeyboardFlags = KittyKeyboardFlags.None; - _kittyKeyboardStack.Clear(); - } - } - - public TerminalSnapshot CreateSnapshot(bool includeHistory = false) - { - lock (_gate) - { - ObjectDisposedException.ThrowIf(_disposed, this); - EnsureProjection(); - var buffer = includeHistory ? CreateHistorySnapshot() : _buffer.CreateSnapshot(); - return new TerminalSnapshot( - buffer, - Title, - WorkingDirectory, - AlternateBufferActive, - CursorVisible, - ApplicationCursorKeys, - BracketedPaste, - MouseTracking, - SgrMouse, - AutoWrap, - InsertMode, - ReverseVideo); - } - } - - public string WrapPaste(string text) - { - ArgumentNullException.ThrowIfNull(text); - return BracketedPaste - ? "\u001b[200~" + text.Replace("\u001b", string.Empty, StringComparison.Ordinal) + "\u001b[201~" - : text; - } - - private void ResetHostKeyboardModes() - { - _ansiMode = true; - _modifyOtherKeys = 0; - _kittyKeyboardFlags = KittyKeyboardFlags.None; - _kittyKeyboardStack.Clear(); - _keyboardModeScan = KeyboardModeScanState.Ground; - ResetKeyboardCsi(); - } - - private void ResetKeyboardCsi() - { - _keyboardCsiPrivate = 0; - _keyboardCsiValue = 0; - _keyboardCsiHasValue = false; - _keyboardCsiCount = 0; - } - - private void TrackHostKeyboardModes(ReadOnlySpan data) - { - foreach (var value in data) - { - switch (_keyboardModeScan) - { - case KeyboardModeScanState.Ground: - if (value == 0x1B) - { - _keyboardModeScan = KeyboardModeScanState.Escape; - } - break; - case KeyboardModeScanState.Escape: - if (value == (byte)'[') - { - _keyboardModeScan = KeyboardModeScanState.Csi; - ResetKeyboardCsi(); - } - else - { - _keyboardModeScan = KeyboardModeScanState.Ground; - } - break; - case KeyboardModeScanState.Csi: - if (value is 0x18 or 0x1A) - { - _keyboardModeScan = KeyboardModeScanState.Ground; - } - else if (_keyboardCsiPrivate == 0 && - !_keyboardCsiHasValue && - _keyboardCsiCount == 0 && - value is (byte)'?' or (byte)'>' or (byte)'<' or (byte)'=') - { - _keyboardCsiPrivate = value; - } - else if (value is >= (byte)'0' and <= (byte)'9') - { - _keyboardCsiHasValue = true; - _keyboardCsiValue = _keyboardCsiValue > 100_000 - ? _keyboardCsiValue - : (_keyboardCsiValue * 10) + (value - (byte)'0'); - } - else if (value == (byte)';') - { - PushKeyboardCsiParam(); - } - else if (value is >= 0x40 and <= 0x7E) - { - PushKeyboardCsiParam(); - ApplyKeyboardCsi(value); - _keyboardModeScan = KeyboardModeScanState.Ground; - } - break; - } - } - } - - private void PushKeyboardCsiParam() - { - if (_keyboardCsiCount >= _keyboardCsiParams.Length) - { - _keyboardCsiHasValue = false; - _keyboardCsiValue = 0; - return; - } - - _keyboardCsiParams[_keyboardCsiCount++] = _keyboardCsiHasValue ? _keyboardCsiValue : 0; - _keyboardCsiHasValue = false; - _keyboardCsiValue = 0; - } - - private void ApplyKeyboardCsi(byte final) - { - if (_keyboardCsiPrivate == (byte)'?' && final is (byte)'h' or (byte)'l') - { - var enable = final == (byte)'h'; - for (var index = 0; index < _keyboardCsiCount; index++) - { - switch (_keyboardCsiParams[index]) - { - case 2: - _ansiMode = enable; - break; - } - } - } - else if (_keyboardCsiPrivate == (byte)'>' && final == (byte)'m' && - _keyboardCsiCount > 0 && _keyboardCsiParams[0] == 4) - { - _modifyOtherKeys = _keyboardCsiCount > 1 - ? Math.Clamp(_keyboardCsiParams[1], 0, 2) - : 0; - } - else if (_allowKittyKeyboard && final == (byte)'u') - { - ApplyKittyKeyboardCsi(); - } - } - - private void ApplyKittyKeyboardCsi() - { - const KittyKeyboardFlags supported = - KittyKeyboardFlags.DisambiguateEscapeCodes | - KittyKeyboardFlags.ReportEventTypes | - KittyKeyboardFlags.ReportAllKeysAsEscapeCodes | - KittyKeyboardFlags.ReportAssociatedText; - switch (_keyboardCsiPrivate) - { - case (byte)'>': - if (_kittyKeyboardStack.Count < 16) - { - _kittyKeyboardStack.Push(_kittyKeyboardFlags); - } - - _kittyKeyboardFlags = (KittyKeyboardFlags)ParamKeyboard(0) & supported; - break; - case (byte)'<': - var count = Math.Max(1, ParamKeyboard(0, 1)); - while (count-- > 0 && _kittyKeyboardStack.TryPop(out var restored)) - { - _kittyKeyboardFlags = restored; - } - - break; - case (byte)'=': - var flags = (KittyKeyboardFlags)ParamKeyboard(0) & supported; - _kittyKeyboardFlags = ParamKeyboard(1, 1) switch - { - 2 => _kittyKeyboardFlags | flags, - 3 => _kittyKeyboardFlags & ~flags, - _ => flags, - }; - break; - } - } - - private int ParamKeyboard(int index, int fallback = 0) => - index < _keyboardCsiCount ? _keyboardCsiParams[index] : fallback; - - private void ProbeUnsupportedImages(ReadOnlySpan data) - { - foreach (var value in data) - { - if (_imageProbeState != ImageProbeState.Ground && value is 0x18 or 0x1A) - { - _imageProbe.Clear(); - _dcsImageCandidate = false; - _imageProbeState = ImageProbeState.Ground; - continue; - } - - switch (_imageProbeState) - { - case ImageProbeState.Ground: - if (value == 0x1B) - { - _imageProbeState = ImageProbeState.Escape; - } - else if (value == 0x90) - { - _dcsImageCandidate = true; - _imageProbeState = ImageProbeState.DcsHeader; - } - else if (value == 0x9D) - { - StartOscProbe(); - } - else if (value == 0x9F) - { - _imageProbeState = ImageProbeState.Apc; - } - break; - case ImageProbeState.Escape: - if (value == (byte)'P') - { - _dcsImageCandidate = true; - _imageProbeState = ImageProbeState.DcsHeader; - } - else if (value == (byte)']') - { - StartOscProbe(); - } - else if (value == (byte)'_') - { - _imageProbeState = ImageProbeState.Apc; - } - else - { - _imageProbeState = ImageProbeState.Ground; - } - break; - case ImageProbeState.Apc: - if (value == (byte)'G') - { - // Chunked kitty transmissions repeat APC G per chunk; - // report once per engine lifetime instead of per chunk. - if (!_kittyImageReported) - { - _kittyImageReported = true; - ReportUnsupportedImage( - "image.kitty.unsupported", - "The pinned libghostty-vt C ABI does not expose kitty graphics resources."); - } - } - - _imageProbeState = ImageProbeState.ApcIgnored; - break; - case ImageProbeState.ApcIgnored: - if (value == 0x9C) - { - _imageProbeState = ImageProbeState.Ground; - } - else if (value == 0x1B) - { - _imageProbeState = ImageProbeState.ApcIgnoredEscape; - } - break; - case ImageProbeState.ApcIgnoredEscape: - _imageProbeState = value == (byte)'\\' - ? ImageProbeState.Ground - : ImageProbeState.ApcIgnored; - break; - case ImageProbeState.DcsHeader: - if (value is >= 0x40 and <= 0x7E) - { - if (value == (byte)'q' && _dcsImageCandidate) - { - ReportUnsupportedImage( - "image.sixel.unsupported", - "The pinned libghostty-vt C ABI does not expose Sixel image resources."); - } - _imageProbeState = ImageProbeState.DcsBody; - } - else if (value is 0x18 or 0x1A) - { - _imageProbeState = ImageProbeState.Ground; - } - else if (value is >= 0x20 and <= 0x2F) - { - _dcsImageCandidate = false; - } - else if (value is >= 0x3A and <= 0x3F) - { - _dcsImageCandidate = false; - } - break; - case ImageProbeState.DcsBody: - if (value == 0x1B) - { - _imageProbeState = ImageProbeState.DcsEscape; - } - else if (value == 0x9C) - { - _imageProbeState = ImageProbeState.Ground; - } - break; - case ImageProbeState.DcsEscape: - _imageProbeState = value == (byte)'\\' - ? ImageProbeState.Ground - : ImageProbeState.DcsBody; - break; - case ImageProbeState.Osc: - if (value is 0x07 or 0x9C) - { - _imageProbeState = ImageProbeState.Ground; - } - else if (value == 0x1B) - { - _imageProbeState = ImageProbeState.OscEscape; - } - else if (_imageProbe.Count < 16) - { - _imageProbe.Add(value); - DetectUnsupportedOscImage(); - } - break; - case ImageProbeState.OscEscape: - _imageProbeState = value == (byte)'\\' - ? ImageProbeState.Ground - : ImageProbeState.Osc; - break; - case ImageProbeState.OscIgnored: - if (value is 0x07 or 0x9C) - { - _imageProbeState = ImageProbeState.Ground; - } - else if (value == 0x1B) - { - _imageProbeState = ImageProbeState.OscIgnoredEscape; - } - break; - case ImageProbeState.OscIgnoredEscape: - _imageProbeState = value == (byte)'\\' - ? ImageProbeState.Ground - : ImageProbeState.OscIgnored; - break; - } - } - } - - private void StartOscProbe() - { - _imageProbe.Clear(); - _imageProbeState = ImageProbeState.Osc; - } - - private void DetectUnsupportedOscImage() - { - if (_imageProbe.Count == 5 && MatchesProbe("1337;"u8)) - { - ReportUnsupportedImage( - "image.osc1337.unsupported", - "The pinned libghostty-vt C ABI does not expose OSC 1337 image resources."); - _imageProbeState = ImageProbeState.OscIgnored; - } - else if (_imageProbe.Count == 7 && MatchesProbe("9;4;st="u8)) - { - ReportUnsupportedImage( - "image.conemu.unsupported", - "The pinned libghostty-vt C ABI does not expose ConEmu image resources."); - _imageProbeState = ImageProbeState.OscIgnored; - } - } - - private bool MatchesProbe(ReadOnlySpan expected) - { - if (_imageProbe.Count != expected.Length) - { - return false; - } - - for (var index = 0; index < expected.Length; index++) - { - if (_imageProbe[index] != expected[index]) - { - return false; - } - } - - return true; - } - - private void ReportUnsupportedImage(string code, string message) => - Diagnostic?.Invoke(this, new TerminalEngineDiagnostic(code, message)); - - public void Dispose() - { - lock (_gate) - { - if (_disposed) - { - return; - } - - _disposed = true; - _rowCells.Dispose(); - _rowIterator.Dispose(); - _renderState.Dispose(); - _historyAnchor?.Dispose(); - _terminal.Dispose(); - if (_selfHandle.IsAllocated) - { - _selfHandle.Free(); - } - } - - GC.SuppressFinalize(this); - } - - private void RegisterEffects() - { - var userData = (void*)GCHandle.ToIntPtr(_selfHandle); - var terminal = _terminal.DangerousGetHandle(); - ThrowIfFailed(GhosttyNative.TerminalSet(terminal, TerminalOptionUserData, userData), "set userdata"); - - delegate* unmanaged[Cdecl] writePty = &OnWritePty; - ThrowIfFailed(GhosttyNative.TerminalSet(terminal, TerminalOptionWritePty, writePty), "set write PTY"); - - delegate* unmanaged[Cdecl] bell = &OnBell; - ThrowIfFailed(GhosttyNative.TerminalSet(terminal, TerminalOptionBell, bell), "set bell"); - - delegate* unmanaged[Cdecl] stateChanged = &OnStateChanged; - ThrowIfFailed( - GhosttyNative.TerminalSet(terminal, TerminalOptionTitleChanged, stateChanged), - "set title callback"); - ThrowIfFailed( - GhosttyNative.TerminalSet(terminal, TerminalOptionPwdChanged, stateChanged), - "set working-directory callback"); - - delegate* unmanaged[Cdecl] size = &OnSize; - ThrowIfFailed( - GhosttyNative.TerminalSet(terminal, TerminalOptionSize, size), - "set size callback"); - - delegate* unmanaged[Cdecl] clipboardWrite = &OnClipboardWrite; - ThrowIfFailed( - GhosttyNative.TerminalSet(terminal, TerminalOptionClipboardWrite, clipboardWrite), - "set clipboard callback"); - - delegate* unmanaged[Cdecl] notification = &OnNotification; - ThrowIfFailed( - GhosttyNative.TerminalSet(terminal, TerminalOptionDesktopNotification, notification), - "set notification callback"); - } - - private void ApplyScheme() - { - var foreground = ToRgb(_scheme.Foreground); - var background = ToRgb(_scheme.Background); - var cursor = ToRgb(_scheme.Cursor); - ThrowIfFailed( - GhosttyNative.TerminalSet(_terminal.DangerousGetHandle(), 11, &foreground), - "set foreground"); - ThrowIfFailed( - GhosttyNative.TerminalSet(_terminal.DangerousGetHandle(), 12, &background), - "set background"); - ThrowIfFailed( - GhosttyNative.TerminalSet(_terminal.DangerousGetHandle(), 13, &cursor), - "set cursor"); - - var palette = stackalloc GhosttyColorRgb[256]; - for (var index = 0; index < 256; index++) - { - palette[index] = ToRgb(_scheme.Resolve(index)); - } - - ThrowIfFailed( - GhosttyNative.TerminalSet(_terminal.DangerousGetHandle(), 14, palette), - "set palette"); - } - - private void EnsureProjection() - { - if (_projectionStale && !_disposed) - { - SynchronizeProjection(reprojectAll: false); - } - } - - /// - /// Copies Ghostty's render state into . Rows the render - /// state reports clean keep their previous projection: it only rebuilds a row - /// (marking it dirty) when its contents change, and a viewport move, resize, - /// screen switch or palette change rebuilds them all, so a clean row reads - /// back exactly what was projected last time. Paths other than feeding pass - /// to keep their previous, unconditional cost. - /// - private void SynchronizeProjection(bool reprojectAll = true) - { - ThrowIfFailed( - GhosttyNative.RenderStateUpdate( - _renderState.DangerousGetHandle(), - _terminal.DangerousGetHandle()), - "ghostty_render_state_update"); - - var columns = GetRenderUInt16(RenderDataCols); - var rows = GetRenderUInt16(RenderDataRows); - if (_buffer.Columns != columns || _buffer.Rows != rows) - { - _buffer.Resize(columns, rows); - } - - var frameDirty = 0; - ThrowIfFailed( - GhosttyNative.RenderStateGet(_renderState.DangerousGetHandle(), RenderDataDirty, &frameDirty), - "get render dirty state"); - if (frameDirty == RenderStateDirtyFull || - _projectedRows.Length != rows || - (_projectedRows.Length > 0 && _projectedRows[0] is { } first && first.Cells.Length != columns)) - { - reprojectAll = true; - } - - if (_projectedRows.Length != rows) - { - _projectedRows = new (Cell[] Cells, int? PromptStart)?[rows]; - } - - var cursor = new GhosttyRenderCursor { Size = (nuint)sizeof(GhosttyRenderCursor) }; - ThrowIfFailed( - GhosttyNative.RenderStateGet(_renderState.DangerousGetHandle(), RenderDataCursor, &cursor), - "get render cursor"); - _cursor = cursor; - - var iteratorValue = _rowIterator.DangerousGetHandle(); - ThrowIfFailed( - GhosttyNative.RenderStateGet( - _renderState.DangerousGetHandle(), - RenderDataRowIterator, - &iteratorValue), - "get row iterator"); - - var projectedRows = new List(rows); - var rowIndex = 0; - while (GhosttyNative.RowIteratorNext(_rowIterator.DangerousGetHandle())) - { - ulong rawRow = 0; - ThrowIfFailed( - GhosttyNative.RowGet( - _rowIterator.DangerousGetHandle(), - RenderRowDataRaw, - &rawRow), - "get raw row"); - byte rowDirty = 1; - if (!reprojectAll) - { - ThrowIfFailed( - GhosttyNative.RowGet( - _rowIterator.DangerousGetHandle(), - RenderRowDataDirty, - &rowDirty), - "get row dirty"); - } - - byte wrapped = 0; - ThrowIfFailed(GhosttyNative.RawRowGet(rawRow, 1, &wrapped), "get row wrap"); - var semanticPrompt = 0; - ThrowIfFailed( - GhosttyNative.RawRowGet(rawRow, 6, &semanticPrompt), - "get row semantic prompt"); - var cached = rowIndex < _projectedRows.Length ? _projectedRows[rowIndex] : null; - if (rowDirty != 0 || cached is null) - { - var cellsValue = _rowCells.DangerousGetHandle(); - ThrowIfFailed( - GhosttyNative.RowGet( - _rowIterator.DangerousGetHandle(), - RenderRowDataCells, - &cellsValue), - "get row cells"); - cached = ProjectRow(columns, rowIndex); - if (rowIndex < _projectedRows.Length) - { - _projectedRows[rowIndex] = cached; - } - } - - var projected = cached.Value; - projectedRows.Add(new TextBufferProjectionRow( - projected.Cells, - wrapped != 0, - semanticPrompt == 1 && projected.PromptStart is { } promptStart - ? [new ShellMark(promptStart)] - : [])); - rowIndex++; - } - - while (projectedRows.Count < rows) - { - projectedRows.Add(new TextBufferProjectionRow( - Enumerable.Repeat(Cell.Blank, columns).ToArray(), - false, - [])); - } - - var cursorX = _cursor.ViewportHasValue != 0 - ? _cursor.ViewportX - : QueryUInt16Core(3); - var cursorY = _cursor.ViewportHasValue != 0 - ? _cursor.ViewportY - : QueryUInt16Core(4); - _buffer.ReplaceViewport( - projectedRows, - cursorX, - cursorY); - // Every row was consumed, so the next update reports only new changes. - ThrowIfFailed( - GhosttyNative.RenderStateClean(_renderState.DangerousGetHandle()), - "ghostty_render_state_clean"); - _projectionStale = false; - SynchronizeMetadata(); - } - - private (Cell[] Cells, int? PromptStart) ProjectRow(int columns, int rowIndex) - { - var row = Enumerable.Repeat(Cell.Blank, columns).ToArray(); - var column = 0; - var previousWidth = 0; - int? promptStart = null; - while (column < columns && GhosttyNative.RowCellsNext(_rowCells.DangerousGetHandle())) - { - ulong rawCell = 0; - ThrowIfFailed( - GhosttyNative.RowCellsGet( - _rowCells.DangerousGetHandle(), - RenderCellDataRaw, - &rawCell), - "get raw cell"); - var wide = GetRawCellInt(rawCell, 3); - var style = new GhosttyStyle { Size = (nuint)sizeof(GhosttyStyle) }; - ThrowIfFailed( - GhosttyNative.RowCellsGet( - _rowCells.DangerousGetHandle(), - RenderCellDataStyle, - &style), - "get cell style"); - var hasHyperlink = GetRawCellBool(rawCell, 7); - var semantic = GetRawCellInt(rawCell, 9); - var projected = Cell.Blank with - { - Attributes = new CellAttributes - { - Foreground = GetCellColor(RenderCellDataForeground), - Background = GetCellColor(RenderCellDataBackground), - Flags = ToCellFlags(style), - }, - IsProtected = GetRawCellBool(rawCell, 8), - HyperlinkUri = hasHyperlink - ? GetHyperlinkUri(column, rowIndex, ref _hyperlinkBuffer) - : null, - ShellIntegration = ShellIntegrationKind.None, - }; - uint graphemeLength = 0; - ThrowIfFailed( - GhosttyNative.RowCellsGet( - _rowCells.DangerousGetHandle(), - RenderCellDataGraphemeLength, - &graphemeLength), - "get grapheme length"); - if (graphemeLength == 0) - { - if (wide == 2 || previousWidth == 2) - { - projected.IsWideContinuation = true; - projected.StoredWidth = 0; - } - - row[column] = projected; - previousWidth = 0; - column++; - continue; - } - - if (_codepointBuffer.Length < graphemeLength) - { - _codepointBuffer = new uint[graphemeLength]; - } - - fixed (uint* codepoints = _codepointBuffer) - { - ThrowIfFailed( - GhosttyNative.RowCellsGet( - _rowCells.DangerousGetHandle(), - RenderCellDataGraphemeBuffer, - codepoints), - "get grapheme"); - } - - var rune = Rune.TryCreate(_codepointBuffer[0], out var value) ? value : Rune.ReplacementChar; - string? combining = null; - if (graphemeLength > 1) - { - var builder = new StringBuilder(); - for (var index = 1; index < graphemeLength; index++) - { - if (Rune.TryCreate(_codepointBuffer[index], out var extra)) - { - builder.Append(extra); - } - } - - combining = builder.Length == 0 ? null : builder.ToString(); - } - - var width = wide == 1 ? 2 : 1; - projected.Rune = rune; - projected.CombiningCharacters = combining; - projected.StoredWidth = (byte)width; - projected.ShellIntegration = semantic switch - { - 1 => ShellIntegrationKind.Command, - 2 => ShellIntegrationKind.Prompt, - _ => ShellIntegrationKind.Output, - }; - if (semantic == 2) - { - promptStart ??= column; - } - - row[column] = projected; - previousWidth = width; - column++; - } - - return (row, promptStart); - } - - private static bool GetRawCellBool(ulong cell, int data) - { - byte value = 0; - ThrowIfFailed(GhosttyNative.CellGet(cell, data, &value), "get raw cell flag"); - return value != 0; - } - - private static int GetRawCellInt(ulong cell, int data) - { - var value = 0; - ThrowIfFailed(GhosttyNative.CellGet(cell, data, &value), "get raw cell value"); - return value; - } - - private string? GetHyperlinkUri(int column, int row, ref byte[] buffer) - { - var gridRef = new GhosttyGridRef { Size = (nuint)sizeof(GhosttyGridRef) }; - var result = GhosttyNative.TerminalGridRef( - _terminal.DangerousGetHandle(), - new GhosttyPoint - { - Tag = 1, - X = checked((ushort)column), - Y = checked((uint)row), - }, - &gridRef); - if (result != GhosttyResult.Success) - { - return null; - } - - nuint written = 0; - fixed (byte* bytes = buffer) - { - result = GhosttyNative.GridRefHyperlinkUri( - &gridRef, - bytes, - (nuint)buffer.Length, - &written); - } - - if (result == GhosttyResult.OutOfSpace) - { - buffer = new byte[checked((int)written)]; - fixed (byte* bytes = buffer) - { - result = GhosttyNative.GridRefHyperlinkUri( - &gridRef, - bytes, - (nuint)buffer.Length, - &written); - } - } - - if (result != GhosttyResult.Success || written == 0) - { - return null; - } - - return Encoding.UTF8.GetString(buffer, 0, checked((int)written)); - } - - private TermColor GetCellColor(int data) - { - GhosttyColorRgb color; - var result = GhosttyNative.RowCellsGet(_rowCells.DangerousGetHandle(), data, &color); - return result == GhosttyResult.Success - ? TermColor.FromRgb(color.R, color.G, color.B) - : TermColor.Default; - } - - private void SynchronizeMetadata() - { - var rawTitle = QueryString(TerminalDataTitle); - var title = string.IsNullOrEmpty(rawTitle) ? "Devolutions Terminal" : rawTitle; - if (!string.Equals(_title, title, StringComparison.Ordinal)) - { - _title = title; - TitleChanged?.Invoke(this, _title); - } - - var rawWorkingDirectory = QueryString(TerminalDataPwd); - var workingDirectory = string.IsNullOrEmpty(rawWorkingDirectory) - ? null - : rawWorkingDirectory; - if (!string.Equals(_workingDirectory, workingDirectory, StringComparison.Ordinal)) - { - _workingDirectory = workingDirectory; - WorkingDirectoryChanged?.Invoke(this, _workingDirectory); - ShellIntegrationChanged?.Invoke(this, EventArgs.Empty); - } - } - - private ushort GetRenderUInt16(int data) - { - ushort value = 0; - ThrowIfFailed( - GhosttyNative.RenderStateGet(_renderState.DangerousGetHandle(), data, &value), - "get render dimension"); - return value; - } - - private int QueryInt(int data) - { - lock (_gate) - { - var value = 0; - ThrowIfFailed( - GhosttyNative.TerminalGet(_terminal.DangerousGetHandle(), data, &value), - "get terminal value"); - return value; - } - } - - private ushort QueryUInt16Core(int data) - { - ushort value = 0; - ThrowIfFailed( - GhosttyNative.TerminalGet(_terminal.DangerousGetHandle(), data, &value), - "get terminal coordinate"); - return value; - } - - private uint QueryUInt32(int data) - { - lock (_gate) - { - uint value = 0; - ThrowIfFailed( - GhosttyNative.TerminalGet(_terminal.DangerousGetHandle(), data, &value), - "get terminal pixel dimension"); - return value; - } - } - - private (int HistoryCount, int ScrollOffset) ReadScrollbar() - { - lock (_gate) - { - return ReadScrollbarCore(); - } - } - - private (int HistoryCount, int ScrollOffset) ReadScrollbarCore() - { - var value = new GhosttyScrollbar(); - ThrowIfFailed( - GhosttyNative.TerminalGet(_terminal.DangerousGetHandle(), 9, &value), - "get scrollbar"); - var historyRows = value.Total > value.Length ? value.Total - value.Length : 0; - var history = checked((int)Math.Min(historyRows, int.MaxValue)); - var top = checked((int)Math.Min(value.Offset, int.MaxValue)); - return (history, Math.Clamp(history - top, 0, history)); - } - - private void ResetHistoryAnchor() - { - _historyAnchor?.Dispose(); - _historyAnchor = null; - var pointTag = ReadScrollbarCore().HistoryCount == 0 ? 0 : 3; - var result = GhosttyNative.TerminalGridRefTrack( - _terminal.DangerousGetHandle(), - new GhosttyPoint { Tag = pointTag }, - out var tracked); - if (result == GhosttyResult.Success) - { - _historyAnchor = new SafeGhosttyTrackedGridRefHandle(tracked); - return; - } - - ThrowIfFailed(result, "track history origin"); - } - - private TextBufferSnapshot CreateHistorySnapshot() - { - var scrollbar = ReadScrollbarCore(); - if (scrollbar.HistoryCount == 0) - { - return _buffer.CreateSnapshot(); - } - - var originalOffset = scrollbar.ScrollOffset; - var lines = new List(scrollbar.HistoryCount + Rows); - var nextAbsoluteRow = 0; - for (var top = 0; top <= scrollbar.HistoryCount;) - { - ScrollToAbsoluteRow(top); - var viewport = _buffer.CreateSnapshot(); - var firstNewRow = Math.Max(0, nextAbsoluteRow - top); - for (var row = firstNewRow; row < viewport.Lines.Count; row++) - { - lines.Add(viewport.Lines[row]); - } - - nextAbsoluteRow = top + viewport.Rows; - if (top == scrollbar.HistoryCount) - { - break; - } - - top = Math.Min(scrollbar.HistoryCount, top + viewport.Rows); - } - - ScrollToAbsoluteRow(scrollbar.HistoryCount - originalOffset); - return new TextBufferSnapshot( - Columns, - Rows, - CursorX, - CursorY, - scrollbar.HistoryCount, - originalOffset, - lines); - } - - private void ScrollToAbsoluteRow(int row) - { - GhosttyNative.TerminalScrollViewport( - _terminal.DangerousGetHandle(), - new GhosttyScrollViewport - { - Tag = 3, - Row = (nuint)Math.Max(0, row), - }); - SynchronizeProjection(); - } - - private bool QueryMode(ushort value, bool ansi = false) - { - lock (_gate) - { - var config = new GhosttyModeConfig - { - Mode = (ushort)(value | (ansi ? 0x8000 : 0)), - }; - var result = GhosttyNative.TerminalGet( - _terminal.DangerousGetHandle(), - TerminalDataMode, - &config); - return result == GhosttyResult.Success && config.Value != 0; - } - } - - private string QueryString(int data) - { - var value = new GhosttyString(); - var result = GhosttyNative.TerminalGet(_terminal.DangerousGetHandle(), data, &value); - if (result == GhosttyResult.NoValue || value.Pointer == 0 || value.Length == 0) - { - return string.Empty; - } - - ThrowIfFailed(result, "get terminal string"); - return Encoding.UTF8.GetString( - new ReadOnlySpan((void*)value.Pointer, checked((int)value.Length))); - } - - private static CellFlags ToCellFlags(GhosttyStyle style) - { - var flags = CellFlags.None; - if (style.Bold != 0) flags |= CellFlags.Bold; - if (style.Faint != 0) flags |= CellFlags.Faint; - if (style.Italic != 0) flags |= CellFlags.Italic; - if (style.Underline != 0) flags |= CellFlags.Underline; - if (style.Blink != 0) flags |= CellFlags.Blink; - if (style.Inverse != 0) flags |= CellFlags.Inverse; - if (style.Invisible != 0) flags |= CellFlags.Invisible; - if (style.Strikethrough != 0) flags |= CellFlags.Strikethrough; - return flags; - } - - private static GhosttyColorRgb ToRgb(uint color) => new() - { - R = (byte)(color >> 16), - G = (byte)(color >> 8), - B = (byte)color, - }; - - private void RaiseInvalidated() - { - try - { - Invalidated?.Invoke(this, EventArgs.Empty); - } - catch (Exception) - { - // Subscribers (scrollbar, UIA) must not fail the PTY output pump. - } - } - - private static void ThrowIfFailed(GhosttyResult result, string operation) - { - if (result != GhosttyResult.Success) - { - throw new GhosttyException(operation, result); - } - } - - [UnmanagedCallersOnly(CallConvs = [typeof(CallConvCdecl)])] - private static void OnWritePty(nint terminal, nint userData, byte* data, nuint length) - { - if (TryGetInstance(userData) is { } instance) - { - try - { - instance.ResponseReady?.Invoke( - instance, - new ReadOnlySpan(data, checked((int)length)).ToArray()); - } - catch (Exception exception) - { - instance._callbackException ??= exception; - } - } - } - - [UnmanagedCallersOnly(CallConvs = [typeof(CallConvCdecl)])] - private static void OnBell(nint terminal, nint userData) - { - if (TryGetInstance(userData) is { } instance) - { - try - { - instance.Bell?.Invoke(instance, EventArgs.Empty); - } - catch (Exception exception) - { - instance._callbackException ??= exception; - } - } - } - - [UnmanagedCallersOnly(CallConvs = [typeof(CallConvCdecl)])] - private static void OnStateChanged(nint terminal, nint userData) - { - } - - [UnmanagedCallersOnly(CallConvs = [typeof(CallConvCdecl)])] - private static byte OnSize(nint terminal, nint userData, GhosttySizeReport* size) - { - var instance = TryGetInstance(userData); - if (instance is null || size is null) - { - return 0; - } - - size->Rows = checked((ushort)instance.Rows); - size->Columns = checked((ushort)instance.Columns); - size->CellWidth = instance._cellWidthPixels; - size->CellHeight = instance._cellHeightPixels; - return 1; - } - - [UnmanagedCallersOnly(CallConvs = [typeof(CallConvCdecl)])] - private static void OnClipboardWrite( - nint terminal, - nint userData, - GhosttyClipboardWrite* write) - { - var instance = TryGetInstance(userData); - var result = 1; - if (instance?._allowClipboardWrite == true && write is not null) - { - string? text = write->ContentsLength == 0 ? string.Empty : null; - for (nuint index = 0; index < write->ContentsLength; index++) - { - var content = write->Contents[index]; - var mime = ReadString(content.Mime); - if (mime.Equals("text/plain", StringComparison.OrdinalIgnoreCase) || - mime.StartsWith("text/plain;", StringComparison.OrdinalIgnoreCase)) - { - text = ReadString(content.Data); - break; - } - } - - if (text is not null) - { - try - { - instance.ClipboardWriteRequested?.Invoke(instance, text); - // Avalonia's clipboard API is asynchronous, while libghostty - // requires this callback to reply synchronously. OSC 52 ignores - // this status; acknowledged protocols are explicitly unsupported. - result = 2; - } - catch (Exception exception) - { - instance._callbackException ??= exception; - result = 5; - } - } - else - { - result = 2; - } - } - - if (write is not null && write->Reply != 0) - { - var reply = new GhosttyClipboardWriteReply - { - Size = (nuint)sizeof(GhosttyClipboardWriteReply), - Result = result, - }; - var callback = - (delegate* unmanaged[Cdecl])write->Reply; - callback(write, &reply); - } - } - - [UnmanagedCallersOnly(CallConvs = [typeof(CallConvCdecl)])] - private static void OnNotification( - nint terminal, - nint userData, - GhosttyDesktopNotification* notification) - { - var instance = TryGetInstance(userData); - if (instance?._allowNotifications != true || notification is null) - { - return; - } - - try - { - instance.NotificationRequested?.Invoke( - instance, - new TerminalNotification( - ReadString(notification->Title), - ReadString(notification->Body))); - } - catch (Exception exception) - { - instance._callbackException ??= exception; - } - } - - private static string ReadString(GhosttyString value) => - value.Pointer == 0 || value.Length == 0 - ? string.Empty - : Encoding.UTF8.GetString( - new ReadOnlySpan((void*)value.Pointer, checked((int)value.Length))); - - private static GhosttyTerminalEngine? TryGetInstance(nint userData) => - userData == 0 ? null : GCHandle.FromIntPtr(userData).Target as GhosttyTerminalEngine; - - private static int EstimateRows(ReadOnlySpan data, int columns) - { - var rows = 0; - var printable = 0; - for (var index = 0; index < data.Length; index++) - { - var value = data[index]; - if (value is (byte)'\n' or 0x0B or 0x0C) - { - rows++; - } - else if (value == 0x1B && - index + 1 < data.Length && - data[index + 1] is (byte)'D' or (byte)'E' or (byte)'M') - { - rows++; - index++; - } - else if (value >= 0x20 && value != 0x7F) - { - printable++; - } - } - - return rows + (printable / Math.Max(1, columns)); - } - -} diff --git a/src/Devolutions.Terminal.Package/Scripts/Test-PackageBuild.ps1 b/src/Devolutions.Terminal.Package/Scripts/Test-PackageBuild.ps1 index 454cb7b..7122b1e 100644 --- a/src/Devolutions.Terminal.Package/Scripts/Test-PackageBuild.ps1 +++ b/src/Devolutions.Terminal.Package/Scripts/Test-PackageBuild.ps1 @@ -83,13 +83,12 @@ function New-PackageFixture { [BitConverter]::GetBytes([uint32]0x60000020).CopyTo($image, 0x1AC) $machine = if ($architecture -eq "arm64") { [uint16]0xAA64 } else { [uint16]0x8664 } [BitConverter]::GetBytes($machine).CopyTo($image, 0x84) - foreach ($name in @("Devolutions.Terminal.exe", "dt.exe", "ghostty-vt.dll")) { + foreach ($name in @("Devolutions.Terminal.exe", "dt.exe")) { [IO.File]::WriteAllBytes((Join-Path $layout $name), $image) } foreach ($name in @("Devolutions.Terminal.ShellExt.dll", "dt-shell-integration.exe")) { [IO.File]::WriteAllBytes((Join-Path $native $name), $image) } - [IO.File]::WriteAllText((Join-Path $layout "THIRD-PARTY-NOTICES-GHOSTTY.txt"), "Fixture license") } } diff --git a/src/Devolutions.Terminal.Package/Scripts/Test-Packages.ps1 b/src/Devolutions.Terminal.Package/Scripts/Test-Packages.ps1 index a3e753a..37388b0 100644 --- a/src/Devolutions.Terminal.Package/Scripts/Test-Packages.ps1 +++ b/src/Devolutions.Terminal.Package/Scripts/Test-Packages.ps1 @@ -120,11 +120,7 @@ begin { $manifestPath = Join-Path $extractPath "AppxManifest.xml" Assert-Condition (Test-Path -LiteralPath $manifestPath) "AppxManifest.xml is missing from '$Path'." Assert-Condition (Test-Path -LiteralPath (Join-Path $extractPath "Devolutions.Terminal.exe")) "Devolutions.Terminal.exe is missing from '$Path'." - $ghosttyPath = Join-Path $extractPath "ghostty-vt.dll" - Assert-Condition (Test-Path -LiteralPath $ghosttyPath) "ghostty-vt.dll is missing from '$Path'." - Assert-Condition ( - Test-Path -LiteralPath (Join-Path $extractPath "THIRD-PARTY-NOTICES-GHOSTTY.txt") - ) "The Ghostty license notice is missing from '$Path'." + Assert-Condition ( Test-Path -LiteralPath (Join-Path $extractPath "SHELL-INTEGRATION-NOTICE.txt") ) "The shell integration notice is missing from '$Path'." @@ -147,10 +143,7 @@ begin { Assert-Condition ($identity.Publisher -ceq $ExpectedPublisher) "Unexpected package publisher '$($identity.Publisher)'; expected '$ExpectedPublisher'." Assert-Condition ($identity.ProcessorArchitecture -in @("x64", "arm64")) "Unexpected package architecture '$($identity.ProcessorArchitecture)'." $expectedMachine = if ($identity.ProcessorArchitecture -eq "arm64") { 0xAA64 } else { 0x8664 } - $ghosttyMachine = Get-PeMachine $ghosttyPath - Assert-Condition ( - $ghosttyMachine -eq $expectedMachine - ) "ghostty-vt.dll architecture 0x$($ghosttyMachine.ToString('X4')) does not match '$($identity.ProcessorArchitecture)' in '$Path'." + foreach ($helper in @("Devolutions.Terminal.ShellExt.dll", "dt-shell-integration.exe")) { $helperPath = Join-Path $extractPath $helper Assert-Condition (Test-Path -LiteralPath $helperPath -PathType Leaf) "'$helper' is missing from '$Path'." diff --git a/src/Devolutions.Terminal.Settings.Editor/PageViewModels.cs b/src/Devolutions.Terminal.Settings.Editor/PageViewModels.cs index de21f87..55fd067 100644 --- a/src/Devolutions.Terminal.Settings.Editor/PageViewModels.cs +++ b/src/Devolutions.Terminal.Settings.Editor/PageViewModels.cs @@ -212,8 +212,6 @@ private void Change(T oldValue, T newValue, Action update) public sealed class ProfileItemViewModel(ProfileSettings profile, Action changed, bool supportsPowerShellIse = true, Func? prepareTypeChange = null) : ObservableObject { - public static IReadOnlyList TerminalEngineChoices { get; } = - ["Inherit", "Built-in", "Ghostty"]; private string _environmentJson = SerializeEnvironment(profile.Environment); public string DisplayName => IsDefaults ? "Defaults" : $"{profile.Name} ({profile.Origin})"; @@ -295,24 +293,6 @@ public string ProfileType public string Name { get => IsDefaults ? "Defaults" : profile.Name; set { if (!IsDefaults) Change(profile.Name, value, v => profile.Name = v); } } public string? Guid => profile.Guid; public string? Source => profile.Source; - public string TerminalEngine - { - get => profile.TerminalEngine switch - { - TerminalEngineKind.BuiltIn => "Built-in", - TerminalEngineKind.Ghostty => "Ghostty", - _ => "Inherit", - }; - set => Change( - profile.TerminalEngine, - value.ToLowerInvariant() switch - { - "built-in" or "builtin" => TerminalEngineKind.BuiltIn, - "ghostty" => TerminalEngineKind.Ghostty, - _ => null, - }, - v => profile.TerminalEngine = v); - } public bool Hidden { get => profile.Hidden; set => Change(profile.Hidden, value, v => profile.Hidden = v); } public string Commandline { get => profile.Commandline; set => Change(profile.Commandline, value, v => profile.Commandline = v); } public string StartingDirectory { get => profile.StartingDirectory; set => Change(profile.StartingDirectory, value, v => profile.StartingDirectory = v); } @@ -863,17 +843,6 @@ private static JsonObject SerializeKnownEntry(NewTabMenuEntry entry) public sealed class RenderingSettingsViewModel(AppSettings settings, Action changed) : SettingsPageViewModel("Rendering", "Configure the graphics API and rendering fallbacks.") { - public IReadOnlyList TerminalEngines { get; } = ["Built-in", "Ghostty"]; - public string TerminalEngine - { - get => settings.TerminalEngine == TerminalEngineKind.Ghostty ? "Ghostty" : "Built-in"; - set => Change( - settings.TerminalEngine, - value.Equals("Ghostty", StringComparison.OrdinalIgnoreCase) - ? TerminalEngineKind.Ghostty - : TerminalEngineKind.BuiltIn, - v => settings.TerminalEngine = v); - } public string GraphicsApi { get => settings.GraphicsApi; set => Change(settings.GraphicsApi, value, v => settings.GraphicsApi = v); } public IReadOnlyList GraphicsApiChoices { get; } = ["automatic", "direct3d11", "opengl", "software"]; public bool DisablePartialInvalidation { get => settings.DisablePartialInvalidation; set => Change(settings.DisablePartialInvalidation, value, v => settings.DisablePartialInvalidation = v); } diff --git a/src/Devolutions.Terminal.Settings.Editor/SettingsView.axaml b/src/Devolutions.Terminal.Settings.Editor/SettingsView.axaml index 0a14bf2..ea78457 100644 --- a/src/Devolutions.Terminal.Settings.Editor/SettingsView.axaml +++ b/src/Devolutions.Terminal.Settings.Editor/SettingsView.axaml @@ -641,17 +641,11 @@ - - - - - - - + + + + + @@ -762,15 +756,9 @@ - - + + - - ["rendering.graphicsAPI"] = settings.GraphicsApi, ["rendering.disablePartialInvalidation"] = settings.DisablePartialInvalidation, ["rendering.software"] = settings.SoftwareRendering, - ["experimental.terminalEngine"] = TerminalEngineName(settings.TerminalEngine), ["compatibility.textMeasurement"] = settings.TextMeasurement, ["compatibility.ambiguousWidth"] = settings.AmbiguousWidth, ["defaultInputScope"] = settings.DefaultInputScope, @@ -1552,14 +1549,6 @@ private static JsonObject SerializeProfile(ProfileSettings profile, bool include } result["ise"] = ise; } - if (profile.TerminalEngine is { } terminalEngine) - { - result["experimental.terminalEngine"] = TerminalEngineName(terminalEngine); - } - else - { - result.Remove("experimental.terminalEngine"); - } result["colorScheme"] = ColorSchemeNode(profile.DarkColorScheme, profile.LightColorScheme); result["font"] = new JsonObject { @@ -2105,22 +2094,6 @@ private static CopyFormat CopyFormats(JsonObject root) _ => JsonValue.Create(false), }; - private static TerminalEngineKind? TerminalEngineValue(JsonNode? node) => - node is JsonValue value && value.TryGetValue(out var name) - ? name.ToLowerInvariant() switch - { - "builtin" or "built-in" or "windows-terminal" => TerminalEngineKind.BuiltIn, - "ghostty" => TerminalEngineKind.Ghostty, - _ => null, - } - : null; - - private static string TerminalEngineName(TerminalEngineKind engine) => engine switch - { - TerminalEngineKind.Ghostty => "ghostty", - _ => "builtin", - }; - private static JsonArray StringArray(IEnumerable values) => new(values.Select(static value => (JsonNode?)JsonValue.Create(value)).ToArray()); diff --git a/src/Devolutions.Terminal/Devolutions.Terminal.csproj b/src/Devolutions.Terminal/Devolutions.Terminal.csproj index 6124e9b..dab3d74 100644 --- a/src/Devolutions.Terminal/Devolutions.Terminal.csproj +++ b/src/Devolutions.Terminal/Devolutions.Terminal.csproj @@ -23,11 +23,6 @@ - + Condition="'%(ResolvedFileToPublish.AssetType)' == 'native' or '%(ResolvedFileToPublish.Filename)' == 'OpenConsole' or ('%(ResolvedFileToPublish.Filename)' == 'dt' and '%(ResolvedFileToPublish.Extension)' == '.exe') or '%(ResolvedFileToPublish.Filename)' == 'dt-pty-host'"> true diff --git a/tests/Devolutions.Terminal.App.Tests/Devolutions.Terminal.App.Tests.csproj b/tests/Devolutions.Terminal.App.Tests/Devolutions.Terminal.App.Tests.csproj index 06204f1..3b5db2c 100644 --- a/tests/Devolutions.Terminal.App.Tests/Devolutions.Terminal.App.Tests.csproj +++ b/tests/Devolutions.Terminal.App.Tests/Devolutions.Terminal.App.Tests.csproj @@ -1,14 +1,10 @@ - - ghostty - - key == "VERSION" || key.EndsWith("_VERSION", StringComparison.Ordinal)); diff --git a/tests/Devolutions.Terminal.App.Tests/PlatformLauncherTests.cs b/tests/Devolutions.Terminal.App.Tests/PlatformLauncherTests.cs index f81200a..13c090b 100644 --- a/tests/Devolutions.Terminal.App.Tests/PlatformLauncherTests.cs +++ b/tests/Devolutions.Terminal.App.Tests/PlatformLauncherTests.cs @@ -281,7 +281,6 @@ public void MacOsNotificationsUseOsascript() Assert.Equal(["-e", "display notification \"Complete\" with title \"Build\""], command.ArgumentList); Assert.Contains("osascript", launcher.GetCapabilityReport(), StringComparison.Ordinal); Assert.Contains("dt-pty-host", launcher.GetCapabilityReport(), StringComparison.Ordinal); - Assert.Contains("Ghostty engine:", launcher.GetCapabilityReport(), StringComparison.Ordinal); Assert.DoesNotContain("not bundled yet", launcher.GetCapabilityReport(), StringComparison.Ordinal); } diff --git a/tests/Devolutions.Terminal.App.Tests/TerminalEngineFactoryTests.cs b/tests/Devolutions.Terminal.App.Tests/TerminalEngineFactoryTests.cs index 86be3d6..62dbfca 100644 --- a/tests/Devolutions.Terminal.App.Tests/TerminalEngineFactoryTests.cs +++ b/tests/Devolutions.Terminal.App.Tests/TerminalEngineFactoryTests.cs @@ -1,5 +1,4 @@ using Devolutions.Terminal.Core; -using Devolutions.Terminal.Ghostty; using Devolutions.Terminal.Settings; using Devolutions.Terminal.App.Connections; using Xunit; @@ -9,7 +8,7 @@ namespace Devolutions.Terminal.App.Tests; public sealed class TerminalEngineFactoryTests { [Fact] - public void BuiltInRemainsTheDefault() + public void CreatesTheBuiltInEngine() { using var engine = TerminalEngineFactory.Create(new AppSettings(), new ProfileSettings()); @@ -17,39 +16,13 @@ public void BuiltInRemainsTheDefault() } [Fact] - public void GlobalGhosttySelectionCreatesGhosttyEngine() + public void LegacyGhosttySelectionLoadsTheBuiltInEngine() { - using var engine = TerminalEngineFactory.Create( - new AppSettings { TerminalEngine = TerminalEngineKind.Ghostty }, - new ProfileSettings()); + const string user = """{ "experimental.terminalEngine": "ghostty" }"""; + var settings = SettingsLoader.Load(SettingsLoader.ReadEmbeddedDefaults(), user); - Assert.IsType(engine); - } - - [Fact] - public void ProfileSelectionOverridesGlobalEngine() - { - using var engine = TerminalEngineFactory.Create( - new AppSettings { TerminalEngine = TerminalEngineKind.Ghostty }, - new ProfileSettings { TerminalEngine = TerminalEngineKind.BuiltIn }); + using var engine = TerminalEngineFactory.Create(settings, new ProfileSettings()); Assert.IsType(engine); } - - [Fact] - public void ProfileDefaultsOverrideGlobalEngineWhenProfileInherits() - { - using var engine = TerminalEngineFactory.Create( - new AppSettings - { - TerminalEngine = TerminalEngineKind.BuiltIn, - ProfileDefaults = new ProfileSettings - { - TerminalEngine = TerminalEngineKind.Ghostty, - }, - }, - new ProfileSettings()); - - Assert.IsType(engine); - } } diff --git a/tests/Devolutions.Terminal.Ghostty.Tests/Devolutions.Terminal.Ghostty.Tests.csproj b/tests/Devolutions.Terminal.Ghostty.Tests/Devolutions.Terminal.Ghostty.Tests.csproj deleted file mode 100644 index 39128a6..0000000 --- a/tests/Devolutions.Terminal.Ghostty.Tests/Devolutions.Terminal.Ghostty.Tests.csproj +++ /dev/null @@ -1,11 +0,0 @@ - - - ghostty - - - - - - - - diff --git a/tests/Devolutions.Terminal.Ghostty.Tests/GhosttyConPtyIntegrationTests.cs b/tests/Devolutions.Terminal.Ghostty.Tests/GhosttyConPtyIntegrationTests.cs deleted file mode 100644 index 8ab4e2b..0000000 --- a/tests/Devolutions.Terminal.Ghostty.Tests/GhosttyConPtyIntegrationTests.cs +++ /dev/null @@ -1,338 +0,0 @@ -using System.Runtime.Versioning; -using System.Text; -using Devolutions.Terminal.Connection; -using Devolutions.Terminal.Core; -using Xunit; - -namespace Devolutions.Terminal.Ghostty.Tests; - -[SupportedOSPlatform("windows")] -public sealed class GhosttyConPtyIntegrationTests -{ - public static bool IsWindows => OperatingSystem.IsWindows(); - - [Fact(Skip = "ConPTY is Windows-only.", SkipUnless = nameof(IsWindows))] - public async Task CmdEchoProjectsIntoGhosttyGrid() - { - var marker = "GHOSTTY_CONPTY_OK"; - await AssertGhosttyProjectsConPtyAsync( - EchoCommand(marker), - writeCommands: [], - marker, - TestContext.Current.CancellationToken); - } - - [Fact(Skip = "ConPTY is Windows-only.", SkipUnless = nameof(IsWindows))] - public async Task WindowsPowerShellMarkerProjectsIntoGhosttyGrid() - { - var marker = "GHOSTTY_PS_OK"; - await AssertGhosttyProjectsConPtyAsync( - WindowsPowerShellCommand("-NoLogo -NoProfile"), - writeCommands: ["Write-Output ([string]::Concat('GHOSTTY','_PS_OK'))\r"], - marker, - TestContext.Current.CancellationToken); - } - - [Fact(Skip = "ConPTY is Windows-only.", SkipUnless = nameof(IsWindows))] - public async Task WindowsPowerShellWithProfileProjectsPrompt() - { - await AssertGhosttyProjectsConPtyAsync( - WindowsPowerShellCommand(string.Empty), - writeCommands: [], - "PS ", - TestContext.Current.CancellationToken); - } - - [Fact(Skip = "ConPTY is Windows-only.", SkipUnless = nameof(IsWindows))] - public async Task ReplayOfPowerShellStartupBytesProjectsIntoGhosttyGrid() - { - var marker = "GHOSTTY_REPLAY_OK"; - var captured = await CaptureConPtyBytesAsync( - WindowsPowerShellCommand("-NoLogo -NoProfile"), - writeCommands: ["Write-Output ([string]::Concat('GHOSTTY','_REPLAY_OK'))\r", "exit\r"], - marker, - TestContext.Current.CancellationToken); - - using var ghostty = new GhosttyTerminalEngine(80, 24); - using var builtIn = new TerminalEngine(80, 24); - Exception? feedError = null; - try - { - ghostty.Feed(captured); - builtIn.Feed(captured); - } - catch (Exception ex) - { - feedError = ex; - } - - AssertProjectionContains( - marker, - captured, - ghostty, - builtIn, - feedError, - responseCount: 0); - } - - private static async Task AssertGhosttyProjectsConPtyAsync( - string commandLine, - string[] writeCommands, - string marker, - CancellationToken cancellationToken) - { - await using var connection = new ConPtyConnection(); - using var ghostty = new GhosttyTerminalEngine(80, 24); - using var builtIn = new TerminalEngine(80, 24); - var captured = new List(); - var responses = new List(); - Exception? feedError = null; - var gate = new object(); - - ghostty.Invalidated += (_, _) => ghostty.CreateSnapshot(includeHistory: true); - ghostty.ResponseReady += (_, data) => - { - lock (gate) - { - responses.Add(data); - } - - try - { - connection.Write(data); - } - catch (Exception ex) - { - lock (gate) - { - feedError ??= ex; - } - } - }; - connection.OutputReceived += (_, data) => - { - try - { - lock (gate) - { - if (feedError is not null) - { - captured.AddRange(data.Span.ToArray()); - return; - } - } - - ghostty.Feed(data.Span); - builtIn.Feed(data.Span); - } - catch (Exception ex) - { - lock (gate) - { - feedError ??= ex; - } - } - - lock (gate) - { - captured.AddRange(data.Span.ToArray()); - } - }; - - await connection.StartAsync(commandLine, null, 80, 24, cancellationToken); - foreach (var command in writeCommands) - { - connection.Write(command); - } - - await WaitForAsync( - () => - { - lock (gate) - { - return Encoding.UTF8.GetString([.. captured]).Contains(marker, StringComparison.Ordinal); - } - }, - TimeSpan.FromSeconds(15), - () => - { - lock (gate) - { - return Encoding.UTF8.GetString([.. captured]); - } - }, - cancellationToken); - - byte[] snapshot; - lock (captured) - { - snapshot = [.. captured]; - } - - AssertProjectionContains( - marker, - snapshot, - ghostty, - builtIn, - feedError, - responses.Count); - } - - private static async Task CaptureConPtyBytesAsync( - string commandLine, - string[] writeCommands, - string marker, - CancellationToken cancellationToken) - { - await using var connection = new ConPtyConnection(); - var captured = new List(); - var exited = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); - connection.OutputReceived += (_, data) => - { - lock (captured) - { - captured.AddRange(data.Span.ToArray()); - } - }; - connection.Exited += (_, code) => exited.TrySetResult(code); - - await connection.StartAsync(commandLine, null, 80, 24, cancellationToken); - foreach (var command in writeCommands) - { - connection.Write(command); - } - - await WaitForAsync( - () => - { - lock (captured) - { - return Encoding.UTF8.GetString([.. captured]).Contains(marker, StringComparison.Ordinal); - } - }, - TimeSpan.FromSeconds(15), - () => - { - lock (captured) - { - return Encoding.UTF8.GetString([.. captured]); - } - }, - cancellationToken); - _ = await exited.Task.WaitAsync(TimeSpan.FromSeconds(10), cancellationToken); - - lock (captured) - { - return [.. captured]; - } - } - - private static void AssertProjectionContains( - string marker, - byte[] captured, - GhosttyTerminalEngine ghostty, - TerminalEngine builtIn, - Exception? feedError, - int responseCount) - { - var raw = Encoding.UTF8.GetString(captured); - var ghosttyText = ViewportText(ghostty); - var builtInText = ViewportText(builtIn); - var detail = - $"feedError={feedError}\n" + - $"responses={responseCount}\n" + - $"rawChars={raw.Length} rawNonWs={CountNonWhitespace(raw)}\n" + - $"ghosttyNonWs={CountNonWhitespace(ghosttyText)} builtinNonWs={CountNonWhitespace(builtInText)}\n" + - $"ghostty={FormatViewport(ghosttyText)}\n" + - $"builtin={FormatViewport(builtInText)}\n" + - $"rawHead={FormatRawHead(captured)}"; - - Assert.True(feedError is null, detail); - Assert.Contains(marker, raw, StringComparison.Ordinal); - Assert.True(CountNonWhitespace(ghosttyText) > 0, detail); - Assert.True( - ghosttyText.Contains(marker, StringComparison.Ordinal) || - Compact(ghosttyText).Contains(Compact(marker), StringComparison.Ordinal), - detail); - Assert.True( - builtInText.Contains(marker, StringComparison.Ordinal) || - Compact(builtInText).Contains(Compact(marker), StringComparison.Ordinal), - detail); - } - - private static async Task WaitForAsync( - Func condition, - TimeSpan timeout, - Func snapshot, - CancellationToken cancellationToken) - { - var deadline = DateTime.UtcNow + timeout; - while (DateTime.UtcNow < deadline) - { - if (condition()) - { - return; - } - - await Task.Delay(25, cancellationToken); - } - - Assert.Fail($"Timed out waiting for ConPTY output. snapshot={FormatViewport(snapshot())}"); - } - - private static string ViewportText(ITerminalEngine engine) - { - var snapshot = engine.CreateSnapshot().Buffer; - var builder = new StringBuilder(); - for (var row = 0; row < snapshot.Rows; row++) - { - foreach (var cell in snapshot.Lines[row].Cells) - { - builder.Append(cell.Text); - } - - builder.Append('\n'); - } - - return builder.ToString(); - } - - private static int CountNonWhitespace(string text) => - text.Count(static value => !char.IsWhiteSpace(value)); - - private static string Compact(string text) => - string.Concat(text.Where(static value => !char.IsWhiteSpace(value))); - - private static string FormatViewport(string text) - { - var trimmed = text.TrimEnd(); - if (trimmed.Length > 400) - { - trimmed = trimmed[..400] + "…"; - } - - return trimmed.Replace("\n", "\\n").Replace("\r", "\\r"); - } - - private static string FormatRawHead(byte[] captured) - { - var length = Math.Min(captured.Length, 160); - return Convert.ToHexString(captured.AsSpan(0, length)); - } - - private static string EchoCommand(string value) - { - var comSpec = Environment.GetEnvironmentVariable("ComSpec") ?? "cmd.exe"; - return $"\"{comSpec}\" /d /s /c \"echo {value}\""; - } - - private static string WindowsPowerShellCommand(string arguments) - { - var powershell = Path.Combine( - Environment.GetFolderPath(Environment.SpecialFolder.System), - "WindowsPowerShell", - "v1.0", - "powershell.exe"); - return $"\"{powershell}\" {arguments}"; - } -} diff --git a/tests/Devolutions.Terminal.Ghostty.Tests/GhosttyProjectionTests.cs b/tests/Devolutions.Terminal.Ghostty.Tests/GhosttyProjectionTests.cs deleted file mode 100644 index ef936a3..0000000 --- a/tests/Devolutions.Terminal.Ghostty.Tests/GhosttyProjectionTests.cs +++ /dev/null @@ -1,91 +0,0 @@ -using System.Text; -using Devolutions.Terminal.Core; -using Devolutions.Terminal.Ghostty; -using Xunit; - -namespace Devolutions.Terminal.Ghostty.Tests; - -/// -/// Feeding only marks the projection stale, and a later read re-projects just -/// the rows Ghostty's render state reports dirty. Reading after every chunk -/// exercises that incremental path; scrolling the viewport to the bottom then -/// forces a full re-projection, and both must describe the same screen. -/// -public sealed class GhosttyProjectionTests -{ - private const string Esc = "\u001b"; - - public static TheoryData Streams => new() - { - { "partial rows", $"one\r\ntwo\r\nthree{Esc}[2;1Hchanged{Esc}[5;3Hx" }, - { "scrolling", string.Concat(Enumerable.Range(0, 30).Select(i => $"line {i:D2} {new string('#', i % 15)}\r\n")) }, - { "region scroll", $"{Esc}[2;5r{Esc}[5;1H" + string.Concat(Enumerable.Range(0, 12).Select(i => $"r{i}\r\n")) + $"{Esc}[r" }, - { "styled wide", $"{Esc}[1;31mred{Esc}[0m \u4E00\u4E00 {Esc}[44mblue{Esc}[0m e\u0301" }, - { "hyperlink and prompt", $"{Esc}]133;A\u0007PS> {Esc}]133;B\u0007{Esc}]8;;https://example.test\u0007link{Esc}]8;;\u0007\r\n" }, - { "alternate screen", $"primary\r\n{Esc}[?1049h{Esc}[Halt{Esc}[3;3Hz{Esc}[?1049lback" }, - { "erase", $"abcdef\r\nghijkl{Esc}[1;3H{Esc}[K{Esc}[2;2H{Esc}[1P" }, - }; - - [Theory] - [MemberData(nameof(Streams))] - public void IncrementalProjectionMatchesFullProjection(string name, string stream) - { - var bytes = Encoding.UTF8.GetBytes(stream); - using var engine = new GhosttyTerminalEngine(16, 6, historySize: 20); - var random = new Random(name.Length); - for (var offset = 0; offset < bytes.Length;) - { - var length = Math.Min(bytes.Length - offset, random.Next(1, 9)); - engine.Feed(bytes.AsSpan(offset, length)); - offset += length; - _ = engine.CreateSnapshot(); - } - - var incremental = engine.CreateSnapshot(); - engine.SetScrollOffset(0); - var full = engine.CreateSnapshot(); - - AssertSameScreen(name, full, incremental); - } - - [Fact] - public void FeedDefersProjectionUntilRead() - { - using var reader = new GhosttyTerminalEngine(20, 4); - using var writer = new GhosttyTerminalEngine(20, 4); - foreach (var line in new[] { "first\r\n", "second\r\n", "third" }) - { - reader.Feed(line); - _ = reader.CreateSnapshot(); - writer.Feed(line); - } - - // The cursor and grid accessors project on demand. - Assert.Equal((reader.CursorX, reader.CursorY), (writer.CursorX, writer.CursorY)); - AssertSameScreen("deferred", reader.CreateSnapshot(), writer.CreateSnapshot()); - Assert.Equal("third", writer.Buffer.GetText(0, 2, 19, 2).TrimEnd()); - } - - private static void AssertSameScreen(string name, TerminalSnapshot expected, TerminalSnapshot actual) - { - Assert.True( - (expected.Buffer.CursorX, expected.Buffer.CursorY) == (actual.Buffer.CursorX, actual.Buffer.CursorY), - $"{name}: cursor"); - Assert.Equal(expected.Buffer.Lines.Count, actual.Buffer.Lines.Count); - for (var row = 0; row < expected.Buffer.Lines.Count; row++) - { - var left = expected.Buffer.Lines[row]; - var right = actual.Buffer.Lines[row]; - Assert.True(left.Wrapped == right.Wrapped, $"{name}: row {row} wrapped"); - Assert.True( - left.Marks.Select(static mark => mark.StartColumn).SequenceEqual(right.Marks.Select(static mark => mark.StartColumn)), - $"{name}: row {row} marks"); - for (var column = 0; column < left.Cells.Count; column++) - { - Assert.True( - left.Cells[column].Equals(right.Cells[column]), - $"{name}: row {row} column {column}: '{left.Cells[column].Text}' vs '{right.Cells[column].Text}'"); - } - } - } -} diff --git a/tests/Devolutions.Terminal.Ghostty.Tests/GhosttyTerminalEngineTests.cs b/tests/Devolutions.Terminal.Ghostty.Tests/GhosttyTerminalEngineTests.cs deleted file mode 100644 index 69633ef..0000000 --- a/tests/Devolutions.Terminal.Ghostty.Tests/GhosttyTerminalEngineTests.cs +++ /dev/null @@ -1,584 +0,0 @@ -using System.Text; -using Devolutions.Terminal.Core; -using Devolutions.Terminal; -using Devolutions.Terminal.Ghostty; -using Avalonia.Input; -using Xunit; - -namespace Devolutions.Terminal.Ghostty.Tests; - -public sealed class GhosttyTerminalEngineTests -{ - public static TheoryData SharedVtCorpus => new() - { - "plain text", - "line one\r\nline two", - "\u001b[2;4Hpositioned", - "\u001b[31mred\u001b[0m normal", - "\u001b[1mbold\u001b[0m \u001b[4munderline\u001b[0m", - "\u001b[?2004hbracketed", - "\u001b[?1049halt screen\u001b[?1049lprimary", - }; - - [Fact] - public void AbiManifestMatchesPinnedSchema() - { - GhosttyAbi.Validate(); - - Assert.Contains("\"schema\":1", GhosttyAbi.TypeManifest); - } - - [Fact] - public void AbiCapabilitiesExplicitlyExcludeUnavailableSharedResources() - { - using var engine = new GhosttyTerminalEngine(); - - Assert.False(engine.Capabilities.HasFlag(TerminalEngineCapabilities.UnicodeGraphemeClusters)); - Assert.True(engine.Capabilities.HasFlag(TerminalEngineCapabilities.Vt52Keyboard)); - Assert.False(engine.Capabilities.HasFlag(TerminalEngineCapabilities.RowRendition)); - Assert.False(engine.Capabilities.HasFlag(TerminalEngineCapabilities.DrcsGlyphs)); - Assert.False(engine.Capabilities.HasFlag(TerminalEngineCapabilities.KittyKeyboard)); - Assert.False(engine.Capabilities.HasFlag(TerminalEngineCapabilities.ModifyOtherKeys)); - Assert.False(engine.Capabilities.HasFlag(TerminalEngineCapabilities.Win32Input)); - Assert.False(engine.Capabilities.HasFlag(TerminalEngineCapabilities.SixelImages)); - Assert.False(engine.Capabilities.HasFlag(TerminalEngineCapabilities.Iterm2Images)); - Assert.False(engine.Capabilities.HasFlag(TerminalEngineCapabilities.ConEmuImages)); - Assert.Empty(engine.CreateSnapshot().Images); - Assert.Empty(engine.CreateSnapshot().DrcsGlyphs); - } - - [Fact] - public void UnsupportedImageProtocolsProduceDeterministicDiagnostics() - { - using var engine = new GhosttyTerminalEngine(); - var diagnostics = new List(); - engine.Diagnostic += (_, value) => diagnostics.Add(value); - - engine.Feed("\u001bP7q~\u001b\\"); - engine.Feed("\u001b]133"); - engine.Feed("7;File=inline=1:AQ==\u0007"); - engine.Feed("\u001b]9;4;st=0;sz=1;AQ==\u001b\\"); - - Assert.Equal( - [ - "image.sixel.unsupported", - "image.osc1337.unsupported", - "image.conemu.unsupported", - ], - diagnostics.Select(static value => value.Code)); - Assert.Empty(engine.CreateSnapshot().Images); - } - - [Fact] - public void NonImageDcsQueriesDoNotProduceImageDiagnostics() - { - using var engine = new GhosttyTerminalEngine(); - var diagnostics = new List(); - engine.Diagnostic += (_, value) => diagnostics.Add(value); - - engine.Feed("\u001bP$qm\u001b\\\u001bP+q544e\u001b\\"); - - Assert.Empty(diagnostics); - } - - [Fact] - public void KittyGraphicsProducesSingleUnsupportedDiagnostic() - { - using var engine = new GhosttyTerminalEngine(); - var diagnostics = new List(); - engine.Diagnostic += (_, value) => diagnostics.Add(value); - - // Chunked kitty transmissions repeat APC G per chunk; only one diagnostic. - engine.Feed("\u001b_Ga=T,f=32,s=1,v=1,i=1,m=1;AQID\u001b\\"); - engine.Feed("\u001b_Gi=1,m=0;BA==\u001b\\"); - - Assert.Equal( - ["image.kitty.unsupported"], - diagnostics.Select(static value => value.Code)); - } - - [Fact] - public void EngineSurvivesAdversarialCorpusOnSmallStackThread() - { - // winterm-ghostty lesson: full libghostty assumed a 16 MB stack and - // overflowed the host default in production. libghostty-vt is VT-only, but - // pin the headroom: the engine must survive an adversarial corpus on a - // 256 KB-stack thread — 4x smaller than the ~1 MB default of the .NET - // thread-pool threads the ConPTY ReadLoop feeds it from. - var corpus = BuildAdversarialCorpus(); - Exception? failure = null; - var thread = new Thread(() => - { - try - { - using var engine = new GhosttyTerminalEngine(); - engine.Feed(corpus); - _ = engine.CreateSnapshot(includeHistory: true); - _ = engine.Title; - } - catch (Exception ex) - { - failure = ex; - } - }, maxStackSize: 256 * 1024); - thread.Start(); - thread.Join(); - - Assert.Null(failure); - } - - private static string BuildAdversarialCorpus() - { - var builder = new StringBuilder(capacity: 512 * 1024); - builder.Append(new string('x', 100 * 1024)); // single over-long line: wrap/reflow - builder.Append("\r\n"); - builder.Append("\u001b]8;;").Append(new string('a', 64 * 1024)).Append("link\u001b]8;;\u0007\r\n"); // long OSC 8 - for (var i = 0; i < 200; i++) - { - builder.Append("\u001b[38;5;").Append(i % 256).Append('m'); // SGR churn - } - - builder.Append("e").Append(new string('̃', 4096)).Append("\r\n"); // deep grapheme - builder.Append("\u001bP7q").Append(new string('~', 4096)).Append("\u001b\\\r\n"); // sixel-ish DCS - builder.Append(new string('進', 16 * 1024)).Append("\r\n"); // wide chars - for (var i = 0; i < 500; i++) - { - builder.Append("\u001b[").Append(i % 40 + 1).Append(';').Append(i % 100 + 1).Append('H'); - builder.Append("cell").Append(i).Append("\r\n"); - } - - return builder.ToString(); - } - - [Theory] - [InlineData("\u001b")] - [InlineData("\u001bP")] - [InlineData("\u001bPq")] - [InlineData("\u001bPq\u001b")] - [InlineData("\u001b]")] - [InlineData("\u001b]1")] - [InlineData("\u001b]1337;")] - [InlineData("\u001b]1337;\u001b")] - public void CancelledImageProbeStateRecoversForFollowingSequence(string prefix) - { - foreach (var cancellation in new[] { '\u0018', '\u001A' }) - { - using var engine = new GhosttyTerminalEngine(); - var diagnostics = new List(); - engine.Diagnostic += (_, value) => diagnostics.Add(value); - engine.Feed(prefix); - diagnostics.Clear(); - - engine.Feed($"{cancellation}\u001bPq~\u001b\\"); - - Assert.Equal( - ["image.sixel.unsupported"], - diagnostics.Select(static value => value.Code)); - } - } - - [Fact] - public void Vt52KeyboardEncodingMatchesBuiltInModeState() - { - using var builtIn = new TerminalEngine(); - using var ghostty = new GhosttyTerminalEngine(); - builtIn.Feed("\u001b[?2l"); - ghostty.Feed("\u001b[?2l"); - - var expected = KeyMapper.ToVt( - Key.Up, - KeyModifiers.None, - PhysicalKey.ArrowUp, - null, - builtIn.InputMode); - var actual = KeyMapper.ToVt( - Key.Up, - KeyModifiers.None, - PhysicalKey.ArrowUp, - null, - ghostty.InputMode); - - Assert.Equal("\u001bA", expected); - Assert.Equal(expected, actual); - } - - [Fact] - public void HistorySnapshotFromInvalidatedDoesNotClearViewport() - { - using var engine = new GhosttyTerminalEngine(120, 30); - engine.Invalidated += (_, _) => engine.CreateSnapshot(includeHistory: true); - - engine.Feed("hello Ghostty"); - - Assert.Equal("hello Ghostty", LineText(engine.CreateSnapshot().Buffer, 0)); - } - - [Fact] - public void InvalidatedSubscriberExceptionDoesNotFailFeed() - { - using var engine = new GhosttyTerminalEngine(20, 3); - engine.Invalidated += (_, _) => throw new InvalidOperationException("scrollbar"); - - engine.Feed("still projected"); - - Assert.Equal("still projected", LineText(engine.CreateSnapshot().Buffer, 0)); - } - - [Fact] - public void DefaultKeyboardModeIsAnsiNotVt52() - { - using var engine = new GhosttyTerminalEngine(); - - Assert.True(engine.InputMode.AnsiMode); - Assert.False(engine.InputMode.Win32InputMode); - Assert.Equal(0, engine.InputMode.ModifyOtherKeys); - Assert.Equal(KittyKeyboardFlags.None, engine.InputMode.KittyFlags); - } - - [Fact] - public void KittyKeyboardFlagsAreProjectedFromHostSequences() - { - using var engine = new GhosttyTerminalEngine(); - - engine.Feed("\u001b[>1u"); - Assert.Equal(KittyKeyboardFlags.DisambiguateEscapeCodes, engine.InputMode.KittyFlags); - - engine.Feed("\u001b[=3;2u"); - Assert.Equal( - KittyKeyboardFlags.DisambiguateEscapeCodes | KittyKeyboardFlags.ReportEventTypes, - engine.InputMode.KittyFlags); - - engine.Feed("\u001b[(); - var bells = 0; - engine.TitleChanged += (_, title) => titles.Add(title); - engine.Bell += (_, _) => bells++; - - engine.Feed("\u001b]0;Ghostty tab\u0007\u0007"); - - Assert.Equal("Ghostty tab", engine.Title); - Assert.Contains("Ghostty tab", titles); - Assert.Equal(1, bells); - } - - [Fact] - public void ScrollbackCanBeNavigatedAndSnapshotted() - { - using var engine = new GhosttyTerminalEngine(20, 3); - engine.Feed("one\r\ntwo\r\nthree\r\nfour\r\nfive"); - - Assert.True(engine.HistoryCount >= 2); - engine.SetScrollOffset(engine.HistoryCount); - - Assert.Equal(engine.HistoryCount, engine.ScrollOffset); - var snapshot = engine.CreateSnapshot(includeHistory: true).Buffer; - Assert.Equal(engine.HistoryCount, snapshot.HistoryCount); - Assert.Contains(snapshot.Lines, line => - string.Concat(line.Cells.Select(static cell => cell.Text)).TrimEnd() == "one"); - } - - [Fact] - public void NativeScrollbackHonorsProfileLimit() - { - using var engine = new GhosttyTerminalEngine(20, 3, historySize: 0); - engine.Feed("one\r\ntwo\r\nthree\r\nfour\r\nfive"); - - Assert.Equal(0, engine.HistoryCount); - } - - [Fact] - public void ZeroScrollbackInvalidatesCoordinatesWhenViewportRowsAreDiscarded() - { - using var engine = new GhosttyTerminalEngine(20, 3, historySize: 0); - var version = engine.Buffer.CoordinateVersion; - - engine.Feed("one\r\ntwo\r\nthree\r\nfour"); - - Assert.True(engine.Buffer.CoordinateVersion > version); - } - - [Fact] - public void ProjectionPreservesBlankBackgroundAndWrappedRows() - { - using var engine = new GhosttyTerminalEngine(5, 3); - var coordinateVersion = engine.Buffer.CoordinateVersion; - - engine.Feed("\u001b[41m\u001b[2J123456"); - - var snapshot = engine.CreateSnapshot().Buffer; - Assert.Equal(Devolutions.Terminal.Core.ColorKind.Rgb, snapshot.Lines[0].Cells[4].Attributes.Background.Kind); - Assert.True(snapshot.Lines[0].Wrapped); - Assert.Equal(coordinateVersion, engine.Buffer.CoordinateVersion); - Assert.Equal(Devolutions.Terminal.Core.ShellIntegrationKind.None, snapshot.Lines[2].Cells[4].ShellIntegration); - } - - [Fact] - public void CursorIsHiddenWhenScrolledOutsideViewport() - { - using var engine = new GhosttyTerminalEngine(20, 3); - engine.Feed("one\r\ntwo\r\nthree\r\nfour\r\nfive"); - - engine.SetScrollOffset(engine.HistoryCount); - - Assert.False(engine.CursorVisible); - Assert.Equal(4, engine.CursorX); - Assert.Equal(2, engine.CursorY); - } - - [Fact] - public void SearchRevealScrollsTheNativeViewport() - { - using var engine = new GhosttyTerminalEngine(20, 3); - using var search = new Devolutions.Terminal.TerminalSearchSession(engine); - engine.Feed("needle\r\ntwo\r\nthree\r\nfour\r\nfive"); - - search.Update("needle"); - - Assert.True(engine.ScrollOffset > 0); - } - - [Fact] - public void PositiveHistoryLimitIsNotConstrainedByGhosttyByteDefault() - { - using var engine = new GhosttyTerminalEngine(20, 3, historySize: 9001); - engine.Feed(string.Concat(Enumerable.Repeat("line\r\n", 5000))); - - Assert.True(engine.HistoryCount > 4000); - } - - [Fact] - public void SemanticPromptCreatesOneMarkAtItsActualColumn() - { - using var engine = new GhosttyTerminalEngine(20, 3); - engine.Feed("\u001b]133;A\u0007P> \u001b]133;B\u0007"); - - var line = engine.CreateSnapshot().Buffer.Lines[0]; - - var mark = Assert.Single(line.Marks); - Assert.Equal(0, mark.StartColumn); - Assert.Equal(Devolutions.Terminal.Core.ShellIntegrationKind.Prompt, line.Cells[0].ShellIntegration); - } - - [Fact] - public void HistoryEvictionAdvancesCoordinateVersion() - { - using var engine = new GhosttyTerminalEngine(20, 3, historySize: 10); - engine.Feed(string.Concat(Enumerable.Repeat("line\r\n", 5000))); - var version = engine.Buffer.CoordinateVersion; - var history = engine.HistoryCount; - - engine.Feed(new string('x', 100_000)); - - Assert.True(engine.Buffer.CoordinateVersion > version); - } - - [Fact] - public void Osc52ClipboardWriteHonorsPolicy() - { - using var engine = new GhosttyTerminalEngine(20, 3); - string? clipboard = null; - engine.ClipboardWriteRequested += (_, text) => clipboard = text; - var payload = Convert.ToBase64String(System.Text.Encoding.UTF8.GetBytes("hello Ω")); - - engine.Feed($"\u001b]52;c;{payload}\u0007"); - Assert.Null(clipboard); - - engine.ConfigureOptionalFeatures(allowClipboardWrite: true, allowNotifications: false); - engine.Feed($"\u001b]52;c;{payload}\u0007"); - Assert.Equal("hello Ω", clipboard); - } - - [Fact] - public void EmptyOsc52RequestsClipboardClear() - { - using var engine = new GhosttyTerminalEngine(20, 3); - string? clipboard = null; - engine.ConfigureOptionalFeatures(allowClipboardWrite: true, allowNotifications: false); - engine.ClipboardWriteRequested += (_, text) => clipboard = text; - - engine.Feed("\u001b]52;c;\u0007"); - - Assert.Equal(string.Empty, clipboard); - } - - [Fact] - public void DesktopNotificationHonorsPolicy() - { - using var engine = new GhosttyTerminalEngine(20, 3); - Devolutions.Terminal.Core.TerminalNotification? notification = null; - engine.NotificationRequested += (_, value) => notification = value; - - engine.Feed("\u001b]777;notify;Deploy;Completed\u0007"); - Assert.Null(notification); - - engine.ConfigureOptionalFeatures(allowClipboardWrite: false, allowNotifications: true); - engine.Feed("\u001b]777;notify;Deploy;Completed\u0007"); - Assert.Equal("Deploy", notification?.Title); - Assert.Equal("Completed", notification?.Body); - } - - [Fact] - public void HyperlinksAreProjectedIntoSharedCells() - { - using var engine = new GhosttyTerminalEngine(20, 3); - - engine.Feed("\u001b]8;;https://example.com\u001b\\link\u001b]8;;\u001b\\"); - - var firstCell = engine.CreateSnapshot().Buffer.Lines[0].Cells[0]; - Assert.Equal("https://example.com", firstCell.HyperlinkUri); - } - - [Fact] - public void DeviceReportsAreReturnedToThePty() - { - using var engine = new GhosttyTerminalEngine(20, 3); - byte[]? response = null; - engine.ResponseReady += (_, value) => response = value; - - engine.Feed("\u001b[2;4H\u001b[6n"); - - Assert.Equal("\u001b[2;4R", System.Text.Encoding.ASCII.GetString(response!)); - } - - [Fact] - public void SizeReportsUseCurrentCellPixelGeometry() - { - using var engine = new GhosttyTerminalEngine(10, 4); - var responses = new List(); - engine.ResponseReady += (_, value) => - responses.Add(System.Text.Encoding.ASCII.GetString(value)); - engine.Resize(10, 4, 8, 16); - - engine.Feed("\u001b[14t\u001b[16t\u001b[18t"); - - Assert.Contains("\u001b[4;64;80t", responses); - Assert.Contains("\u001b[6;16;8t", responses); - Assert.Contains("\u001b[8;4;10t", responses); - } - - [Fact] - public void PlainOutputDoesNotEmitFalseMetadataChanges() - { - using var engine = new GhosttyTerminalEngine(20, 3); - var titleChanges = 0; - var workingDirectoryChanges = 0; - engine.TitleChanged += (_, _) => titleChanges++; - engine.WorkingDirectoryChanged += (_, _) => workingDirectoryChanges++; - - engine.Feed("ordinary output"); - - Assert.Equal(0, titleChanges); - Assert.Equal(0, workingDirectoryChanges); - } - - [Fact] - public void FirstLargeFeedEvictionAdvancesCoordinateVersion() - { - using var engine = new GhosttyTerminalEngine(20, 3, historySize: 10); - var version = engine.Buffer.CoordinateVersion; - - engine.Feed(new string('x', 100_000)); - - Assert.True(engine.Buffer.CoordinateVersion > version); - } - - [Fact] - public void FirstControlSequenceScrollEvictionAdvancesCoordinateVersion() - { - using var engine = new GhosttyTerminalEngine(20, 3, historySize: 10); - var version = engine.Buffer.CoordinateVersion; - - engine.Feed(string.Concat(Enumerable.Repeat("\u001bD", 5000))); - - Assert.True(engine.Buffer.CoordinateVersion > version); - } - - [Fact] - public void FirstLineFeedEvictionAtLimitAdvancesCoordinateVersion() - { - using var engine = new GhosttyTerminalEngine(20, 3, historySize: 5000); - var version = engine.Buffer.CoordinateVersion; - - engine.Feed(string.Concat(Enumerable.Repeat("\n", 10_000))); - - Assert.True(engine.Buffer.CoordinateVersion > version); - } - - [Theory] - [MemberData(nameof(SharedVtCorpus))] - public void SharedCorpusMatchesBuiltInProjection(string sequence) - { - using var builtIn = new Devolutions.Terminal.Core.TerminalEngine(40, 5); - using var ghostty = new GhosttyTerminalEngine(40, 5); - - builtIn.Feed(sequence); - ghostty.Feed(sequence); - - var expected = builtIn.CreateSnapshot().Buffer; - var actual = ghostty.CreateSnapshot().Buffer; - Assert.Equal(expected.CursorX, actual.CursorX); - Assert.Equal(expected.CursorY, actual.CursorY); - Assert.Equal( - Enumerable.Range(0, expected.Rows).Select(row => LineText(expected, row)), - Enumerable.Range(0, actual.Rows).Select(row => LineText(actual, row))); - Assert.Equal(builtIn.BracketedPaste, ghostty.BracketedPaste); - Assert.Equal(builtIn.AlternateBufferActive, ghostty.AlternateBufferActive); - } - - private static string LineText(Devolutions.Terminal.Core.TextBufferSnapshot snapshot, int row) => - string.Concat(snapshot.Lines[row].Cells.Select(static cell => cell.Text)).TrimEnd(); -} diff --git a/tests/Devolutions.Terminal.Settings.Tests/SettingsParityTests.cs b/tests/Devolutions.Terminal.Settings.Tests/SettingsParityTests.cs index a25b45c..5295f94 100644 --- a/tests/Devolutions.Terminal.Settings.Tests/SettingsParityTests.cs +++ b/tests/Devolutions.Terminal.Settings.Tests/SettingsParityTests.cs @@ -474,8 +474,10 @@ public void ParsesCopyFormattingCompatibilityForms(string json, CopyFormat expec } [Fact] - public void TerminalEngineCanBeSelectedGloballyAndPerProfile() + public void RetiredTerminalEngineSettingLoadsAndIsLeftInPlace() { + // The Ghostty engine was removed; older files that still select it must load + // cleanly, and saving must not rewrite a key the user wrote. const string user = """ { "experimental.terminalEngine": "ghostty", @@ -484,7 +486,7 @@ public void TerminalEngineCanBeSelectedGloballyAndPerProfile() { "guid": "{11111111-1111-1111-1111-111111111111}", "name": "Inbox", - "experimental.terminalEngine": "builtin" + "experimental.terminalEngine": "ghostty" } ] } @@ -493,36 +495,10 @@ public void TerminalEngineCanBeSelectedGloballyAndPerProfile() var settings = SettingsLoader.Load(Defaults, user); - Assert.Equal(TerminalEngineKind.Ghostty, settings.TerminalEngine); - Assert.Equal(TerminalEngineKind.BuiltIn, Assert.Single(settings.Profiles).TerminalEngine); - + Assert.DoesNotContain(settings.Diagnostics, d => d.Message.Contains("terminalEngine", StringComparison.Ordinal)); + Assert.Equal("Inbox", Assert.Single(settings.Profiles).Name); var serialized = SettingsLoader.SerializeUserDocument(settings); Assert.Contains("\"experimental.terminalEngine\": \"ghostty\"", serialized); - Assert.Contains("\"experimental.terminalEngine\": \"builtin\"", serialized); - } - - [Fact] - public void InheritedTerminalEngineRemovesProfileOverride() - { - const string user = """ - { - "profiles": { - "list": [ - { - "guid": "{11111111-1111-1111-1111-111111111111}", - "name": "Inbox", - "experimental.terminalEngine": "ghostty" - } - ] - } - } - """; - var settings = SettingsLoader.Load(Defaults, user); - Assert.Single(settings.Profiles).TerminalEngine = null; - - var serialized = SettingsLoader.SerializeUserDocument(settings); - - Assert.DoesNotContain("\"experimental.terminalEngine\": \"ghostty\"", serialized); } [Fact] diff --git a/tools/Devolutions.Terminal.Bench/Devolutions.Terminal.Bench.csproj b/tools/Devolutions.Terminal.Bench/Devolutions.Terminal.Bench.csproj index bb18397..c43046a 100644 --- a/tools/Devolutions.Terminal.Bench/Devolutions.Terminal.Bench.csproj +++ b/tools/Devolutions.Terminal.Bench/Devolutions.Terminal.Bench.csproj @@ -7,7 +7,6 @@ false enable false - true @@ -16,9 +15,7 @@ - - diff --git a/tools/Devolutions.Terminal.Bench/Program.cs b/tools/Devolutions.Terminal.Bench/Program.cs index bb4d84b..02f70b9 100644 --- a/tools/Devolutions.Terminal.Bench/Program.cs +++ b/tools/Devolutions.Terminal.Bench/Program.cs @@ -22,12 +22,12 @@ namespace Devolutions.Terminal.Bench; /// coalescing ratio). /// render — snapshot/planning and CPU Skia rasterization, measured separately /// for a static screen, one-row updates, and scrolling screens. -/// corpus — write the deterministic VT input for reuse with Ghostty benchmarks. -/// vt — same-process ingest comparison of the builtin engine against raw -/// libghostty-vt and DT's Ghostty engine over per-workload corpora. +/// corpus — write the deterministic VT input for reuse with other benchmarks. +/// vt — same-process ingest throughput of the builtin engine against an +/// in-run clock control over per-workload corpora. /// /// Caveat: control mode does not paint. Render mode uses a raster surface, not -/// Avalonia's GPU/compositor or Ghostty's OpenGL/Metal renderer. +/// Avalonia's GPU/compositor. /// internal static class Program { diff --git a/tools/Devolutions.Terminal.Bench/VtBench.cs b/tools/Devolutions.Terminal.Bench/VtBench.cs index 7da7156..6d3a5a5 100644 --- a/tools/Devolutions.Terminal.Bench/VtBench.cs +++ b/tools/Devolutions.Terminal.Bench/VtBench.cs @@ -5,28 +5,24 @@ using System.Text.Json; using System.Text.Json.Serialization; using Devolutions.Terminal.Core; -using Devolutions.Terminal.Ghostty; namespace Devolutions.Terminal.Bench; /// -/// Same-process, same-bytes VT ingest comparison between the builtin engine and -/// Ghostty. Three targets consume identical chunks: -/// builtin — DT's own (the default engine). -/// ghostty-vt — raw ghostty_terminal_vt_write: Ghostty's parser and -/// screen with nothing else, the bar the builtin engine must clear. -/// ghostty — DT's , i.e. ghostty-vt plus -/// the per-feed render-state projection DT needs to paint it. +/// Same-process, same-bytes VT ingest throughput of the builtin engine, measured +/// against an in-run control. Two targets consume identical chunks: +/// builtin — DT's . +/// control — FNV-1a over the fed bytes: a serial loop that does no terminal +/// work, so its speed follows only the core clock and load. /// Runs interleave the targets in rotating order so thermal drift and -/// background load hit every target alike; fresh engines are built outside the -/// timed region. A verdict is only "faster"/"slower" when the run ranges do -/// not overlap. +/// background load hit both alike; fresh engines are built outside the timed +/// region. builtin/control is the number to compare across sessions, because +/// absolute MB/s drifts with the CPU's turbo state. /// -internal static partial class VtBench +internal static class VtBench { public const string Builtin = "builtin"; - public const string GhosttyVt = "ghostty-vt"; - public const string GhosttyDt = "ghostty"; + public const string Control = "control"; public static int Run(string[] args) { @@ -40,7 +36,7 @@ public static int Run(string[] args) var columns = Program.GetOption(args, "--columns", 120); var rows = Program.GetOption(args, "--rows", 30); var workloads = Split(Program.GetStringOption(args, "--workloads"), VtWorkloads.Names); - var targets = Split(Program.GetStringOption(args, "--targets"), [Builtin, GhosttyVt, GhosttyDt]); + var targets = Split(Program.GetStringOption(args, "--targets"), [Builtin, Control]); var exportDirectory = Program.GetStringOption(args, "--export"); var jsonPath = Program.GetStringOption(args, "--json"); var dataPath = Program.GetStringOption(args, "--data"); @@ -52,9 +48,9 @@ public static int Run(string[] args) foreach (var target in targets) { - if (target is not (Builtin or GhosttyVt or GhosttyDt)) + if (target is not (Builtin or Control)) { - throw new ArgumentException($"unknown target '{target}' (expected {Builtin}|{GhosttyVt}|{GhosttyDt})"); + throw new ArgumentException($"unknown target '{target}' (expected {Builtin}|{Control})"); } } @@ -95,19 +91,11 @@ public static int Run(string[] args) frameBytes, runs, []); - var exitCode = 0; foreach (var (name, bytes) in inputs) { var sha = Convert.ToHexString(SHA256.HashData(bytes)); Console.WriteLine(); Console.WriteLine($"[{name}] {bytes.Length / (1024.0 * 1024):F1} MiB sha256={sha[..16]}… {VtWorkloads.Describe(name)}"); - var equivalence = targets.Contains(Builtin) && (targets.Contains(GhosttyVt) || targets.Contains(GhosttyDt)) - ? CheckEquivalence(bytes, columns, rows, chunkSize) - : null; - if (equivalence is not null) - { - Console.WriteLine($" screen equivalence vs ghostty: {equivalence}"); - } var samples = targets.ToDictionary(target => target, _ => new List()); for (var run = -warmup; run < runs; run++) @@ -134,24 +122,16 @@ public static int Run(string[] args) $"retained={result.RetainedMiB:F1} MiB"); } - string? verdict = null; double? ratio = null; var builtin = results.FirstOrDefault(result => result.Target == Builtin); - var reference = results.FirstOrDefault(result => result.Target == GhosttyVt); - if (builtin is not null && reference is not null) + var control = results.FirstOrDefault(result => result.Target == Control); + if (builtin is not null && control is not null) { - ratio = builtin.MedianMBps / reference.MedianMBps; - verdict = builtin.MinMBps > reference.MaxMBps ? "faster" - : builtin.MaxMBps < reference.MinMBps ? "slower" - : "overlap"; - Console.WriteLine($" builtin vs ghostty-vt: {ratio:F2}x median throughput, verdict={verdict}"); - if (verdict != "faster") - { - exitCode = 1; - } + ratio = builtin.MedianMBps / control.MedianMBps; + Console.WriteLine($" builtin / control: {ratio:F4} median throughput"); } - report.Workloads.Add(new VtWorkloadReport(name, bytes.Length, sha, equivalence, results, ratio, verdict)); + report.Workloads.Add(new VtWorkloadReport(name, bytes.Length, sha, results, ratio)); } if (jsonPath is not null) @@ -161,9 +141,7 @@ public static int Run(string[] args) Console.WriteLine($"wrote {jsonPath}"); } - // A non-zero exit means "builtin did not beat ghostty-vt on every workload"; - // scripts use it as a gate, so --no-gate keeps a measurement-only run green. - return Array.IndexOf(args, "--no-gate") >= 0 ? 0 : exitCode; + return 0; } private static readonly JsonSerializerOptions JsonOptions = new() @@ -244,52 +222,10 @@ private static long PrivateBytes() private static IVtTarget Create(string target, int columns, int rows) => target switch { Builtin => new EngineTarget(new TerminalEngine(columns, rows)), - GhosttyDt => new EngineTarget(new GhosttyTerminalEngine(columns, rows)), - GhosttyVt => new RawGhosttyTarget(columns, rows), + Control => new ControlTarget(), _ => throw new ArgumentOutOfRangeException(nameof(target), target, null), }; - /// - /// Throughput is only comparable if both engines did the same work, so the - /// final visible screen and cursor must match. A mismatch is reported (with the - /// first differing row) rather than failing, because some workloads hit - /// documented engine differences such as grapheme-width policy. - /// - private static string CheckEquivalence(byte[] bytes, int columns, int rows, int chunkSize) - { - using var builtin = new TerminalEngine(columns, rows); - using var ghostty = new GhosttyTerminalEngine(columns, rows); - for (var offset = 0; offset < bytes.Length; offset += chunkSize) - { - var chunk = bytes.AsSpan(offset, Math.Min(chunkSize, bytes.Length - offset)); - builtin.Feed(chunk); - ghostty.Feed(chunk); - } - - var differing = 0; - var first = -1; - for (var row = 0; row < rows; row++) - { - var expected = builtin.Buffer.GetText(0, row, columns - 1, row).TrimEnd(); - var actual = ghostty.Buffer.GetText(0, row, columns - 1, row).TrimEnd(); - if (!string.Equals(expected, actual, StringComparison.Ordinal)) - { - differing++; - first = first < 0 ? row : first; - } - } - - var cursor = builtin.CursorX == ghostty.CursorX && builtin.CursorY == ghostty.CursorY - ? "cursor equal" - : $"cursor builtin=({builtin.CursorX},{builtin.CursorY}) ghostty=({ghostty.CursorX},{ghostty.CursorY})"; - var history = builtin.HistoryCount == ghostty.HistoryCount - ? $"history equal ({builtin.HistoryCount})" - : $"history builtin={builtin.HistoryCount} ghostty={ghostty.HistoryCount}"; - return differing == 0 - ? $"identical text, {cursor}, {history}" - : $"MISMATCH {differing}/{rows} rows (first row {first}), {cursor}, {history}"; - } - private static VtTargetReport Summarize(string target, List samples) { static double Mebibytes(VtSample sample) => sample.Bytes / (1024.0 * 1024.0); @@ -333,102 +269,30 @@ private sealed class EngineTarget(ITerminalEngine engine) : IVtTarget } /// - /// The pinned libghostty-vt with only the scrollback limit configured to match - /// the builtin engine's 9,001 lines; no effects or projection. A frame is what - /// Ghostty's renderer does per frame: update its render state, then clean it. + /// FNV-1a over every fed byte. Each step depends on the previous one, so the + /// loop cannot be vectorized and its speed tracks the core clock rather than + /// memory bandwidth, which is what drifts between sessions. /// - private sealed unsafe partial class RawGhosttyTarget : IVtTarget + private sealed class ControlTarget : IVtTarget { - private const int ScrollbackBytesOption = 27; - private const int ScrollbackLinesOption = 28; - private nint _terminal; - private nint _renderState; - - public RawGhosttyTarget(int columns, int rows) - { - Check(TerminalNew(0, out _terminal, checked((ushort)columns), checked((ushort)rows)), "ghostty_terminal_new"); - try - { - Check(TerminalSet(_terminal, ScrollbackBytesOption, null), "remove scrollback byte limit"); - var lines = (nuint)9001; - Check(TerminalSet(_terminal, ScrollbackLinesOption, &lines), "set scrollback line limit"); - Check(RenderStateNew(0, out _renderState), "ghostty_render_state_new"); - } - catch - { - Dispose(); - throw; - } - } + private uint _hash = 2166136261; public void Feed(ReadOnlySpan data) { - fixed (byte* bytes = data) + var hash = _hash; + foreach (var value in data) { - TerminalWrite(_terminal, bytes, (nuint)data.Length); + hash = (hash ^ value) * 16777619; } - } - - public void Frame() - { - Check(RenderStateUpdate(_renderState, _terminal), "ghostty_render_state_update"); - Check(RenderStateClean(_renderState), "ghostty_render_state_clean"); - } - public void Dispose() - { - if (_renderState != 0) - { - RenderStateFree(_renderState); - _renderState = 0; - } - - if (_terminal != 0) - { - TerminalFree(_terminal); - _terminal = 0; - } + _hash = hash; } - private static void Check(int result, string operation) + public void Frame() { - if (result != 0) - { - throw new InvalidOperationException($"{operation} failed with {result}"); - } } - [LibraryImport("ghostty-vt", EntryPoint = "ghostty_terminal_new")] - [UnmanagedCallConv(CallConvs = [typeof(CallConvCdecl)])] - private static partial int TerminalNew(nint allocator, out nint terminal, ushort columns, ushort rows); - - [LibraryImport("ghostty-vt", EntryPoint = "ghostty_terminal_set")] - [UnmanagedCallConv(CallConvs = [typeof(CallConvCdecl)])] - private static partial int TerminalSet(nint terminal, int option, void* value); - - [LibraryImport("ghostty-vt", EntryPoint = "ghostty_terminal_vt_write")] - [UnmanagedCallConv(CallConvs = [typeof(CallConvCdecl)])] - private static partial void TerminalWrite(nint terminal, byte* bytes, nuint length); - - [LibraryImport("ghostty-vt", EntryPoint = "ghostty_terminal_free")] - [UnmanagedCallConv(CallConvs = [typeof(CallConvCdecl)])] - private static partial void TerminalFree(nint terminal); - - [LibraryImport("ghostty-vt", EntryPoint = "ghostty_render_state_new")] - [UnmanagedCallConv(CallConvs = [typeof(CallConvCdecl)])] - private static partial int RenderStateNew(nint allocator, out nint state); - - [LibraryImport("ghostty-vt", EntryPoint = "ghostty_render_state_update")] - [UnmanagedCallConv(CallConvs = [typeof(CallConvCdecl)])] - private static partial int RenderStateUpdate(nint state, nint terminal); - - [LibraryImport("ghostty-vt", EntryPoint = "ghostty_render_state_clean")] - [UnmanagedCallConv(CallConvs = [typeof(CallConvCdecl)])] - private static partial int RenderStateClean(nint state); - - [LibraryImport("ghostty-vt", EntryPoint = "ghostty_render_state_free")] - [UnmanagedCallConv(CallConvs = [typeof(CallConvCdecl)])] - private static partial void RenderStateFree(nint state); + public void Dispose() => GC.KeepAlive(_hash); } private readonly record struct VtSample( @@ -458,10 +322,8 @@ private sealed record VtWorkloadReport( string Name, long Bytes, string Sha256, - string? Equivalence, VtTargetReport[] Targets, - double? BuiltinVsGhosttyVt, - string? Verdict); + double? BuiltinVsControl); private sealed record VtTargetReport( string Target, diff --git a/tools/Devolutions.Terminal.Bench/VtWorkloads.cs b/tools/Devolutions.Terminal.Bench/VtWorkloads.cs index 82ce6dd..9a0a393 100644 --- a/tools/Devolutions.Terminal.Bench/VtWorkloads.cs +++ b/tools/Devolutions.Terminal.Bench/VtWorkloads.cs @@ -4,7 +4,7 @@ namespace Devolutions.Terminal.Bench; /// /// Deterministic VT byte streams for engine-ingest comparisons. Each workload -/// isolates one kind of terminal work so a regression or a gap against Ghostty +/// isolates one kind of terminal work so a regression /// points at a code path instead of an average. The shapes follow /// alacritty/vtebench (dense/light cells, cursor motion, scrolling regions, /// unicode) and Ghostty's own `ghostty-gen` generators (+ascii, +utf8, +osc). @@ -186,9 +186,9 @@ private static byte[] Utf8(int targetBytes, int columns) { if (!started) { - // DECSET 2027 (grapheme clustering): without it Ghostty measures a ZWJ - // sequence per codepoint while the builtin engine clusters it, and the - // two screens would wrap differently. + // DECSET 2027 (grapheme clustering) keeps ZWJ sequences clustered on + // terminals that make clustering opt-in, so the corpus wraps the same + // everywhere it is replayed. writer.Append($"{Esc}[?2027h"); started = true; }