diff --git a/.github/FUNDING.yml b/.github/FUNDING.yml
new file mode 100644
index 00000000000..4318ba4f9d7
--- /dev/null
+++ b/.github/FUNDING.yml
@@ -0,0 +1,15 @@
+# These are supported funding model platforms
+
+github: [deblasis]
+patreon: # Replace with a single Patreon username
+open_collective: # Replace with a single Open Collective username
+ko_fi: deblasis
+tidelift: # Replace with a single Tidelift platform-name/package-name e.g., npm/babel
+community_bridge: # Replace with a single Community Bridge project-name e.g., cloud-foundry
+liberapay: # Replace with a single Liberapay username
+issuehunt: # Replace with a single IssueHunt username
+lfx_crowdfunding: # Replace with a single LFX Crowdfunding project-name e.g., cloud-foundry
+polar: # Replace with a single Polar username
+buy_me_a_coffee: # Replace with a single Buy Me a Coffee username
+thanks_dev: # Replace with a single thanks.dev username
+custom: deblasis.eth
diff --git a/.github/workflows/clean-artifacts.yml b/.github/workflows/clean-artifacts.yml
index 69cb74ae522..0b388af5bc8 100644
--- a/.github/workflows/clean-artifacts.yml
+++ b/.github/workflows/clean-artifacts.yml
@@ -6,6 +6,7 @@ on:
workflow_dispatch:
jobs:
remove-old-artifacts:
+ if: github.repository == 'ghostty-org/ghostty'
runs-on: ubuntu-latest
timeout-minutes: 10
steps:
diff --git a/.github/workflows/dispatch-release-bump.yml b/.github/workflows/dispatch-release-bump.yml
new file mode 100644
index 00000000000..8e00a96927e
--- /dev/null
+++ b/.github/workflows/dispatch-release-bump.yml
@@ -0,0 +1,31 @@
+# Fires a repository_dispatch at deblasis/wintty-release on windows-branch pushes.
+
+name: dispatch-release-bump
+
+on:
+ push:
+ branches: [windows]
+
+concurrency:
+ group: ${{ github.workflow }}-${{ github.ref }}
+ cancel-in-progress: false
+
+permissions: {}
+
+jobs:
+ dispatch:
+ if: vars.ENABLE_WINTTY_RELEASE_DISPATCH == 'true'
+ runs-on: ubuntu-latest
+ timeout-minutes: 5
+ steps:
+ - name: Dispatch wintty-windows-head to deblasis/wintty-release
+ env:
+ GH_TOKEN: ${{ secrets.WINTTY_RELEASE_DISPATCH_PAT }}
+ COMMIT_SUBJECT: ${{ github.event.head_commit.message }}
+ run: |
+ gh api /repos/deblasis/wintty-release/dispatches \
+ -f event_type=wintty-windows-head \
+ -f "client_payload[sha]=${{ github.sha }}" \
+ -f "client_payload[source_repo]=deblasis/wintty" \
+ -f "client_payload[ref]=${{ github.ref }}" \
+ -f "client_payload[subject]=$COMMIT_SUBJECT"
diff --git a/.github/workflows/milestone.yml b/.github/workflows/milestone.yml
index 34e7652a7b9..1392dc443cc 100644
--- a/.github/workflows/milestone.yml
+++ b/.github/workflows/milestone.yml
@@ -13,7 +13,7 @@ jobs:
update-milestone:
# Ignore bot-authored pull requests (dependabot, app bots, etc)
# and CI-only PRs.
- if: github.event_name == 'issues' || (github.event.pull_request.user.type != 'Bot' && !startsWith(github.event.pull_request.title, 'ci:'))
+ if: github.repository == 'ghostty-org/ghostty' && (github.event_name == 'issues' || (github.event.pull_request.user.type != 'Bot' && !startsWith(github.event.pull_request.title, 'ci:')))
runs-on: namespace-profile-ghostty-sm
name: Milestone Update
steps:
diff --git a/.github/workflows/publish-tag.yml b/.github/workflows/publish-tag.yml
index acb1ab1f13c..b300898e41f 100644
--- a/.github/workflows/publish-tag.yml
+++ b/.github/workflows/publish-tag.yml
@@ -15,6 +15,7 @@ concurrency:
jobs:
setup:
+ if: github.repository == 'ghostty-org/ghostty'
runs-on: namespace-profile-ghostty-sm
outputs:
version: ${{ steps.extract_version.outputs.version }}
diff --git a/.github/workflows/release-tag.yml b/.github/workflows/release-tag.yml
index 04ee3dabcd0..8edea506c2e 100644
--- a/.github/workflows/release-tag.yml
+++ b/.github/workflows/release-tag.yml
@@ -22,6 +22,7 @@ concurrency:
jobs:
setup:
+ if: github.repository == 'ghostty-org/ghostty'
runs-on: namespace-profile-ghostty-sm
outputs:
version: ${{ steps.extract_version.outputs.version }}
diff --git a/.github/workflows/snap.yml b/.github/workflows/snap.yml
index f908110c2e9..c7e855f80a2 100644
--- a/.github/workflows/snap.yml
+++ b/.github/workflows/snap.yml
@@ -14,6 +14,7 @@ name: Snap
jobs:
build:
+ if: github.repository == 'ghostty-org/ghostty'
strategy:
fail-fast: false
matrix:
diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml
index 5ba099c8d04..e591dce6c83 100644
--- a/.github/workflows/test.yml
+++ b/.github/workflows/test.yml
@@ -79,7 +79,7 @@ jobs:
required:
name: "Required Checks: Test"
- if: always()
+ if: always() && github.repository == 'ghostty-org/ghostty'
runs-on: namespace-profile-ghostty-xsm
needs:
- skip
diff --git a/.github/workflows/trigger-release.yml b/.github/workflows/trigger-release.yml
new file mode 100644
index 00000000000..c8b3a85c796
--- /dev/null
+++ b/.github/workflows/trigger-release.yml
@@ -0,0 +1,40 @@
+# On tag push (v*), fires a repository_dispatch at deblasis/wintty-release so
+# the private release repo can build, pack, and upload without R2 or signing
+# credentials living in this public repo.
+#
+# Dormant until vars.ENABLE_WINTTY_RELEASE_DISPATCH == 'true', so it stays
+# quiet before the private repo + dispatch PAT are provisioned.
+
+name: trigger-release
+
+on:
+ push:
+ tags:
+ - 'v*'
+
+concurrency:
+ group: ${{ github.workflow }}-${{ github.ref }}
+ cancel-in-progress: false
+
+jobs:
+ dispatch:
+ if: vars.ENABLE_WINTTY_RELEASE_DISPATCH == 'true'
+ runs-on: ubuntu-latest
+ steps:
+ - name: Derive channel from ref
+ id: chan
+ shell: bash
+ run: |
+ case "${{ github.ref }}" in
+ refs/tags/v*) echo "channel=stable" >> "$GITHUB_OUTPUT" ;;
+ *) echo "channel=tip" >> "$GITHUB_OUTPUT" ;;
+ esac
+
+ - name: Dispatch release event to deblasis/wintty-release
+ env:
+ GH_TOKEN: ${{ secrets.WINTTY_RELEASE_DISPATCH_PAT }}
+ run: |
+ gh api /repos/deblasis/wintty-release/dispatches \
+ -f event_type=release \
+ -f "client_payload[channel]=${{ steps.chan.outputs.channel }}" \
+ -f "client_payload[ref]=${{ github.ref }}"
diff --git a/.github/workflows/vouch-check-issue.yml b/.github/workflows/vouch-check-issue.yml
index d01f2485384..8a6c57b5414 100644
--- a/.github/workflows/vouch-check-issue.yml
+++ b/.github/workflows/vouch-check-issue.yml
@@ -6,6 +6,7 @@ name: "Vouch - Check Issue"
jobs:
check:
+ if: github.repository == 'ghostty-org/ghostty'
runs-on: namespace-profile-ghostty-xsm
steps:
- uses: actions/create-github-app-token@1b10c78c7865c340bc4f6099eb2f838309f1e8c3 # v3.1.1
diff --git a/.github/workflows/vouch-check-pr.yml b/.github/workflows/vouch-check-pr.yml
index cbba619cea1..b33fd21ea5c 100644
--- a/.github/workflows/vouch-check-pr.yml
+++ b/.github/workflows/vouch-check-pr.yml
@@ -6,6 +6,7 @@ name: "Vouch - Check PR"
jobs:
check:
+ if: github.repository == 'ghostty-org/ghostty'
runs-on: namespace-profile-ghostty-xsm
steps:
- uses: actions/create-github-app-token@1b10c78c7865c340bc4f6099eb2f838309f1e8c3 # v3.1.1
diff --git a/.github/workflows/vouch-manage-by-discussion.yml b/.github/workflows/vouch-manage-by-discussion.yml
index 0c8a4eab8f1..36d4028ba37 100644
--- a/.github/workflows/vouch-manage-by-discussion.yml
+++ b/.github/workflows/vouch-manage-by-discussion.yml
@@ -10,6 +10,7 @@ concurrency:
jobs:
manage:
+ if: github.repository == 'ghostty-org/ghostty'
runs-on: namespace-profile-ghostty-xsm
steps:
- uses: actions/create-github-app-token@1b10c78c7865c340bc4f6099eb2f838309f1e8c3 # v3.1.1
diff --git a/.github/workflows/vouch-manage-by-issue.yml b/.github/workflows/vouch-manage-by-issue.yml
index d07e247a224..042a9e79374 100644
--- a/.github/workflows/vouch-manage-by-issue.yml
+++ b/.github/workflows/vouch-manage-by-issue.yml
@@ -10,6 +10,7 @@ concurrency:
jobs:
manage:
+ if: github.repository == 'ghostty-org/ghostty'
runs-on: namespace-profile-ghostty-xsm
steps:
- uses: actions/create-github-app-token@1b10c78c7865c340bc4f6099eb2f838309f1e8c3 # v3.1.1
diff --git a/.github/workflows/vouch-sync-codeowners.yml b/.github/workflows/vouch-sync-codeowners.yml
index 7db9dcefb37..81902c824c2 100644
--- a/.github/workflows/vouch-sync-codeowners.yml
+++ b/.github/workflows/vouch-sync-codeowners.yml
@@ -11,6 +11,7 @@ concurrency:
jobs:
sync:
+ if: github.repository == 'ghostty-org/ghostty'
runs-on: namespace-profile-ghostty-xsm
steps:
- uses: actions/create-github-app-token@1b10c78c7865c340bc4f6099eb2f838309f1e8c3 # v3.1.1
diff --git a/.gitignore b/.gitignore
index 699ac9a5f21..f63ff458e3d 100644
--- a/.gitignore
+++ b/.gitignore
@@ -4,6 +4,18 @@
*.log
.DS_Store
.vscode/
+.vs/
+
+# .NET build output (scoped to known .NET project roots to avoid
+# masking Zig build paths). windows/ is the WinUI 3 shell; dist/windows/
+# is the IconGen build tool and its tests.
+windows/**/bin/
+windows/**/obj/
+dist/windows/**/bin/
+dist/windows/**/obj/
+# MSBuild binary log, created by `dotnet build -bl` and some Visual
+# Studio workflows. Never something we want committed.
+*.binlog
.direnv/
.envrc.local
.flatpak-builder/
@@ -29,3 +41,12 @@ glad.zip
vgcore.*
+# MSVC build outputs for glslang shader wrapper (regenerated by build_msvc.bat)
+pkg/glslang/glslang_dll/
+pkg/glslang/msvc_build/
+pkg/glslang/msvc_build_md/
+
+# IconGen preview output from `just branding-preview`
+/dist/windows/preview/
+
+zig-pkg/
diff --git a/.zig-version b/.zig-version
new file mode 100644
index 00000000000..4312e0d0cae
--- /dev/null
+++ b/.zig-version
@@ -0,0 +1 @@
+0.15.2
diff --git a/README.md b/README.md
index 808b684daf0..f00d59fec67 100644
--- a/README.md
+++ b/README.md
@@ -1,8 +1,9 @@
-
-
Ghostty
+
+
Wintty
+
Fast, native, feature-rich terminal emulator pushing modern features.
@@ -21,6 +22,121 @@
+> [!IMPORTANT]
+> ## Wintty (Ghostty R&D Soft Fork)
+>
+> 🍬🍴
+>
+> This is a soft fork focused on bringing Ghostty to Windows.
+> The `windows` branch is the default and contains all Windows-specific work
+> rebased on top of upstream `main`, which is synced daily.
+>
+> **Status:** DX12 renderer in progress, WinUI 3 shell with tabs, splits, jump list, taskbar progress, and runtime layout switch landed
+>
+> **MVWT** Minimum Viewable Windows Terminal
+> `[█████████████████▌░░] 88%`
+>
+> **MVT** Moonshot Viable Terminal ([#26](https://github.com/deblasis/ghostty/issues/26))
+> `[░░░░░░░░░░░░░░░░░░░░] 0%`
+>
+> The Windows app is a native C# GUI wrapping `libghostty.dll`, same architecture
+> as macOS where Swift wraps `libghostty`. All terminal emulation stays in Zig.
+> The C# layer handles windowing, input, and platform integration via P/Invoke.
+> The renderer uses DirectX 12 with DXGI swap chains and DirectComposition.
+>
+> ### Building
+>
+> Prerequisites: [Zig](https://ziglang.org/) (version in `build.zig.zon`), [Just](https://github.com/casey/just)
+>
+> ```bash
+> just # Run tests + build DLL
+> just test # Run all Zig tests
+> just test-lib-vt # Fast: test libghostty-vt only
+> just build-dll # Build libghostty.dll
+> just sync # Rebase on latest upstream
+> ```
+>
+> See [docs/windows/tooling.md](docs/windows/tooling.md) for why Just and how CI (I should call it DisContinous Integration) works.
+>
+> ### Branching Model
+>
+> - `main` - mirror of upstream `ghostty-org/ghostty`, synced daily, if it's a good day
+> - `windows` (default) - all Windows work rebased on upstream
+> - Feature branches - branch off `windows`, PR back into `windows`
+>
+> ### What is done
+>
+> **Build infrastructure** (17 PRs merged upstream)
+>
+> - [x] `zig build test` passing on Windows (2604 tests, 53 skipped)
+> - [x] All shared dependencies building (FreeType, HarfBuzz, zlib, oniguruma, glslang, etc.)
+> - [x] `zig build test-lib-vt` passing on all platforms
+> - [x] Windows CI running without `continue-on-error`
+> - [x] Backslash path handling in config parsing
+> - [x] CRLF line ending fix for comptime parsing + `.gitattributes` normalization
+> - [x] `ghostty.dll` building on Windows (CRT init fix for MSVC DLL mode)
+> - [x] DLL init regression test and build instructions
+> - [x] Full Windows CI test suite
+>
+> **DX12 renderer infrastructure** (in fork, in progress)
+>
+> - [x] DXGI bindings (adapters, factories, swap chains -- carried from DX11)
+> - [x] DirectComposition bindings (DWM composition -- carried from DX11)
+> - [x] COM helpers and test infrastructure (carried from DX11)
+> - [x] HLSL shaders (5 pipelines, SM 6.0 via dxc.exe)
+> - [x] D3D12 COM interface bindings
+> - [x] DX12 device lifecycle (command queue, fence, descriptor heaps)
+> - [x] DX12 render pipeline (PSOs, root signatures, command lists)
+> - [x] DX12 GPU primitives (upload heap buffers, textures, samplers)
+> - [x] Backend enum with `directx12` variant
+>
+> **SwapChainPanel spike** (in fork, [demo video](https://www.youtube.com/watch?v=-Cn9mlxX_GA))
+>
+> - [x] DX11 swap chain created from Zig, bound to WinUI 3 SwapChainPanel
+> - [x] Instanced cell grid rendering, bitmap font, animated demo scenes, resize, DPI
+>
+> **App scaffold** (in fork)
+>
+> - [x] C# WinUI 3 project scaffold (`windows/Ghostty/`)
+> - [x] P/Invoke bindings for libghostty C API
+> - [x] `--version` flag working from command line
+> - [x] Interop test suite (7 tests against the real DLL)
+>
+> ### Architecture: Surface Modes
+>
+> The DX12 renderer supports three surface modes at the library level so that
+> libghostty consumers can pick whichever model fits their host:
+> - **HWND** -- `CreateSwapChainForHwnd` via DXGI, for standalone windows, test harnesses, and third-party embedders
+> - **SwapChainPanel** (composition) -- `CreateSwapChainForComposition` via DXGI, for WinUI 3 / XAML hosts
+> - **Shared texture** -- renders to a standalone `ID3D12Resource` (texture) with a DXGI shared handle, for game engines, custom renderers, and offscreen scenarios
+>
+> The device picks the path based on what the caller provides. No compile-time flags.
+>
+> ### What is next
+>
+> **Feature parity** (later)
+>
+> - [ ] Multi-window, tabs, splits
+> - [ ] Native settings UI, desktop notifications
+> - [ ] Quick terminal, command palette, global keybinds
+> - [ ] Installer packages (MSI, MSIX, winget), auto-update
+>
+> ### .NET Examples
+>
+> .NET-specific examples live in [deblasis/libghostty-dotnet](https://github.com/deblasis/libghostty-dotnet),
+> separate from the `example/` directory in this repo which is for C and Zig.
+> These examples help surface friction points, bugs, and integration gaps
+> from the perspective of a .NET consumer of libghostty.
+>
+> ### History
+>
+> This fork started as an upstream contribution effort. 17 PRs were merged
+> into ghostty-org/ghostty covering build fixes, CI, and DLL infrastructure.
+> The project continues as a soft fork - upstream doesn't have capacity to
+> maintain Windows-specific changes right now, so here we are.
+> GitHub Actions are disabled for this fork because we are poor and just.
+> I mean, we use `just` for insanity checks.
+
## About
Ghostty is a terminal emulator that differentiates itself by being
diff --git a/build.zig b/build.zig
index 8ef7701e22c..865cbacaab2 100644
--- a/build.zig
+++ b/build.zig
@@ -194,8 +194,14 @@ pub fn build(b: *std.Build) !void {
if (!config.target.result.os.tag.isDarwin()) {
lib_shared.installHeader(); // Only need one header
if (config.target.result.os.tag == .windows) {
- lib_shared.install("ghostty-internal.dll");
- lib_static.install("ghostty-internal-static.lib");
+ lib_shared.install("ghostty.dll");
+ if (lib_shared.implib) |implib| {
+ b.getInstallStep().dependOn(&b.addInstallLibFile(
+ implib,
+ "ghostty.lib",
+ ).step);
+ }
+ lib_static.install("ghostty-static.lib");
} else {
lib_shared.install("ghostty-internal.so");
lib_static.install("ghostty-internal.a");
diff --git a/build.zig.zon b/build.zig.zon
index 814145c30cd..4c0325e05b0 100644
--- a/build.zig.zon
+++ b/build.zig.zon
@@ -80,8 +80,7 @@
.zlib = .{ .path = "./pkg/zlib", .lazy = true },
// Shader translation
- .glslang = .{ .path = "./pkg/glslang", .lazy = true },
- .spirv_cross = .{ .path = "./pkg/spirv-cross", .lazy = true },
+ .glslpp = .{ .path = "../glslpp" },
// Wayland
.wayland = .{
diff --git a/dev-configs/validate-transport/README.md b/dev-configs/validate-transport/README.md
new file mode 100644
index 00000000000..ab5b294b085
--- /dev/null
+++ b/dev-configs/validate-transport/README.md
@@ -0,0 +1,39 @@
+# validate-transport fixtures
+
+Config fixtures for the ConPTY-mode smoke test. Consumed by
+`just validate-transport-smoke ROW` and the sibling assertion script
+at `scripts/validate-transport-assert.ps1`.
+
+Each fixture pins a `conpty-mode` value and a one-shot `command`
+whose only job is to emit a known OSC 11 query (or to sanity-spawn,
+for cmd) and then exit. `quit-after-last-window-closed = true` plus
+the default `wait-after-command = false` brings the app down
+automatically, and `confirm-close-surface = false` skips the exit
+confirmation prompt.
+
+The pwsh fixtures emit OSC 11 via `pwsh.exe -EncodedCommand `.
+The base64 payload is UTF-16LE of:
+
+```
+[Console]::Out.Write([char]0x1B + ']11;?' + [char]0x1B + '\')
+```
+
+which writes `ESC ]11;? ESC \` (OSC 11 query with ST terminator) to
+stdout and exits. Inlining via `-EncodedCommand` avoids three things
+that broke earlier iterations: cwd-relative script paths (Ghostty's
+working-directory resolution is surprising under launch-by-test),
+PowerShell execution policy blocking `.ps1` loads, and cmd-metachar
+wrapping in ghostty's spawn path (ghostty wraps commands containing
+`&`, `|`, `(`, `)`, `%`, `!` with `cmd /c`, which mangles quoted
+arguments). The base64 alphabet (`A-Za-z0-9+/=`) contains none of
+those.
+
+All `command =` values use the full `.exe` suffix (`pwsh.exe`,
+`cmd.exe`). Ghostty's `internal_os.path.expand` does PATH lookup
+with the literal name and does not try PATHEXT-style suffix hunting;
+a bare `pwsh` would fail to resolve to `pwsh.exe` and CreateProcessW
+would error with ERROR_FILE_NOT_FOUND.
+
+Never edit these to chase a test failure. If an expected verdict
+changes, update the assertion table in
+`scripts/validate-transport-assert.ps1` in the same commit.
diff --git a/dev-configs/validate-transport/cmd-auto.conf b/dev-configs/validate-transport/cmd-auto.conf
new file mode 100644
index 00000000000..a85f1fce6c1
--- /dev/null
+++ b/dev-configs/validate-transport/cmd-auto.conf
@@ -0,0 +1,16 @@
+# Smoke fixture: cmd + conpty-mode=auto. Expected transport=conpty.
+# cmd.exe does not emit OSC 11 natively, so OSC 11 is N/A for this
+# row - the assertion script skips the OSC 11 check.
+conpty-mode = auto
+command = cmd.exe /c exit
+
+quit-after-last-window-closed = true
+confirm-close-surface = false
+
+# Keep the global floor at warn so only errors/warnings from other
+# scopes land in the log; raise just the validate_transport scope to
+# info so the runner's assertion can see verdict + OSC 11 lines.
+# log-filter requires CATEGORY=LEVEL pairs (MEL semantics, longest
+# prefix wins); a bare "validate" would be silently skipped.
+log-level = warn
+log-filter = Ghostty.Zig.validate_transport=info
diff --git a/dev-configs/validate-transport/pwsh-always.conf b/dev-configs/validate-transport/pwsh-always.conf
new file mode 100644
index 00000000000..ac205e02fb1
--- /dev/null
+++ b/dev-configs/validate-transport/pwsh-always.conf
@@ -0,0 +1,19 @@
+# Smoke fixture: pwsh + conpty-mode=always. Expected transport=bypass,
+# OSC 11 observed.
+conpty-mode = always
+command = pwsh.exe -NoProfile -NoLogo -EncodedCommand WwBDAG8AbgBzAG8AbABlAF0AOgA6AE8AdQB0AC4AVwByAGkAdABlACgAWwBjAGgAYQByAF0AMAB4ADEAQgAgACsAIAAnAF0AMQAxADsAPwAnACAAKwAgAFsAYwBoAGEAcgBdADAAeAAxAEIAIAArACAAJwBcACcAKQA=
+
+# App shutdown on shell exit: wait-after-command defaults to false
+# (surface closes on command exit); quit-after-last-window-closed
+# makes the process exit when the only surface closes;
+# confirm-close-surface suppresses any exit confirmation prompt.
+quit-after-last-window-closed = true
+confirm-close-surface = false
+
+# Keep the global floor at warn so only errors/warnings from other
+# scopes land in the log; raise just the validate_transport scope to
+# info so the runner's assertion can see verdict + OSC 11 lines.
+# log-filter requires CATEGORY=LEVEL pairs (MEL semantics, longest
+# prefix wins); a bare "validate" would be silently skipped.
+log-level = warn
+log-filter = Ghostty.Zig.validate_transport=info
diff --git a/dev-configs/validate-transport/pwsh-auto.conf b/dev-configs/validate-transport/pwsh-auto.conf
new file mode 100644
index 00000000000..8b43c452831
--- /dev/null
+++ b/dev-configs/validate-transport/pwsh-auto.conf
@@ -0,0 +1,19 @@
+# Smoke fixture: pwsh + conpty-mode=auto. Expected transport=bypass,
+# OSC 11 observed.
+conpty-mode = auto
+command = pwsh.exe -NoProfile -NoLogo -EncodedCommand WwBDAG8AbgBzAG8AbABlAF0AOgA6AE8AdQB0AC4AVwByAGkAdABlACgAWwBjAGgAYQByAF0AMAB4ADEAQgAgACsAIAAnAF0AMQAxADsAPwAnACAAKwAgAFsAYwBoAGEAcgBdADAAeAAxAEIAIAArACAAJwBcACcAKQA=
+
+# App shutdown on shell exit: wait-after-command defaults to false
+# (surface closes on command exit); quit-after-last-window-closed
+# makes the process exit when the only surface closes;
+# confirm-close-surface suppresses any exit confirmation prompt.
+quit-after-last-window-closed = true
+confirm-close-surface = false
+
+# Keep the global floor at warn so only errors/warnings from other
+# scopes land in the log; raise just the validate_transport scope to
+# info so the runner's assertion can see verdict + OSC 11 lines.
+# log-filter requires CATEGORY=LEVEL pairs (MEL semantics, longest
+# prefix wins); a bare "validate" would be silently skipped.
+log-level = warn
+log-filter = Ghostty.Zig.validate_transport=info
diff --git a/dev-configs/validate-transport/pwsh-never.conf b/dev-configs/validate-transport/pwsh-never.conf
new file mode 100644
index 00000000000..f93bbb953ff
--- /dev/null
+++ b/dev-configs/validate-transport/pwsh-never.conf
@@ -0,0 +1,19 @@
+# Smoke fixture: pwsh + conpty-mode=never. Expected transport=conpty,
+# OSC 11 absent (conhost swallows).
+conpty-mode = never
+command = pwsh.exe -NoProfile -NoLogo -EncodedCommand WwBDAG8AbgBzAG8AbABlAF0AOgA6AE8AdQB0AC4AVwByAGkAdABlACgAWwBjAGgAYQByAF0AMAB4ADEAQgAgACsAIAAnAF0AMQAxADsAPwAnACAAKwAgAFsAYwBoAGEAcgBdADAAeAAxAEIAIAArACAAJwBcACcAKQA=
+
+# App shutdown on shell exit: wait-after-command defaults to false
+# (surface closes on command exit); quit-after-last-window-closed
+# makes the process exit when the only surface closes;
+# confirm-close-surface suppresses any exit confirmation prompt.
+quit-after-last-window-closed = true
+confirm-close-surface = false
+
+# Keep the global floor at warn so only errors/warnings from other
+# scopes land in the log; raise just the validate_transport scope to
+# info so the runner's assertion can see verdict + OSC 11 lines.
+# log-filter requires CATEGORY=LEVEL pairs (MEL semantics, longest
+# prefix wins); a bare "validate" would be silently skipped.
+log-level = warn
+log-filter = Ghostty.Zig.validate_transport=info
diff --git a/dist/windows/IconGen.Tests/.gitignore b/dist/windows/IconGen.Tests/.gitignore
new file mode 100644
index 00000000000..cd42ee34e87
--- /dev/null
+++ b/dist/windows/IconGen.Tests/.gitignore
@@ -0,0 +1,2 @@
+bin/
+obj/
diff --git a/dist/windows/IconGen.Tests/CliTests.cs b/dist/windows/IconGen.Tests/CliTests.cs
new file mode 100644
index 00000000000..2026e89b8d9
--- /dev/null
+++ b/dist/windows/IconGen.Tests/CliTests.cs
@@ -0,0 +1,35 @@
+using Xunit;
+
+namespace Ghostty.IconGen.Tests;
+
+public class CliTests
+{
+ [Fact]
+ public void ParsesStableChannelAndOutputDir()
+ {
+ var options = Cli.Parse(new[] { "--channel", "stable", "--out", "C:\\tmp\\x" });
+ Assert.Equal(Channel.Stable, options.Channel);
+ Assert.Equal("C:\\tmp\\x", options.OutputDir);
+ }
+
+ [Fact]
+ public void ParsesNightlyChannel()
+ {
+ var options = Cli.Parse(new[] { "--channel", "nightly", "--out", "out" });
+ Assert.Equal(Channel.Nightly, options.Channel);
+ }
+
+ [Fact]
+ public void UnknownChannelThrows()
+ {
+ Assert.Throws(
+ () => Cli.Parse(new[] { "--channel", "banana", "--out", "out" }));
+ }
+
+ [Fact]
+ public void MissingOutThrows()
+ {
+ Assert.Throws(
+ () => Cli.Parse(new[] { "--channel", "stable" }));
+ }
+}
diff --git a/dist/windows/IconGen.Tests/EndToEndTests.cs b/dist/windows/IconGen.Tests/EndToEndTests.cs
new file mode 100644
index 00000000000..b88c542a153
--- /dev/null
+++ b/dist/windows/IconGen.Tests/EndToEndTests.cs
@@ -0,0 +1,88 @@
+using System.Drawing;
+using Xunit;
+
+namespace Ghostty.IconGen.Tests;
+
+public class EndToEndTests
+{
+ [Fact]
+ public void StableProducesIcoAndAllPngs()
+ {
+ using var tempDir = new TempDir();
+ var repoRoot = TempDir.FindRepoRoot();
+
+ int exitCode = Program.Run(
+ new[] { "--channel", "stable", "--out", tempDir.Path },
+ repoRoot);
+
+ Assert.Equal(0, exitCode);
+ Assert.True(File.Exists(Path.Combine(tempDir.Path, "ghostty.ico")));
+ Assert.True(File.Exists(Path.Combine(tempDir.Path, "AppIcon.scale-100.png")));
+ Assert.True(File.Exists(Path.Combine(tempDir.Path, "AppIcon.scale-400.png")));
+ }
+
+ [Fact]
+ public void NightlyPngHasHazardStripe()
+ {
+ using var tempDir = new TempDir();
+ var repoRoot = TempDir.FindRepoRoot();
+
+ Program.Run(new[] { "--channel", "nightly", "--out", tempDir.Path }, repoRoot);
+
+ using var img = new Bitmap(Path.Combine(tempDir.Path, "AppIcon.scale-400.png"));
+ // Bottom 15% of 160 px is rows 136..159. Look for yellow pixels.
+ int yellowCount = 0;
+ for (int y = 136; y < 160; y++)
+ for (int x = 0; x < 160; x++)
+ {
+ var c = img.GetPixel(x, y);
+ if (c.R > 200 && c.G > 150 && c.G < 220 && c.B < 80)
+ yellowCount++;
+ }
+ Assert.True(yellowCount > 50,
+ $"Expected yellow stripe pixels in nightly icon; got {yellowCount}.");
+ }
+
+ [Fact]
+ public void StablePngHasNoYellowStripe()
+ {
+ using var tempDir = new TempDir();
+ var repoRoot = TempDir.FindRepoRoot();
+
+ Program.Run(new[] { "--channel", "stable", "--out", tempDir.Path }, repoRoot);
+
+ using var img = new Bitmap(Path.Combine(tempDir.Path, "AppIcon.scale-400.png"));
+ int yellowCount = 0;
+ for (int y = 136; y < 160; y++)
+ for (int x = 0; x < 160; x++)
+ {
+ var c = img.GetPixel(x, y);
+ if (c.R > 200 && c.G > 150 && c.G < 220 && c.B < 80)
+ yellowCount++;
+ }
+ Assert.True(yellowCount == 0,
+ $"Stable icon should have no yellow stripes; got {yellowCount}.");
+ }
+
+ // TODO(icongen): GDI+ antialiasing is not byte-stable across
+ // different GDI+ versions shipped with various Windows 10/11
+ // builds. Two runs on the same machine produce identical bytes
+ // today, but CI on a different host image can drift. If this
+ // flakes, either (a) pin the antialiasing in HazardStripe.Apply
+ // so the diagonal polygons are deterministic, or (b) hash only
+ // the non-stripe region of the icon.
+ [Fact]
+ public void DeterministicAcrossRuns()
+ {
+ using var dir1 = new TempDir();
+ using var dir2 = new TempDir();
+ var repoRoot = TempDir.FindRepoRoot();
+
+ Program.Run(new[] { "--channel", "nightly", "--out", dir1.Path }, repoRoot);
+ Program.Run(new[] { "--channel", "nightly", "--out", dir2.Path }, repoRoot);
+
+ var bytes1 = File.ReadAllBytes(Path.Combine(dir1.Path, "ghostty.ico"));
+ var bytes2 = File.ReadAllBytes(Path.Combine(dir2.Path, "ghostty.ico"));
+ Assert.Equal(bytes1, bytes2);
+ }
+}
diff --git a/dist/windows/IconGen.Tests/HazardStripeTests.cs b/dist/windows/IconGen.Tests/HazardStripeTests.cs
new file mode 100644
index 00000000000..73cfdda94c6
--- /dev/null
+++ b/dist/windows/IconGen.Tests/HazardStripeTests.cs
@@ -0,0 +1,59 @@
+using System.Drawing;
+using Xunit;
+
+namespace Ghostty.IconGen.Tests;
+
+public class HazardStripeTests
+{
+ // Color values must match HazardStripe.StripeYellow.
+ private static readonly Color StripeYellow = Color.FromArgb(0xFF, 0xF5, 0xC5, 0x18);
+
+ [Fact]
+ public void ApplyDrawsYellowPixelsInBottomBand()
+ {
+ using var bitmap = new Bitmap(256, 256);
+ using (var g = Graphics.FromImage(bitmap))
+ g.Clear(Color.Blue);
+
+ HazardStripe.Apply(bitmap);
+
+ int yellowCount = CountColor(bitmap, StripeYellow, tolerance: 8);
+ Assert.True(yellowCount > 500,
+ $"Expected hazard stripes to contain yellow pixels; got {yellowCount}.");
+ }
+
+ [Fact]
+ public void ApplyLeavesTopEightyPercentUntouched()
+ {
+ using var bitmap = new Bitmap(256, 256);
+ using (var g = Graphics.FromImage(bitmap))
+ g.Clear(Color.Blue);
+
+ HazardStripe.Apply(bitmap);
+
+ // Top 80% (rows 0..204) should still be pure blue.
+ for (int y = 0; y < 204; y++)
+ {
+ for (int x = 0; x < 256; x++)
+ {
+ var c = bitmap.GetPixel(x, y);
+ Assert.Equal(Color.Blue.ToArgb(), c.ToArgb());
+ }
+ }
+ }
+
+ private static int CountColor(Bitmap bitmap, Color target, int tolerance)
+ {
+ int count = 0;
+ for (int y = 0; y < bitmap.Height; y++)
+ for (int x = 0; x < bitmap.Width; x++)
+ {
+ var c = bitmap.GetPixel(x, y);
+ if (Math.Abs(c.R - target.R) <= tolerance &&
+ Math.Abs(c.G - target.G) <= tolerance &&
+ Math.Abs(c.B - target.B) <= tolerance)
+ count++;
+ }
+ return count;
+ }
+}
diff --git a/dist/windows/IconGen.Tests/IcoWriterTests.cs b/dist/windows/IconGen.Tests/IcoWriterTests.cs
new file mode 100644
index 00000000000..1f431130193
--- /dev/null
+++ b/dist/windows/IconGen.Tests/IcoWriterTests.cs
@@ -0,0 +1,46 @@
+using Xunit;
+
+namespace Ghostty.IconGen.Tests;
+
+public class IcoWriterTests
+{
+ [Fact]
+ public void WritesIcoFileWithExpectedFrameSizes()
+ {
+ using var tempDir = new TempDir();
+ using var masters = MasterRasters.Load(TempDir.FindRepoRoot());
+
+ var icoPath = Path.Combine(tempDir.Path, "ghostty.ico");
+ IcoWriter.Write(masters, icoPath);
+
+ Assert.True(File.Exists(icoPath));
+
+ var frameSizes = IcoReader.ReadFrameSizes(icoPath).ToHashSet();
+ Assert.Contains(16, frameSizes);
+ Assert.Contains(32, frameSizes);
+ Assert.Contains(48, frameSizes);
+ Assert.Contains(256, frameSizes);
+ }
+}
+
+// Minimal ICO reader for tests. Parses the ICONDIR header and enumerates
+// frame widths.
+internal static class IcoReader
+{
+ public static IEnumerable ReadFrameSizes(string path)
+ {
+ var bytes = File.ReadAllBytes(path);
+ if (bytes.Length < 6) yield break;
+ ushort type = BitConverter.ToUInt16(bytes, 2);
+ ushort count = BitConverter.ToUInt16(bytes, 4);
+ if (type != 1) yield break;
+
+ for (int i = 0; i < count; i++)
+ {
+ int offset = 6 + i * 16;
+ if (offset + 16 > bytes.Length) break;
+ byte w = bytes[offset];
+ yield return w == 0 ? 256 : w;
+ }
+ }
+}
diff --git a/dist/windows/IconGen.Tests/IconGen.Tests.csproj b/dist/windows/IconGen.Tests/IconGen.Tests.csproj
new file mode 100644
index 00000000000..8709fbbd370
--- /dev/null
+++ b/dist/windows/IconGen.Tests/IconGen.Tests.csproj
@@ -0,0 +1,21 @@
+
+
+ net10.0-windows
+ enable
+ enable
+ latest
+ false
+ Ghostty.IconGen.Tests
+ $(NoWarn);CA1416
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/dist/windows/IconGen.Tests/MasterRastersTests.cs b/dist/windows/IconGen.Tests/MasterRastersTests.cs
new file mode 100644
index 00000000000..a86fae1e0d6
--- /dev/null
+++ b/dist/windows/IconGen.Tests/MasterRastersTests.cs
@@ -0,0 +1,38 @@
+using System.Drawing;
+using Xunit;
+
+namespace Ghostty.IconGen.Tests;
+
+public class MasterRastersTests
+{
+ private static readonly string RepoRoot = FindRepoRoot();
+
+ [Fact]
+ public void LoadsAllExpectedSizes()
+ {
+ var masters = MasterRasters.Load(RepoRoot);
+ Assert.Contains(16, masters.Sizes);
+ Assert.Contains(256, masters.Sizes);
+ Assert.Contains(1024, masters.Sizes);
+ }
+
+ [Fact]
+ public void ReturnsSquareBitmaps()
+ {
+ var masters = MasterRasters.Load(RepoRoot);
+ foreach (var size in masters.Sizes)
+ {
+ using var bitmap = masters.Get(size);
+ Assert.Equal(size, bitmap.Width);
+ Assert.Equal(size, bitmap.Height);
+ }
+ }
+
+ private static string FindRepoRoot()
+ {
+ var dir = new DirectoryInfo(AppContext.BaseDirectory);
+ while (dir is not null && !Directory.Exists(Path.Combine(dir.FullName, "images", "icons")))
+ dir = dir.Parent;
+ return dir?.FullName ?? throw new DirectoryNotFoundException("repo root with images/icons not found");
+ }
+}
diff --git a/dist/windows/IconGen.Tests/PngWriterTests.cs b/dist/windows/IconGen.Tests/PngWriterTests.cs
new file mode 100644
index 00000000000..7003d3a58ac
--- /dev/null
+++ b/dist/windows/IconGen.Tests/PngWriterTests.cs
@@ -0,0 +1,65 @@
+using System.Drawing;
+using System.Drawing.Imaging;
+using Xunit;
+
+namespace Ghostty.IconGen.Tests;
+
+public class PngWriterTests
+{
+ [Fact]
+ public void WritesAllFourScalePngs()
+ {
+ using var tempDir = new TempDir();
+ using var masters = MasterRasters.Load(TempDir.FindRepoRoot());
+
+ PngWriter.WriteScalePngs(masters, tempDir.Path);
+
+ Assert.True(File.Exists(Path.Combine(tempDir.Path, "AppIcon.scale-100.png")));
+ Assert.True(File.Exists(Path.Combine(tempDir.Path, "AppIcon.scale-150.png")));
+ Assert.True(File.Exists(Path.Combine(tempDir.Path, "AppIcon.scale-200.png")));
+ Assert.True(File.Exists(Path.Combine(tempDir.Path, "AppIcon.scale-400.png")));
+ }
+
+ [Theory]
+ [InlineData("AppIcon.scale-100.png", 40)]
+ [InlineData("AppIcon.scale-150.png", 60)]
+ [InlineData("AppIcon.scale-200.png", 80)]
+ [InlineData("AppIcon.scale-400.png", 160)]
+ public void EachScalePngHasExpectedDimensions(string fileName, int expectedPx)
+ {
+ using var tempDir = new TempDir();
+ using var masters = MasterRasters.Load(TempDir.FindRepoRoot());
+
+ PngWriter.WriteScalePngs(masters, tempDir.Path);
+
+ using var img = new Bitmap(Path.Combine(tempDir.Path, fileName));
+ Assert.Equal(expectedPx, img.Width);
+ Assert.Equal(expectedPx, img.Height);
+ }
+}
+
+internal sealed class TempDir : IDisposable
+{
+ public string Path { get; }
+
+ public TempDir()
+ {
+ Path = System.IO.Path.Combine(
+ System.IO.Path.GetTempPath(),
+ "icongen-test-" + Guid.NewGuid().ToString("N"));
+ Directory.CreateDirectory(Path);
+ }
+
+ public static string FindRepoRoot()
+ {
+ var dir = new DirectoryInfo(AppContext.BaseDirectory);
+ while (dir is not null && !Directory.Exists(System.IO.Path.Combine(dir.FullName, "images", "icons")))
+ dir = dir.Parent;
+ return dir?.FullName ?? throw new DirectoryNotFoundException();
+ }
+
+ public void Dispose()
+ {
+ try { Directory.Delete(Path, recursive: true); } catch { /* best-effort */ }
+ }
+}
diff --git a/dist/windows/IconGen.Tests/SmokeTest.cs b/dist/windows/IconGen.Tests/SmokeTest.cs
new file mode 100644
index 00000000000..4d601fdd037
--- /dev/null
+++ b/dist/windows/IconGen.Tests/SmokeTest.cs
@@ -0,0 +1,19 @@
+using Xunit;
+
+namespace Ghostty.IconGen.Tests;
+
+public class SmokeTest
+{
+ [Fact]
+ public void ProgramRunWithValidArgsReturnsZero()
+ {
+ using var tempDir = new TempDir();
+ var repoRoot = TempDir.FindRepoRoot();
+
+ var exitCode = Program.Run(
+ new[] { "--channel", "stable", "--out", tempDir.Path },
+ repoRoot);
+
+ Assert.Equal(0, exitCode);
+ }
+}
diff --git a/dist/windows/IconGen/.gitignore b/dist/windows/IconGen/.gitignore
new file mode 100644
index 00000000000..cd42ee34e87
--- /dev/null
+++ b/dist/windows/IconGen/.gitignore
@@ -0,0 +1,2 @@
+bin/
+obj/
diff --git a/dist/windows/IconGen/Cli.cs b/dist/windows/IconGen/Cli.cs
new file mode 100644
index 00000000000..35987775cc3
--- /dev/null
+++ b/dist/windows/IconGen/Cli.cs
@@ -0,0 +1,48 @@
+namespace Ghostty.IconGen;
+
+internal enum Channel
+{
+ Stable,
+ Nightly,
+}
+
+internal sealed record Options(Channel Channel, string OutputDir);
+
+internal static class Cli
+{
+ public static Options Parse(string[] args)
+ {
+ Channel? channel = null;
+ string? outputDir = null;
+
+ for (int i = 0; i < args.Length; i++)
+ {
+ switch (args[i])
+ {
+ case "--channel":
+ if (i + 1 >= args.Length)
+ throw new ArgumentException("--channel requires a value");
+ channel = args[++i].ToLowerInvariant() switch
+ {
+ "stable" => Channel.Stable,
+ "nightly" => Channel.Nightly,
+ var other => throw new ArgumentException(
+ $"Unknown channel '{other}'. Expected 'stable' or 'nightly'."),
+ };
+ break;
+ case "--out":
+ if (i + 1 >= args.Length)
+ throw new ArgumentException("--out requires a value");
+ outputDir = args[++i];
+ break;
+ }
+ }
+
+ if (channel is null)
+ throw new ArgumentException("--channel is required");
+ if (outputDir is null)
+ throw new ArgumentException("--out is required");
+
+ return new Options(channel.Value, outputDir);
+ }
+}
diff --git a/dist/windows/IconGen/HazardStripe.cs b/dist/windows/IconGen/HazardStripe.cs
new file mode 100644
index 00000000000..a8c17e4b776
--- /dev/null
+++ b/dist/windows/IconGen/HazardStripe.cs
@@ -0,0 +1,69 @@
+using System.Diagnostics;
+using System.Drawing;
+using System.Drawing.Drawing2D;
+
+namespace Ghostty.IconGen;
+
+///
+/// Draws a yellow/black diagonal hazard-stripe band across the bottom
+/// 15 percent of a bitmap. Used for nightly/dev build icon variants so
+/// Windows dev builds visually align with the GNOME nightly convention
+/// (images/gnome/nightly-*.png) without committing any overlay assets.
+///
+internal static class HazardStripe
+{
+ public static readonly Color StripeYellow = Color.FromArgb(0xFF, 0xF5, 0xC5, 0x18);
+ public static readonly Color StripeBlack = Color.FromArgb(0xFF, 0x10, 0x10, 0x10);
+
+ // Band covers the bottom 15 percent of the icon.
+ public const double BandHeightFraction = 0.15;
+
+ // Stripe width scales with icon size so the pattern is visible at 16
+ // px and not washed out at 1024 px.
+ private const double StripesAcrossAt256 = 32;
+
+ public static void Apply(Bitmap bitmap)
+ {
+ // Icon masters are square and the band-height / stripe-width
+ // math below assumes Width == Height. Assert explicitly so a
+ // future non-square caller fails loud in Debug instead of
+ // silently producing off-axis stripes.
+ Debug.Assert(bitmap.Width == bitmap.Height,
+ "HazardStripe.Apply expects a square bitmap.");
+
+ int size = bitmap.Width;
+ int bandHeight = (int)Math.Round(size * BandHeightFraction);
+ if (bandHeight < 2) bandHeight = 2;
+ int bandTop = size - bandHeight;
+
+ double stripeWidth = size / StripesAcrossAt256;
+ if (stripeWidth < 2) stripeWidth = 2;
+
+ using var g = Graphics.FromImage(bitmap);
+ g.SmoothingMode = SmoothingMode.AntiAlias;
+ g.CompositingQuality = CompositingQuality.HighQuality;
+
+ // Fill band with solid black first so gaps between yellow
+ // stripes are black rather than showing the base image.
+ using (var black = new SolidBrush(StripeBlack))
+ g.FillRectangle(black, 0, bandTop, size, bandHeight);
+
+ // Diagonal yellow stripes at 45 degrees.
+ using var yellow = new SolidBrush(StripeYellow);
+ double xStart = -size;
+ double xEnd = size * 2;
+ double step = stripeWidth * 2; // yellow every other stripe
+
+ for (double x = xStart; x < xEnd; x += step)
+ {
+ var pts = new[]
+ {
+ new PointF((float)x, bandTop),
+ new PointF((float)(x + stripeWidth), bandTop),
+ new PointF((float)(x + stripeWidth + bandHeight), size),
+ new PointF((float)(x + bandHeight), size),
+ };
+ g.FillPolygon(yellow, pts);
+ }
+ }
+}
diff --git a/dist/windows/IconGen/IcoWriter.cs b/dist/windows/IconGen/IcoWriter.cs
new file mode 100644
index 00000000000..a91e70a869a
--- /dev/null
+++ b/dist/windows/IconGen/IcoWriter.cs
@@ -0,0 +1,48 @@
+using System.Drawing;
+using System.Drawing.Imaging;
+
+namespace Ghostty.IconGen;
+
+internal static class IcoWriter
+{
+ // Standard Windows icon sizes for an app .ico. Covers taskbar (16,
+ // 20, 24, 32, 40, 48), larger icon views (64), and full-size (256).
+ private static readonly int[] FrameSizes = { 16, 20, 24, 32, 40, 48, 64, 256 };
+
+ public static void Write(MasterRasters masters, string outPath)
+ {
+ var frames = new List<(int Px, byte[] PngBytes)>();
+ foreach (var px in FrameSizes)
+ {
+ using var resized = PngWriter.Resize(masters, px);
+ using var ms = new MemoryStream();
+ resized.Save(ms, ImageFormat.Png);
+ frames.Add((px, ms.ToArray()));
+ }
+
+ Directory.CreateDirectory(Path.GetDirectoryName(outPath)!);
+ using var fs = File.Create(outPath);
+ using var bw = new BinaryWriter(fs);
+
+ bw.Write((ushort)0); // reserved
+ bw.Write((ushort)1); // type: icon
+ bw.Write((ushort)frames.Count); // count
+
+ int dataOffset = 6 + frames.Count * 16;
+ foreach (var (px, png) in frames)
+ {
+ bw.Write((byte)(px == 256 ? 0 : px)); // width (0 means 256)
+ bw.Write((byte)(px == 256 ? 0 : px)); // height
+ bw.Write((byte)0); // color palette
+ bw.Write((byte)0); // reserved
+ bw.Write((ushort)1); // color planes
+ bw.Write((ushort)32); // bits per pixel
+ bw.Write((uint)png.Length); // image size
+ bw.Write((uint)dataOffset); // image offset
+ dataOffset += png.Length;
+ }
+
+ foreach (var (_, png) in frames)
+ bw.Write(png);
+ }
+}
diff --git a/dist/windows/IconGen/IconGen.csproj b/dist/windows/IconGen/IconGen.csproj
new file mode 100644
index 00000000000..aaf51dea103
--- /dev/null
+++ b/dist/windows/IconGen/IconGen.csproj
@@ -0,0 +1,22 @@
+
+
+ Exe
+ net10.0-windows
+ enable
+ enable
+ latest
+ Ghostty.IconGen
+ Ghostty.IconGen
+
+ $(NoWarn);CA1416
+
+
+
+
+
+
+
+
+
+
diff --git a/dist/windows/IconGen/MasterRasters.cs b/dist/windows/IconGen/MasterRasters.cs
new file mode 100644
index 00000000000..3595890256c
--- /dev/null
+++ b/dist/windows/IconGen/MasterRasters.cs
@@ -0,0 +1,58 @@
+using System.Drawing;
+
+namespace Ghostty.IconGen;
+
+///
+/// Loads the macOS-style icon masters from images/icons/icon_*.png.
+/// These are the single source of truth for the Windows icon raster;
+/// we never read anything from dist/windows/ or duplicate assets.
+///
+internal sealed class MasterRasters : IDisposable
+{
+ private readonly Dictionary _byPx;
+
+ private MasterRasters(Dictionary byPx)
+ {
+ _byPx = byPx;
+ }
+
+ internal static MasterRasters FromDictionary(Dictionary byPx)
+ => new(byPx);
+
+ public IReadOnlyCollection Sizes => _byPx.Keys;
+
+ public Bitmap Get(int px)
+ {
+ if (!_byPx.TryGetValue(px, out var bitmap))
+ throw new KeyNotFoundException($"No master raster for {px}x{px}.");
+ return new Bitmap(bitmap); // clone so caller can dispose freely
+ }
+
+ public static MasterRasters Load(string repoRoot)
+ {
+ var iconsDir = Path.Combine(repoRoot, "images", "icons");
+ if (!Directory.Exists(iconsDir))
+ throw new DirectoryNotFoundException($"{iconsDir} not found");
+
+ var byPx = new Dictionary();
+
+ int[] sizes = { 16, 32, 64, 128, 256, 512, 1024 };
+ foreach (var px in sizes)
+ {
+ var path = Path.Combine(iconsDir, $"icon_{px}.png");
+ if (!File.Exists(path)) continue;
+ byPx[px] = new Bitmap(path);
+ }
+
+ if (byPx.Count == 0)
+ throw new InvalidOperationException($"No master rasters found in {iconsDir}");
+
+ return new MasterRasters(byPx);
+ }
+
+ public void Dispose()
+ {
+ foreach (var b in _byPx.Values) b.Dispose();
+ _byPx.Clear();
+ }
+}
diff --git a/dist/windows/IconGen/PngWriter.cs b/dist/windows/IconGen/PngWriter.cs
new file mode 100644
index 00000000000..880c8c1774a
--- /dev/null
+++ b/dist/windows/IconGen/PngWriter.cs
@@ -0,0 +1,49 @@
+using System.Drawing;
+using System.Drawing.Drawing2D;
+using System.Drawing.Imaging;
+
+namespace Ghostty.IconGen;
+
+internal static class PngWriter
+{
+ // WinUI 3 asset scale -> target pixel size for a 40 DIP icon.
+ // Matches the standard WinUI .scale-xxx ladder.
+ private static readonly (string Name, int Px)[] ScaleTargets =
+ {
+ ("AppIcon.scale-100.png", 40),
+ ("AppIcon.scale-150.png", 60),
+ ("AppIcon.scale-200.png", 80),
+ ("AppIcon.scale-400.png", 160),
+ };
+
+ public static void WriteScalePngs(MasterRasters masters, string outDir)
+ {
+ Directory.CreateDirectory(outDir);
+ foreach (var (name, px) in ScaleTargets)
+ {
+ using var resized = Resize(masters, px);
+ resized.Save(Path.Combine(outDir, name), ImageFormat.Png);
+ }
+ }
+
+ public static Bitmap Resize(MasterRasters masters, int targetPx)
+ {
+ // Pick the smallest master >= target for cleanest downsample.
+ // If none are large enough, fall back to the largest available
+ // and let DrawImage upscale.
+ var largeEnough = masters.Sizes.Where(s => s >= targetPx).ToList();
+ int sourcePx = largeEnough.Count > 0 ? largeEnough.Min() : masters.Sizes.Max();
+ using var source = masters.Get(sourcePx);
+
+ var output = new Bitmap(targetPx, targetPx, PixelFormat.Format32bppArgb);
+ using (var g = Graphics.FromImage(output))
+ {
+ g.InterpolationMode = InterpolationMode.HighQualityBicubic;
+ g.SmoothingMode = SmoothingMode.HighQuality;
+ g.PixelOffsetMode = PixelOffsetMode.HighQuality;
+ g.CompositingQuality = CompositingQuality.HighQuality;
+ g.DrawImage(source, new Rectangle(0, 0, targetPx, targetPx));
+ }
+ return output;
+ }
+}
diff --git a/dist/windows/IconGen/Program.cs b/dist/windows/IconGen/Program.cs
new file mode 100644
index 00000000000..dc0d5fae4c5
--- /dev/null
+++ b/dist/windows/IconGen/Program.cs
@@ -0,0 +1,64 @@
+namespace Ghostty.IconGen;
+
+internal static class Program
+{
+ public static int Main(string[] args)
+ {
+ // When invoked from MSBuild, CWD is the IconGen project directory.
+ // Walk up to find the repo root (directory containing images/icons).
+ var repoRoot = FindRepoRoot(AppContext.BaseDirectory);
+ return Run(args, repoRoot);
+ }
+
+ public static int Run(string[] args, string repoRoot)
+ {
+ try
+ {
+ var options = Cli.Parse(args);
+ Directory.CreateDirectory(options.OutputDir);
+
+ using var masters = MasterRasters.Load(repoRoot);
+
+ if (options.Channel == Channel.Nightly)
+ {
+ using var striped = StripeMasters(masters);
+ PngWriter.WriteScalePngs(striped, options.OutputDir);
+ IcoWriter.Write(striped, Path.Combine(options.OutputDir, "ghostty.ico"));
+ }
+ else
+ {
+ PngWriter.WriteScalePngs(masters, options.OutputDir);
+ IcoWriter.Write(masters, Path.Combine(options.OutputDir, "ghostty.ico"));
+ }
+
+ return 0;
+ }
+ catch (Exception ex)
+ {
+ Console.Error.WriteLine($"IconGen failed: {ex.Message}");
+ return 1;
+ }
+ }
+
+ private static MasterRasters StripeMasters(MasterRasters original)
+ {
+ // Caller disposes the returned instance.
+ var dict = new Dictionary();
+ foreach (var px in original.Sizes)
+ {
+ var bitmap = original.Get(px); // MasterRasters.Get clones
+ HazardStripe.Apply(bitmap);
+ dict[px] = bitmap;
+ }
+ return MasterRasters.FromDictionary(dict);
+ }
+
+ private static string FindRepoRoot(string start)
+ {
+ var dir = new DirectoryInfo(start);
+ while (dir is not null && !Directory.Exists(Path.Combine(dir.FullName, "images", "icons")))
+ dir = dir.Parent;
+ return dir?.FullName
+ ?? throw new DirectoryNotFoundException("Repo root with images/icons not found");
+ }
+}
diff --git a/dist/windows/ghostty.ico b/dist/windows/ghostty.ico
deleted file mode 100644
index 1c5afc258c5..00000000000
Binary files a/dist/windows/ghostty.ico and /dev/null differ
diff --git a/dist/windows/ghostty.manifest b/dist/windows/ghostty.manifest
index 9a07906486f..94b1a3100f7 100644
--- a/dist/windows/ghostty.manifest
+++ b/dist/windows/ghostty.manifest
@@ -4,6 +4,7 @@
true/pm
PerMonitorV2
+ UTF-8
diff --git a/docs/windows/logging.md b/docs/windows/logging.md
new file mode 100644
index 00000000000..6abce7c4eb7
--- /dev/null
+++ b/docs/windows/logging.md
@@ -0,0 +1,106 @@
+# Windows logging
+
+Ghostty's Windows shell emits diagnostics through `Microsoft.Extensions.Logging`
+with two sinks wired at startup.
+
+## Sinks
+
+### ETW (EventSource)
+
+`Microsoft-Extensions-Logging` EventSource, via the built-in
+`EventSourceLoggerProvider`. Capture with:
+
+```
+dotnet-trace collect -n Ghostty --providers Microsoft-Extensions-Logging
+```
+
+The same provider is visible in PerfView and Windows Performance Analyzer.
+
+> Known issue (# 269): the provider is registered and the file sink
+> captures events end-to-end, but `dotnet-trace` does not currently
+> surface our events. If you hit empty output, the file sink below is
+> the reliable channel until # 269 lands. PerfView is a useful
+> independent check.
+
+### Rolling file
+
+`%LOCALAPPDATA%\Ghostty\logs\ghostty-YYYYMMDD.log`. One file per UTC day.
+Each file caps at 16 MB; when full, the writer rolls to
+`ghostty-YYYYMMDD-1.log`, `-2.log`, and so on. Files older than 14 days
+are deleted at startup and again whenever the writer crosses a UTC-day
+boundary, so a long-running session prunes itself.
+
+Line format is pipe-separated, easy to grep:
+
+```
+2026-04-17T14:23:17.042Z | Warn | 2100 | Ghostty.Clipboard.WinUiClipboardBackend | clipboard read failed: 0x8001010E
+```
+
+When a log call includes an exception, the record line is followed by
+indented frames (up to 10) so the stack is readable in the same file.
+
+The writer is backed by a bounded channel with drop-oldest semantics, so
+a logging storm never blocks the UI or termio threads. On overflow a
+synthetic "N log record(s) dropped" warning is flushed at the top of the
+next batch, so drops are visible to operators.
+
+## Config keys
+
+Both keys live in the regular Ghostty config file.
+
+| Key | Type | Default | Values |
+|---|---|---|---|
+| `log-level` | enum | `info` | `trace`, `debug`, `info`, `warn`, `error`, `off` |
+| `log-filter` | list of `CATEGORY=LEVEL` pairs | empty | see below |
+
+`log-filter` lets you override the default level per component:
+
+```
+log-filter = Ghostty.Services.ThemePreviewService=trace, Ghostty.Core.Config=warn
+```
+
+Longest matching category prefix wins, mirroring the
+`Microsoft.Extensions.Logging` filter semantics. Unknown levels in a
+filter pair are silently skipped rather than failing the config load.
+
+Both keys are re-read whenever the config file changes on disk; the
+filter rules rebuild in place via a volatile reference swap, no restart
+needed.
+
+## Event id taxonomy
+
+EventIds are assigned from disjoint per-component ranges so they stay
+stable across renames. You can assert on an id by itself without looking
+at the message text.
+
+| Range | Component |
+|---|---|
+| 1000-1099 | Config (ConfigService, ConfigWriteScheduler, SystemSchedulerTimer) |
+| 1100-1199 | FrecencyStore (command palette history) |
+| 2000-2099 | Startup (AUMID, jump list) |
+| 2100-2199 | Clipboard (backend + bridge + confirm dialog) |
+| 2200-2299 | ThemePreviewService |
+| 2300-2399 | WindowState + WindowStateMigration |
+| 2400-2499 | Shell (taskbar host, backdrop) |
+| 2500-2599 | MainWindow |
+| 2600-2699 | Settings UI |
+
+The constants live in `windows/Ghostty.Core/Logging/LogEvents.cs` (Core
+types) and `windows/Ghostty/Logging/LogEvents.cs` (WinUI shell types).
+Each id appears in exactly two places: the constant definition and one
+`[LoggerMessage(EventId = ...)]` attribute.
+
+## Relationship to instrumentation (#54-#59)
+
+Logging, as described here, is the coarse-grained "what happened" channel
+for diagnostics, errors, and user-reportable events.
+
+The `[Conditional("GHOSTTY_INSTRUMENT")]` trace primitives tracked in
+issues #54-#59 are a separate channel for nanosecond-precision
+performance data. They emit Chrome Tracing JSON, not ETW or file, and
+are compiled out entirely when the symbol is undefined. The two
+channels share nothing at runtime.
+
+When you need to know *what went wrong*, use logging. When you need to
+know *when it happened, to the microsecond*, use the instrumentation
+channel (once it lands).
diff --git a/docs/windows/tooling.md b/docs/windows/tooling.md
new file mode 100644
index 00000000000..faad727bf3d
--- /dev/null
+++ b/docs/windows/tooling.md
@@ -0,0 +1,52 @@
+# Tooling & CI
+
+## Why Just
+
+This project uses [Just](https://github.com/casey/just) as a command runner for the Windows
+development inner loop.
+
+Just is like Make but simpler and more natural on Windows. No tabs-vs-spaces footguns,
+no implicit rules, just named recipes that run commands. It's common in the Zig and Rust
+ecosystems and installs with `winget install Casey.Just`.
+
+The `justfile` at the repo root has recipes for testing, building the DLL, and syncing
+with upstream. Run `just --list` to see them all.
+
+## How CI works
+
+There is no automated CI pipeline. GitHub Actions is disabled.
+
+This is a solo passion project developed on a cross-platform homelab - Windows, macOS,
+and Linux machines are all available. The primary development loop happens on Windows
+since that's where the port runs.
+
+**Inner loop (every change):**
+
+```bash
+just test-lib-vt # ~30s, catches most regressions
+just build-dll # verify the DLL builds
+```
+
+**Before merging a PR:**
+
+```bash
+just test # full Zig test suite on Windows
+```
+
+**Cross-platform testing (on demand):**
+
+Tests run natively on all three platforms via SSH. This happens before merging
+anything that touches shared code (Zig, build system, renderer abstractions).
+It's skipped for Windows-only changes like C# code.
+
+Upstream keeps Linux and macOS CI healthy. Since the fork rebases daily, any
+cross-platform regression from upstream gets caught during the next sync.
+
+## Why not GitHub Actions
+
+GitHub's free tier gives 2,000 minutes/month, but Windows runners consume minutes
+at 2x rate. A single full test run can burn through a meaningful chunk of that budget.
+Running locally on real hardware costs nothing and gives faster feedback.
+
+If the project grows beyond solo development, CI can be added back - the `justfile`
+recipes define exactly what a pipeline would run.
diff --git a/example/.gitignore b/example/.gitignore
index 9f88ccfeb40..83f05955852 100644
--- a/example/.gitignore
+++ b/example/.gitignore
@@ -4,3 +4,6 @@ node_modules/
example.wasm*
build/
.build/
+*.exe
+*.pdb
+*.dll
diff --git a/example/c-win32-terminal/README.md b/example/c-win32-terminal/README.md
new file mode 100644
index 00000000000..58fb1939116
--- /dev/null
+++ b/example/c-win32-terminal/README.md
@@ -0,0 +1,89 @@
+# Example: Win32 Terminal (C)
+
+Minimal C program that embeds libghostty in a Win32 window.
+Uses the ghostty C API to create an app and surface with DX12
+rendering. Creates the window, initializes ghostty, and forwards
+keyboard, mouse, resize, focus, and DPI events to the surface.
+
+Unlike the `c-vt-*` examples which use the VT parser library,
+this example uses the full libghostty runtime (app, surface,
+renderer, terminal, PTY).
+
+## Prerequisites
+
+- Windows 10 or later
+- ghostty.dll built from the repo root:
+ ```
+ just build-dll
+ ```
+- An import library (ghostty.lib) -- see below
+- Zig 0.15+ (used as C compiler), or MSVC, or MinGW-w64
+
+## Creating the Import Library
+
+The build produces ghostty.dll but not the import library needed by the
+linker. Generate it from the DLL exports:
+
+```bash
+# From the repo root, after just build-dll:
+python3 -c "
+import struct
+dll = 'zig-out/lib/ghostty.dll'
+with open(dll, 'rb') as f: data = f.read()
+pe = struct.unpack_from('
+#include
+#include
+#include
+
+// --- Globals ---
+
+static HWND g_hwnd = NULL;
+static ghostty_app_t g_app = NULL;
+static ghostty_surface_t g_surface = NULL;
+static WCHAR g_high_surrogate = 0;
+
+// --- Forward declarations ---
+
+static LRESULT CALLBACK wnd_proc(HWND, UINT, WPARAM, LPARAM);
+
+// Prevent default Ctrl+C handler from killing us. Custom handlers
+// (unlike NULL) are not inherited by child processes.
+static BOOL WINAPI ctrl_handler(DWORD type) {
+ return type == CTRL_C_EVENT || type == CTRL_BREAK_EVENT;
+}
+
+// --- Runtime callbacks ---
+// ghostty calls wakeup from background threads when the app needs to tick.
+// We post a message to the main thread's message loop.
+
+#define WM_GHOSTTY_WAKEUP (WM_APP + 1)
+#define WM_GHOSTTY_RESIZE_TIMER 1
+#define RESIZE_TIMER_MS 8 // ~120 Hz for smooth resize
+
+static void wakeup_cb(void* userdata) {
+ (void)userdata;
+ if (g_hwnd) PostMessage(g_hwnd, WM_GHOSTTY_WAKEUP, 0, 0);
+}
+
+// Stub callbacks -- minimum required by ghostty_runtime_config_s.
+// A real app would implement clipboard, close surface, etc.
+
+static bool action_cb(ghostty_app_t app, ghostty_target_s target,
+ ghostty_action_s action) {
+ (void)app; (void)target; (void)action;
+ return false;
+}
+
+static bool read_clipboard_cb(void* userdata, ghostty_clipboard_e loc,
+ void* state) {
+ (void)userdata; (void)loc; (void)state;
+ return false;
+}
+
+static void confirm_read_clipboard_cb(void* userdata, const char* str,
+ void* state,
+ ghostty_clipboard_request_e req) {
+ (void)userdata; (void)str; (void)state; (void)req;
+}
+
+static void write_clipboard_cb(void* userdata, ghostty_clipboard_e loc,
+ const ghostty_clipboard_content_s* content,
+ size_t content_count, bool confirm) {
+ (void)userdata; (void)loc; (void)content;
+ (void)content_count; (void)confirm;
+}
+
+static void close_surface_cb(void* userdata, bool process_alive) {
+ (void)userdata; (void)process_alive;
+ if (g_hwnd) PostMessage(g_hwnd, WM_CLOSE, 0, 0);
+}
+
+// --- Input helpers ---
+
+// Extract the Win32 scancode from WM_KEYDOWN/WM_KEYUP lParam.
+// Bits 16-23 are the scancode. Bit 24 is the extended key flag.
+// Extended keys (arrows, numpad, etc.) need the 0xE000 prefix.
+static uint32_t scancode_from_lparam(LPARAM lp) {
+ uint32_t sc = (lp >> 16) & 0xFF;
+ if (lp & (1 << 24)) sc |= 0xE000; // extended key
+ return sc;
+}
+
+// Map Win32 modifier state to ghostty mods.
+static ghostty_input_mods_e current_mods(void) {
+ ghostty_input_mods_e mods = GHOSTTY_MODS_NONE;
+ if (GetKeyState(VK_SHIFT) & 0x8000) mods |= GHOSTTY_MODS_SHIFT;
+ if (GetKeyState(VK_CONTROL) & 0x8000) mods |= GHOSTTY_MODS_CTRL;
+ if (GetKeyState(VK_MENU) & 0x8000) mods |= GHOSTTY_MODS_ALT;
+ if (GetKeyState(VK_LWIN) & 0x8000 || GetKeyState(VK_RWIN) & 0x8000)
+ mods |= GHOSTTY_MODS_SUPER;
+ if (GetKeyState(VK_CAPITAL) & 0x0001) mods |= GHOSTTY_MODS_CAPS;
+ if (GetKeyState(VK_NUMLOCK) & 0x0001) mods |= GHOSTTY_MODS_NUM;
+ return mods;
+}
+
+// --- Window procedure ---
+
+static LRESULT CALLBACK wnd_proc(HWND hwnd, UINT msg, WPARAM wp, LPARAM lp) {
+ switch (msg) {
+ case WM_GHOSTTY_WAKEUP:
+ if (g_app) ghostty_app_tick(g_app);
+ return 0;
+
+ case WM_KEYDOWN:
+ case WM_SYSKEYDOWN: {
+ if (!g_surface) break;
+ ghostty_input_key_s key = {
+ .action = (lp & (1 << 30)) ? GHOSTTY_ACTION_REPEAT : GHOSTTY_ACTION_PRESS,
+ .mods = current_mods(),
+ .consumed_mods = GHOSTTY_MODS_NONE,
+ .keycode = scancode_from_lparam(lp),
+ .text = NULL,
+ .unshifted_codepoint = 0,
+ .composing = false,
+ };
+ ghostty_surface_key(g_surface, key);
+ return 0;
+ }
+
+ case WM_KEYUP:
+ case WM_SYSKEYUP: {
+ if (!g_surface) break;
+ ghostty_input_key_s key = {
+ .action = GHOSTTY_ACTION_RELEASE,
+ .mods = current_mods(),
+ .consumed_mods = GHOSTTY_MODS_NONE,
+ .keycode = scancode_from_lparam(lp),
+ .text = NULL,
+ .unshifted_codepoint = 0,
+ .composing = false,
+ };
+ ghostty_surface_key(g_surface, key);
+ return 0;
+ }
+
+ // WM_UNICHAR is sent by some IME frameworks (e.g. IBUS on Wine) and
+ // accessibility tools that bypass TranslateMessage. Unlike WM_CHAR
+ // which delivers UTF-16, WM_UNICHAR carries a full UTF-32 codepoint.
+ case WM_UNICHAR: {
+ if (wp == UNICODE_NOCHAR) return TRUE;
+ if (!g_surface) break;
+
+ // Convert the UTF-32 codepoint to a surrogate pair (or single
+ // wchar_t) so we can use WideCharToMultiByte like WM_CHAR.
+ uint32_t cp = (uint32_t)wp;
+ wchar_t wc_buf[3] = {0};
+ int count;
+ if (cp >= 0x10000 && cp < 0x110000) {
+ cp -= 0x10000;
+ wc_buf[0] = (wchar_t)(0xD800 | (cp >> 10));
+ wc_buf[1] = (wchar_t)(0xDC00 | (cp & 0x3FF));
+ count = 2;
+ } else {
+ wc_buf[0] = (wchar_t)cp;
+ count = 1;
+ }
+
+ char utf8[8] = {0};
+ int len = WideCharToMultiByte(CP_UTF8, 0, wc_buf, count,
+ utf8, sizeof(utf8) - 1, NULL, NULL);
+ if (len <= 0) return 0;
+ utf8[len] = '\0';
+
+ ghostty_surface_text(g_surface, utf8, (uintptr_t)len);
+ return 0;
+ }
+
+ case WM_DEADCHAR:
+ case WM_SYSDEADCHAR:
+ return 0;
+
+ case WM_CHAR:
+ case WM_SYSCHAR: {
+ if (!g_surface) break;
+
+ WCHAR wc = (WCHAR)wp;
+
+ if (IS_HIGH_SURROGATE(wc)) {
+ g_high_surrogate = wc;
+ return 0;
+ }
+
+ wchar_t wc_buf[3] = {0};
+ int count;
+ if (IS_LOW_SURROGATE(wc)) {
+ if (g_high_surrogate) {
+ wc_buf[0] = g_high_surrogate;
+ wc_buf[1] = wc;
+ g_high_surrogate = 0;
+ count = 2;
+ } else {
+ return 0;
+ }
+ } else {
+ g_high_surrogate = 0;
+ wc_buf[0] = wc;
+ count = 1;
+ }
+
+ char utf8[8] = {0};
+ int len = WideCharToMultiByte(CP_UTF8, 0, wc_buf, count,
+ utf8, sizeof(utf8) - 1, NULL, NULL);
+ if (len <= 0) return 0;
+ utf8[len] = '\0';
+
+ ghostty_surface_text(g_surface, utf8, (uintptr_t)len);
+ return 0;
+ }
+
+ case WM_MOUSEMOVE:
+ if (g_surface) {
+ // GET_X/Y_LPARAM handles sign correctly during mouse capture
+ double x = (double)GET_X_LPARAM(lp);
+ double y = (double)GET_Y_LPARAM(lp);
+ ghostty_surface_mouse_pos(g_surface, x, y, current_mods());
+ }
+ return 0;
+
+ case WM_LBUTTONDOWN:
+ if (g_surface) {
+ SetCapture(g_hwnd);
+ ghostty_surface_mouse_button(g_surface,
+ GHOSTTY_MOUSE_PRESS, GHOSTTY_MOUSE_LEFT, current_mods());
+ }
+ return 0;
+
+ case WM_LBUTTONUP:
+ if (g_surface) {
+ ReleaseCapture();
+ ghostty_surface_mouse_button(g_surface,
+ GHOSTTY_MOUSE_RELEASE, GHOSTTY_MOUSE_LEFT, current_mods());
+ }
+ return 0;
+
+ case WM_RBUTTONDOWN:
+ if (g_surface)
+ ghostty_surface_mouse_button(g_surface,
+ GHOSTTY_MOUSE_PRESS, GHOSTTY_MOUSE_RIGHT, current_mods());
+ return 0;
+
+ case WM_RBUTTONUP:
+ if (g_surface)
+ ghostty_surface_mouse_button(g_surface,
+ GHOSTTY_MOUSE_RELEASE, GHOSTTY_MOUSE_RIGHT, current_mods());
+ return 0;
+
+ case WM_MBUTTONDOWN:
+ if (g_surface)
+ ghostty_surface_mouse_button(g_surface,
+ GHOSTTY_MOUSE_PRESS, GHOSTTY_MOUSE_MIDDLE, current_mods());
+ return 0;
+
+ case WM_MBUTTONUP:
+ if (g_surface)
+ ghostty_surface_mouse_button(g_surface,
+ GHOSTTY_MOUSE_RELEASE, GHOSTTY_MOUSE_MIDDLE, current_mods());
+ return 0;
+
+ case WM_MOUSEWHEEL: {
+ if (!g_surface) break;
+ double delta = (double)GET_WHEEL_DELTA_WPARAM(wp) / WHEEL_DELTA;
+ ghostty_surface_mouse_scroll(g_surface, 0, delta, 0);
+ return 0;
+ }
+
+ case WM_MOUSEHWHEEL: {
+ if (!g_surface) break;
+ double delta = (double)GET_WHEEL_DELTA_WPARAM(wp) / WHEEL_DELTA;
+ ghostty_surface_mouse_scroll(g_surface, delta, 0, 0);
+ return 0;
+ }
+
+ case WM_ENTERSIZEMOVE:
+ // Windows enters a modal loop during resize/move. Normal messages
+ // don't flow, so we use a timer to keep the renderer ticking.
+ SetTimer(hwnd, WM_GHOSTTY_RESIZE_TIMER, RESIZE_TIMER_MS, NULL);
+ return 0;
+
+ case WM_EXITSIZEMOVE:
+ KillTimer(hwnd, WM_GHOSTTY_RESIZE_TIMER);
+ // One final tick to render at the settled size.
+ if (g_app) ghostty_app_tick(g_app);
+ return 0;
+
+ case WM_TIMER:
+ if (wp == WM_GHOSTTY_RESIZE_TIMER && g_app) {
+ ghostty_app_tick(g_app);
+ }
+ return 0;
+
+ case WM_SIZE:
+ if (g_surface) {
+ ghostty_surface_set_size(g_surface, LOWORD(lp), HIWORD(lp));
+ }
+ return 0;
+
+ case WM_SETFOCUS:
+ if (g_surface) ghostty_surface_set_focus(g_surface, true);
+ return 0;
+
+ case WM_KILLFOCUS:
+ if (g_surface) ghostty_surface_set_focus(g_surface, false);
+ return 0;
+
+ case WM_DPICHANGED: {
+ if (g_surface) {
+ UINT new_dpi = HIWORD(wp);
+ double new_scale = (double)new_dpi / 96.0;
+ ghostty_surface_set_content_scale(g_surface, new_scale, new_scale);
+ }
+ // Resize to the suggested rect
+ RECT* suggested = (RECT*)lp;
+ SetWindowPos(g_hwnd, NULL,
+ suggested->left, suggested->top,
+ suggested->right - suggested->left,
+ suggested->bottom - suggested->top,
+ SWP_NOZORDER | SWP_NOACTIVATE);
+ return 0;
+ }
+
+ case WM_DESTROY:
+ KillTimer(hwnd, WM_GHOSTTY_RESIZE_TIMER);
+ PostQuitMessage(0);
+ return 0;
+
+ default:
+ break;
+ }
+
+ return DefWindowProc(hwnd, msg, wp, lp);
+}
+
+// --- Entry point ---
+
+int WINAPI WinMain(HINSTANCE hInst, HINSTANCE hPrev, LPSTR cmdLine, int show) {
+ (void)hPrev; (void)cmdLine;
+
+ // Attach to parent console or allocate one so we can see stderr/stdout.
+ // A console is required for Ctrl+C keystrokes to reach the GUI window.
+ if (!AttachConsole(ATTACH_PARENT_PROCESS)) AllocConsole();
+ freopen("CONOUT$", "w", stdout);
+ freopen("CONOUT$", "w", stderr);
+
+ // Prevent Ctrl+C from killing this process. A custom handler
+ // (unlike NULL) is not inherited by child processes.
+ SetConsoleCtrlHandler(ctrl_handler, TRUE);
+
+ // 1. Register window class
+ WNDCLASSEX wc = {
+ .cbSize = sizeof(wc),
+ .style = CS_HREDRAW | CS_VREDRAW,
+ .lpfnWndProc = wnd_proc,
+ .hInstance = hInst,
+ .hCursor = LoadCursor(NULL, IDC_IBEAM),
+ .hbrBackground = NULL, // ghostty renders the background
+ .lpszClassName = "GhosttyExample",
+ };
+ RegisterClassEx(&wc);
+
+ // 2. Create window
+ g_hwnd = CreateWindowEx(
+ 0, "GhosttyExample", "Ghostty Win32 Example",
+ WS_OVERLAPPEDWINDOW,
+ CW_USEDEFAULT, CW_USEDEFAULT, 800, 600,
+ NULL, NULL, hInst, NULL);
+ if (!g_hwnd) {
+ fprintf(stderr, "CreateWindowEx failed: %lu\n", GetLastError());
+ return 1;
+ }
+
+ // 3. Initialize ghostty global state
+ char* argv[] = { "ghostty-example" };
+ if (ghostty_init(1, argv) != 0) {
+ fprintf(stderr, "ghostty_init failed\n");
+ return 1;
+ }
+
+ // 4. Create ghostty config
+ ghostty_config_t config = ghostty_config_new();
+ ghostty_config_load_default_files(config);
+ ghostty_config_load_recursive_files(config);
+ ghostty_config_finalize(config);
+
+ // 5. Create ghostty app with runtime callbacks
+ ghostty_runtime_config_s runtime_cfg = {
+ .userdata = NULL,
+ .supports_selection_clipboard = false,
+ .wakeup_cb = wakeup_cb,
+ .action_cb = action_cb,
+ .read_clipboard_cb = read_clipboard_cb,
+ .confirm_read_clipboard_cb = confirm_read_clipboard_cb,
+ .write_clipboard_cb = write_clipboard_cb,
+ .close_surface_cb = close_surface_cb,
+ };
+
+ g_app = ghostty_app_new(&runtime_cfg, config);
+ ghostty_config_free(config);
+ if (!g_app) {
+ fprintf(stderr, "ghostty_app_new failed\n");
+ return 1;
+ }
+
+ // 6. Create surface with HWND
+ UINT dpi = GetDpiForWindow(g_hwnd);
+ double scale = (double)dpi / 96.0;
+
+ ghostty_surface_config_s surface_cfg = ghostty_surface_config_new();
+ surface_cfg.platform_tag = GHOSTTY_PLATFORM_WINDOWS;
+ surface_cfg.platform.windows.hwnd = (void*)g_hwnd;
+ surface_cfg.scale_factor = scale;
+
+ g_surface = ghostty_surface_new(g_app, &surface_cfg);
+ if (!g_surface) {
+ fprintf(stderr, "ghostty_surface_new failed\n");
+ ghostty_app_free(g_app);
+ return 1;
+ }
+
+ // 7. Set initial size
+ RECT rc;
+ GetClientRect(g_hwnd, &rc);
+ ghostty_surface_set_size(g_surface,
+ (uint32_t)(rc.right - rc.left),
+ (uint32_t)(rc.bottom - rc.top));
+
+ // 8. Show window and tell ghostty the surface is visible and focused
+ ShowWindow(g_hwnd, show);
+ UpdateWindow(g_hwnd);
+ ghostty_surface_set_occlusion(g_surface, true);
+ ghostty_surface_set_focus(g_surface, true);
+
+ MSG msg;
+ while (GetMessage(&msg, NULL, 0, 0) > 0) {
+ TranslateMessage(&msg);
+ DispatchMessage(&msg);
+ }
+
+ // 9. Cleanup
+ ghostty_surface_free(g_surface);
+ ghostty_app_free(g_app);
+
+ return (int)msg.wParam;
+}
diff --git a/global.json b/global.json
new file mode 100644
index 00000000000..e15758c6957
--- /dev/null
+++ b/global.json
@@ -0,0 +1,6 @@
+{
+ "sdk": {
+ "version": "10.0.202",
+ "rollForward": "latestPatch"
+ }
+}
diff --git a/images/Ghostty.icon/Assets/Ghostty.png b/images/Ghostty.icon/Assets/Ghostty.png
index 49795c006c6..aa730f04c7c 100644
Binary files a/images/Ghostty.icon/Assets/Ghostty.png and b/images/Ghostty.icon/Assets/Ghostty.png differ
diff --git a/images/gnome/1024.png b/images/gnome/1024.png
index 8accee3dcf8..5934e9a13a2 100644
Binary files a/images/gnome/1024.png and b/images/gnome/1024.png differ
diff --git a/images/gnome/128.png b/images/gnome/128.png
index cab4dcf9b2f..a26de996083 100644
Binary files a/images/gnome/128.png and b/images/gnome/128.png differ
diff --git a/images/gnome/16.png b/images/gnome/16.png
index 0212014c960..9e835a5e6f9 100644
Binary files a/images/gnome/16.png and b/images/gnome/16.png differ
diff --git a/images/gnome/2048.png b/images/gnome/2048.png
index 1327d461b0e..59f60a49ad3 100644
Binary files a/images/gnome/2048.png and b/images/gnome/2048.png differ
diff --git a/images/gnome/256.png b/images/gnome/256.png
index 423465c2ed8..9977ab4777e 100644
Binary files a/images/gnome/256.png and b/images/gnome/256.png differ
diff --git a/images/gnome/32.png b/images/gnome/32.png
index 28891311e5d..72502058a36 100644
Binary files a/images/gnome/32.png and b/images/gnome/32.png differ
diff --git a/images/gnome/512.png b/images/gnome/512.png
index 2045204b2d4..ef95a9f0a54 100644
Binary files a/images/gnome/512.png and b/images/gnome/512.png differ
diff --git a/images/gnome/64.png b/images/gnome/64.png
index fae92b4250f..d3facce5ad2 100644
Binary files a/images/gnome/64.png and b/images/gnome/64.png differ
diff --git a/images/gnome/nightly-1024.png b/images/gnome/nightly-1024.png
index 983f6200654..d3d0d1853ff 100644
Binary files a/images/gnome/nightly-1024.png and b/images/gnome/nightly-1024.png differ
diff --git a/images/gnome/nightly-128.png b/images/gnome/nightly-128.png
index 718da82be86..aef0bcdde1e 100644
Binary files a/images/gnome/nightly-128.png and b/images/gnome/nightly-128.png differ
diff --git a/images/gnome/nightly-16.png b/images/gnome/nightly-16.png
index 134df7fd1ae..595a5361cb5 100644
Binary files a/images/gnome/nightly-16.png and b/images/gnome/nightly-16.png differ
diff --git a/images/gnome/nightly-2048.png b/images/gnome/nightly-2048.png
index fc4949ada79..8bc99b28d8b 100644
Binary files a/images/gnome/nightly-2048.png and b/images/gnome/nightly-2048.png differ
diff --git a/images/gnome/nightly-256.png b/images/gnome/nightly-256.png
index dee768b4113..29d25346e72 100644
Binary files a/images/gnome/nightly-256.png and b/images/gnome/nightly-256.png differ
diff --git a/images/gnome/nightly-32.png b/images/gnome/nightly-32.png
index 3fe62a22fa5..351e1a28798 100644
Binary files a/images/gnome/nightly-32.png and b/images/gnome/nightly-32.png differ
diff --git a/images/gnome/nightly-512.png b/images/gnome/nightly-512.png
index 48238ad3de2..2071bd0acfa 100644
Binary files a/images/gnome/nightly-512.png and b/images/gnome/nightly-512.png differ
diff --git a/images/gnome/nightly-64.png b/images/gnome/nightly-64.png
index a9ac16923df..f56726dc4c4 100644
Binary files a/images/gnome/nightly-64.png and b/images/gnome/nightly-64.png differ
diff --git a/images/icons/icon_1024.png b/images/icons/icon_1024.png
index 22361edcbb4..5934e9a13a2 100644
Binary files a/images/icons/icon_1024.png and b/images/icons/icon_1024.png differ
diff --git a/images/icons/icon_1024@2x.png b/images/icons/icon_1024@2x.png
index 22361edcbb4..59f60a49ad3 100644
Binary files a/images/icons/icon_1024@2x.png and b/images/icons/icon_1024@2x.png differ
diff --git a/images/icons/icon_128.png b/images/icons/icon_128.png
index 317ad9f0f18..a26de996083 100644
Binary files a/images/icons/icon_128.png and b/images/icons/icon_128.png differ
diff --git a/images/icons/icon_128@2x.png b/images/icons/icon_128@2x.png
index 46c3f7050fe..9977ab4777e 100644
Binary files a/images/icons/icon_128@2x.png and b/images/icons/icon_128@2x.png differ
diff --git a/images/icons/icon_16.png b/images/icons/icon_16.png
index cacff7a5403..9e835a5e6f9 100644
Binary files a/images/icons/icon_16.png and b/images/icons/icon_16.png differ
diff --git a/images/icons/icon_16@2x.png b/images/icons/icon_16@2x.png
index b35e66641d7..72502058a36 100644
Binary files a/images/icons/icon_16@2x.png and b/images/icons/icon_16@2x.png differ
diff --git a/images/icons/icon_256.png b/images/icons/icon_256.png
index 9988ac11e54..9977ab4777e 100644
Binary files a/images/icons/icon_256.png and b/images/icons/icon_256.png differ
diff --git a/images/icons/icon_256@2x.png b/images/icons/icon_256@2x.png
index 9988ac11e54..ef95a9f0a54 100644
Binary files a/images/icons/icon_256@2x.png and b/images/icons/icon_256@2x.png differ
diff --git a/images/icons/icon_32.png b/images/icons/icon_32.png
index b647bcf3585..72502058a36 100644
Binary files a/images/icons/icon_32.png and b/images/icons/icon_32.png differ
diff --git a/images/icons/icon_32@2x.png b/images/icons/icon_32@2x.png
index e394a5170a9..d3facce5ad2 100644
Binary files a/images/icons/icon_32@2x.png and b/images/icons/icon_32@2x.png differ
diff --git a/images/icons/icon_512.png b/images/icons/icon_512.png
index 759511f68a7..ef95a9f0a54 100644
Binary files a/images/icons/icon_512.png and b/images/icons/icon_512.png differ
diff --git a/images/icons/icon_512@2x.png b/images/icons/icon_512@2x.png
index 759511f68a7..5934e9a13a2 100644
Binary files a/images/icons/icon_512@2x.png and b/images/icons/icon_512@2x.png differ
diff --git a/include/ghostty.h b/include/ghostty.h
index b099741fc23..1eb37908222 100644
--- a/include/ghostty.h
+++ b/include/ghostty.h
@@ -66,6 +66,7 @@ typedef enum {
GHOSTTY_PLATFORM_INVALID,
GHOSTTY_PLATFORM_MACOS,
GHOSTTY_PLATFORM_IOS,
+ GHOSTTY_PLATFORM_WINDOWS,
} ghostty_platform_e;
typedef enum {
@@ -78,6 +79,15 @@ typedef struct {
const char *data;
} ghostty_clipboard_content_s;
+typedef struct {
+ const char *version;
+ const char *version_string;
+ const char *commit;
+ const char *channel;
+ const char *zig_version;
+ const char *build_mode;
+} ghostty_build_info_s;
+
typedef enum {
GHOSTTY_CLIPBOARD_REQUEST_PASTE,
GHOSTTY_CLIPBOARD_REQUEST_OSC_52_READ,
@@ -453,9 +463,31 @@ typedef struct {
void* uiview;
} ghostty_platform_ios_s;
+typedef struct {
+ // Exactly one of the surface mode fields below should be set:
+ // - hwnd != NULL -> HWND surface (DirectComposition)
+ // - swap_chain_panel != NULL -> SwapChainPanel surface (WinUI 3)
+ // - shared_texture.enabled -> offscreen shared texture
+ void* hwnd;
+ void* swap_chain_panel;
+
+ // Shared-texture surface configuration. When `enabled` is true and
+ // both `hwnd` and `swap_chain_panel` are NULL, the renderer creates
+ // an offscreen ID3D12Resource at the given initial pixel dimensions.
+ // This sub-struct carries only the INITIAL configuration. Retrieve
+ // the current shared handle, fence, and dimensions via
+ // ghostty_surface_shared_texture().
+ struct {
+ bool enabled;
+ uint32_t width;
+ uint32_t height;
+ } shared_texture;
+} ghostty_platform_windows_s;
+
typedef union {
ghostty_platform_macos_s macos;
ghostty_platform_ios_s ios;
+ ghostty_platform_windows_s windows;
} ghostty_platform_u;
typedef enum {
@@ -1061,6 +1093,7 @@ typedef enum {
GHOSTTY_API int ghostty_init(uintptr_t, char**);
GHOSTTY_API void ghostty_cli_try_action(void);
+GHOSTTY_API void ghostty_build_info(ghostty_build_info_s *out);
GHOSTTY_API ghostty_info_s ghostty_info(void);
GHOSTTY_API const char* ghostty_translate(const char*);
GHOSTTY_API void ghostty_string_free(ghostty_string_s);
@@ -1113,6 +1146,56 @@ GHOSTTY_API void ghostty_surface_set_content_scale(ghostty_surface_t, double, do
GHOSTTY_API void ghostty_surface_set_focus(ghostty_surface_t, bool);
GHOSTTY_API void ghostty_surface_set_occlusion(ghostty_surface_t, bool);
GHOSTTY_API void ghostty_surface_set_size(ghostty_surface_t, uint32_t, uint32_t);
+// Returns the ID3D12Device* used by this surface's renderer. Shared texture
+// consumers should call OpenSharedResource1 on this device to avoid
+// cross-device synchronization issues. Returns NULL on non-DX12 builds or if
+// the renderer device is not yet initialized.
+GHOSTTY_API void* ghostty_surface_get_d3d12_device(ghostty_surface_t);
+
+// Snapshot of the shared-texture state for a surface. All fields are
+// filled in atomically by a single ghostty_surface_shared_texture()
+// call so consumers never observe a torn read.
+//
+// Ownership: ghostty retains both NT HANDLEs in this struct for the
+// surface lifetime -- do NOT CloseHandle either of them. The
+// ID3D12Resource and ID3D12Fence returned by OpenSharedHandle on the
+// consumer's device ARE owned by the consumer; Release() them when
+// done, and re-open the resource whenever `version` changes.
+typedef struct {
+ // NT HANDLE from CreateSharedHandle on the underlying ID3D12Resource.
+ // Consumers open via ID3D12Device::OpenSharedHandle on their own
+ // device (cross-device) or on ghostty's device (same-device).
+ void* resource_handle;
+
+ // NT HANDLE from CreateSharedHandle on ghostty's ID3D12Fence. Stable
+ // for the surface lifetime (does not change on resize).
+ void* fence_handle;
+
+ // Fence value ghostty will signal after completing the most recently
+ // submitted frame. Consumers Wait for this value on their own
+ // command queue before sampling the resource.
+ uint64_t fence_value;
+
+ // Pixel dimensions of the shared resource. These match the size the
+ // renderer most recently (re)created the resource at, and stay in
+ // sync with `version` -- they change in the same atomic snapshot.
+ uint32_t width;
+ uint32_t height;
+
+ // Monotonically increasing counter, incremented whenever the shared
+ // resource is recreated (initial creation, resize, device-removed
+ // recovery). Re-open the shared handle whenever this changes.
+ uint64_t version;
+} ghostty_surface_shared_texture_s;
+
+// Fill `out` with the current shared-texture state for this surface.
+// Returns true on success, false if the surface is not in shared
+// texture mode (in which case `out` is left untouched). The read is
+// atomic -- all fields correspond to the same renderer state snapshot.
+GHOSTTY_API bool ghostty_surface_shared_texture(
+ ghostty_surface_t,
+ ghostty_surface_shared_texture_s* out);
+
GHOSTTY_API ghostty_surface_size_s ghostty_surface_size(ghostty_surface_t);
GHOSTTY_API uint64_t ghostty_surface_foreground_pid(ghostty_surface_t);
GHOSTTY_API ghostty_string_s ghostty_surface_tty_name(ghostty_surface_t);
@@ -1200,6 +1283,23 @@ GHOSTTY_API void ghostty_set_window_background_blur(ghostty_app_t, void*);
// Benchmark API, if available.
GHOSTTY_API bool ghostty_benchmark_cli(const char*, const char*);
+// Log bridge. Lets an embedder receive every std.log message libghostty
+// emits. scope_ptr/message_ptr are NOT null-terminated; use the
+// companion length. user_data is echoed verbatim from the registration
+// call. Invoked from whichever thread produces the log, so callbacks
+// must be thread-safe. Level ordinals are pinned:
+// 0 = debug, 1 = info, 2 = warn, 3 = err.
+// Pass NULL cb to clear any previously-installed callback.
+typedef void (*ghostty_log_callback)(
+ uint32_t level,
+ const char *scope_ptr,
+ uintptr_t scope_len,
+ const char *message_ptr,
+ uintptr_t message_len,
+ void *user_data);
+GHOSTTY_API void ghostty_log_set_callback(ghostty_log_callback cb,
+ void *user_data);
+
#ifdef __cplusplus
}
#endif
diff --git a/justfile b/justfile
new file mode 100644
index 00000000000..8ae182198a2
--- /dev/null
+++ b/justfile
@@ -0,0 +1,370 @@
+# Ghostty Windows Fork - Build Orchestration
+# Run `just` for the default (full test + build), or `just ` for individual steps.
+
+# Cross-platform shell selection.
+#
+# On unix the default `sh` is fine and most recipes are single program
+# invocations (zig build, dotnet build) that work in any POSIX shell.
+#
+# On Windows we pin pwsh.exe so users do not need git-bash on PATH for the
+# common build/run recipes. The few recipes that genuinely need bash (the
+# example test loops, the sync helper) carry an explicit `#!/usr/bin/env bash`
+# shebang, which bypasses this setting and runs under bash regardless. Those
+# recipes still need git-bash on Windows; the build/run path does not.
+set windows-shell := ["pwsh.exe", "-NoLogo", "-NoProfile", "-Command"]
+
+# Default: run tests and build the DLL
+default: test build-dll
+
+# === CI Pipeline (local equivalent) ===
+
+# Run everything CI would run
+ci: check-zig test build-release
+ @echo ""
+ @echo "═══════════════════════════════════════"
+ @echo " CI PASSED — all gates green"
+ @echo "═══════════════════════════════════════"
+
+# Verify Zig version matches project requirement
+check-zig:
+ @mise exec -- zig version | grep -q "0.15.2" && echo "Zig 0.15.2 ✓" || (echo "ERROR: expected Zig 0.15.2" && exit 1)
+
+# Build in ReleaseSafe (what CI uses)
+build-release:
+ mise exec -- zig build -Doptimize=ReleaseSafe --summary all
+
+# === Testing ===
+
+# Run all Zig tests
+test: test-lib-vt test-full
+
+# Test libghostty-vt (fastest feedback loop)
+test-lib-vt:
+ zig build test-lib-vt --summary all
+
+# Full Zig test suite
+test-full:
+ zig build test -Dapp-runtime=none --summary all
+
+# Cross-platform sanity check (on demand)
+# Uses the cross-platform-test Claude Code skill for native SSH-based testing.
+test-cross:
+ @echo "Use the cross-platform-test Claude Code skill for native multi-platform testing."
+ @echo "It runs zig build test natively on Windows, Linux, and Mac via SSH."
+
+# Build and test all examples (mirrors CI: clean zig-out, build zig + cmake examples)
+test-examples: _test-examples-zig _test-examples-cmake
+ @echo "All examples done."
+
+# Zig examples (zig build in each example dir)
+_test-examples-zig:
+ #!/usr/bin/env bash
+ set -e
+ rm -rf zig-out .zig-cache
+ failed=""
+ for dir in example/*/; do
+ [ -f "$dir/build.zig.zon" ] || continue
+ name=$(basename "$dir")
+ echo "=== zig: $name ==="
+ (cd "$dir" && zig build 2>&1) || failed="$failed $name"
+ done
+ if [ -n "$failed" ]; then
+ echo "FAILED:$failed"
+ exit 1
+ fi
+
+# CMake examples (requires VS Dev Shell on Windows)
+_test-examples-cmake:
+ #!/usr/bin/env bash
+ set -e
+ failed=""
+ # Convert MSYS /c/... paths to C:\... for PowerShell/CMake
+ if [[ "$OSTYPE" == "msys"* || "$OSTYPE" == "cygwin"* || -n "$WINDIR" ]]; then
+ win_root=$(cygpath -w "$PWD")
+ fi
+ for dir in example/*/; do
+ [ -f "$dir/CMakeLists.txt" ] || continue
+ name=$(basename "$dir")
+ echo "=== cmake: $name ==="
+ rm -rf "$dir/build"
+ if [ -n "$win_root" ]; then
+ win_dir="$win_root\\$dir"
+ powershell.exe -NoProfile -Command "
+ Import-Module 'C:\Program Files\Microsoft Visual Studio\18\Community\Common7\Tools\Microsoft.VisualStudio.DevShell.dll'
+ Enter-VsDevShell -VsInstallPath 'C:\Program Files\Microsoft Visual Studio\18\Community' -DevCmdArguments '-arch=x64' -SkipAutomaticLocation
+ cd '$win_dir'
+ cmake -B build -DFETCHCONTENT_SOURCE_DIR_GHOSTTY='$win_root'
+ cmake --build build
+ " || failed="$failed $name"
+ else
+ repo_root="$PWD"
+ (cd "$dir" && cmake -B build -DFETCHCONTENT_SOURCE_DIR_GHOSTTY="$repo_root" && cmake --build build) || failed="$failed $name"
+ fi
+ done
+ if [ -n "$failed" ]; then
+ echo "FAILED:$failed"
+ exit 1
+ fi
+
+# === Building ===
+
+# Build libghostty DLL
+build-dll:
+ zig build -Dapp-runtime=none
+
+# === WinUI 3 app shell ===
+
+# Build the WinUI 3 app shell (expects ghostty.dll at zig-out/bin/).
+[windows]
+build-win:
+ dotnet build windows/Ghostty.sln /p:Platform=x64
+
+# Recipe body has no shebang so it runs under the platform shell selected by
+# `set windows-shell` above (pwsh on Windows). The previous version used a
+# bash shebang to `exec` the .exe, which forced git-bash on Windows for no
+# reason - launching a Windows .exe works fine from pwsh.
+
+# Build the DLL and the shell, then launch it.
+[windows]
+run-win: build-dll build-win
+ ./windows/Ghostty/bin/x64/Debug/net10.0-windows10.0.19041.0/Wintty.exe
+
+# === ConPTY-mode validation ===
+
+# Run one smoke row. ROW = pwsh-auto | pwsh-always | pwsh-never | cmd-auto.
+[windows]
+validate-transport-smoke ROW: build-dll build-win
+ pwsh -NoProfile -File scripts/validate-transport-run.ps1 -Row {{ROW}}
+
+# Run all four smoke rows; summarize, non-zero exit if any fail.
+[windows]
+validate-transport-smoke-all: build-dll build-win
+ pwsh -NoProfile -File scripts/validate-transport-run-all.ps1
+
+# === Shader Wrapper DLL (REMOVED) ===
+# shader_wrapper.dll has been replaced by glslpp (pure-Zig GLSL→HLSL).
+# The old recipes (build-shader-wrapper, deploy-shader-wrapper) are removed.
+# glslpp compiles GLSL→SPIR-V→HLSL in-process — no C++ DLL, no MSVC, no 8MB thread.
+
+# Build shader_wrapper.dll with MSVC (isolates C++ ABI from ghostty.dll).
+# Must be run from a VS Developer Shell or with MSVC on PATH.
+build-shader-wrapper-legacy:
+ #!/usr/bin/env bash
+ set -euo pipefail
+ ROOT="{{justfile_directory()}}"
+ PKG="$ROOT/pkg/glslang"
+ OUTDIR="$PKG/glslang_dll"
+ mkdir -p "$OUTDIR"
+
+ # Resolve zig cache paths
+ GLSLANG_SRC=""
+ SPIRV_CROSS_SRC=""
+ for d in "$LOCALAPPDATA/zig/p/"*/; do
+ if [ -f "${d}glslang/Include/glslang_c_interface.h" ]; then
+ GLSLANG_SRC="$d"
+ fi
+ if [ -f "${d}spirv_cross_c.h" ]; then
+ SPIRV_CROSS_SRC="$d"
+ fi
+ done
+ if [ -z "$GLSLANG_SRC" ]; then echo "ERROR: glslang not found in zig cache" >&2; exit 1; fi
+ if [ -z "$SPIRV_CROSS_SRC" ]; then echo "ERROR: SPIRV-Cross not found in zig cache" >&2; exit 1; fi
+ echo "glslang: $GLSLANG_SRC"
+ echo "SPIRV-Cross: $SPIRV_CROSS_SRC"
+
+ # Convert to Windows paths
+ win_outdir=$(cygpath -w "$OUTDIR")
+ win_glslang=$(cygpath -w "$GLSLANG_SRC")
+ win_spvc=$(cygpath -w "$SPIRV_CROSS_SRC")
+ win_pkg=$(cygpath -w "$PKG")
+
+ # Write a response file so we avoid shell quoting hell with cl.exe
+ resp="$OUTDIR/compile.rsp"
+ {
+ echo /nologo
+ echo /c
+ echo /std:c++17
+ echo /DNDEBUG
+ echo /DNOMINMAX
+ echo /D_CRT_SECURE_NO_WARNINGS
+ echo /EHsc
+ echo /MT
+ echo /O2
+ echo /W0
+ echo "/Fo${win_outdir}\\"
+ echo "/I${win_glslang}"
+ echo "/I${win_glslang}\\glslang\\Include"
+ echo "/I${win_glslang}\\SPIRV"
+ echo "/I${win_spvc}"
+ echo "/I${win_pkg}\\override"
+ echo /DSPIRV_CROSS_C_API_HLSL=1
+ echo /DSPIRV_CROSS_C_API_GLSL=1
+ echo /DSPIRV_CROSS_C_API_MSL=1
+ echo /DSPIRV_CROSS_C_API_CPP=1
+ echo /DSPIRV_CROSS_C_API_REFLECT=1
+
+ # glslang sources
+ echo "${win_glslang}\\glslang\\GenericCodeGen\\CodeGen.cpp"
+ echo "${win_glslang}\\glslang\\GenericCodeGen\\Link.cpp"
+ echo "${win_glslang}\\glslang\\MachineIndependent\\glslang_tab.cpp"
+ echo "${win_glslang}\\glslang\\MachineIndependent\\attribute.cpp"
+ echo "${win_glslang}\\glslang\\MachineIndependent\\Constant.cpp"
+ echo "${win_glslang}\\glslang\\MachineIndependent\\iomapper.cpp"
+ echo "${win_glslang}\\glslang\\MachineIndependent\\InfoSink.cpp"
+ echo "${win_glslang}\\glslang\\MachineIndependent\\Initialize.cpp"
+ echo "${win_glslang}\\glslang\\MachineIndependent\\IntermTraverse.cpp"
+ echo "${win_glslang}\\glslang\\MachineIndependent\\Intermediate.cpp"
+ echo "${win_glslang}\\glslang\\MachineIndependent\\ParseContextBase.cpp"
+ echo "${win_glslang}\\glslang\\MachineIndependent\\ParseHelper.cpp"
+ echo "${win_glslang}\\glslang\\MachineIndependent\\PoolAlloc.cpp"
+ echo "${win_glslang}\\glslang\\MachineIndependent\\RemoveTree.cpp"
+ echo "${win_glslang}\\glslang\\MachineIndependent\\Scan.cpp"
+ echo "${win_glslang}\\glslang\\MachineIndependent\\ShaderLang.cpp"
+ echo "${win_glslang}\\glslang\\MachineIndependent\\SpirvIntrinsics.cpp"
+ echo "${win_glslang}\\glslang\\MachineIndependent\\SymbolTable.cpp"
+ echo "${win_glslang}\\glslang\\MachineIndependent\\Versions.cpp"
+ echo "${win_glslang}\\glslang\\MachineIndependent\\intermOut.cpp"
+ echo "${win_glslang}\\glslang\\MachineIndependent\\limits.cpp"
+ echo "${win_glslang}\\glslang\\MachineIndependent\\linkValidate.cpp"
+ echo "${win_glslang}\\glslang\\MachineIndependent\\parseConst.cpp"
+ echo "${win_glslang}\\glslang\\MachineIndependent\\reflection.cpp"
+ echo "${win_glslang}\\glslang\\MachineIndependent\\preprocessor\\Pp.cpp"
+ echo "${win_glslang}\\glslang\\MachineIndependent\\preprocessor\\PpAtom.cpp"
+ echo "${win_glslang}\\glslang\\MachineIndependent\\preprocessor\\PpContext.cpp"
+ echo "${win_glslang}\\glslang\\MachineIndependent\\preprocessor\\PpScanner.cpp"
+ echo "${win_glslang}\\glslang\\MachineIndependent\\preprocessor\\PpTokens.cpp"
+ echo "${win_glslang}\\glslang\\MachineIndependent\\propagateNoContraction.cpp"
+ echo "${win_glslang}\\glslang\\CInterface\\glslang_c_interface.cpp"
+ echo "${win_glslang}\\glslang\\ResourceLimits\\ResourceLimits.cpp"
+ echo "${win_glslang}\\glslang\\ResourceLimits\\resource_limits_c.cpp"
+ echo "${win_glslang}\\glslang\\OSDependent\\Windows\\ossource.cpp"
+ echo "${win_glslang}\\SPIRV\\GlslangToSpv.cpp"
+ echo "${win_glslang}\\SPIRV\\InReadableOrder.cpp"
+ echo "${win_glslang}\\SPIRV\\Logger.cpp"
+ echo "${win_glslang}\\SPIRV\\SpvBuilder.cpp"
+ echo "${win_glslang}\\SPIRV\\SpvPostProcess.cpp"
+ echo "${win_glslang}\\SPIRV\\doc.cpp"
+ echo "${win_glslang}\\SPIRV\\disassemble.cpp"
+ echo "${win_glslang}\\SPIRV\\CInterface\\spirv_c_interface.cpp"
+
+ # SPIRV-Cross sources
+ echo "${win_spvc}\\spirv_cross.cpp"
+ echo "${win_spvc}\\spirv_cross_c.cpp"
+ echo "${win_spvc}\\spirv_cfg.cpp"
+ echo "${win_spvc}\\spirv_glsl.cpp"
+ echo "${win_spvc}\\spirv_hlsl.cpp"
+ echo "${win_spvc}\\spirv_msl.cpp"
+ echo "${win_spvc}\\spirv_cpp.cpp"
+ echo "${win_spvc}\\spirv_parser.cpp"
+ echo "${win_spvc}\\spirv_reflect.cpp"
+ echo "${win_spvc}\\spirv_cross_parsed_ir.cpp"
+ echo "${win_spvc}\\spirv_cross_util.cpp"
+
+ # wrapper entry point
+ echo "${win_pkg}\\shader_wrapper.cpp"
+ } > "$resp"
+
+ echo "Compiling glslang + SPIRV-Cross + shader_wrapper with MSVC..."
+ win_resp=$(cygpath -w "$resp")
+ cl.exe @"$win_resp"
+ if [ $? -ne 0 ]; then echo "ERROR: Compilation failed" >&2; exit 1; fi
+
+ echo "Linking shader_wrapper.dll..."
+ # Discover libcpmt.lib via VCToolsInstallDir (set in VS Developer Shell)
+ # or vswhere. Using /DEFAULTLIB:libcpmt would work too but explicit path
+ # avoids ambiguity when multiple MSVC versions are installed.
+ LIBCPMT=""
+ if [ -n "$VCToolsInstallDir" ]; then
+ LIBCPMT="$(cygpath -w "${VCToolsInstallDir}lib\\x64\\libcpmt.lib")"
+ else
+ VSWHERE="/c/Program Files (x86)/Microsoft Visual Studio/Installer/vswhere.exe"
+ if [ -f "$VSWHERE" ]; then
+ VS_PATH=$("$VSWHERE" -latest -property installationPath 2>/dev/null)
+ if [ -n "$VS_PATH" ]; then
+ MSVC_VER=$(ls -1 "${VS_PATH}/VC/Tools/MSVC/" 2>/dev/null | sort -rV | head -1)
+ if [ -n "$MSVC_VER" ]; then
+ LIBCPMT="$(cygpath -w "${VS_PATH}\\VC\\Tools\\MSVC\\${MSVC_VER}\\lib\\x64\\libcpmt.lib")"
+ fi
+ fi
+ fi
+ fi
+ if [ -z "$LIBCPMT" ]; then echo "ERROR: Could not find MSVC libcpmt.lib" >&2; exit 1; fi
+ link.exe /nologo /dll /out:"${win_outdir}\\shader_wrapper.dll" \
+ "${win_outdir}\\*.obj" \
+ "$LIBCPMT"
+ if [ $? -ne 0 ]; then echo "ERROR: Linking failed" >&2; exit 1; fi
+
+ echo "Success: ${OUTDIR}/shader_wrapper.dll"
+
+# Build everything and deploy to .NET output (full dev cycle)
+[windows]
+deploy: build-dll build-win
+ #!/usr/bin/env bash
+ set -e
+ DOTNET_OUT="windows/Ghostty/bin/x64/Debug/net10.0-windows10.0.19041.0"
+ cp zig-out/lib/ghostty.dll "${DOTNET_OUT}/native/"
+ echo "Deployed ghostty.dll (no shader_wrapper.dll needed — glslpp is linked in)"
+
+# === Worktree Setup ===
+
+# Seed pkg/glslang/msvc_build/ with the .obj files the zig build expects.
+# Prefers copying from a sibling worktree (seconds) over running build_msvc.bat
+# from scratch (minute-plus). Meant to be run once after `git worktree add`.
+# Temporary helper until the MSVC pre-build is folded into the zig build step.
+#
+# Bash shebang (requires git-bash on PATH, same as other complex recipes in
+# this file) so control flow and arrays work reliably; pwsh's -File mode
+# rejects just's extension-less temp scripts.
+[windows]
+prepare-worktree:
+ #!/usr/bin/env bash
+ set -euo pipefail
+ ROOT="{{justfile_directory()}}"
+ TARGET="$ROOT/pkg/glslang/msvc_build"
+ mkdir -p "$TARGET"
+
+ existing=$(find "$TARGET" -maxdepth 1 -name '*.obj' 2>/dev/null | wc -l)
+ if [ "$existing" -ge 40 ]; then
+ echo "pkg/glslang/msvc_build already seeded ($existing .obj files)."
+ exit 0
+ fi
+
+ # Fast path: copy from another worktree. If this tree were the source,
+ # we would have exited above, so no self-check needed.
+ while IFS= read -r line; do
+ [[ "$line" == worktree\ * ]] || continue
+ wt="${line#worktree }"
+ src="$wt/pkg/glslang/msvc_build"
+ src_count=$(find "$src" -maxdepth 1 -name '*.obj' 2>/dev/null | wc -l)
+ if [ "$src_count" -ge 40 ]; then
+ echo "Copying msvc_build artifacts from $wt ..."
+ cp "$src"/*.obj "$TARGET/"
+ [ -f "$src/dummy.c" ] && cp "$src/dummy.c" "$TARGET/"
+ echo "Seeded $TARGET with $src_count .obj files."
+ exit 0
+ fi
+ done < <(git worktree list --porcelain)
+
+ # Fallback: build from source. Needs MSVC discoverable via vswhere
+ # (bundled with VS 2017+) or on PATH.
+ echo "No sibling worktree has msvc_build artifacts; running build_msvc.bat ..."
+ cd "$ROOT/pkg/glslang"
+ ./build_msvc.bat
+
+# === Upstream Sync ===
+
+# Pinned to bash via shebang so the POSIX `[` branch test below works
+# regardless of the platform shell. On Windows this requires git-bash on
+# PATH; sync is a maintainer command and the maintainer has it.
+
+# Fetch upstream and rebase windows branch.
+sync force="":
+ #!/usr/bin/env bash
+ set -e
+ if [ "{{ force }}" != "--force" ] && [ "$(git branch --show-current)" != "windows" ]; then
+ echo "WARNING: you are on '$(git branch --show-current)', not 'windows'. Switch to windows branch first. Use 'just sync --force' to override."
+ exit 1
+ fi
+ git fetch upstream
+ git rebase upstream/main
+ echo "Rebase complete. Review any conflicts, then: git push --force-with-lease origin windows"
diff --git a/macos/Assets.xcassets/AppIconImage.imageset/macOS-AppIcon-1024px.png b/macos/Assets.xcassets/AppIconImage.imageset/macOS-AppIcon-1024px.png
index a0b716c8781..5934e9a13a2 100644
Binary files a/macos/Assets.xcassets/AppIconImage.imageset/macOS-AppIcon-1024px.png and b/macos/Assets.xcassets/AppIconImage.imageset/macOS-AppIcon-1024px.png differ
diff --git a/macos/Assets.xcassets/AppIconImage.imageset/macOS-AppIcon-256px-128pt@2x.png b/macos/Assets.xcassets/AppIconImage.imageset/macOS-AppIcon-256px-128pt@2x.png
index 46c3f7050fe..9977ab4777e 100644
Binary files a/macos/Assets.xcassets/AppIconImage.imageset/macOS-AppIcon-256px-128pt@2x.png and b/macos/Assets.xcassets/AppIconImage.imageset/macOS-AppIcon-256px-128pt@2x.png differ
diff --git a/macos/Assets.xcassets/AppIconImage.imageset/macOS-AppIcon-512px.png b/macos/Assets.xcassets/AppIconImage.imageset/macOS-AppIcon-512px.png
index 6d44fc9f340..ef95a9f0a54 100644
Binary files a/macos/Assets.xcassets/AppIconImage.imageset/macOS-AppIcon-512px.png and b/macos/Assets.xcassets/AppIconImage.imageset/macOS-AppIcon-512px.png differ
diff --git a/mise.toml b/mise.toml
new file mode 100644
index 00000000000..4c98ba1df7e
--- /dev/null
+++ b/mise.toml
@@ -0,0 +1,2 @@
+[tools]
+zig = "0.15.2"
diff --git a/msbuild.binlog b/msbuild.binlog
new file mode 100644
index 00000000000..dc4552b788a
Binary files /dev/null and b/msbuild.binlog differ
diff --git a/pkg/glslang/build.zig b/pkg/glslang/build.zig
deleted file mode 100644
index 1dc82a6e304..00000000000
--- a/pkg/glslang/build.zig
+++ /dev/null
@@ -1,169 +0,0 @@
-const std = @import("std");
-
-pub fn build(b: *std.Build) !void {
- const target = b.standardTargetOptions(.{});
- const optimize = b.standardOptimizeOption(.{});
-
- const module = b.addModule("glslang", .{
- .root_source_file = b.path("main.zig"),
- .target = target,
- .optimize = optimize,
- });
-
- const upstream = b.lazyDependency("glslang", .{});
- const lib = try buildGlslang(b, upstream, target, optimize);
- b.installArtifact(lib);
-
- if (upstream) |v| module.addIncludePath(v.path(""));
- module.addIncludePath(b.path("override"));
-
- if (target.query.isNative()) {
- const test_exe = b.addTest(.{
- .name = "test",
- .root_module = b.createModule(.{
- .root_source_file = b.path("main.zig"),
- .target = target,
- .optimize = optimize,
- }),
- });
- test_exe.linkLibrary(lib);
- const tests_run = b.addRunArtifact(test_exe);
- const test_step = b.step("test", "Run tests");
- test_step.dependOn(&tests_run.step);
-
- // Uncomment this if we're debugging tests
- // b.installArtifact(test_exe);
- }
-}
-
-fn buildGlslang(
- b: *std.Build,
- upstream_: ?*std.Build.Dependency,
- target: std.Build.ResolvedTarget,
- optimize: std.builtin.OptimizeMode,
-) !*std.Build.Step.Compile {
- const lib = b.addLibrary(.{
- .name = "glslang",
- .root_module = b.createModule(.{
- .target = target,
- .optimize = optimize,
- }),
- .linkage = .static,
- });
- lib.linkLibC();
- // On MSVC, we must not use linkLibCpp because Zig unconditionally
- // passes -nostdinc++ and then adds its bundled libc++/libc++abi
- // include paths, which conflict with MSVC's own C++ runtime headers.
- // The MSVC SDK include directories (added via linkLibC) contain
- // both C and C++ headers, so linkLibCpp is not needed.
- if (target.result.abi != .msvc) {
- lib.linkLibCpp();
- }
- if (upstream_) |upstream| lib.addIncludePath(upstream.path(""));
- lib.addIncludePath(b.path("override"));
- if (target.result.os.tag.isDarwin()) {
- const apple_sdk = @import("apple_sdk");
- try apple_sdk.addPaths(b, lib);
- }
-
- var flags: std.ArrayList([]const u8) = .empty;
- defer flags.deinit(b.allocator);
- try flags.appendSlice(b.allocator, &.{
- "-fno-sanitize=undefined",
- "-fno-sanitize-trap=undefined",
- });
- // MSVC requires explicit std specification otherwise C++17 features
- // like std::variant, std::filesystem, and inline variables are
- // guarded behind _HAS_CXX17.
- try flags.append(b.allocator, "-std=c++17");
-
- if (target.result.os.tag == .freebsd or target.result.abi == .musl) {
- try flags.append(b.allocator, "-fPIC");
- }
-
- if (upstream_) |upstream| {
- lib.addCSourceFiles(.{
- .root = upstream.path(""),
- .flags = flags.items,
- .files = &.{
- // GenericCodeGen
- "glslang/GenericCodeGen/CodeGen.cpp",
- "glslang/GenericCodeGen/Link.cpp",
-
- // MachineIndependent
- //"MachineIndependent/glslang.y",
- "glslang/MachineIndependent/glslang_tab.cpp",
- "glslang/MachineIndependent/attribute.cpp",
- "glslang/MachineIndependent/Constant.cpp",
- "glslang/MachineIndependent/iomapper.cpp",
- "glslang/MachineIndependent/InfoSink.cpp",
- "glslang/MachineIndependent/Initialize.cpp",
- "glslang/MachineIndependent/IntermTraverse.cpp",
- "glslang/MachineIndependent/Intermediate.cpp",
- "glslang/MachineIndependent/ParseContextBase.cpp",
- "glslang/MachineIndependent/ParseHelper.cpp",
- "glslang/MachineIndependent/PoolAlloc.cpp",
- "glslang/MachineIndependent/RemoveTree.cpp",
- "glslang/MachineIndependent/Scan.cpp",
- "glslang/MachineIndependent/ShaderLang.cpp",
- "glslang/MachineIndependent/SpirvIntrinsics.cpp",
- "glslang/MachineIndependent/SymbolTable.cpp",
- "glslang/MachineIndependent/Versions.cpp",
- "glslang/MachineIndependent/intermOut.cpp",
- "glslang/MachineIndependent/limits.cpp",
- "glslang/MachineIndependent/linkValidate.cpp",
- "glslang/MachineIndependent/parseConst.cpp",
- "glslang/MachineIndependent/reflection.cpp",
- "glslang/MachineIndependent/preprocessor/Pp.cpp",
- "glslang/MachineIndependent/preprocessor/PpAtom.cpp",
- "glslang/MachineIndependent/preprocessor/PpContext.cpp",
- "glslang/MachineIndependent/preprocessor/PpScanner.cpp",
- "glslang/MachineIndependent/preprocessor/PpTokens.cpp",
- "glslang/MachineIndependent/propagateNoContraction.cpp",
-
- // C Interface
- "glslang/CInterface/glslang_c_interface.cpp",
-
- // ResourceLimits
- "glslang/ResourceLimits/ResourceLimits.cpp",
- "glslang/ResourceLimits/resource_limits_c.cpp",
-
- // SPIRV
- "SPIRV/GlslangToSpv.cpp",
- "SPIRV/InReadableOrder.cpp",
- "SPIRV/Logger.cpp",
- "SPIRV/SpvBuilder.cpp",
- "SPIRV/SpvPostProcess.cpp",
- "SPIRV/doc.cpp",
- "SPIRV/disassemble.cpp",
- "SPIRV/CInterface/spirv_c_interface.cpp",
- },
- });
-
- if (target.result.os.tag != .windows) {
- lib.addCSourceFiles(.{
- .root = upstream.path(""),
- .flags = flags.items,
- .files = &.{
- "glslang/OSDependent/Unix/ossource.cpp",
- },
- });
- } else {
- lib.addCSourceFiles(.{
- .root = upstream.path(""),
- .flags = flags.items,
- .files = &.{
- "glslang/OSDependent/Windows/ossource.cpp",
- },
- });
- }
-
- lib.installHeadersDirectory(
- upstream.path(""),
- "",
- .{ .include_extensions = &.{".h"} },
- );
- }
-
- return lib;
-}
diff --git a/pkg/glslang/build.zig.zon b/pkg/glslang/build.zig.zon
deleted file mode 100644
index 252237e58bb..00000000000
--- a/pkg/glslang/build.zig.zon
+++ /dev/null
@@ -1,16 +0,0 @@
-.{
- .name = .glslang,
- .version = "14.2.0",
- .fingerprint = 0x274a35558e2e504,
- .paths = .{""},
- .dependencies = .{
- // KhronosGroup/glslang
- .glslang = .{
- .url = "https://deps.files.ghostty.org/glslang-12201278a1a05c0ce0b6eb6026c65cd3e9247aa041b1c260324bf29cee559dd23ba1.tar.gz",
- .hash = "N-V-__8AABzkUgISeKGgXAzgtutgJsZc0-kkeqBBscJgMkvy",
- .lazy = true,
- },
-
- .apple_sdk = .{ .path = "../apple-sdk" },
- },
-}
diff --git a/pkg/glslang/c.zig b/pkg/glslang/c.zig
deleted file mode 100644
index c00108463b4..00000000000
--- a/pkg/glslang/c.zig
+++ /dev/null
@@ -1,4 +0,0 @@
-pub const c = @cImport({
- @cInclude("glslang/Include/glslang_c_interface.h");
- @cInclude("glslang/Public/resource_limits_c.h");
-});
diff --git a/pkg/glslang/init.zig b/pkg/glslang/init.zig
deleted file mode 100644
index a865e9e7965..00000000000
--- a/pkg/glslang/init.zig
+++ /dev/null
@@ -1,9 +0,0 @@
-const c = @import("c.zig").c;
-
-pub fn init() !void {
- if (c.glslang_initialize_process() == 0) return error.GlslangInitFailed;
-}
-
-pub fn finalize() void {
- c.glslang_finalize_process();
-}
diff --git a/pkg/glslang/main.zig b/pkg/glslang/main.zig
deleted file mode 100644
index 2743650c60e..00000000000
--- a/pkg/glslang/main.zig
+++ /dev/null
@@ -1,15 +0,0 @@
-const initpkg = @import("init.zig");
-const program = @import("program.zig");
-const shader = @import("shader.zig");
-
-pub const c = @import("c.zig").c;
-pub const testing = @import("test.zig");
-
-pub const init = initpkg.init;
-pub const finalize = initpkg.finalize;
-pub const Program = program.Program;
-pub const Shader = shader.Shader;
-
-test {
- @import("std").testing.refAllDecls(@This());
-}
diff --git a/pkg/glslang/override/glslang/build_info.h b/pkg/glslang/override/glslang/build_info.h
deleted file mode 100644
index c25117eefee..00000000000
--- a/pkg/glslang/override/glslang/build_info.h
+++ /dev/null
@@ -1,62 +0,0 @@
-// Copyright (C) 2020 The Khronos Group Inc.
-//
-// All rights reserved.
-//
-// Redistribution and use in source and binary forms, with or without
-// modification, are permitted provided that the following conditions
-// are met:
-//
-// Redistributions of source code must retain the above copyright
-// notice, this list of conditions and the following disclaimer.
-//
-// Redistributions in binary form must reproduce the above
-// copyright notice, this list of conditions and the following
-// disclaimer in the documentation and/or other materials provided
-// with the distribution.
-//
-// Neither the name of The Khronos Group Inc. nor the names of its
-// contributors may be used to endorse or promote products derived
-// from this software without specific prior written permission.
-//
-// THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS
-// "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT
-// LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS
-// FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE
-// COPYRIGHT HOLDERS OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT,
-// INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING,
-// BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES;
-// LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER
-// CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT
-// LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN
-// ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE
-// POSSIBILITY OF SUCH DAMAGE.
-
-#ifndef GLSLANG_BUILD_INFO
-#define GLSLANG_BUILD_INFO
-
-#define GLSLANG_VERSION_MAJOR 13
-#define GLSLANG_VERSION_MINOR 1
-#define GLSLANG_VERSION_PATCH 1
-#define GLSLANG_VERSION_FLAVOR ""
-
-#define GLSLANG_VERSION_GREATER_THAN(major, minor, patch) \
- ((GLSLANG_VERSION_MAJOR) > (major) || ((major) == GLSLANG_VERSION_MAJOR && \
- ((GLSLANG_VERSION_MINOR) > (minor) || ((minor) == GLSLANG_VERSION_MINOR && \
- (GLSLANG_VERSION_PATCH) > (patch)))))
-
-#define GLSLANG_VERSION_GREATER_OR_EQUAL_TO(major, minor, patch) \
- ((GLSLANG_VERSION_MAJOR) > (major) || ((major) == GLSLANG_VERSION_MAJOR && \
- ((GLSLANG_VERSION_MINOR) > (minor) || ((minor) == GLSLANG_VERSION_MINOR && \
- (GLSLANG_VERSION_PATCH >= (patch))))))
-
-#define GLSLANG_VERSION_LESS_THAN(major, minor, patch) \
- ((GLSLANG_VERSION_MAJOR) < (major) || ((major) == GLSLANG_VERSION_MAJOR && \
- ((GLSLANG_VERSION_MINOR) < (minor) || ((minor) == GLSLANG_VERSION_MINOR && \
- (GLSLANG_VERSION_PATCH) < (patch)))))
-
-#define GLSLANG_VERSION_LESS_OR_EQUAL_TO(major, minor, patch) \
- ((GLSLANG_VERSION_MAJOR) < (major) || ((major) == GLSLANG_VERSION_MAJOR && \
- ((GLSLANG_VERSION_MINOR) < (minor) || ((minor) == GLSLANG_VERSION_MINOR && \
- (GLSLANG_VERSION_PATCH <= (patch))))))
-
-#endif // GLSLANG_BUILD_INFO
diff --git a/pkg/glslang/program.zig b/pkg/glslang/program.zig
deleted file mode 100644
index 4af687cc53c..00000000000
--- a/pkg/glslang/program.zig
+++ /dev/null
@@ -1,60 +0,0 @@
-const std = @import("std");
-const c = @import("c.zig").c;
-const testlib = @import("test.zig");
-const Shader = @import("shader.zig").Shader;
-
-pub const Program = opaque {
- pub fn create() !*Program {
- if (c.glslang_program_create()) |ptr| return @ptrCast(ptr);
- return error.OutOfMemory;
- }
-
- pub fn delete(self: *Program) void {
- c.glslang_program_delete(@ptrCast(self));
- }
-
- pub fn addShader(self: *Program, shader: *Shader) void {
- c.glslang_program_add_shader(@ptrCast(self), @ptrCast(shader));
- }
-
- pub fn link(self: *Program, messages: c_int) !void {
- if (c.glslang_program_link(@ptrCast(self), messages) != 0) return;
- return error.GlslangFailed;
- }
-
- pub fn spirvGenerate(self: *Program, stage: c.glslang_stage_t) void {
- c.glslang_program_SPIRV_generate(@ptrCast(self), stage);
- }
-
- pub fn spirvGetSize(self: *Program) usize {
- return @intCast(c.glslang_program_SPIRV_get_size(@ptrCast(self)));
- }
-
- pub fn spirvGet(self: *Program, buf: []u32) void {
- c.glslang_program_SPIRV_get(@ptrCast(self), buf.ptr);
- }
-
- pub fn spirvGetPtr(self: *Program) ![*]u32 {
- return @ptrCast(c.glslang_program_SPIRV_get_ptr(@ptrCast(self)));
- }
-
- pub fn spirvGetMessages(self: *Program) ![:0]const u8 {
- const ptr = c.glslang_program_SPIRV_get_messages(@ptrCast(self));
- return std.mem.sliceTo(ptr, 0);
- }
-
- pub fn getInfoLog(self: *Program) ![:0]const u8 {
- const ptr = c.glslang_program_get_info_log(@ptrCast(self));
- return std.mem.sliceTo(ptr, 0);
- }
-
- pub fn getDebugInfoLog(self: *Program) ![:0]const u8 {
- const ptr = c.glslang_program_get_info_debug_log(@ptrCast(self));
- return std.mem.sliceTo(ptr, 0);
- }
-};
-
-test {
- var program = try Program.create();
- defer program.delete();
-}
diff --git a/pkg/glslang/shader.zig b/pkg/glslang/shader.zig
deleted file mode 100644
index 36a09f34d61..00000000000
--- a/pkg/glslang/shader.zig
+++ /dev/null
@@ -1,58 +0,0 @@
-const std = @import("std");
-const c = @import("c.zig").c;
-const testlib = @import("test.zig");
-
-pub const Shader = opaque {
- pub fn create(input: *const c.glslang_input_t) !*Shader {
- if (c.glslang_shader_create(input)) |ptr| return @ptrCast(ptr);
- return error.OutOfMemory;
- }
-
- pub fn delete(self: *Shader) void {
- c.glslang_shader_delete(@ptrCast(self));
- }
-
- pub fn preprocess(self: *Shader, input: *const c.glslang_input_t) !void {
- if (c.glslang_shader_preprocess(@ptrCast(self), input) == 0)
- return error.GlslangFailed;
- }
-
- pub fn parse(self: *Shader, input: *const c.glslang_input_t) !void {
- if (c.glslang_shader_parse(@ptrCast(self), input) == 0)
- return error.GlslangFailed;
- }
-
- pub fn getInfoLog(self: *Shader) ![:0]const u8 {
- const ptr = c.glslang_shader_get_info_log(@ptrCast(self));
- return std.mem.sliceTo(ptr, 0);
- }
-
- pub fn getDebugInfoLog(self: *Shader) ![:0]const u8 {
- const ptr = c.glslang_shader_get_info_debug_log(@ptrCast(self));
- return std.mem.sliceTo(ptr, 0);
- }
-};
-
-test {
- const input: c.glslang_input_t = .{
- .language = c.GLSLANG_SOURCE_GLSL,
- .stage = c.GLSLANG_STAGE_FRAGMENT,
- .client = c.GLSLANG_CLIENT_VULKAN,
- .client_version = c.GLSLANG_TARGET_VULKAN_1_2,
- .target_language = c.GLSLANG_TARGET_SPV,
- .target_language_version = c.GLSLANG_TARGET_SPV_1_5,
- .code = @embedFile("test/simple.frag"),
- .default_version = 100,
- .default_profile = c.GLSLANG_NO_PROFILE,
- .force_default_version_and_profile = 0,
- .forward_compatible = 0,
- .messages = c.GLSLANG_MSG_DEFAULT_BIT,
- .resource = c.glslang_default_resource(),
- };
-
- try testlib.ensureInit();
- const shader = try Shader.create(&input);
- defer shader.delete();
- try shader.preprocess(&input);
- try shader.parse(&input);
-}
diff --git a/pkg/glslang/test.zig b/pkg/glslang/test.zig
deleted file mode 100644
index 8cdf98f7590..00000000000
--- a/pkg/glslang/test.zig
+++ /dev/null
@@ -1,10 +0,0 @@
-const glslang = @import("main.zig");
-
-var initialized: bool = false;
-
-/// Call this function before any other tests in this package to ensure that
-/// the glslang library is initialized.
-pub fn ensureInit() !void {
- if (initialized) return;
- try glslang.init();
-}
diff --git a/pkg/glslang/test/simple.frag b/pkg/glslang/test/simple.frag
deleted file mode 100644
index c1cd903ced4..00000000000
--- a/pkg/glslang/test/simple.frag
+++ /dev/null
@@ -1,56 +0,0 @@
-#version 430 core
-
-layout(binding = 0) uniform Globals {
- uniform vec3 iResolution;
- uniform float iTime;
- uniform float iTimeDelta;
- uniform float iFrameRate;
- uniform int iFrame;
- uniform float iChannelTime[4];
- uniform vec3 iChannelResolution[4];
- uniform vec4 iMouse;
- uniform vec4 iDate;
- uniform float iSampleRate;
-};
-
-layout(binding = 0) uniform sampler2D iChannel0;
-layout(binding = 1) uniform sampler2D iChannel1;
-layout(binding = 2) uniform sampler2D iChannel2;
-layout(binding = 3) uniform sampler2D iChannel3;
-
-layout(location = 0) in vec4 gl_FragCoord;
-layout(location = 0) out vec4 _fragColor;
-
-#define texture2D texture
-
-void mainImage( out vec4 fragColor, in vec2 fragCoord );
-void main() { mainImage (_fragColor, gl_FragCoord.xy); }
-
-#define t iTime
-
-void mainImage( out vec4 fragColor, in vec2 fragCoord )
-{
- // Normalized pixel coordinates (from 0 to 1)
- vec2 uv = ( fragCoord - .5*iResolution.xy) / iResolution.y;
- vec3 col = vec3(0.);
- float a = atan(uv.y,uv.x);
- float r = 0.5*length(uv);
- float counter = 100.;
- a = 4.*a+20.*r+50.*cos(r)*cos(.1*t)+abs(a*r);
- float f = 0.02*abs(cos(a))/(r*r);
-
-
- vec2 v = vec2(0.);
- for(float i=0.;i2.){
- counter = i;
- break;
- }
- }
-
- col=vec3(min(0.9,1.2*exp(-pow(f,0.45)*counter)));
-
- fragColor = min(0.9,1.2*exp(-pow(f,0.45)*counter) )
- * ( 0.7 + 0.3* cos(10.*r - 2.*t -vec4(.7,1.4,2.1,0) ) );
-}
diff --git a/pkg/spirv-cross/build.zig b/pkg/spirv-cross/build.zig
deleted file mode 100644
index 72ce61eb60f..00000000000
--- a/pkg/spirv-cross/build.zig
+++ /dev/null
@@ -1,120 +0,0 @@
-const std = @import("std");
-
-pub fn build(b: *std.Build) !void {
- const target = b.standardTargetOptions(.{});
- const optimize = b.standardOptimizeOption(.{});
-
- const module = b.addModule("spirv_cross", .{ .root_source_file = b.path("main.zig"), .target = target, .optimize = optimize });
-
- // For dynamic linking, we prefer dynamic linking and to search by
- // mode first. Mode first will search all paths for a dynamic library
- // before falling back to static.
- const dynamic_link_opts: std.Build.Module.LinkSystemLibraryOptions = .{
- .preferred_link_mode = .dynamic,
- .search_strategy = .mode_first,
- };
-
- var test_exe: ?*std.Build.Step.Compile = null;
- if (target.query.isNative()) {
- test_exe = b.addTest(.{
- .name = "test",
- .root_module = b.createModule(.{
- .root_source_file = b.path("main.zig"),
- .target = target,
- .optimize = optimize,
- }),
- });
- const tests_run = b.addRunArtifact(test_exe.?);
- const test_step = b.step("test", "Run tests");
- test_step.dependOn(&tests_run.step);
-
- // Uncomment this if we're debugging tests
- // b.installArtifact(test_exe.?);
- }
- if (b.systemIntegrationOption("spirv-cross", .{})) {
- module.linkSystemLibrary("spirv-cross-c-shared", dynamic_link_opts);
- if (test_exe) |exe| {
- exe.linkSystemLibrary2("spirv-cross-c-shared", dynamic_link_opts);
- }
- } else {
- const lib = try buildSpirvCross(b, module, target, optimize);
- b.installArtifact(lib);
- if (test_exe) |exe| exe.linkLibrary(lib);
- }
-}
-
-fn buildSpirvCross(
- b: *std.Build,
- module: *std.Build.Module,
- target: std.Build.ResolvedTarget,
- optimize: std.builtin.OptimizeMode,
-) !*std.Build.Step.Compile {
- const lib = b.addLibrary(.{
- .name = "spirv_cross",
- .root_module = b.createModule(.{
- .target = target,
- .optimize = optimize,
- }),
- .linkage = .static,
- });
- lib.linkLibC();
- // On MSVC, we must not use linkLibCpp because Zig unconditionally
- // passes -nostdinc++ and then adds its bundled libc++/libc++abi
- // include paths, which conflict with MSVC's own C++ runtime headers.
- // The MSVC SDK include directories (added via linkLibC) contain
- // both C and C++ headers, so linkLibCpp is not needed.
- if (target.result.abi != .msvc) {
- lib.linkLibCpp();
- }
- if (target.result.os.tag.isDarwin()) {
- const apple_sdk = @import("apple_sdk");
- try apple_sdk.addPaths(b, lib);
- }
-
- var flags: std.ArrayList([]const u8) = .empty;
- defer flags.deinit(b.allocator);
- try flags.appendSlice(b.allocator, &.{
- "-DSPIRV_CROSS_C_API_GLSL=1",
- "-DSPIRV_CROSS_C_API_MSL=1",
-
- "-fno-sanitize=undefined",
- "-fno-sanitize-trap=undefined",
- });
-
- if (target.result.os.tag == .freebsd or target.result.abi == .musl) {
- try flags.append(b.allocator, "-fPIC");
- }
-
- if (b.lazyDependency("spirv_cross", .{})) |upstream| {
- lib.addIncludePath(upstream.path(""));
- module.addIncludePath(upstream.path(""));
- lib.addCSourceFiles(.{
- .root = upstream.path(""),
- .flags = flags.items,
- .files = &.{
- // Core
- "spirv_cross.cpp",
- "spirv_parser.cpp",
- "spirv_cross_parsed_ir.cpp",
- "spirv_cfg.cpp",
-
- // C
- "spirv_cross_c.cpp",
-
- // GLSL
- "spirv_glsl.cpp",
-
- // MSL
- "spirv_msl.cpp",
- },
- });
-
- lib.installHeadersDirectory(
- upstream.path(""),
- "",
- .{ .include_extensions = &.{".h"} },
- );
- }
-
- return lib;
-}
diff --git a/pkg/spirv-cross/build.zig.zon b/pkg/spirv-cross/build.zig.zon
deleted file mode 100644
index 30eea95012b..00000000000
--- a/pkg/spirv-cross/build.zig.zon
+++ /dev/null
@@ -1,16 +0,0 @@
-.{
- .name = .spirv_cross,
- .version = "13.1.1",
- .fingerprint = 0x7ea1d8312b06cca,
- .paths = .{""},
- .dependencies = .{
- // KhronosGroup/SPIRV-Cross
- .spirv_cross = .{
- .url = "https://deps.files.ghostty.org/spirv_cross-1220fb3b5586e8be67bc3feb34cbe749cf42a60d628d2953632c2f8141302748c8da.tar.gz",
- .hash = "N-V-__8AANb6pwD7O1WG6L5nvD_rNMvnSc9Cpg1ijSlTYywv",
- .lazy = true,
- },
-
- .apple_sdk = .{ .path = "../apple-sdk" },
- },
-}
diff --git a/pkg/spirv-cross/c.zig b/pkg/spirv-cross/c.zig
deleted file mode 100644
index 08a999a3be9..00000000000
--- a/pkg/spirv-cross/c.zig
+++ /dev/null
@@ -1,3 +0,0 @@
-pub const c = @cImport({
- @cInclude("spirv_cross_c.h");
-});
diff --git a/pkg/spirv-cross/main.zig b/pkg/spirv-cross/main.zig
deleted file mode 100644
index 1ef5493ba5f..00000000000
--- a/pkg/spirv-cross/main.zig
+++ /dev/null
@@ -1 +0,0 @@
-pub const c = @import("c.zig").c;
diff --git a/scripts/validate-transport-assert.ps1 b/scripts/validate-transport-assert.ps1
new file mode 100644
index 00000000000..78782125aca
--- /dev/null
+++ b/scripts/validate-transport-assert.ps1
@@ -0,0 +1,89 @@
+<#
+.SYNOPSIS
+ Assert conpty-mode smoke verdict from ghostty log.
+
+.DESCRIPTION
+ Reads the most recent ghostty log under -LogDir, extracts the
+ validate_transport verdict line and OSC 11 receipt line, and
+ asserts the pairing matches the expected outcome for -Row.
+
+ Exit 0 on match, 1 on mismatch, 2 on missing instrumentation or
+ unknown row (distinguishes test-infra broken from test-failed).
+
+.PARAMETER Row
+ One of: pwsh-auto, pwsh-always, pwsh-never, cmd-auto.
+
+.PARAMETER LogDir
+ Directory containing ghostty log files. Defaults to
+ %LOCALAPPDATA%\Ghostty\logs.
+#>
+param(
+ [Parameter(Mandatory)][string]$Row,
+ [string]$LogDir = "$env:LOCALAPPDATA\Ghostty\logs",
+ [string]$Since
+)
+$ErrorActionPreference = 'Stop'
+
+$Expected = @{
+ 'pwsh-auto' = @{ Transport = 'bypass'; Osc11 = 'observed' }
+ 'pwsh-always' = @{ Transport = 'bypass'; Osc11 = 'observed' }
+ 'pwsh-never' = @{ Transport = 'conpty'; Osc11 = 'absent' }
+ 'cmd-auto' = @{ Transport = 'conpty'; Osc11 = 'na' }
+}
+
+if (-not $Expected.ContainsKey($Row)) {
+ Write-Host "ERROR: unknown row '$Row'. Valid rows: $($Expected.Keys -join ', ')"
+ exit 2
+}
+
+if (-not (Test-Path $LogDir)) {
+ Write-Host "ERROR: log directory not found: $LogDir"
+ exit 2
+}
+
+$log = Get-ChildItem $LogDir -Filter '*.log' -ErrorAction SilentlyContinue |
+ Sort-Object LastWriteTime -Descending |
+ Select-Object -First 1
+if (-not $log) {
+ Write-Host "ERROR: no log files under $LogDir"
+ exit 2
+}
+
+$lines = Get-Content -LiteralPath $log.FullName
+
+# Filter to entries emitted on or after -Since so earlier runs in the
+# same log file cannot contaminate the result. Log lines begin with
+# "YYYY-MM-DDTHH:MM:SS.fffZ |"; compare lexicographically against the
+# caller-provided ISO-8601 UTC timestamp.
+if ($PSBoundParameters.ContainsKey('Since') -and $Since) {
+ $lines = $lines | Where-Object { $_.Length -ge 24 -and $_.Substring(0, 24) -ge $Since }
+}
+
+# Verdict: "transport resolved: shell=\"...\" config_mode= resolved="
+$verdictMatch = $lines |
+ Select-String -Pattern 'transport resolved:.*resolved=(\w+)' |
+ Select-Object -Last 1
+if (-not $verdictMatch) {
+ Write-Host "ERROR: no verdict line in $($log.Name) (validate_transport instrumentation missing or filter excludes it)"
+ exit 2
+}
+$observedTransport = $verdictMatch.Matches[0].Groups[1].Value
+
+# OSC 11 receipt: "osc11 from pty: kind=query"
+$osc11Match = $lines | Select-String -Pattern 'osc11 from pty' | Select-Object -Last 1
+$observedOsc11 = if ($osc11Match) { 'observed' } else { 'absent' }
+
+$exp = $Expected[$Row]
+$transportOk = ($observedTransport -eq $exp.Transport)
+$osc11Ok = if ($exp.Osc11 -eq 'na') { $true } else { $observedOsc11 -eq $exp.Osc11 }
+
+if ($transportOk -and $osc11Ok) {
+ Write-Host "OK: $Row transport=$observedTransport osc11=$observedOsc11 (log=$($log.Name))"
+ exit 0
+}
+
+Write-Host "FAIL: $Row"
+Write-Host " expected: transport=$($exp.Transport) osc11=$($exp.Osc11)"
+Write-Host " observed: transport=$observedTransport osc11=$observedOsc11"
+Write-Host " log: $($log.FullName)"
+exit 1
diff --git a/scripts/validate-transport-run-all.ps1 b/scripts/validate-transport-run-all.ps1
new file mode 100644
index 00000000000..a9e930a8775
--- /dev/null
+++ b/scripts/validate-transport-run-all.ps1
@@ -0,0 +1,36 @@
+<#
+.SYNOPSIS
+ Run all four conpty-mode smoke rows and aggregate results.
+
+.DESCRIPTION
+ Invokes scripts/validate-transport-run.ps1 for each of the four
+ rows, captures the exit code per row, prints a summary table,
+ and exits non-zero if any row failed or timed out.
+#>
+param(
+ [int]$TimeoutMs = 15000
+)
+$ErrorActionPreference = 'Stop'
+
+$rows = @('pwsh-auto', 'pwsh-always', 'pwsh-never', 'cmd-auto')
+$results = [ordered]@{}
+
+foreach ($r in $rows) {
+ Write-Host "===== $r ====="
+ pwsh -NoProfile -File scripts/validate-transport-run.ps1 -Row $r -TimeoutMs $TimeoutMs
+ $results[$r] = switch ($LASTEXITCODE) {
+ 0 { 'pass' }
+ 1 { 'fail' }
+ default { "infra ($LASTEXITCODE)" }
+ }
+}
+
+Write-Host ''
+Write-Host '=== Summary ==='
+foreach ($r in $rows) {
+ Write-Host ("{0,-14} {1}" -f $r, $results[$r])
+}
+
+$anyFailed = ($results.Values | Where-Object { $_ -ne 'pass' }).Count -gt 0
+if ($anyFailed) { exit 1 }
+exit 0
diff --git a/scripts/validate-transport-run.ps1 b/scripts/validate-transport-run.ps1
new file mode 100644
index 00000000000..fd18a828e38
--- /dev/null
+++ b/scripts/validate-transport-run.ps1
@@ -0,0 +1,95 @@
+<#
+.SYNOPSIS
+ Run one conpty-mode smoke row end-to-end.
+
+.DESCRIPTION
+ Copies dev-configs/validate-transport/.conf into an isolated
+ XDG_CONFIG_HOME directory (as ghostty/config.ghostty), launches
+ the built Wintty.exe with XDG_CONFIG_HOME pointing at it, waits
+ for exit, then invokes scripts/validate-transport-assert.ps1 -Row
+ and exits with its exit code.
+
+ If the app does not exit within -TimeoutMs (default 10 seconds), it
+ is killed and the script exits with code 2 (infra failure). The app
+ should exit within a second or two after the shell exits; a timeout
+ indicates a regression in ConPTY/bypass teardown.
+
+ The WinUI shell does not honor a --config-file CLI flag - it calls
+ ghostty_config_load_default_files which reads from the XDG path.
+ So we isolate via environment instead of argv.
+
+ Assumes the app is already built. Call from `just
+ validate-transport-smoke ` which chains the build.
+
+.PARAMETER Row
+ One of: pwsh-auto, pwsh-always, pwsh-never, cmd-auto.
+
+.PARAMETER TimeoutMs
+ Safety timeout. Defaults to 10000 (10 seconds).
+
+.PARAMETER ExePath
+ Path to the built Wintty.exe. Defaults to the Debug x64 output.
+#>
+param(
+ [Parameter(Mandatory)][string]$Row,
+ [int]$TimeoutMs = 10000,
+ [string]$ExePath = './windows/Ghostty/bin/x64/Debug/net10.0-windows10.0.19041.0/Wintty.exe'
+)
+$ErrorActionPreference = 'Stop'
+
+$fixturePath = "dev-configs/validate-transport/$Row.conf"
+if (-not (Test-Path $fixturePath)) {
+ Write-Host "ERROR: fixture not found: $fixturePath"
+ exit 2
+}
+if (-not (Test-Path $ExePath)) {
+ Write-Host "ERROR: exe not found: $ExePath (run ``just build-dll build-win`` first)"
+ exit 2
+}
+
+# Isolated XDG_CONFIG_HOME: Ghostty looks up its default config at
+# $XDG_CONFIG_HOME/ghostty/config.ghostty. Stage our fixture there
+# and point the env var at the temp dir.
+$tempXdg = Join-Path $env:TEMP "ghostty-validate-xdg-$Row-$((New-Guid).Guid)"
+$ghosttyDir = Join-Path $tempXdg 'ghostty'
+New-Item -ItemType Directory -Path $ghosttyDir -Force | Out-Null
+$configPath = Join-Path $ghosttyDir 'config.ghostty'
+Copy-Item -LiteralPath $fixturePath -Destination $configPath -Force
+
+$originalXdgSet = Test-Path Env:XDG_CONFIG_HOME
+$originalXdg = if ($originalXdgSet) { $env:XDG_CONFIG_HOME } else { $null }
+
+try {
+ $env:XDG_CONFIG_HOME = $tempXdg
+ # Capture UTC start time at millisecond precision so the assertion
+ # can filter log entries to just this run's window. Log lines are
+ # "YYYY-MM-DDTHH:MM:SS.fffZ | ..." so a lexical >= comparison works.
+ $runStart = [DateTime]::UtcNow.ToString("yyyy-MM-ddTHH:mm:ss.fffZ")
+ Write-Host "launching: $ExePath (XDG_CONFIG_HOME=$tempXdg) since=$runStart"
+ $proc = Start-Process -FilePath $ExePath -PassThru
+ $exited = $proc.WaitForExit($TimeoutMs)
+ if (-not $exited) {
+ # Unexpected hang: the app should exit within a second or two
+ # once the shell's command completes. If we hit this branch the
+ # ConPTY/bypass teardown path has regressed (see # 293's fix via
+ # PR # 297 for the prior working state). Kill the app and fail
+ # with exit 2 so the regression is loud, not papered over.
+ Write-Host "FAIL: $Row (app did not exit within ${TimeoutMs}ms; possible regression of child-exit teardown)"
+ try { Stop-Process -Id $proc.Id -Force } catch {}
+ exit 2
+ }
+
+ Write-Host "app exited with code $($proc.ExitCode); running assertion"
+ pwsh -NoProfile -File scripts/validate-transport-assert.ps1 -Row $Row -Since $runStart
+ exit $LASTEXITCODE
+}
+finally {
+ if ($originalXdgSet) {
+ $env:XDG_CONFIG_HOME = $originalXdg
+ } else {
+ Remove-Item Env:XDG_CONFIG_HOME -ErrorAction SilentlyContinue
+ }
+ if (Test-Path $tempXdg) {
+ Remove-Item -LiteralPath $tempXdg -Recurse -Force -ErrorAction SilentlyContinue
+ }
+}
diff --git a/src/Command.zig b/src/Command.zig
index b81936257d1..063390dd078 100644
--- a/src/Command.zig
+++ b/src/Command.zig
@@ -321,12 +321,80 @@ fn startWindows(self: *Command, arena: Allocator) !void {
const stdin = if (self.stdin) |f| f.handle else null_fd.?;
const stdout = if (self.stdout) |f| f.handle else null_fd.?;
const stderr = if (self.stderr) |f| f.handle else null_fd.?;
- break :b .{ null, stdin, stdout, stderr };
+
+ // All three handles must be HANDLE_FLAG_INHERIT for
+ // PROC_THREAD_ATTRIBUTE_HANDLE_LIST. The PTY path flips _pty
+ // ends inheritable in pty.zig; null_fd and caller-provided
+ // files may or may not be. Flip them unconditionally here -
+ // it's idempotent, and handles our code owns are closed after
+ // spawn anyway.
+ try windows.SetHandleInformation(stdin, windows.HANDLE_FLAG_INHERIT, windows.HANDLE_FLAG_INHERIT);
+ try windows.SetHandleInformation(stdout, windows.HANDLE_FLAG_INHERIT, windows.HANDLE_FLAG_INHERIT);
+ try windows.SetHandleInformation(stderr, windows.HANDLE_FLAG_INHERIT, windows.HANDLE_FLAG_INHERIT);
+
+ // Bypass path: restrict inheritance to just these three handles
+ // via PROC_THREAD_ATTRIBUTE_HANDLE_LIST. Without this,
+ // bInheritHandles = TRUE leaks every inheritable parent handle
+ // to the child - a real security bug.
+ var attribute_list_size: usize = undefined;
+ _ = windows.exp.kernel32.InitializeProcThreadAttributeList(
+ null,
+ 1,
+ 0,
+ &attribute_list_size,
+ );
+
+ const attribute_list_buf = try arena.alloc(u8, attribute_list_size);
+ if (windows.exp.kernel32.InitializeProcThreadAttributeList(
+ attribute_list_buf.ptr,
+ 1,
+ 0,
+ &attribute_list_size,
+ ) == 0) return windows.unexpectedError(windows.kernel32.GetLastError());
+
+ // Allocate the handle list from the arena so its lifetime
+ // outlives the attribute list until after CreateProcessW.
+ // PROC_THREAD_ATTRIBUTE_HANDLE_LIST rejects duplicate handles,
+ // which is easy to hit: tests use null_fd for both stdin and
+ // stderr, and PTY bypass uses out_pipe_pty for both stdout and
+ // stderr. Build a list of unique handles only.
+ var unique: [3]windows.HANDLE = .{ stdin, stdout, stderr };
+ var unique_len: usize = 1;
+ for (unique[1..]) |h| {
+ var seen = false;
+ for (unique[0..unique_len]) |u| {
+ if (u == h) {
+ seen = true;
+ break;
+ }
+ }
+ if (!seen) {
+ unique[unique_len] = h;
+ unique_len += 1;
+ }
+ }
+ const handles = try arena.alloc(windows.HANDLE, unique_len);
+ @memcpy(handles, unique[0..unique_len]);
+
+ // lpValue for PROC_THREAD_ATTRIBUTE_HANDLE_LIST is the handles
+ // array pointer passed BY VALUE (not by ref), matching how
+ // PROC_THREAD_ATTRIBUTE_PSEUDOCONSOLE above passes `pseudo_console`.
+ if (windows.exp.kernel32.UpdateProcThreadAttribute(
+ attribute_list_buf.ptr,
+ 0,
+ windows.exp.PROC_THREAD_ATTRIBUTE_HANDLE_LIST,
+ @ptrCast(handles.ptr),
+ @sizeOf(windows.HANDLE) * handles.len,
+ null,
+ null,
+ ) == 0) return windows.unexpectedError(windows.kernel32.GetLastError());
+
+ break :b .{ attribute_list_buf.ptr, stdin, stdout, stderr };
};
var startup_info_ex = windows.exp.STARTUPINFOEX{
.StartupInfo = .{
- .cb = if (attribute_list != null) @sizeOf(windows.exp.STARTUPINFOEX) else @sizeOf(windows.STARTUPINFOW),
+ .cb = @sizeOf(windows.exp.STARTUPINFOEX),
.hStdError = stderr,
.hStdOutput = stdout,
.hStdInput = stdin,
@@ -349,7 +417,22 @@ fn startWindows(self: *Command, arena: Allocator) !void {
};
var flags: windows.DWORD = windows.exp.CREATE_UNICODE_ENVIRONMENT;
- if (attribute_list != null) flags |= windows.exp.EXTENDED_STARTUPINFO_PRESENT;
+ flags |= windows.exp.EXTENDED_STARTUPINFO_PRESENT;
+
+ // Suppress console window for raw-pipe sessions. ConPTY attaches a
+ // pseudo-console which automatically suppresses the window; raw pipes
+ // need the flag explicitly.
+ //
+ // Note: pre-#341 the gate also checked `utf8_console_owned` (an
+ // AllocConsole-based hack) to let raw-pipe children inherit a hidden
+ // UTF-8 parent console. That mechanism turned out not to actually
+ // reach the spawned shell on the user's repro path; the new
+ // utf8-console knob + bypass-path preamble injection (in
+ // `termio/Exec.zig`) is the real fix. The gate is now just the
+ // pseudo-console check.
+ if (self.pseudo_console == null) {
+ flags |= windows.exp.CREATE_NO_WINDOW;
+ }
var process_information: windows.PROCESS_INFORMATION = undefined;
if (windows.exp.kernel32.CreateProcessW(
@@ -900,7 +983,7 @@ test "Command: posix fork handles execveZ failure" {
// If cmd.start fails with error.ExecFailedInChild it's the _child_ process that is running. If it does not
// terminate in response to that error both the parent and child will continue as if they _are_ the test suite
// process.
-fn testingStart(self: *Command) !void {
+pub fn testingStart(self: *Command) !void {
self.start(testing.allocator) catch |err| {
if (err == error.ExecFailedInChild) {
// I am a child process, I must not get confused and continue running the rest of the test suite.
diff --git a/src/Surface.zig b/src/Surface.zig
index 5d16f33267c..2b3f448dc09 100644
--- a/src/Surface.zig
+++ b/src/Surface.zig
@@ -665,6 +665,12 @@ pub fn init(
.working_directory = if (config.@"working-directory") |wd| wd.value() else null,
.resources_dir = global_state.resources_dir.host(),
.term = config.term,
+ .conpty_mode = if (comptime builtin.os.tag == .windows)
+ config.@"conpty-mode"
+ else {},
+ .utf8_console = if (comptime builtin.os.tag == .windows)
+ config.@"utf8-console"
+ else {},
.rt_pre_exec_info = .init(config),
.rt_post_fork_info = .init(config),
});
@@ -892,6 +898,32 @@ pub fn draw(self: *Surface) !void {
try self.renderer.drawFrame(true);
}
+/// Write raw VT bytes into this surface's terminal, as if they came
+/// from the PTY. Used by in-process features (theme picker) that
+/// render into the terminal without a subprocess.
+pub fn writeVt(self: *Surface, data: []const u8) void {
+ self.io.processOutput(data);
+}
+
+/// Write bytes to the PTY input (the subprocess stdin). Used by
+/// in-process features to nudge the shell after restoring the
+/// terminal (e.g. send a newline so cmd.exe redraws its prompt).
+pub fn writePtyInput(self: *Surface, data: []const u8) void {
+ if (data.len == 0) return;
+ const small_cap = @typeInfo(termio.Message.WriteReq.Small.Array).array.len;
+ if (data.len <= small_cap) {
+ var arr: termio.Message.WriteReq.Small.Array = undefined;
+ @memcpy(arr[0..data.len], data);
+ self.queueIo(.{ .write_small = .{
+ .data = arr,
+ .len = @intCast(data.len),
+ } }, .unlocked);
+ } else {
+ const msg = termio.Message.writeReq(self.alloc, data) catch return;
+ self.queueIo(msg, .unlocked);
+ }
+}
+
/// Activate the inspector. This will begin collecting inspection data.
/// This will not affect the GUI. The GUI must use performAction to
/// show/hide the inspector UI.
@@ -2481,6 +2513,7 @@ fn resize(self: *Surface, size: rendererpkg.ScreenSize) !void {
// Mail the IO thread
self.queueIo(.{ .resize = self.size }, .unlocked);
+
}
/// Recalculate the balanced padding if needed.
@@ -2673,6 +2706,7 @@ pub fn keyCallback(
event,
if (insp_ev) |*ev| ev else null,
)) |v| return v;
+
// If we allow KAM and KAM is enabled then we do nothing.
if (self.config.vt_kam_allowed) {
self.renderer_state.mutex.lock();
@@ -2786,9 +2820,9 @@ pub fn keyCallback(
errdefer write_req.deinit();
self.queueIo(switch (write_req) {
- .small => |v| .{ .write_small = v },
- .stable => |v| .{ .write_stable = v },
- .alloc => |v| .{ .write_alloc = v },
+ .small => |v| .{ .write_small = .{ .data = v.data, .len = v.len } },
+ .stable => |v| .{ .write_stable = .{ .data = v } },
+ .alloc => |v| .{ .write_alloc = .{ .alloc = v.alloc, .data = v.data } },
}, .unlocked);
} else {
// No valid request means that we didn't encode anything.
@@ -3137,9 +3171,9 @@ fn endKeySequence(
switch (action) {
.flush => for (self.keyboard.sequence_queued.items) |write_req| {
self.queueIo(switch (write_req) {
- .small => |v| .{ .write_small = v },
- .stable => |v| .{ .write_stable = v },
- .alloc => |v| .{ .write_alloc = v },
+ .small => |v| .{ .write_small = .{ .data = v.data, .len = v.len } },
+ .stable => |v| .{ .write_stable = .{ .data = v } },
+ .alloc => |v| .{ .write_alloc = .{ .alloc = v.alloc, .data = v.data } },
}, .unlocked);
},
@@ -3531,7 +3565,7 @@ pub fn scrollCallback(
};
};
for (0..y.magnitude()) |_| {
- self.queueIo(.{ .write_stable = seq }, .locked);
+ self.queueIo(.{ .write_stable = .{ .data = seq } }, .locked);
}
}
@@ -4241,13 +4275,13 @@ fn maybePromptClick(self: *Surface) !bool {
const move = screen.promptClickMove(click_pin);
for (0..move.left) |_| {
self.queueIo(
- .{ .write_stable = left_arrow },
+ .{ .write_stable = .{ .data = left_arrow } },
.locked,
);
}
for (0..move.right) |_| {
self.queueIo(
- .{ .write_stable = right_arrow },
+ .{ .write_stable = .{ .data = right_arrow } },
.locked,
);
}
@@ -5094,9 +5128,9 @@ pub fn performBindingAction(self: *Surface, action: input.Binding.Action) !bool
};
if (normal) {
- self.queueIo(.{ .write_stable = ck.normal }, .unlocked);
+ self.queueIo(.{ .write_stable = .{ .data = ck.normal } }, .unlocked);
} else {
- self.queueIo(.{ .write_stable = ck.application }, .unlocked);
+ self.queueIo(.{ .write_stable = .{ .data = ck.application } }, .unlocked);
}
},
diff --git a/src/apprt/embedded.zig b/src/apprt/embedded.zig
index 730913eba1c..2ec0ea943d4 100644
--- a/src/apprt/embedded.zig
+++ b/src/apprt/embedded.zig
@@ -344,6 +344,7 @@ pub const App = struct {
pub const Platform = union(PlatformTag) {
macos: MacOS,
ios: IOS,
+ windows: Windows,
// If our build target for libghostty is not darwin then we do
// not include macos support at all.
@@ -357,6 +358,24 @@ pub const Platform = union(PlatformTag) {
uiview: objc.Object,
} else void;
+ pub const Windows = if (builtin.target.os.tag == .windows) struct {
+ /// The HWND to render into, or null for composition/shared texture modes.
+ hwnd: ?std.os.windows.HANDLE,
+ /// ISwapChainPanelNative pointer for composition swap chain, or null.
+ swap_chain_panel: ?*anyopaque = null,
+ /// Shared-texture surface configuration. Only honoured when
+ /// both `hwnd` and `swap_chain_panel` are null and
+ /// `shared_texture.enabled` is true. Mirrors the nested
+ /// `shared_texture` struct in ghostty_platform_windows_s.
+ shared_texture: SharedTexture = .{},
+
+ pub const SharedTexture = struct {
+ enabled: bool = false,
+ width: u32 = 0,
+ height: u32 = 0,
+ };
+ } else void;
+
// The C ABI compatible version of this union. The tag is expected
// to be stored elsewhere.
pub const C = extern union {
@@ -367,6 +386,20 @@ pub const Platform = union(PlatformTag) {
ios: extern struct {
uiview: ?*anyopaque,
},
+
+ windows: extern struct {
+ hwnd: ?*anyopaque,
+ swap_chain_panel: ?*anyopaque,
+ // Mirrors the anonymous `shared_texture` sub-struct in
+ // ghostty_platform_windows_s. The C side declares this as
+ // an anonymous nested struct; we flatten it into an inline
+ // extern struct here to preserve the same layout.
+ shared_texture: extern struct {
+ enabled: bool,
+ width: u32,
+ height: u32,
+ },
+ },
};
/// Initialize a Platform a tag and configuration from the C ABI.
@@ -386,6 +419,16 @@ pub const Platform = union(PlatformTag) {
break :ios error.UIViewMustBeSet);
break :ios .{ .ios = .{ .uiview = uiview } };
} else error.UnsupportedPlatform,
+
+ .windows => if (Windows != void) .{ .windows = .{
+ .hwnd = c_platform.windows.hwnd,
+ .swap_chain_panel = c_platform.windows.swap_chain_panel,
+ .shared_texture = .{
+ .enabled = c_platform.windows.shared_texture.enabled,
+ .width = c_platform.windows.shared_texture.width,
+ .height = c_platform.windows.shared_texture.height,
+ },
+ } } else error.UnsupportedPlatform,
};
}
};
@@ -396,6 +439,7 @@ pub const PlatformTag = enum(c_int) {
macos = 1,
ios = 2,
+ windows = 3,
};
pub const EnvVar = extern struct {
@@ -420,12 +464,68 @@ pub const Surface = struct {
/// that getTitle works without the implementer needing to save it.
title: ?[:0]const u8 = null,
+ /// Windows buffers WM_KEYDOWN here so a following WM_CHAR can
+ /// attach text before dispatching through key encoding.
+ pending_key: if (builtin.os.tag == .windows)
+ ?PendingKey
+ else
+ void = if (builtin.os.tag == .windows) null else {},
+
+ /// Text buffer for the pending key. Lives outside the optional so
+ /// it isn't poisoned when the optional is cleared in debug builds.
+ /// Sized to match GTK's im_buf so IME compositions aren't truncated.
+ pending_key_text: if (builtin.os.tag == .windows)
+ [128]u8
+ else
+ void = if (builtin.os.tag == .windows) .{0} ** 128 else {},
+
+ /// Input redirect for in-process features (e.g., theme picker).
+ input_redirect: if (builtin.os.tag == .windows)
+ ?InputRedirect
+ else
+ void = if (builtin.os.tag == .windows) null else {},
+
+ scroll_redirect: if (builtin.os.tag == .windows)
+ ?ScrollRedirect
+ else
+ void = if (builtin.os.tag == .windows) null else {},
+
+ resize_redirect: if (builtin.os.tag == .windows)
+ ?ResizeRedirect
+ else
+ void = if (builtin.os.tag == .windows) null else {},
+
+ const PendingKey = struct {
+ event: App.KeyEvent,
+ };
+
+ /// Intercepts events at the apprt boundary before they reach
+ /// core Surface. Used by the inline theme picker.
+ const InputRedirect = struct {
+ callback: *const fn (ud: ?*anyopaque, event: *const input.KeyEvent) bool,
+ userdata: ?*anyopaque,
+ };
+
+ /// yoff is positive for scroll-up, negative for scroll-down.
+ /// Return true if consumed.
+ const ScrollRedirect = struct {
+ callback: *const fn (ud: ?*anyopaque, yoff: f64) bool,
+ userdata: ?*anyopaque,
+ };
+
+ /// Called after the core surface recalculates its grid, so cols/rows
+ /// reflect the new size.
+ const ResizeRedirect = struct {
+ callback: *const fn (ud: ?*anyopaque, cols: u16, rows: u16) void,
+ userdata: ?*anyopaque,
+ };
+
/// Surface initialization options.
pub const Options = extern struct {
/// The platform that this surface is being initialized for and
/// the associated platform-specific configuration.
platform_tag: c_int = 0,
- platform: Platform.C = undefined,
+ platform: Platform.C = std.mem.zeroes(Platform.C),
/// Userdata passed to some of the callbacks.
userdata: ?*anyopaque = null,
@@ -809,6 +909,14 @@ pub const Surface = struct {
log.err("error in size callback err={}", .{err});
return;
};
+
+ // Notify in-process features after the grid has been recalculated.
+ if (comptime builtin.os.tag == .windows) {
+ if (self.resize_redirect) |redirect| {
+ const grid = self.core_surface.size.grid();
+ redirect.callback(redirect.userdata, @intCast(grid.columns), @intCast(grid.rows));
+ }
+ }
}
pub fn colorSchemeCallback(self: *Surface, scheme: apprt.ColorScheme) void {
@@ -847,6 +955,13 @@ pub const Surface = struct {
yoff: f64,
mods: input.ScrollMods,
) void {
+ if (comptime builtin.os.tag == .windows) {
+ if (self.scroll_redirect) |redirect| {
+ if (redirect.callback(redirect.userdata, yoff))
+ return;
+ }
+ }
+
self.core_surface.scrollCallback(xoff, yoff, mods) catch |err| {
log.err("error in scroll callback err={}", .{err});
return;
@@ -904,7 +1019,32 @@ pub const Surface = struct {
};
}
+ /// Dispatch a key event through the core key handling path.
+ fn dispatchKey(self: *Surface, event: App.KeyEvent) bool {
+ return self.app.keyEvent(
+ .{ .surface = self },
+ event,
+ ) catch |err| {
+ log.warn("error processing key event err={}", .{err});
+ return false;
+ };
+ }
+
+ /// Flush a buffered Windows key event. No-op on non-Windows.
+ fn flushPendingKey(self: *Surface) void {
+ if (comptime builtin.os.tag != .windows) return;
+ if (self.pending_key) |pending| {
+ self.pending_key = null;
+ _ = self.dispatchKey(pending.event);
+ }
+ }
+
pub fn focusCallback(self: *Surface, focused: bool) void {
+ // Flush any buffered key event on focus loss so it isn't
+ // silently dropped (e.g. user presses a key then Alt-Tabs
+ // before WM_CHAR arrives).
+ if (!focused) self.flushPendingKey();
+
self.core_surface.focusCallback(focused) catch |err| {
log.err("error in focus callback err={}", .{err});
return;
@@ -1267,10 +1407,40 @@ pub const CAPI = struct {
)),
.keycode = self.keycode,
.text = if (self.text) |ptr| std.mem.sliceTo(ptr, 0) else null,
- .unshifted_codepoint = self.unshifted_codepoint,
+ .unshifted_codepoint = if (self.unshifted_codepoint != 0)
+ self.unshifted_codepoint
+ else
+ unshiftedCodepointFromKeycode(self.keycode),
.composing = self.composing,
};
}
+
+ /// Derive the unshifted codepoint from a Win32 scancode via
+ /// MapVirtualKeyW (scancode -> VK -> base character). Returns
+ /// 0 on non-Windows.
+ fn unshiftedCodepointFromKeycode(keycode: u32) u32 {
+ if (comptime builtin.os.tag != .windows) return 0;
+
+ const win32 = struct {
+ const MAPVK_VSC_TO_VK_EX = 3;
+ const MAPVK_VK_TO_CHAR = 2;
+ extern "user32" fn MapVirtualKeyW(uCode: u32, uMapType: u32) callconv(.winapi) u32;
+ };
+
+ // Extended keys have 0xE000 prefix in our scancode
+ // encoding. MapVirtualKeyW expects the raw scancode
+ // with the extended bit in the high byte (0xE0xx).
+ const vk = win32.MapVirtualKeyW(keycode, win32.MAPVK_VSC_TO_VK_EX);
+ if (vk == 0) return 0;
+
+ // Bit 31 set means dead key -- mask it off.
+ var ch = win32.MapVirtualKeyW(vk, win32.MAPVK_VK_TO_CHAR) & 0x7FFFFFFF;
+
+ // Lowercase A-Z to match the unshifted physical key.
+ if (ch >= 'A' and ch <= 'Z') ch = ch - 'A' + 'a';
+
+ return ch;
+ }
};
const SurfaceSize = extern struct {
@@ -1691,10 +1861,218 @@ pub const CAPI = struct {
surface.draw();
}
+ /// Write raw VT bytes into the surface's terminal parser. The bytes
+ /// are processed as if they came from the PTY -- VT sequences update
+ /// the terminal grid, cursor, colors, etc. Thread-safe.
+ export fn ghostty_surface_vt_write(
+ surface: *Surface,
+ data: [*]const u8,
+ len: usize,
+ ) void {
+ surface.core_surface.writeVt(data[0..len]);
+ }
+
+ /// Run the inline theme picker on a surface. Non-blocking: sets
+ /// up picker state and input redirect, then returns. The
+ /// theme_callback fires on preview (browsing) and confirm (Enter).
+ /// The embedder must call ghostty_surface_list_themes_deinit once
+ /// the picker signals should_quit. Returns null on error.
+ export fn ghostty_surface_list_themes(
+ surface: *Surface,
+ theme_cb: ?*const fn ([*:0]const u8, bool) callconv(.c) void,
+ ) ?*anyopaque {
+ const picker_mod = @import("../cli/inline_theme_picker.zig");
+ const alloc = global.alloc;
+
+ // Discover themes using an arena.
+ var arena = std.heap.ArenaAllocator.init(alloc);
+ const arena_alloc = arena.allocator();
+
+ const themes = picker_mod.discoverThemes(arena_alloc) catch return null;
+ if (themes.len == 0) {
+ arena.deinit();
+ return null;
+ }
+
+ const grid = surface.core_surface.size.grid();
+
+ // Write callback: feeds VT bytes into the surface's terminal.
+ const write_cb = struct {
+ fn write(ud: ?*anyopaque, data: [*]const u8, len: usize) void {
+ const cs: *CoreSurface = @ptrCast(@alignCast(ud));
+ cs.writeVt(data[0..len]);
+ }
+ }.write;
+
+ const picker = picker_mod.InlineThemePicker.init(
+ alloc,
+ themes,
+ arena,
+ @intCast(grid.columns),
+ @intCast(grid.rows),
+ write_cb,
+ @ptrCast(&surface.core_surface),
+ theme_cb,
+ ) catch {
+ arena.deinit();
+ return null;
+ };
+
+ // Set up input redirect so keys go to the picker.
+ const input_cb = struct {
+ fn handle(ud: ?*anyopaque, event: *const input.KeyEvent) bool {
+ const p: *picker_mod.InlineThemePicker = @ptrCast(@alignCast(ud));
+ return p.handleKey(event);
+ }
+ }.handle;
+
+ surface.input_redirect = .{
+ .callback = input_cb,
+ .userdata = @ptrCast(picker),
+ };
+
+ // Set up scroll redirect so mouse wheel goes to the picker.
+ const scroll_cb = struct {
+ fn handle(ud: ?*anyopaque, yoff: f64) bool {
+ const p: *picker_mod.InlineThemePicker = @ptrCast(@alignCast(ud));
+ return p.handleScroll(yoff);
+ }
+ }.handle;
+
+ surface.scroll_redirect = .{
+ .callback = scroll_cb,
+ .userdata = @ptrCast(picker),
+ };
+
+ // Set up resize redirect so the picker redraws on window resize.
+ const resize_cb = struct {
+ fn handle(ud: ?*anyopaque, cols: u16, rows: u16) void {
+ const p: *picker_mod.InlineThemePicker = @ptrCast(@alignCast(ud));
+ p.resize(cols, rows);
+ }
+ }.handle;
+
+ surface.resize_redirect = .{
+ .callback = resize_cb,
+ .userdata = @ptrCast(picker),
+ };
+
+ // Enter alt screen and render initial frame.
+ picker.enter();
+
+ return @ptrCast(picker);
+ }
+
+ /// Check if the inline theme picker has finished (user confirmed
+ /// or cancelled). Returns true if the picker wants to quit.
+ export fn ghostty_surface_list_themes_should_quit(picker_ptr: ?*anyopaque) bool {
+ const picker_mod = @import("../cli/inline_theme_picker.zig");
+ const picker: *picker_mod.InlineThemePicker = @ptrCast(@alignCast(picker_ptr orelse return true));
+ return picker.should_quit;
+ }
+
+ /// Clean up the inline theme picker and restore the surface.
+ /// Must be called after the picker signals should_quit.
+ export fn ghostty_surface_list_themes_deinit(
+ surface: *Surface,
+ picker_ptr: ?*anyopaque,
+ ) void {
+ const picker_mod = @import("../cli/inline_theme_picker.zig");
+ const picker: *picker_mod.InlineThemePicker = @ptrCast(@alignCast(picker_ptr orelse return));
+
+ // Clear input, scroll, and resize redirects.
+ surface.input_redirect = null;
+ surface.scroll_redirect = null;
+ surface.resize_redirect = null;
+
+ // Exit alt screen and restore terminal.
+ picker.exit();
+
+ // Nudge the shell to redraw its prompt; it was blocked on
+ // stdin while the picker ran in-process.
+ surface.core_surface.writePtyInput("\r");
+
+ picker.deinit();
+ }
+
+ /// Return the ID3D12Device* used by this surface's renderer. Shared
+ /// texture consumers should call OpenSharedResource1 on this same
+ /// device to avoid cross-device synchronization issues. Returns
+ /// null on non-DX12 builds or before the device finishes init.
+ export fn ghostty_surface_get_d3d12_device(surface: *Surface) ?*anyopaque {
+ if (comptime builtin.os.tag != .windows) return null;
+ const api = surface.core_surface.renderer.api;
+ // Only the DX12 renderer has a `dev` field holding an ID3D12Device.
+ if (comptime !@hasField(@TypeOf(api), "dev")) return null;
+ const dev = api.dev orelse return null;
+ return @ptrCast(dev.device);
+ }
+
+ /// Return the IDXGISwapChain1* used by this surface's renderer.
+ /// Bind it to a Windows.UI.Composition visual via
+ /// ICompositorInterop.CreateCompositionSurfaceForSwapChain.
+ /// Returns null on non-DX12 or before the swap chain is created.
+ export fn ghostty_surface_get_swap_chain(surface: *Surface) ?*anyopaque {
+ if (comptime builtin.os.tag != .windows) return null;
+ const api = surface.core_surface.renderer.api;
+ if (comptime !@hasField(@TypeOf(api), "dev")) return null;
+ const dev = api.dev orelse return null;
+ const sc = dev.swap_chain orelse return null;
+ return @ptrCast(sc);
+ }
+
+ /// Mirrors ghostty_surface_shared_texture_s in include/ghostty.h.
+ const SharedTextureSnapshotC = extern struct {
+ resource_handle: ?*anyopaque,
+ fence_handle: ?*anyopaque,
+ fence_value: u64,
+ width: u32,
+ height: u32,
+ version: u64,
+ };
+
+ /// Fill `out` with an atomic snapshot of the shared-texture state
+ /// for this surface. Returns false if the surface is not in
+ /// shared-texture mode (in which case `out` is untouched).
+ export fn ghostty_surface_shared_texture(
+ surface: *Surface,
+ out: *SharedTextureSnapshotC,
+ ) bool {
+ if (comptime builtin.os.tag != .windows) return false;
+ const api_ptr = &surface.core_surface.renderer.api;
+ if (comptime @TypeOf(api_ptr.*) != renderer.DirectX12) return false;
+ if (api_ptr.dev == null) return false;
+ const dev = &api_ptr.dev.?;
+
+ dev.shared_texture_mutex.lock();
+ defer dev.shared_texture_mutex.unlock();
+
+ const st = dev.shared_texture orelse return false;
+
+ out.* = .{
+ .resource_handle = @ptrCast(st.resource_handle),
+ .fence_handle = @ptrCast(st.fence_handle),
+ .fence_value = dev.fence_value.load(.acquire),
+ .width = st.width,
+ .height = st.height,
+ .version = st.version,
+ };
+ return true;
+ }
+
/// Update the size of a surface. This will trigger resize notifications
/// to the pty and the renderer.
export fn ghostty_surface_set_size(surface: *Surface, w: u32, h: u32) void {
surface.updateSize(w, h);
+ // For composition surfaces (no HWND), the renderer cannot query
+ // the window size via GetClientRect. Forward the desired dimensions
+ // so the resize detection loop in beginFrame picks up the change.
+ surface.core_surface.renderer.setTargetSize(w, h);
+ // Wake the renderer thread so it applies the new size in
+ // beginFrame without waiting for the ~8ms draw-timer tick.
+ // Single futex op, safe from any thread. The 120Hz draw timer
+ // is the backstop if the wakeup is coalesced.
+ surface.core_surface.renderer_thread.wakeup.notify() catch {};
}
/// Return the size information a surface has.
@@ -1777,17 +2155,38 @@ pub const CAPI = struct {
/// Send this for raw keypresses (i.e. the keyDown event on macOS).
/// This will handle the keymap translation and send the appropriate
/// key and char events.
+ ///
+ /// On Windows, a press/repeat with no text is buffered until the
+ /// following ghostty_surface_text attaches text, handling the split
+ /// WM_KEYDOWN / WM_CHAR pattern so embedders don't combine manually.
export fn ghostty_surface_key(
surface: *Surface,
event: KeyEvent,
) bool {
- return surface.app.keyEvent(
- .{ .surface = surface },
- event.keyEvent(),
- ) catch |err| {
- log.warn("error processing key event err={}", .{err});
- return false;
- };
+ const key_event = event.keyEvent();
+
+ if (comptime builtin.os.tag == .windows) {
+ // Theme picker etc. intercepts before keybinding resolution.
+ if (surface.input_redirect) |redirect| {
+ const core_event = key_event.core() orelse return false;
+ if (redirect.callback(redirect.userdata, &core_event))
+ return true;
+ }
+
+ // Flush any prior pending key that never got text (arrows,
+ // function keys, backspace).
+ surface.flushPendingKey();
+
+ // No text yet: buffer until ghostty_surface_text arrives.
+ if (key_event.text == null and key_event.action != .release) {
+ surface.pending_key = .{ .event = key_event };
+ return false;
+ }
+
+ return surface.dispatchKey(key_event);
+ }
+
+ return surface.dispatchKey(key_event);
}
/// Returns true if the given key event would trigger a binding
@@ -1811,14 +2210,44 @@ pub const CAPI = struct {
return true;
}
- /// Send raw text to the terminal. This is treated like a paste
- /// so this isn't useful for sending escape sequences. For that,
- /// individual key input should be used.
+ /// Send raw text to the terminal. Treated as paste, unless on
+ /// Windows there is a pending key event from ghostty_surface_key,
+ /// in which case the text attaches to that key and dispatches
+ /// through key encoding (WM_CHAR after WM_KEYDOWN).
export fn ghostty_surface_text(
surface: *Surface,
ptr: [*]const u8,
len: usize,
) void {
+ if (comptime builtin.os.tag == .windows) {
+ if (surface.pending_key) |*pending| {
+ const text = ptr[0..len];
+
+ // Don't attach C0 control characters. WM_CHAR delivers
+ // the raw byte (e.g. 0x03 for Ctrl+C) but the key
+ // encoder wants the printable character. Mirrors GTK.
+ const is_c0 = text.len == 1 and (text[0] < 0x20 or text[0] == 0x7f);
+
+ var event = pending.event;
+
+ if (!is_c0) {
+ const copy_len: usize = @min(text.len, surface.pending_key_text.len - 1);
+
+ // Buffer lives outside the optional so the text
+ // survives setting pending_key to null (Zig poisons
+ // optional payloads in debug builds).
+ @memcpy(surface.pending_key_text[0..copy_len], text[0..copy_len]);
+ surface.pending_key_text[copy_len] = 0;
+
+ event.text = surface.pending_key_text[0..copy_len :0];
+ }
+
+ surface.pending_key = null;
+ _ = surface.dispatchKey(event);
+ return;
+ }
+ }
+
surface.textCallback(ptr[0..len]);
}
diff --git a/src/build/Config.zig b/src/build/Config.zig
index 0a99473174e..b2a83fed29a 100644
--- a/src/build/Config.zig
+++ b/src/build/Config.zig
@@ -502,8 +502,6 @@ pub fn init(b: *std.Build, appVersion: []const u8, libVersion: []const u8) !Conf
// These default to false because they're rarely available as
// system packages so we usually want to statically link them.
for (&[_][]const u8{
- "glslang",
- "spirv-cross",
"simdutf",
}) |dep| {
_ = b.systemIntegrationOption(dep, .{ .default = false });
diff --git a/src/build/GhosttyLib.zig b/src/build/GhosttyLib.zig
index b762da8bbf8..5f28a80891b 100644
--- a/src/build/GhosttyLib.zig
+++ b/src/build/GhosttyLib.zig
@@ -10,8 +10,10 @@ const LipoStep = @import("LipoStep.zig");
/// The step that generates the file.
step: *std.Build.Step,
-/// The final static library file
+/// The final library file (DLL or static .lib/.a).
output: std.Build.LazyPath,
+/// The import library for DLL builds on Windows (.lib), null otherwise.
+implib: ?std.Build.LazyPath = null,
dsym: ?std.Build.LazyPath,
pkg_config: ?std.Build.LazyPath,
pkg_config_static: ?std.Build.LazyPath,
@@ -127,6 +129,14 @@ pub fn initShared(
lib.linkSystemLibrary("libucrt");
}
+ // Link DirectX 12 libraries on Windows when using the directx12 renderer.
+ if (deps.config.target.result.os.tag == .windows and
+ deps.config.renderer == .directx12)
+ {
+ lib.linkSystemLibrary("d3d12");
+ lib.linkSystemLibrary("dxgi");
+ }
+
// Get our debug symbols
const dsymutil: ?std.Build.LazyPath = dsymutil: {
if (!deps.config.target.result.os.tag.isDarwin()) {
@@ -144,17 +154,21 @@ pub fn initShared(
// pkg-config
//
// pkg-config's --static only expands Libs.private / Requires.private;
- // it doesn't rewrite Libs: into an archive-only reference when both
- // shared and static libraries are installed. Install a dedicated
+ // it doesn't change -lghostty into an archive-only reference when
+ // both shared and static libraries are installed. Install a dedicated
// static module so consumers can request the archive explicitly.
const pcs = pkgConfigFiles(b, deps);
return .{
.step = &lib.step,
.output = lib.getEmittedBin(),
+ .implib = if (deps.config.target.result.os.tag == .windows)
+ lib.getEmittedImplib()
+ else
+ null,
.dsym = dsymutil,
.pkg_config = pcs.shared,
- .pkg_config_static = pcs.static,
+ .pkg_config_static = pcs.@"static",
};
}
@@ -200,14 +214,14 @@ pub fn install(self: *const GhosttyLib, name: []const u8) void {
step.dependOn(&b.addInstallFileWithDir(
pc,
.prefix,
- "share/pkgconfig/ghostty-internal.pc",
+ "share/pkgconfig/libghostty.pc",
).step);
}
if (self.pkg_config_static) |pc| {
step.dependOn(&b.addInstallFileWithDir(
pc,
.prefix,
- "share/pkgconfig/ghostty-internal-static.pc",
+ "share/pkgconfig/libghostty-static.pc",
).step);
}
}
@@ -223,7 +237,7 @@ pub fn installHeader(self: *const GhosttyLib) void {
const PkgConfigFiles = struct {
shared: std.Build.LazyPath,
- static: std.Build.LazyPath,
+ @"static": std.Build.LazyPath,
};
fn pkgConfigFiles(
@@ -234,47 +248,36 @@ fn pkgConfigFiles(
const wf = b.addWriteFiles();
return .{
- .shared = wf.add("ghostty-internal.pc", b.fmt(
+ .shared = wf.add("libghostty.pc", b.fmt(
\\prefix={s}
\\includedir=${{prefix}}/include
\\libdir=${{prefix}}/lib
\\
- \\Name: ghostty-internal
+ \\Name: libghostty
\\URL: https://github.com/ghostty-org/ghostty
- \\Description: Ghostty internal library (not for external use)
+ \\Description: Ghostty terminal emulator library
\\Version: {f}
\\Cflags: -I${{includedir}}
- \\Libs: ${{libdir}}/{s}
- \\Libs.private:
- \\Requires.private:
- , .{ b.install_prefix, deps.config.version, sharedLibraryName(os_tag) })),
- .static = wf.add("ghostty-internal-static.pc", b.fmt(
+ \\Libs: -L${{libdir}} -lghostty
+ , .{ b.install_prefix, deps.config.version })),
+ .@"static" = wf.add("libghostty-static.pc", b.fmt(
\\prefix={s}
\\includedir=${{prefix}}/include
\\libdir=${{prefix}}/lib
\\
- \\Name: ghostty-internal-static
+ \\Name: libghostty-static
\\URL: https://github.com/ghostty-org/ghostty
- \\Description: Ghostty internal library, static (not for external use)
+ \\Description: Ghostty terminal emulator library (static)
\\Version: {f}
\\Cflags: -I${{includedir}}
\\Libs: ${{libdir}}/{s}
- \\Libs.private:
- \\Requires.private:
, .{ b.install_prefix, deps.config.version, staticLibraryName(os_tag) })),
};
}
-fn sharedLibraryName(os_tag: std.Target.Os.Tag) []const u8 {
- return if (os_tag == .windows)
- "ghostty-internal.dll"
- else
- "ghostty-internal.so";
-}
-
fn staticLibraryName(os_tag: std.Target.Os.Tag) []const u8 {
return if (os_tag == .windows)
- "ghostty-internal-static.lib"
+ "ghostty-static.lib"
else
- "ghostty-internal.a";
+ "libghostty.a";
}
diff --git a/src/build/HlslStep.zig b/src/build/HlslStep.zig
new file mode 100644
index 00000000000..96f76f922bd
--- /dev/null
+++ b/src/build/HlslStep.zig
@@ -0,0 +1,124 @@
+/// A zig build step that compiles a set of ".hlsl" files into
+/// ".dxil" (DirectX Intermediate Language) files using dxc.exe,
+/// the DirectX Shader Compiler.
+const HlslStep = @This();
+
+const std = @import("std");
+const Step = std.Build.Step;
+const RunStep = std.Build.Step.Run;
+const LazyPath = std.Build.LazyPath;
+
+pub const ShaderEntry = struct {
+ /// The HLSL source file.
+ source: LazyPath,
+ /// Shader profile (e.g. "vs_6_0", "ps_6_0").
+ profile: []const u8,
+ /// Entry point function name (e.g. "VSMain", "PSMain").
+ entry_point: []const u8,
+ /// Output name (e.g. "cell_vs" -> "cell_vs.dxil").
+ output_name: []const u8,
+};
+
+pub const Options = struct {
+ target: std.Build.ResolvedTarget,
+ shaders: []const ShaderEntry,
+};
+
+step: *Step,
+/// String-keyed outputs so callers look up by name, not index.
+outputs: std.StringHashMapUnmanaged(LazyPath),
+
+pub fn create(b: *std.Build, opts: Options) ?*HlslStep {
+ if (opts.target.result.os.tag != .windows) return null;
+
+ const self = b.allocator.create(HlslStep) catch @panic("OOM");
+
+ // Find dxc.exe from the Windows SDK or PATH.
+ const dxc_path = findDxc(b, opts.target.result.cpu.arch) orelse {
+ std.log.warn("dxc.exe not found; HLSL shaders will not be compiled", .{});
+ return null;
+ };
+
+ var outputs: std.StringHashMapUnmanaged(LazyPath) = .empty;
+ var step_wip = Step.init(.{
+ .id = .custom,
+ .name = "hlsl",
+ .owner = b,
+ });
+
+ for (opts.shaders) |shader| {
+ const run = RunStep.create(
+ b,
+ b.fmt("hlsl {s}", .{shader.output_name}),
+ );
+ run.addArgs(&.{
+ dxc_path,
+ "-T",
+ shader.profile,
+ "-E",
+ shader.entry_point,
+ "-Fo",
+ });
+ const output = run.addOutputFileArg(
+ b.fmt("{s}.dxil", .{shader.output_name}),
+ );
+ run.addFileArg(shader.source);
+
+ outputs.put(b.allocator, shader.output_name, output) catch @panic("OOM");
+ step_wip.dependOn(&run.step);
+ }
+
+ self.* = .{
+ .step = b.allocator.create(Step) catch @panic("OOM"),
+ .outputs = outputs,
+ };
+ self.step.* = step_wip;
+
+ return self;
+}
+
+fn findDxc(b: *std.Build, arch: std.Target.Cpu.Arch) ?[]const u8 {
+ const arch_str: []const u8 = switch (arch) {
+ .x86_64 => "x64",
+ .x86 => "x86",
+ .aarch64 => "arm64",
+ else => return null,
+ };
+
+ // Try the Windows SDK first.
+ if (findDxcInSdk(b, arch, arch_str)) |path| return path;
+
+ // Fall back to PATH (e.g. Vulkan SDK ships dxc.exe).
+ return findDxcInPath(b);
+}
+
+fn findDxcInSdk(b: *std.Build, arch: std.Target.Cpu.Arch, arch_str: []const u8) ?[]const u8 {
+ const sdk = std.zig.WindowsSdk.find(b.allocator, arch) catch return null;
+ const w10 = sdk.windows10sdk orelse return null;
+
+ const path = std.fmt.allocPrint(
+ b.allocator,
+ "{s}\\bin\\{s}\\{s}\\dxc.exe",
+ .{ w10.path, w10.version, arch_str },
+ ) catch return null;
+
+ std.fs.accessAbsolute(path, .{}) catch return null;
+ return path;
+}
+
+fn findDxcInPath(b: *std.Build) ?[]const u8 {
+ const result = std.process.Child.run(.{
+ .allocator = b.allocator,
+ .argv = &.{ "where", "dxc.exe" },
+ }) catch return null;
+
+ if (result.term.Exited != 0) return null;
+
+ // "where" returns one path per line; take the first.
+ const first_line = std.mem.sliceTo(result.stdout, '\n');
+ const trimmed = std.mem.trimRight(u8, first_line, "\r\n ");
+ if (trimmed.len == 0) return null;
+
+ // Dupe onto the build allocator so it outlives the process result.
+ return b.allocator.dupe(u8, trimmed) catch return null;
+}
diff --git a/src/build/SharedDeps.zig b/src/build/SharedDeps.zig
index b68be92d011..b90b951dcfe 100644
--- a/src/build/SharedDeps.zig
+++ b/src/build/SharedDeps.zig
@@ -5,6 +5,7 @@ const builtin = @import("builtin");
const Config = @import("Config.zig");
const HelpStrings = @import("HelpStrings.zig");
+const HlslStep = @import("HlslStep.zig");
const MetallibStep = @import("MetallibStep.zig");
const UnicodeTables = @import("UnicodeTables.zig");
const GhosttyFrameData = @import("GhosttyFrameData.zig");
@@ -14,6 +15,7 @@ config: *const Config,
options: *std.Build.Step.Options,
help_strings: HelpStrings,
+hlsl: ?*HlslStep,
metallib: ?*MetallibStep,
unicode_tables: UnicodeTables,
framedata: GhosttyFrameData,
@@ -40,6 +42,7 @@ pub fn init(b: *std.Build, cfg: *const Config) !SharedDeps {
// Setup by retarget
.options = undefined,
+ .hlsl = undefined,
.metallib = undefined,
};
try result.initTarget(b, cfg.target);
@@ -89,6 +92,79 @@ fn initTarget(
.sources = &.{b.path("src/renderer/shaders/shaders.metal")},
});
+ // Update our HLSL shaders
+ self.hlsl = .create(b, .{
+ .target = target,
+ .shaders = &.{
+ .{
+ .source = b.path("src/renderer/shaders/hlsl/cell.hlsl"),
+ .profile = "vs_6_0",
+ .entry_point = "VSMain",
+ .output_name = "cell_vs",
+ },
+ .{
+ .source = b.path("src/renderer/shaders/hlsl/cell.hlsl"),
+ .profile = "ps_6_0",
+ .entry_point = "PSMain",
+ .output_name = "cell_ps",
+ },
+ .{
+ .source = b.path("src/renderer/shaders/hlsl/shaders.hlsl"),
+ .profile = "vs_6_0",
+ .entry_point = "BgColorVS",
+ .output_name = "bg_color_vs",
+ },
+ .{
+ .source = b.path("src/renderer/shaders/hlsl/shaders.hlsl"),
+ .profile = "ps_6_0",
+ .entry_point = "BgColorPS",
+ .output_name = "bg_color_ps",
+ },
+ .{
+ .source = b.path("src/renderer/shaders/hlsl/shaders.hlsl"),
+ .profile = "ps_6_0",
+ .entry_point = "CellBgPS",
+ .output_name = "cell_bg_ps",
+ },
+ .{
+ .source = b.path("src/renderer/shaders/hlsl/shaders.hlsl"),
+ .profile = "vs_6_0",
+ .entry_point = "CellTextVS",
+ .output_name = "cell_text_vs",
+ },
+ .{
+ .source = b.path("src/renderer/shaders/hlsl/shaders.hlsl"),
+ .profile = "ps_6_0",
+ .entry_point = "CellTextPS",
+ .output_name = "cell_text_ps",
+ },
+ .{
+ .source = b.path("src/renderer/shaders/hlsl/shaders.hlsl"),
+ .profile = "vs_6_0",
+ .entry_point = "ImageVS",
+ .output_name = "image_vs",
+ },
+ .{
+ .source = b.path("src/renderer/shaders/hlsl/shaders.hlsl"),
+ .profile = "ps_6_0",
+ .entry_point = "ImagePS",
+ .output_name = "image_ps",
+ },
+ .{
+ .source = b.path("src/renderer/shaders/hlsl/shaders.hlsl"),
+ .profile = "vs_6_0",
+ .entry_point = "BgImageVS",
+ .output_name = "bg_image_vs",
+ },
+ .{
+ .source = b.path("src/renderer/shaders/hlsl/shaders.hlsl"),
+ .profile = "ps_6_0",
+ .entry_point = "BgImagePS",
+ .output_name = "bg_image_ps",
+ },
+ },
+ });
+
// Change our config
const config = try b.allocator.create(Config);
config.* = self.config.*;
@@ -305,45 +381,13 @@ pub fn add(
}
}
- // Glslang
- if (b.lazyDependency("glslang", .{
- .target = target,
- .optimize = optimize,
- })) |glslang_dep| {
- step.root_module.addImport("glslang", glslang_dep.module("glslang"));
- if (b.systemIntegrationOption("glslang", .{})) {
- step.linkSystemLibrary2("glslang", dynamic_link_opts);
- step.linkSystemLibrary2(
- "glslang-default-resource-limits",
- dynamic_link_opts,
- );
- } else {
- step.linkLibrary(glslang_dep.artifact("glslang"));
- try static_libs.append(
- b.allocator,
- glslang_dep.artifact("glslang").getEmittedBin(),
- );
- }
- }
-
- // Spirv-cross
- if (b.lazyDependency("spirv_cross", .{
- .target = target,
- .optimize = optimize,
- })) |spirv_cross_dep| {
- step.root_module.addImport(
- "spirv_cross",
- spirv_cross_dep.module("spirv_cross"),
- );
- if (b.systemIntegrationOption("spirv-cross", .{})) {
- step.linkSystemLibrary2("spirv-cross-c-shared", dynamic_link_opts);
- } else {
- step.linkLibrary(spirv_cross_dep.artifact("spirv_cross"));
- try static_libs.append(
- b.allocator,
- spirv_cross_dep.artifact("spirv_cross").getEmittedBin(),
- );
- }
+ // glslpp — pure-Zig GLSL->SPIR-V->HLSL/MSL/GLSL compiler (replaces glslang+spirv-cross)
+ {
+ const glslpp_dep = b.dependency("glslpp", .{
+ .target = target,
+ .optimize = optimize,
+ });
+ step.root_module.addImport("glslpp", glslpp_dep.module("glslpp"));
}
// Sentry
@@ -448,6 +492,45 @@ pub fn add(
});
}
+ if (step.rootModuleTarget().os.tag == .windows) {
+ if (self.hlsl) |hlsl| {
+ step.step.dependOn(hlsl.step);
+ step.root_module.addAnonymousImport("ghostty_hlsl_cell_vs", .{
+ .root_source_file = hlsl.outputs.get("cell_vs").?,
+ });
+ step.root_module.addAnonymousImport("ghostty_hlsl_cell_ps", .{
+ .root_source_file = hlsl.outputs.get("cell_ps").?,
+ });
+ step.root_module.addAnonymousImport("ghostty_hlsl_bg_color_vs", .{
+ .root_source_file = hlsl.outputs.get("bg_color_vs").?,
+ });
+ step.root_module.addAnonymousImport("ghostty_hlsl_bg_color_ps", .{
+ .root_source_file = hlsl.outputs.get("bg_color_ps").?,
+ });
+ step.root_module.addAnonymousImport("ghostty_hlsl_cell_bg_ps", .{
+ .root_source_file = hlsl.outputs.get("cell_bg_ps").?,
+ });
+ step.root_module.addAnonymousImport("ghostty_hlsl_cell_text_vs", .{
+ .root_source_file = hlsl.outputs.get("cell_text_vs").?,
+ });
+ step.root_module.addAnonymousImport("ghostty_hlsl_cell_text_ps", .{
+ .root_source_file = hlsl.outputs.get("cell_text_ps").?,
+ });
+ step.root_module.addAnonymousImport("ghostty_hlsl_image_vs", .{
+ .root_source_file = hlsl.outputs.get("image_vs").?,
+ });
+ step.root_module.addAnonymousImport("ghostty_hlsl_image_ps", .{
+ .root_source_file = hlsl.outputs.get("image_ps").?,
+ });
+ step.root_module.addAnonymousImport("ghostty_hlsl_bg_image_vs", .{
+ .root_source_file = hlsl.outputs.get("bg_image_vs").?,
+ });
+ step.root_module.addAnonymousImport("ghostty_hlsl_bg_image_ps", .{
+ .root_source_file = hlsl.outputs.get("bg_image_ps").?,
+ });
+ }
+ }
+
// Other dependencies, mostly pure Zig
if (b.lazyDependency("opengl", .{})) |dep| {
step.root_module.addImport("opengl", dep.module("opengl"));
@@ -764,6 +847,15 @@ pub fn addSimd(
const optimize = m.optimize.?;
const system_highway = b.systemIntegrationOption("highway", .{ .default = false });
+ // MSVC's C++ static-init pass populates simdutf's implementation
+ // singleton; the upstream no_libcxx path compiles that out with
+ // -fno-exceptions -fno-rtti, which leaves get_default_implementation()
+ // returning null and AVs on the first UTF-8 decode. Keep no_libcxx
+ // and -DSIMDUTF_NO_LIBCXX off on MSVC; simdutf's own build.zig
+ // already skips linkLibCpp there, so libghostty-vt still depends
+ // only on libc.
+ const is_msvc = target.result.abi == .msvc;
+
// Simdutf
if (b.systemIntegrationOption("simdutf", .{})) {
m.linkSystemLibrary("simdutf", dynamic_link_opts);
@@ -771,7 +863,7 @@ pub fn addSimd(
if (b.lazyDependency("simdutf", .{
.target = target,
.optimize = optimize,
- .no_libcxx = true,
+ .no_libcxx = !is_msvc,
})) |simdutf_dep| {
m.linkLibrary(simdutf_dep.artifact("simdutf"));
if (static_libs) |v| try v.append(
@@ -839,8 +931,9 @@ pub fn addSimd(
// When using the vendored simdutf, build its headers in no-libcxx
// mode so we don't need C++ standard library headers at all.
- // System simdutf headers may not support this define.
- if (!b.systemIntegrationOption("simdutf", .{})) try flags.append(
+ // System simdutf headers may not support this define. Skipped on
+ // MSVC for the same static-init reason as the library build above.
+ if (!b.systemIntegrationOption("simdutf", .{}) and !is_msvc) try flags.append(
b.allocator,
"-DSIMDUTF_NO_LIBCXX",
);
diff --git a/src/cli/ghostty.zig b/src/cli/ghostty.zig
index 3acb9004305..4f391f80d34 100644
--- a/src/cli/ghostty.zig
+++ b/src/cli/ghostty.zig
@@ -40,6 +40,9 @@ pub const Action = enum {
/// List available themes
@"list-themes",
+ /// List available themes (always use the terminal TUI, never in-process)
+ @"list-themes-tui",
+
/// List named RGB colors
@"list-colors",
@@ -140,7 +143,7 @@ pub const Action = enum {
.help => try help.run(alloc),
.@"list-fonts" => try list_fonts.run(alloc),
.@"list-keybinds" => try list_keybinds.run(alloc),
- .@"list-themes" => try list_themes.run(alloc),
+ .@"list-themes", .@"list-themes-tui" => try list_themes.run(alloc),
.@"list-colors" => try list_colors.run(alloc),
.@"list-actions" => try list_actions.run(alloc),
.@"ssh-cache" => try ssh_cache.run(alloc),
@@ -159,6 +162,9 @@ pub const Action = enum {
/// path from the root src/ directory.
pub fn file(comptime self: Action) []const u8 {
comptime {
+ // Aliases share the same source file as their canonical action.
+ if (self == .@"list-themes-tui") return "cli/list_themes.zig";
+
const filename = filename: {
const tag = @tagName(self);
var filename: [tag.len]u8 = undefined;
@@ -180,7 +186,7 @@ pub const Action = enum {
.help => help.Options,
.@"list-fonts" => list_fonts.Options,
.@"list-keybinds" => list_keybinds.Options,
- .@"list-themes" => list_themes.Options,
+ .@"list-themes", .@"list-themes-tui" => list_themes.Options,
.@"list-colors" => list_colors.Options,
.@"list-actions" => list_actions.Options,
.@"ssh-cache" => ssh_cache.Options,
diff --git a/src/cli/inline_theme_picker.zig b/src/cli/inline_theme_picker.zig
new file mode 100644
index 00000000000..cb7262249f8
--- /dev/null
+++ b/src/cli/inline_theme_picker.zig
@@ -0,0 +1,1092 @@
+const std = @import("std");
+const Allocator = std.mem.Allocator;
+const Config = @import("../config/Config.zig");
+const configpkg = @import("../config.zig");
+const themepkg = @import("../config/theme.zig");
+const input = @import("../input.zig");
+const global_state = &@import("../global.zig").state;
+
+const zf = @import("zf");
+
+const log = std.log.scoped(.inline_theme_picker);
+
+/// Callback fired when the user previews or confirms a theme.
+/// First arg is the null-terminated theme name, second is true on confirm.
+pub const ThemeCallback = *const fn ([*:0]const u8, bool) callconv(.c) void;
+
+/// Callback to write VT bytes into the surface's terminal.
+pub const WriteCallback = *const fn (ud: ?*anyopaque, data: [*]const u8, len: usize) void;
+
+/// A code segment for syntax-highlighted display in the preview.
+const CodeSegment = struct {
+ text: []const u8,
+ pal: ?usize = null,
+ selection: bool = false,
+ cursor: bool = false,
+};
+
+/// Theme entry discovered from the filesystem.
+pub const ThemeEntry = struct {
+ name: []const u8,
+ path: []const u8,
+ location: themepkg.Location,
+ rank: ?f64 = null,
+
+ fn lessThan(_: void, lhs: ThemeEntry, rhs: ThemeEntry) bool {
+ return std.ascii.orderIgnoreCase(lhs.name, rhs.name) == .lt;
+ }
+};
+
+/// Discover all available themes from the standard theme directories.
+/// Caller owns the returned slice and the arena backing the strings.
+pub fn discoverThemes(arena: Allocator) ![]ThemeEntry {
+ var themes: std.ArrayList(ThemeEntry) = .empty;
+
+ var it: themepkg.LocationIterator = .{ .arena_alloc = arena };
+
+ while (try it.next()) |loc| {
+ var dir = std.fs.cwd().openDir(loc.dir, .{ .iterate = true }) catch |err| {
+ if (err != error.FileNotFound)
+ log.warn("failed to open theme dir {s}: {}", .{ loc.dir, err });
+ continue;
+ };
+ defer dir.close();
+
+ var walker = dir.iterate();
+ while (try walker.next()) |entry| {
+ switch (entry.kind) {
+ .file, .sym_link => {
+ if (std.mem.eql(u8, entry.name, ".DS_Store"))
+ continue;
+ const path = try std.fs.path.join(arena, &.{ loc.dir, entry.name });
+ try themes.append(arena, .{
+ .path = path,
+ .location = loc.location,
+ .name = try arena.dupe(u8, entry.name),
+ });
+ },
+ else => {},
+ }
+ }
+ }
+
+ std.mem.sortUnstable(ThemeEntry, themes.items, {}, ThemeEntry.lessThan);
+ return themes.items;
+}
+
+/// In-process theme picker that writes raw ANSI sequences through
+/// the write callback (no Vaxis).
+pub const InlineThemePicker = struct {
+ allocator: Allocator,
+ /// Arena for per-frame allocations, reset each draw call.
+ frame_arena: std.heap.ArenaAllocator,
+ /// Arena that owns the theme name/path strings. Freed on deinit.
+ theme_arena: ?std.heap.ArenaAllocator,
+ themes: []ThemeEntry,
+ filtered: std.ArrayList(usize),
+ current: usize,
+ window: usize,
+ cols: u16,
+ rows: u16,
+ hex: bool,
+ mode: Mode,
+ search_buf: std.ArrayList(u8),
+ should_quit: bool,
+ confirmed: bool,
+
+ // Callbacks
+ write_fn: WriteCallback,
+ write_ud: ?*anyopaque,
+ theme_cb: ?ThemeCallback,
+
+ // Track previous theme index for change detection
+ prev_theme_idx: ?usize,
+
+ const Mode = enum { normal, search, help };
+
+ // Layout constants
+ const list_width: u16 = 32;
+ const palette_height: u16 = 6;
+ const code_height: u16 = 24;
+
+ pub fn init(
+ allocator: Allocator,
+ themes: []ThemeEntry,
+ theme_arena: ?std.heap.ArenaAllocator,
+ cols: u16,
+ rows: u16,
+ write_fn: WriteCallback,
+ write_ud: ?*anyopaque,
+ theme_cb: ?ThemeCallback,
+ ) !*InlineThemePicker {
+ const self = try allocator.create(InlineThemePicker);
+ self.* = .{
+ .allocator = allocator,
+ .frame_arena = std.heap.ArenaAllocator.init(allocator),
+ .theme_arena = theme_arena,
+ .themes = themes,
+ .filtered = try .initCapacity(allocator, themes.len),
+ .current = 0,
+ .window = 0,
+ .cols = cols,
+ .rows = rows,
+ .hex = false,
+ .mode = .normal,
+ .search_buf = .empty,
+ .should_quit = false,
+ .confirmed = false,
+ .write_fn = write_fn,
+ .write_ud = write_ud,
+ .theme_cb = theme_cb,
+ .prev_theme_idx = null,
+ };
+
+ // Initialize filtered list with all themes
+ for (0..themes.len) |i| {
+ try self.filtered.append(allocator, i);
+ }
+
+ return self;
+ }
+
+ pub fn deinit(self: *InlineThemePicker) void {
+ const allocator = self.allocator;
+ self.frame_arena.deinit();
+ self.filtered.deinit(allocator);
+ self.search_buf.deinit(allocator);
+ if (self.theme_arena) |*arena| arena.deinit();
+ self.* = undefined;
+ allocator.destroy(self);
+ }
+
+ /// Write VT bytes via the write callback.
+ fn write(self: *InlineThemePicker, data: []const u8) void {
+ self.write_fn(self.write_ud, data.ptr, data.len);
+ }
+
+ /// Write a formatted string.
+ fn print(self: *InlineThemePicker, comptime fmt: []const u8, args: anytype) void {
+ var buf: [4096]u8 = undefined;
+ const slice = std.fmt.bufPrint(&buf, fmt, args) catch return;
+ self.write(slice);
+ }
+
+ /// Move cursor to row, col (1-based).
+ fn moveTo(self: *InlineThemePicker, row: u16, col: u16) void {
+ self.print("\x1b[{d};{d}H", .{ row + 1, col + 1 });
+ }
+
+ /// Set foreground color from RGB.
+ fn setFg(self: *InlineThemePicker, r: u8, g: u8, b: u8) void {
+ self.print("\x1b[38;2;{d};{d};{d}m", .{ r, g, b });
+ }
+
+ /// Set background color from RGB.
+ fn setBg(self: *InlineThemePicker, r: u8, g: u8, b: u8) void {
+ self.print("\x1b[48;2;{d};{d};{d}m", .{ r, g, b });
+ }
+
+ /// Reset all attributes.
+ fn resetAttr(self: *InlineThemePicker) void {
+ self.write("\x1b[0m");
+ }
+
+ /// Set bold.
+ fn setBold(self: *InlineThemePicker) void {
+ self.write("\x1b[1m");
+ }
+
+ /// Set italic.
+ fn setItalic(self: *InlineThemePicker) void {
+ self.write("\x1b[3m");
+ }
+
+ /// Set underline.
+ fn setUnderline(self: *InlineThemePicker) void {
+ self.write("\x1b[4m");
+ }
+
+ /// Enter alt screen and hide cursor, render initial frame.
+ pub fn enter(self: *InlineThemePicker) void {
+ self.write("\x1b[?1049h"); // alt screen
+ self.write("\x1b[?25l"); // hide cursor
+ self.draw();
+ }
+
+ /// Exit alt screen, restore terminal.
+ pub fn exit(self: *InlineThemePicker) void {
+ // Restore original terminal default colors before exiting
+ // alt screen. OSC 110/111 reset fg/bg to their defaults.
+ self.write("\x1b]110\x1b\\"); // reset fg
+ self.write("\x1b]111\x1b\\"); // reset bg
+ self.write("\x1b[?25h"); // show cursor
+ self.write("\x1b[?1049l"); // exit alt screen
+ }
+
+ /// Process a key event from the surface input redirect.
+ /// Returns true if the event was consumed.
+ pub fn handleKey(self: *InlineThemePicker, event: *const input.KeyEvent) bool {
+ // Already done -- consume but ignore.
+ if (self.should_quit) return true;
+
+ // Only handle press and repeat
+ if (event.action == .release) return true;
+
+ const key = event.key;
+ const mods = event.mods;
+
+ // Ctrl+C always quits
+ if (key == .key_c and mods.ctrl) {
+ self.should_quit = true;
+ self.notifyThemeChange();
+ self.draw();
+ return true;
+ }
+
+ switch (self.mode) {
+ .normal => {
+ if (key == .key_q or key == .escape) {
+ self.should_quit = true;
+ } else if (key == .slash and !mods.ctrl) {
+ self.mode = .search;
+ } else if (key == .f1 or (key == .slash and mods.ctrl)) {
+ self.mode = .help;
+ } else if (key == .enter or key == .numpad_enter) {
+ self.confirmed = true;
+ self.should_quit = true;
+ // Fire confirm callback
+ if (self.theme_cb) |cb| {
+ if (self.filtered.items.len > 0 and self.current < self.filtered.items.len) {
+ const idx = self.filtered.items[self.current];
+ const name = self.themes[idx].name;
+ if (name.len < 256) {
+ var buf: [256]u8 = undefined;
+ @memcpy(buf[0..name.len], name);
+ buf[name.len] = 0;
+ cb(@ptrCast(&buf), true);
+ } else {
+ log.warn("theme name too long for callback ({d} bytes): {s}...", .{ name.len, name[0..@min(name.len, 64)] });
+ }
+ }
+ }
+ } else if (key == .key_j or key == .arrow_down) {
+ self.moveDown(1);
+ } else if (key == .key_k or key == .arrow_up) {
+ self.moveUp(1);
+ } else if (key == .page_down) {
+ self.moveDown(20);
+ } else if (key == .page_up) {
+ self.moveUp(20);
+ } else if (key == .home) {
+ self.current = 0;
+ } else if (key == .end) {
+ if (self.filtered.items.len > 0)
+ self.current = self.filtered.items.len - 1;
+ } else if (key == .key_h or key == .key_x) {
+ if (!mods.ctrl) self.hex = true;
+ } else if (key == .key_d) {
+ self.hex = false;
+ } else if (key == .key_f) {
+ // Color-scheme filter skipped: would require loading
+ // every theme config up front.
+ }
+ self.notifyThemeChange();
+ self.draw();
+ },
+ .search => {
+ if (key == .escape or key == .enter) {
+ self.mode = .normal;
+ } else if (mods.ctrl and (key == .key_x or key == .slash)) {
+ self.search_buf.clearRetainingCapacity();
+ self.updateFiltered();
+ } else if (key == .backspace) {
+ if (self.search_buf.items.len > 0) {
+ _ = self.search_buf.pop();
+ self.updateFiltered();
+ }
+ } else {
+ // Try to get a printable character from utf8 or the key
+ if (event.utf8.len > 0) {
+ self.search_buf.appendSlice(self.allocator, event.utf8) catch {};
+ self.updateFiltered();
+ }
+ }
+ self.notifyThemeChange();
+ self.draw();
+ },
+ .help => {
+ if (key == .escape or key == .f1 or key == .key_q) {
+ self.mode = .normal;
+ }
+ self.draw();
+ },
+ }
+
+ return true;
+ }
+
+ /// Process a mouse scroll event. Positive yoff = scroll up,
+ /// negative = scroll down. Returns true if consumed.
+ pub fn handleScroll(self: *InlineThemePicker, yoff: f64) bool {
+ if (self.should_quit) return true;
+ if (self.mode != .normal) return true;
+
+ if (yoff > 0) {
+ self.moveUp(1);
+ } else if (yoff < 0) {
+ self.moveDown(1);
+ } else {
+ return true;
+ }
+
+ self.notifyThemeChange();
+ self.draw();
+ return true;
+ }
+
+ fn moveUp(self: *InlineThemePicker, count: usize) void {
+ if (self.filtered.items.len == 0) {
+ self.current = 0;
+ return;
+ }
+ self.current -|= count;
+ }
+
+ fn moveDown(self: *InlineThemePicker, count: usize) void {
+ if (self.filtered.items.len == 0) {
+ self.current = 0;
+ return;
+ }
+ self.current += count;
+ if (self.current >= self.filtered.items.len)
+ self.current = self.filtered.items.len - 1;
+ }
+
+ fn notifyThemeChange(self: *InlineThemePicker) void {
+ if (self.theme_cb) |cb| {
+ const cur_idx: ?usize = if (self.filtered.items.len > 0 and self.current < self.filtered.items.len)
+ self.filtered.items[self.current]
+ else
+ null;
+ if (cur_idx != self.prev_theme_idx) {
+ self.prev_theme_idx = cur_idx;
+ if (cur_idx) |idx| {
+ const name = self.themes[idx].name;
+ if (name.len < 256) {
+ var buf: [256]u8 = undefined;
+ @memcpy(buf[0..name.len], name);
+ buf[name.len] = 0;
+ cb(@ptrCast(&buf), false);
+ } else {
+ log.warn("theme name too long for callback ({d} bytes): {s}...", .{ name.len, name[0..@min(name.len, 64)] });
+ }
+ }
+ }
+ }
+ }
+
+ fn updateFiltered(self: *InlineThemePicker) void {
+ // Save current selection name for re-finding after filter
+ var selected: []const u8 = "";
+ if (self.filtered.items.len > 0 and self.current < self.filtered.items.len) {
+ selected = self.themes[self.filtered.items[self.current]].name;
+ }
+
+ self.filtered.clearRetainingCapacity();
+
+ if (self.search_buf.items.len > 0) {
+ const query = std.ascii.allocLowerString(self.allocator, self.search_buf.items) catch return;
+ defer self.allocator.free(query);
+
+ var tokens: std.ArrayList([]const u8) = .empty;
+ defer tokens.deinit(self.allocator);
+
+ var it = std.mem.tokenizeScalar(u8, query, ' ');
+ while (it.next()) |token| tokens.append(self.allocator, token) catch return;
+
+ for (self.themes, 0..) |*theme, i| {
+ theme.rank = zf.rank(theme.name, tokens.items, .{
+ .to_lower = true,
+ .plain = true,
+ });
+ if (theme.rank != null) self.filtered.append(self.allocator, i) catch {};
+ }
+ } else {
+ for (0..self.themes.len) |i| {
+ self.themes[i].rank = null;
+ self.filtered.append(self.allocator, i) catch {};
+ }
+ }
+
+ if (self.filtered.items.len == 0) {
+ self.current = 0;
+ self.window = 0;
+ return;
+ }
+
+ // Try to find the previously selected theme
+ for (self.filtered.items, 0..) |index, i| {
+ if (std.mem.eql(u8, self.themes[index].name, selected)) {
+ self.current = i;
+ return;
+ }
+ }
+ self.current = 0;
+ self.window = 0;
+ }
+
+ /// Update the terminal dimensions. Call when the surface resizes.
+ pub fn resize(self: *InlineThemePicker, cols: u16, rows: u16) void {
+ if (self.should_quit) return;
+ self.cols = cols;
+ self.rows = rows;
+ // Clear immediately to avoid showing stale content from the
+ // alt screen reflow during the resize.
+ self.write("\x1b[2J\x1b[H");
+ self.draw();
+ }
+
+ /// Render the current state via VT escape sequences.
+ pub fn draw(self: *InlineThemePicker) void {
+ _ = self.frame_arena.reset(.retain_capacity);
+ const alloc = self.frame_arena.allocator();
+
+ // Clear screen and home cursor
+ self.write("\x1b[2J\x1b[H");
+
+ // Adjust window scrolling
+ if (self.filtered.items.len == 0) {
+ self.current = 0;
+ self.window = 0;
+ } else {
+ const visible_rows = self.rows;
+ const end = self.window + visible_rows - 1;
+ if (self.current > end)
+ self.window = self.current - visible_rows + 1;
+ if (self.current < self.window)
+ self.window = self.current;
+ if (self.window >= self.filtered.items.len)
+ self.window = self.filtered.items.len - 1;
+ }
+
+ // Draw the theme list (left panel)
+ self.drawThemeList();
+
+ // Draw the preview panel (right side)
+ self.drawPreview(alloc);
+
+ // Draw overlays based on mode
+ switch (self.mode) {
+ .normal => {},
+ .search => self.drawSearchBox(),
+ .help => self.drawHelpOverlay(),
+ }
+
+ }
+
+ fn drawThemeList(self: *InlineThemePicker) void {
+ const w = @min(list_width, self.cols);
+
+ for (0..self.rows) |row_idx| {
+ const index = self.window + row_idx;
+ self.moveTo(@intCast(row_idx), 0);
+ self.resetAttr();
+
+ if (index >= self.filtered.items.len) {
+ // Fill empty rows
+ self.writeSpaces(w);
+ continue;
+ }
+
+ const theme = self.themes[self.filtered.items[index]];
+ const is_selected = index == self.current;
+
+ if (is_selected) {
+ // Selected: green on dark bg
+ self.write("\x1b[38;2;0;170;0m\x1b[48;2;51;51;51m");
+ self.write("\xe2\x9d\xaf "); // ">" marker
+ } else {
+ self.resetAttr();
+ self.write(" ");
+ }
+
+ // Print theme name, truncated to fit
+ const max_name = w -| 4;
+ const name_len = @min(theme.name.len, max_name);
+ self.write(theme.name[0..name_len]);
+
+ // Fill remaining space
+ const used: u16 = @intCast(2 + name_len);
+ if (is_selected) {
+ if (used < w -| 2) {
+ self.writeSpaces(w - used - 2);
+ self.write(" \xe2\x9d\xae"); // "<" marker
+ } else {
+ self.writeSpaces(w -| used);
+ }
+ } else {
+ self.writeSpaces(w -| used);
+ }
+ }
+ }
+
+ fn drawPreview(self: *InlineThemePicker, alloc: Allocator) void {
+ const x_off = list_width;
+ if (x_off >= self.cols) return;
+ const width = self.cols - x_off;
+
+ if (self.filtered.items.len == 0 or self.current >= self.filtered.items.len) {
+ // No theme selected -- show "No theme found"
+ const msg = "No theme found!";
+ const center_row = self.rows / 2;
+ const center_col = x_off + (width / 2) -| @as(u16, @intCast(msg.len / 2));
+ self.moveTo(center_row, center_col);
+ self.resetAttr();
+ self.write(msg);
+ return;
+ }
+
+ const theme = self.themes[self.filtered.items[self.current]];
+
+ // Load theme config to get colors
+ var config = Config.default(alloc) catch return;
+ defer config.deinit();
+ config.loadFile(config._arena.?.allocator(), theme.path) catch {
+ // Show error
+ const center_row = self.rows / 2;
+ self.moveTo(center_row, x_off + 2);
+ self.resetAttr();
+ self.write("\x1b[31m"); // red
+ self.print("Unable to open {s}", .{theme.name});
+ return;
+ };
+
+ // Set the terminal's default fg/bg via OSC 10/11 so the
+ // entire terminal background matches the theme, not just
+ // the cells we explicitly paint.
+ {
+ const fg = config.foreground;
+ const bg = config.background;
+ self.print("\x1b]10;rgb:{x:0>2}/{x:0>2}/{x:0>2}\x1b\\", .{ fg.r, fg.g, fg.b });
+ self.print("\x1b]11;rgb:{x:0>2}/{x:0>2}/{x:0>2}\x1b\\", .{ bg.r, bg.g, bg.b });
+ }
+
+ const fg = config.foreground;
+ const bg = config.background;
+
+ var next_row: u16 = 0;
+
+ // Theme name header (4 rows)
+ {
+ self.moveTo(1, x_off + width / 2 -| @as(u16, @intCast(theme.name.len / 2)));
+ self.setFg(fg.r, fg.g, fg.b);
+ self.setBg(bg.r, bg.g, bg.b);
+ self.setBold();
+ self.setItalic();
+ self.write(theme.name);
+ self.resetAttr();
+
+ // Path on next line
+ self.moveTo(2, x_off + width / 2 -| @as(u16, @intCast(theme.path.len / 2)));
+ self.setFg(fg.r, fg.g, fg.b);
+ self.setBg(bg.r, bg.g, bg.b);
+ self.write(theme.path[0..@min(theme.path.len, width)]);
+ self.resetAttr();
+ next_row = 4;
+ }
+
+ // Palette grid (16 colors, 8 per row, 2 rows of blocks)
+ {
+ for (0..16) |i| {
+ const ci: u16 = @intCast(i);
+ const r = ci / 8;
+ const c = ci % 8;
+
+ // Number label
+ self.moveTo(next_row + 3 * r, x_off + c * 8);
+ self.setFg(fg.r, fg.g, fg.b);
+ self.setBg(bg.r, bg.g, bg.b);
+ if (self.hex) {
+ self.print(" {x:0>2}", .{i});
+ } else {
+ self.print("{d:3}", .{i});
+ }
+
+ // Color block (2 rows of 4 chars)
+ const pc = paletteColor(config, i);
+ self.setFg(pc[0], pc[1], pc[2]);
+ self.setBg(bg.r, bg.g, bg.b);
+ self.moveTo(next_row + 3 * r, x_off + 4 + c * 8);
+ self.write("\xe2\x96\x88\xe2\x96\x88\xe2\x96\x88\xe2\x96\x88"); // ">>>>"
+ self.moveTo(next_row + 3 * r + 1, x_off + 4 + c * 8);
+ self.write("\xe2\x96\x88\xe2\x96\x88\xe2\x96\x88\xe2\x96\x88");
+ }
+ next_row += palette_height;
+ }
+
+ // Bat-style code sample
+ {
+ self.drawCodeSample(config, next_row, x_off, width);
+ next_row += code_height;
+ }
+
+ // Fill remaining space with lorem ipsum
+ if (next_row < self.rows) {
+ self.drawLoremIpsum(config, next_row, x_off, width);
+ }
+ }
+
+ fn drawCodeSample(self: *InlineThemePicker, config: Config, start_row: u16, x_off: u16, width: u16) void {
+ const fg = config.foreground;
+ const bg = config.background;
+
+ // Helper to set a palette color as fg
+ const SetPalette = struct {
+ picker: *InlineThemePicker,
+ cfg: Config,
+ bg_color: @TypeOf(config.background),
+
+ fn fg_pal(s: @This(), idx: usize) void {
+ const pc = paletteColor(s.cfg, idx);
+ s.picker.setFg(pc[0], pc[1], pc[2]);
+ s.picker.setBg(s.bg_color.r, s.bg_color.g, s.bg_color.b);
+ }
+ };
+
+ const sp = SetPalette{ .picker = self, .cfg = config, .bg_color = bg };
+
+ // Line 0: prompt
+ self.moveTo(start_row, x_off + 2);
+ sp.fg_pal(2);
+ self.write("\xe2\x86\x92"); // arrow
+ self.resetAttr();
+ sp.fg_pal(0);
+ self.write(" ");
+ sp.fg_pal(4);
+ self.write("bat");
+ self.resetAttr();
+ sp.fg_pal(0);
+ self.write(" ");
+ sp.fg_pal(6);
+ self.setUnderline();
+ self.write("ziggzagg.zig");
+ self.resetAttr();
+
+ // Line 1: top border
+ self.moveTo(start_row + 1, x_off + 2);
+ const c238 = paletteColor(config, 238);
+ self.setFg(c238[0], c238[1], c238[2]);
+ self.setBg(bg.r, bg.g, bg.b);
+ self.write("\xe2\x94\x80\xe2\x94\x80\xe2\x94\x80\xe2\x94\x80\xe2\x94\x80\xe2\x94\x80\xe2\x94\x80\xe2\x94\xac"); // "-------+"
+ // Fill rest with horizontal lines
+ if (width > 8) {
+ for (0..@min(width - 8, 80)) |_| {
+ self.write("\xe2\x94\x80");
+ }
+ }
+
+ // Line 2: File header
+ self.moveTo(start_row + 2, x_off + 2);
+ self.setFg(c238[0], c238[1], c238[2]);
+ self.setBg(bg.r, bg.g, bg.b);
+ self.write(" \xe2\x94\x82 ");
+ self.setFg(fg.r, fg.g, fg.b);
+ self.write("File: ");
+ self.setBold();
+ self.write("ziggzagg.zig");
+ self.resetAttr();
+
+ // Line 3: separator
+ self.moveTo(start_row + 3, x_off + 2);
+ self.setFg(c238[0], c238[1], c238[2]);
+ self.setBg(bg.r, bg.g, bg.b);
+ self.write("\xe2\x94\x80\xe2\x94\x80\xe2\x94\x80\xe2\x94\x80\xe2\x94\x80\xe2\x94\x80\xe2\x94\x80\xe2\x94\xbc");
+ if (width > 8) {
+ for (0..@min(width - 8, 80)) |_| {
+ self.write("\xe2\x94\x80");
+ }
+ }
+
+ // Code lines 1-17
+ const code_lines = [_]struct { num: []const u8, segments: []const CodeSegment }{
+ .{ .num = " 1 ", .segments = &.{
+ .{ .text = "const", .pal = 5 },
+ .{ .text = " std ", .pal = null },
+ .{ .text = "= @import", .pal = 5 },
+ .{ .text = "(", .pal = null },
+ .{ .text = "\"std\"", .pal = 10 },
+ .{ .text = ");", .pal = null },
+ } },
+ .{ .num = " 2 ", .segments = &.{} },
+ .{ .num = " 3 ", .segments = &.{
+ .{ .text = "pub ", .pal = 5 },
+ .{ .text = "fn ", .pal = 12 },
+ .{ .text = "main", .pal = 2 },
+ .{ .text = "() ", .pal = null },
+ .{ .text = "!", .pal = 5 },
+ .{ .text = "void", .pal = 12 },
+ .{ .text = " {", .pal = null },
+ } },
+ .{ .num = " 4 ", .segments = &.{
+ .{ .text = " ", .pal = null },
+ .{ .text = "const ", .pal = 5 },
+ .{ .text = "stdout ", .pal = null },
+ .{ .text = "=", .pal = 5 },
+ .{ .text = " std.Io.getStdOut().writer();", .pal = null },
+ } },
+ .{ .num = " 5 ", .segments = &.{
+ .{ .text = " ", .pal = null },
+ .{ .text = "var ", .pal = 5 },
+ .{ .text = "i:", .pal = null },
+ .{ .text = " usize", .pal = 12 },
+ .{ .text = " =", .pal = 5 },
+ .{ .text = " 1", .pal = 4 },
+ .{ .text = ";", .pal = null },
+ } },
+ .{ .num = " 6 ", .segments = &.{
+ .{ .text = " ", .pal = null },
+ .{ .text = "while ", .pal = 5 },
+ .{ .text = "(i ", .pal = null },
+ .{ .text = "<= ", .pal = 5 },
+ .{ .text = "16", .pal = 4 },
+ .{ .text = ") : (i ", .pal = null },
+ .{ .text = "+= ", .pal = 5 },
+ .{ .text = "1", .pal = 4 },
+ .{ .text = ") {", .pal = null },
+ } },
+ .{ .num = " 7 ", .segments = &.{
+ .{ .text = " ", .pal = null },
+ .{ .text = "if ", .pal = 5 },
+ .{ .text = "(i ", .pal = null },
+ .{ .text = "% ", .pal = 5 },
+ .{ .text = "15 ", .pal = 4 },
+ .{ .text = "== ", .pal = 5 },
+ .{ .text = "0", .pal = 4 },
+ .{ .text = ") {", .pal = null },
+ } },
+ .{ .num = " 8 ", .segments = &.{
+ .{ .text = " ", .pal = null },
+ .{ .text = "try ", .pal = 5 },
+ .{ .text = "stdout.writeAll(", .pal = null },
+ .{ .text = "\"ZiggZagg", .pal = 10 },
+ .{ .text = "\\n", .pal = 12 },
+ .{ .text = "\"", .pal = 10 },
+ .{ .text = ");", .pal = null },
+ } },
+ .{ .num = " 9 ", .segments = &.{
+ .{ .text = " ", .pal = null },
+ .{ .text = "} ", .pal = null },
+ .{ .text = "else if ", .pal = 5 },
+ .{ .text = "(i ", .pal = null },
+ .{ .text = "% ", .pal = 5 },
+ .{ .text = "3 ", .pal = 4 },
+ .{ .text = "== ", .pal = 5 },
+ .{ .text = "0", .pal = 4 },
+ .{ .text = ") {", .pal = null },
+ } },
+ .{ .num = " 10 ", .segments = &.{
+ .{ .text = " ", .pal = null },
+ .{ .text = "try ", .pal = 5 },
+ .{ .text = "stdout.writeAll(", .pal = null },
+ .{ .text = "\"Zigg", .pal = 10 },
+ .{ .text = "\\n", .pal = 12 },
+ .{ .text = "\"", .pal = 10 },
+ .{ .text = ");", .pal = null },
+ } },
+ .{ .num = " 11 ", .segments = &.{
+ .{ .text = " ", .pal = null },
+ .{ .text = "} ", .pal = null },
+ .{ .text = "else if ", .pal = 5 },
+ .{ .text = "(i ", .pal = null },
+ .{ .text = "% ", .pal = 5 },
+ .{ .text = "5 ", .pal = 4 },
+ .{ .text = "== ", .pal = 5 },
+ .{ .text = "0", .pal = 4 },
+ .{ .text = ") {", .pal = null },
+ } },
+ .{ .num = " 12 ", .segments = &.{
+ .{ .text = " ", .pal = null },
+ .{ .text = "try ", .pal = 5 },
+ .{ .text = "stdout.writeAll(", .pal = null },
+ .{ .text = "\"Zagg", .pal = 10 },
+ .{ .text = "\\n", .pal = 12 },
+ .{ .text = "\"", .pal = 10 },
+ .{ .text = ");", .pal = null },
+ } },
+ .{ .num = " 13 ", .segments = &.{
+ .{ .text = " ", .pal = null },
+ .{ .text = "} ", .pal = null },
+ .{ .text = "else ", .pal = 5 },
+ .{ .text = "{", .pal = null },
+ } },
+ .{ .num = " 14 ", .segments = &.{
+ .{ .text = " ", .pal = null },
+ .{ .text = "try ", .pal = 5 },
+ .{ .text = "stdout.print(\"{d}\\n\", .{i})", .pal = null, .selection = true },
+ .{ .text = ";", .pal = null, .cursor = true },
+ } },
+ .{ .num = " 15 ", .segments = &.{
+ .{ .text = " ", .pal = null },
+ .{ .text = "}", .pal = null },
+ } },
+ .{ .num = " 16 ", .segments = &.{
+ .{ .text = " ", .pal = null },
+ .{ .text = "}", .pal = null },
+ } },
+ .{ .num = " 17 ", .segments = &.{
+ .{ .text = " ", .pal = null },
+ .{ .text = "}", .pal = null },
+ } },
+ };
+
+ for (code_lines, 0..) |line, li| {
+ const row = start_row + 4 + @as(u16, @intCast(li));
+ if (row >= self.rows) break;
+
+ self.moveTo(row, x_off + 2);
+
+ // Line number + separator
+ self.setFg(c238[0], c238[1], c238[2]);
+ self.setBg(bg.r, bg.g, bg.b);
+ self.write(line.num);
+ self.write("\xe2\x94\x82 ");
+
+ // Code segments
+ for (line.segments) |seg| {
+ if (seg.selection) {
+ // Selection style
+ const sel_fg = if (config.@"selection-foreground") |sf| sf.color else bg;
+ const sel_bg = if (config.@"selection-background") |sb| sb.color else config.foreground;
+ self.setFg(sel_fg.r, sel_fg.g, sel_fg.b);
+ self.setBg(sel_bg.r, sel_bg.g, sel_bg.b);
+ } else if (seg.cursor) {
+ // Cursor style
+ const cur_fg = if (config.@"cursor-text") |ct| ct.color else bg;
+ const cur_bg = if (config.@"cursor-color") |cc| cc.color else config.foreground;
+ self.setFg(cur_fg.r, cur_fg.g, cur_fg.b);
+ self.setBg(cur_bg.r, cur_bg.g, cur_bg.b);
+ } else if (seg.pal) |pal_idx| {
+ const pc = paletteColor(config, pal_idx);
+ self.setFg(pc[0], pc[1], pc[2]);
+ self.setBg(bg.r, bg.g, bg.b);
+ } else {
+ self.setFg(fg.r, fg.g, fg.b);
+ self.setBg(bg.r, bg.g, bg.b);
+ }
+ self.write(seg.text);
+ }
+ self.resetAttr();
+ }
+
+ // Bottom border
+ const bottom_row = start_row + 4 + code_lines.len;
+ if (bottom_row < self.rows) {
+ self.moveTo(@intCast(bottom_row), x_off + 2);
+ self.setFg(c238[0], c238[1], c238[2]);
+ self.setBg(bg.r, bg.g, bg.b);
+ self.write("\xe2\x94\x80\xe2\x94\x80\xe2\x94\x80\xe2\x94\x80\xe2\x94\x80\xe2\x94\x80\xe2\x94\x80\xe2\x94\xb4");
+ if (width > 8) {
+ for (0..@min(width - 8, 80)) |_| {
+ self.write("\xe2\x94\x80");
+ }
+ }
+ }
+
+ // Starship-style prompt
+ const prompt_row: u16 = @intCast(bottom_row + 1);
+ if (prompt_row < self.rows) {
+ self.moveTo(prompt_row, x_off + 2);
+ sp.fg_pal(6);
+ self.write("ghostty ");
+ self.setFg(fg.r, fg.g, fg.b);
+ self.setBg(bg.r, bg.g, bg.b);
+ self.write("on ");
+ sp.fg_pal(4);
+ self.write(" main ");
+ sp.fg_pal(1);
+ self.write("[+] ");
+ self.setFg(fg.r, fg.g, fg.b);
+ self.setBg(bg.r, bg.g, bg.b);
+ self.write("via ");
+ sp.fg_pal(3);
+ self.write(" v0.13.0 ");
+ self.setFg(fg.r, fg.g, fg.b);
+ self.setBg(bg.r, bg.g, bg.b);
+ self.write("via ");
+ sp.fg_pal(4);
+ self.write(" impure (ghostty-env)");
+ self.resetAttr();
+ }
+ if (prompt_row + 1 < self.rows) {
+ self.moveTo(prompt_row + 1, x_off + 2);
+ sp.fg_pal(4);
+ self.write("\xe2\x9c\xa6 ");
+ self.setFg(fg.r, fg.g, fg.b);
+ self.setBg(bg.r, bg.g, bg.b);
+ self.write("at ");
+ sp.fg_pal(3);
+ self.write("10:36:15 ");
+ sp.fg_pal(2);
+ self.write("\xe2\x86\x92");
+ self.resetAttr();
+ }
+ }
+
+ fn drawLoremIpsum(self: *InlineThemePicker, config: Config, start_row: u16, x_off: u16, width: u16) void {
+ const fg = config.foreground;
+ const bg = config.background;
+
+ const lorem = "Lorem ipsum dolor sit amet consectetur adipiscing elit sed do eiusmod tempor incididunt ut labore et dolore magna aliqua";
+
+ var row = start_row + 1;
+ var col: u16 = 2;
+
+ self.setFg(fg.r, fg.g, fg.b);
+ self.setBg(bg.r, bg.g, bg.b);
+
+ var it = std.mem.tokenizeScalar(u8, lorem, ' ');
+ while (row < self.rows) {
+ const word = it.next() orelse {
+ it.reset();
+ continue;
+ };
+ const wlen: u16 = @intCast(word.len);
+
+ if (col + wlen > width) {
+ row += 1;
+ col = 2;
+ if (row >= self.rows) break;
+ }
+
+ self.moveTo(row, x_off + col);
+
+ // Apply special styles for certain words.
+ if (std.mem.eql(u8, "ipsum", word)) {
+ const pc = paletteColor(config, 2);
+ self.setFg(pc[0], pc[1], pc[2]);
+ } else if (std.mem.eql(u8, "consectetur", word)) {
+ self.setFg(fg.r, fg.g, fg.b);
+ self.setBold();
+ } else {
+ self.setFg(fg.r, fg.g, fg.b);
+ }
+ self.setBg(bg.r, bg.g, bg.b);
+ self.write(word);
+ self.resetAttr();
+ self.setFg(fg.r, fg.g, fg.b);
+ self.setBg(bg.r, bg.g, bg.b);
+
+ col += wlen + 1;
+ }
+ self.resetAttr();
+ }
+
+ fn drawSearchBox(self: *InlineThemePicker) void {
+ const box_width: u16 = @min(self.cols -| 40, 60);
+ const box_x = 20;
+ const box_y = self.rows -| 5;
+
+ // Top border
+ self.moveTo(box_y, box_x);
+ self.resetAttr();
+ self.write("\xe2\x94\x8c"); // top-left corner
+ for (0..box_width) |_| self.write("\xe2\x94\x80");
+ self.write("\xe2\x94\x90"); // top-right corner
+
+ // Content row
+ self.moveTo(box_y + 1, box_x);
+ self.write("\xe2\x94\x82"); // left border
+ self.write(self.search_buf.items[0..@min(self.search_buf.items.len, box_width)]);
+ const used: u16 = @intCast(@min(self.search_buf.items.len, box_width));
+ self.writeSpaces(box_width - used);
+ self.write("\xe2\x94\x82"); // right border
+
+ // Bottom border
+ self.moveTo(box_y + 2, box_x);
+ self.write("\xe2\x94\x94"); // bottom-left corner
+ for (0..box_width) |_| self.write("\xe2\x94\x80");
+ self.write("\xe2\x94\x98"); // bottom-right corner
+
+ // Show cursor at end of search text
+ self.write("\x1b[?25h"); // show cursor
+ self.moveTo(box_y + 1, box_x + 1 + used);
+ }
+
+ fn drawHelpOverlay(self: *InlineThemePicker) void {
+ const help_width: u16 = 60;
+ const help_height: u16 = 18;
+ const x = self.cols / 2 -| help_width / 2;
+ const y = self.rows / 2 -| help_height / 2;
+
+ const key_help = [_]struct { keys: []const u8, desc: []const u8 }{
+ .{ .keys = "^C, q, ESC", .desc = "Quit." },
+ .{ .keys = "F1, ^/", .desc = "Toggle help window." },
+ .{ .keys = "k, Up", .desc = "Move up 1 theme." },
+ .{ .keys = "PgUp", .desc = "Move up 20 themes." },
+ .{ .keys = "j, Down", .desc = "Move down 1 theme." },
+ .{ .keys = "PgDown", .desc = "Move down 20 themes." },
+ .{ .keys = "h, x", .desc = "Show palette numbers in hex." },
+ .{ .keys = "d", .desc = "Show palette numbers in decimal." },
+ .{ .keys = "Home", .desc = "Go to start of the list." },
+ .{ .keys = "End", .desc = "Go to end of the list." },
+ .{ .keys = "/", .desc = "Start search." },
+ .{ .keys = "^X, ^/", .desc = "Clear search." },
+ .{ .keys = "Enter", .desc = "Confirm theme." },
+ };
+
+ // Draw border
+ self.moveTo(y, x);
+ self.resetAttr();
+ self.write("\xe2\x94\x8c");
+ for (0..help_width) |_| self.write("\xe2\x94\x80");
+ self.write("\xe2\x94\x90");
+
+ for (0..help_height) |row_idx| {
+ const row: u16 = @intCast(row_idx);
+ self.moveTo(y + 1 + row, x);
+ self.write("\xe2\x94\x82");
+
+ if (row_idx < key_help.len) {
+ const entry = key_help[row_idx];
+ // Key column (15 chars)
+ self.write(" ");
+ self.write(entry.keys);
+ const key_len: u16 = @intCast(entry.keys.len);
+ if (key_len < 14) self.writeSpaces(14 - key_len);
+ self.write(" - ");
+ self.write(entry.desc);
+ const desc_len: u16 = @intCast(entry.desc.len);
+ const total = 1 + 14 + 3 + desc_len;
+ if (total < help_width) self.writeSpaces(help_width - @as(u16, @intCast(total)));
+ } else {
+ self.writeSpaces(help_width);
+ }
+
+ self.write("\xe2\x94\x82");
+ }
+
+ self.moveTo(y + 1 + help_height, x);
+ self.write("\xe2\x94\x94");
+ for (0..help_width) |_| self.write("\xe2\x94\x80");
+ self.write("\xe2\x94\x98");
+ }
+
+ fn writeSpaces(self: *InlineThemePicker, count: u16) void {
+ const spaces = " ";
+ var remaining = count;
+ while (remaining > 0) {
+ const chunk = @min(remaining, @as(u16, @intCast(spaces.len)));
+ self.write(spaces[0..chunk]);
+ remaining -= chunk;
+ }
+ }
+
+};
+
+fn paletteColor(config: Config, idx: usize) [3]u8 {
+ return [3]u8{
+ config.palette.value[idx].r,
+ config.palette.value[idx].g,
+ config.palette.value[idx].b,
+ };
+}
diff --git a/src/cli/list_themes.zig b/src/cli/list_themes.zig
index 42aff9d566a..38c7708a04c 100644
--- a/src/cli/list_themes.zig
+++ b/src/cli/list_themes.zig
@@ -18,6 +18,17 @@ const SMALL_LIST_THRESHOLD = 10;
const ColorScheme = enum { all, dark, light };
+/// Theme change callback type. Called with the theme name and whether the
+/// selection is confirmed (accept) or just a preview (browsing).
+pub const ThemeCallback = *const fn ([*:0]const u8, bool) callconv(.c) void;
+
+/// Set by the embedder via setThemeCallback before run().
+var theme_callback: ?ThemeCallback = null;
+
+pub fn setThemeCallback(cb: ?ThemeCallback) void {
+ theme_callback = cb;
+}
+
pub const Options = struct {
/// If true, print the full path to the theme.
path: bool = false,
@@ -306,6 +317,8 @@ const Preview = struct {
if (self.vx.caps.color_scheme_updates)
try self.vx.subscribeToColorSchemeUpdates(writer);
+ var prev_theme_idx: ?usize = null;
+
while (!self.should_quit) {
var arena = std.heap.ArenaAllocator.init(self.allocator);
defer arena.deinit();
@@ -315,6 +328,27 @@ const Preview = struct {
while (loop.tryEvent()) |event| {
try self.update(event, alloc);
}
+
+ // Notify the embedder when the selected theme changes.
+ if (theme_callback) |cb| {
+ const cur_idx: ?usize = if (self.filtered.items.len > 0 and self.current < self.filtered.items.len)
+ self.filtered.items[self.current]
+ else
+ null;
+ if (cur_idx != prev_theme_idx) {
+ prev_theme_idx = cur_idx;
+ if (cur_idx) |idx| {
+ const name = self.themes[idx].theme;
+ if (name.len < 256) {
+ var buf: [256]u8 = undefined;
+ @memcpy(buf[0..name.len], name);
+ buf[name.len] = 0;
+ cb(@ptrCast(&buf), false);
+ }
+ }
+ }
+ }
+
try self.draw(alloc);
try self.vx.render(writer);
@@ -444,8 +478,27 @@ const Preview = struct {
self.mode = .help;
if (key.matches('/', .{}))
self.mode = .search;
- if (key.matchesAny(&.{ vaxis.Key.enter, vaxis.Key.kp_enter }, .{}))
- self.mode = .save;
+ if (key.matchesAny(&.{ vaxis.Key.enter, vaxis.Key.kp_enter }, .{})) {
+ // When an embedder callback is registered, Enter
+ // confirms the selection directly (the embedder
+ // handles persistence). Without a callback, show
+ // the default save instructions screen.
+ if (theme_callback) |cb| {
+ if (self.filtered.items.len > 0 and self.current < self.filtered.items.len) {
+ const idx = self.filtered.items[self.current];
+ const name = self.themes[idx].theme;
+ if (name.len < 256) {
+ var buf: [256]u8 = undefined;
+ @memcpy(buf[0..name.len], name);
+ buf[name.len] = 0;
+ cb(@ptrCast(&buf), true);
+ }
+ }
+ self.should_quit = true;
+ } else {
+ self.mode = .save;
+ }
+ }
if (key.matchesAny(&.{ 'x', '/' }, .{ .ctrl = true })) {
self.text_input.buf.clearRetainingCapacity();
try self.updateFiltered();
@@ -1704,6 +1757,13 @@ const Preview = struct {
writeAutoThemeFile(self.allocator, theme.theme) catch {
return;
};
+
+ // Notify the embedder that the theme was accepted.
+ if (theme_callback) |cb| {
+ const name_z = self.allocator.dupeZ(u8, theme.theme) catch return;
+ defer self.allocator.free(name_z);
+ cb(name_z, true);
+ }
}
};
diff --git a/src/config/Config.zig b/src/config/Config.zig
index 5b1d73deb6c..aeffd99be43 100644
--- a/src/config/Config.zig
+++ b/src/config/Config.zig
@@ -512,8 +512,9 @@ language: ?[:0]const u8 = null,
/// to turn all flags on or off.
///
/// This configuration only applies to Ghostty builds that use FreeType.
-/// This is usually the case only for Linux builds. macOS uses CoreText
-/// and does not have an equivalent configuration.
+/// This is usually the case for Linux builds and Windows builds using
+/// the `directwrite_freetype` backend. macOS uses CoreText and does not
+/// have an equivalent configuration.
///
/// Available flags:
///
@@ -2411,10 +2412,11 @@ keybind: Keybinds = .{},
/// selection clipboard); with `clipboard` it reads from the system
/// clipboard.
///
-/// The default value is true on Linux and macOS.
+/// The default value is true on Linux, macOS, and Windows.
@"copy-on-select": CopyOnSelect = switch (builtin.os.tag) {
.linux => .true,
.macos => .true,
+ .windows => .true,
else => .false,
},
@@ -2856,6 +2858,28 @@ keybind: Keybinds = .{},
/// `xterm-256color` with environment variables if terminfo installation fails.
@"shell-integration-features": ShellIntegrationFeatures = .{},
+/// Controls how Windows terminal sessions communicate with the child
+/// shell. `auto` picks the bypass path (raw stdin/stdout/stderr
+/// pipes, no CreatePseudoConsole) for VT-aware shells like pwsh,
+/// wsl, ssh, bash, and nu, and keeps ConPTY for cmd.exe and
+/// PowerShell 5.1. `always` forces the bypass path; `never` forces
+/// ConPTY. Has no effect on non-Windows platforms.
+///
+/// Bypass enables Kitty graphics and avoids conhost's VT mangling,
+/// at the cost of losing ConPTY's compatibility shims for Win32
+/// Console API programs. Resize signalling under bypass is
+/// best-effort via CSI 8;rows;cols t; use `conpty-mode = never` if
+/// precise resize behavior matters.
+@"conpty-mode": if (builtin.os.tag == .windows) ConptyMode else void =
+ if (builtin.os.tag == .windows) .auto else {},
+
+/// Controls whether Ghostty injects a UTF-8 codepage setup into
+/// spawned cmd / PowerShell shells on Windows. See the `Utf8Console`
+/// enum for the full description of `auto`, `always`, and `never`.
+/// Has no effect on non-Windows platforms.
+@"utf8-console": if (builtin.os.tag == .windows) Utf8Console else void =
+ if (builtin.os.tag == .windows) .auto else {},
+
/// Custom entries into the command palette.
///
/// Each entry requires the title, the corresponding action, and an optional
@@ -3672,6 +3696,23 @@ else
/// Available since: 1.1.0
@"gtk-custom-css": RepeatablePath = .{},
+/// Automatically reload the configuration when the config file
+/// changes on disk. Uses a platform-specific file watcher (e.g.
+/// FileSystemWatcher on Windows). When false, config is only
+/// reloaded via explicit keybind or menu action.
+///
+/// Available since: 1.1.1
+@"auto-reload-config": bool = false,
+
+/// Enable the built-in Settings UI on Windows. On other platforms,
+/// this option has no effect. When false, the "open config" action
+/// opens the config file in the default editor (matching macOS
+/// behavior). When true, opens the built-in settings window with
+/// structured editing.
+///
+/// Available since: 1.1.1
+@"windows-settings-ui": bool = false,
+
/// If `true` (default), applications running in the terminal can show desktop
/// notifications using certain escape sequences such as OSC 9 or OSC 777.
@"desktop-notifications": bool = true,
@@ -8694,6 +8735,35 @@ pub const ShellIntegrationFeatures = packed struct {
path: bool = true,
};
+pub const ConptyMode = enum { auto, always, never };
+
+/// Controls whether Ghostty injects a UTF-8 codepage setup into spawned
+/// cmd / PowerShell shells on Windows.
+///
+/// * `always`: always inject (`chcp 65001` for cmd; `[Console]::
+/// OutputEncoding = [System.Text.UTF8Encoding]::new()` for pwsh).
+/// This is what most Western Windows users want: it makes Nerd Font
+/// prompts render correctly and stops CSI responses leaking into the
+/// prompt buffer on installs whose OEM codepage is not 65001.
+///
+/// * `never`: never inject. Useful if you run legacy `.bat` scripts
+/// containing literal multi-byte characters in your system OEM
+/// codepage (e.g. Shift-JIS on Japanese Windows) and don't want the
+/// process flipped to UTF-8 underneath them.
+///
+/// * `auto` (default): inject unless the system ANSI codepage is one
+/// of the legacy double-byte CJK codepages (932, 936, 949, 950, 1361).
+/// Equivalent to `always` on Western Windows and `never` on default-
+/// locale Japanese / Simplified Chinese / Korean / Traditional
+/// Chinese installs.
+///
+/// This option has no effect on macOS or Linux.
+pub const Utf8Console = enum {
+ auto,
+ always,
+ never,
+};
+
pub const SplitPreserveZoom = packed struct {
navigation: bool = false,
};
diff --git a/src/font/Collection.zig b/src/font/Collection.zig
index 5d7bfa519fb..1e4932bd9e5 100644
--- a/src/font/Collection.zig
+++ b/src/font/Collection.zig
@@ -1438,6 +1438,7 @@ test "face metrics" {
.cell_width = switch (options.backend) {
.freetype,
.fontconfig_freetype,
+ .directwrite_freetype,
.coretext_freetype,
=> 8.0,
.coretext,
@@ -1458,6 +1459,7 @@ test "face metrics" {
.ascii_height = switch (options.backend) {
.freetype,
.fontconfig_freetype,
+ .directwrite_freetype,
.coretext_freetype,
=> 18.0625,
.coretext,
@@ -1472,6 +1474,7 @@ test "face metrics" {
.cell_width = switch (options.backend) {
.freetype,
.fontconfig_freetype,
+ .directwrite_freetype,
.coretext_freetype,
=> 10.0,
.coretext,
@@ -1492,6 +1495,7 @@ test "face metrics" {
.ascii_height = switch (options.backend) {
.freetype,
.fontconfig_freetype,
+ .directwrite_freetype,
.coretext_freetype,
=> 16.0,
.coretext,
diff --git a/src/font/DeferredFace.zig b/src/font/DeferredFace.zig
index 425f3c28398..333ddefa623 100644
--- a/src/font/DeferredFace.zig
+++ b/src/font/DeferredFace.zig
@@ -10,6 +10,7 @@ const std = @import("std");
const Allocator = std.mem.Allocator;
const fontconfig = @import("fontconfig");
const macos = @import("macos");
+const dwrite = @import("directwrite.zig");
const font = @import("main.zig");
const options = @import("main.zig").options;
const Library = @import("main.zig").Library;
@@ -34,6 +35,10 @@ win: if (options.backend == .freetype_windows) ?Windows else void =
wc: if (options.backend == .web_canvas) ?WebCanvas else void =
if (options.backend == .web_canvas) null else {},
+/// DirectWrite
+dw: if (options.backend == .directwrite_freetype) ?DirectWrite else void =
+ if (options.backend == .directwrite_freetype) null else {},
+
/// Fontconfig specific data. This is only present if building with fontconfig.
pub const Fontconfig = struct {
/// The pattern for this font. This must be the "render prepared" pattern.
@@ -124,11 +129,22 @@ pub const WebCanvas = struct {
}
};
+pub const DirectWrite = struct {
+ font: *dwrite.IDWriteFont,
+ variations: []const font.face.Variation,
+
+ pub fn deinit(self: *DirectWrite) void {
+ _ = self.font.Release();
+ self.* = undefined;
+ }
+};
+
pub fn deinit(self: *DeferredFace) void {
switch (options.backend) {
.fontconfig_freetype => if (self.fc) |*fc| fc.deinit(),
.freetype => {},
.freetype_windows => if (self.win) |*w| w.deinit(),
+ .directwrite_freetype => if (self.dw) |*dw_| dw_.deinit(),
.web_canvas => if (self.wc) |*wc| wc.deinit(),
.coretext,
.coretext_freetype,
@@ -146,6 +162,19 @@ pub fn familyName(self: DeferredFace, buf: []u8) ![]const u8 {
.freetype_windows => if (self.win) |w| return try w.peek.name(buf),
+ .directwrite_freetype => if (self.dw) |dw_| {
+ var names: ?*dwrite.IDWriteLocalizedStrings = null;
+ var str_exists: i32 = 0;
+ const hr = dw_.font.GetInformationalStrings(.WIN32_FAMILY_NAMES, &names, &str_exists);
+ if (dwrite.SUCCEEDED(hr) and str_exists != 0) {
+ if (names) |n| {
+ defer _ = n.Release();
+ return dwrite.getLocalizedString(n, buf);
+ }
+ }
+ return "";
+ },
+
.fontconfig_freetype => if (self.fc) |fc|
return (try fc.pattern.get(.family, 0)).string,
@@ -176,6 +205,29 @@ pub fn name(self: DeferredFace, buf: []u8) ![]const u8 {
.freetype_windows => if (self.win) |w| return try w.peek.name(buf),
+ .directwrite_freetype => if (self.dw) |dw_| {
+ // Try full name first
+ var names: ?*dwrite.IDWriteLocalizedStrings = null;
+ var str_exists: i32 = 0;
+ var hr = dw_.font.GetInformationalStrings(.FULL_NAME, &names, &str_exists);
+ if (dwrite.SUCCEEDED(hr) and str_exists != 0) {
+ if (names) |n| {
+ defer _ = n.Release();
+ return dwrite.getLocalizedString(n, buf);
+ }
+ }
+ // Fall back to face names
+ var face_names: ?*dwrite.IDWriteLocalizedStrings = null;
+ hr = dw_.font.GetFaceNames(&face_names);
+ if (dwrite.SUCCEEDED(hr)) {
+ if (face_names) |n| {
+ defer _ = n.Release();
+ return dwrite.getLocalizedString(n, buf);
+ }
+ }
+ return "";
+ },
+
.fontconfig_freetype => if (self.fc) |fc|
return (try fc.pattern.get(.fullname, 0)).string,
@@ -210,6 +262,7 @@ pub fn load(
return switch (options.backend) {
.fontconfig_freetype => try self.loadFontconfig(lib, opts),
.freetype_windows => try self.loadWindows(lib, opts),
+ .directwrite_freetype => try self.loadDirectWrite(lib, opts),
.coretext, .coretext_harfbuzz, .coretext_noshape => try self.loadCoreText(lib, opts),
.coretext_freetype => try self.loadCoreTextFreetype(lib, opts),
.web_canvas => try self.loadWebCanvas(opts),
@@ -315,6 +368,69 @@ fn loadWebCanvas(
return try .initNamed(wc.alloc, wc.font_str, opts, wc.presentation);
}
+fn loadDirectWrite(self: *DeferredFace, lib: Library, opts: font.face.Options) !Face {
+ const dw_ = self.dw.?;
+
+ var dw_face: ?*dwrite.IDWriteFontFace = null;
+ var hr = dw_.font.CreateFontFace(&dw_face);
+ if (dwrite.FAILED(hr)) return error.DirectWriteError;
+ defer _ = dw_face.?.Release();
+
+ // Get file count
+ var num_files: u32 = 0;
+ hr = dw_face.?.GetFiles(&num_files, null);
+ if (dwrite.FAILED(hr) or num_files == 0) return error.FontHasNoFile;
+
+ // Get first font file
+ var font_file: ?*dwrite.IDWriteFontFile = null;
+ var one: u32 = 1;
+ hr = dw_face.?.GetFiles(&one, @ptrCast(&font_file));
+ if (dwrite.FAILED(hr)) return error.FontHasNoFile;
+ defer _ = font_file.?.Release();
+
+ // Get reference key
+ var key: ?*const anyopaque = null;
+ var key_size: u32 = 0;
+ hr = font_file.?.GetReferenceKey(&key, &key_size);
+ if (dwrite.FAILED(hr)) return error.FontHasNoFile;
+
+ // Get loader and QI to local loader
+ var loader: ?*dwrite.IDWriteFontFileLoader = null;
+ hr = font_file.?.GetLoader(&loader);
+ if (dwrite.FAILED(hr)) return error.FontHasNoFile;
+ defer _ = loader.?.Release();
+
+ var local_loader_raw: ?*anyopaque = null;
+ hr = loader.?.QueryInterface(&dwrite.IDWriteLocalFontFileLoader.IID, &local_loader_raw);
+ if (dwrite.FAILED(hr)) return error.FontHasNoFile;
+ const local_loader: *dwrite.IDWriteLocalFontFileLoader = @ptrCast(@alignCast(local_loader_raw.?));
+ defer _ = local_loader.Release();
+
+ // Get file path length then path
+ var path_len: u32 = 0;
+ hr = local_loader.GetFilePathLengthFromKey(key.?, key_size, &path_len);
+ if (dwrite.FAILED(hr)) return error.FontHasNoFile;
+
+ var wpath_buf: [512]u16 = undefined;
+ if (path_len + 1 > wpath_buf.len) return error.FontPathCantDecode;
+ hr = local_loader.GetFilePathFromKey(key.?, key_size, &wpath_buf, path_len + 1);
+ if (dwrite.FAILED(hr)) return error.FontHasNoFile;
+
+ // Convert UTF-16 path to null-terminated UTF-8 for FreeType
+ var path_buf: [1024]u8 = undefined;
+ const utf8_len = std.unicode.utf16LeToUtf8(path_buf[0 .. path_buf.len - 1], wpath_buf[0..path_len]) catch
+ return error.FontPathCantDecode;
+ path_buf[utf8_len] = 0;
+ const path: [:0]const u8 = path_buf[0..utf8_len :0];
+
+ const face_index: i32 = @intCast(dw_face.?.GetIndex());
+
+ var face = try Face.initFile(lib, path, face_index, opts);
+ errdefer face.deinit();
+ try face.setVariations(dw_.variations, opts);
+ return face;
+}
+
/// Returns true if this face can satisfy the given codepoint and
/// presentation. If presentation is null, then it just checks if the
/// codepoint is present at all.
@@ -386,6 +502,19 @@ pub fn hasCodepoint(self: DeferredFace, cp: u32, p: ?Presentation) bool {
// Canvas always has the codepoint because we have no way of
// really checking and we let the browser handle it.
+ .directwrite_freetype => {
+ if (self.dw) |dw_| {
+ if (p) |desired_p| {
+ const is_color = dw_.font.IsColorFont() != 0;
+ const actual_p: Presentation = if (is_color) .emoji else .text;
+ if (actual_p != desired_p) return false;
+ }
+ var cp_exists: i32 = 0;
+ const hr = dw_.font.HasCharacter(cp, &cp_exists);
+ return dwrite.SUCCEEDED(hr) and cp_exists != 0;
+ }
+ },
+
.web_canvas => if (self.wc) |wc| {
// Fast-path if we have a specific presentation and we
// don't match, then it is definitely not this face.
@@ -528,3 +657,31 @@ test "coretext" {
defer face.deinit();
try testing.expect(face.glyphIndex(' ') != null);
}
+
+test "directwrite" {
+ if (options.backend != .directwrite_freetype) return error.SkipZigTest;
+
+ const discovery_mod = @import("main.zig").discovery;
+ const testing = std.testing;
+ const alloc = testing.allocator;
+
+ var lib = try Library.init(alloc);
+ defer lib.deinit();
+
+ var def = def: {
+ var dw = discovery_mod.DirectWrite.init(undefined);
+ defer dw.deinit();
+ var it = try dw.discover(alloc, .{ .family = "Consolas", .size = 12 });
+ defer it.deinit();
+ break :def (try it.next()).?;
+ };
+ defer def.deinit();
+
+ var buf_dw: [1024]u8 = undefined;
+ const n_dw = try def.name(&buf_dw);
+ try testing.expect(n_dw.len > 0);
+
+ var face_dw = try def.load(lib, .{ .size = .{ .points = 12 } });
+ defer face_dw.deinit();
+ try testing.expect(face_dw.glyphIndex(' ') != null);
+}
diff --git a/src/font/SharedGridSet.zig b/src/font/SharedGridSet.zig
index 9d8148bdceb..d9e8d510ae9 100644
--- a/src/font/SharedGridSet.zig
+++ b/src/font/SharedGridSet.zig
@@ -353,9 +353,31 @@ fn collection(
}
}
- // Emoji fallback. We don't include this on Mac since Mac is expected
- // to always have the Apple Emoji available on the system.
- if (comptime !builtin.target.os.tag.isDarwin() or Discover == void) {
+ // On Windows, always search for and add the Segoe UI Emoji font
+ // as our preferred emoji font for fallback. We do this in case
+ // people add other emoji fonts to their system, we always want to
+ // prefer the official one. Users can override this by explicitly
+ // specifying a font-family for emoji.
+ if (comptime builtin.target.os.tag == .windows and Discover != void) windows_emoji: {
+ const disco = try self.discover() orelse break :windows_emoji;
+ var disco_it = try disco.discover(self.alloc, .{
+ .family = "Segoe UI Emoji",
+ });
+ defer disco_it.deinit();
+ if (try disco_it.next()) |face| {
+ _ = try c.addDeferred(self.alloc, face, .{
+ .style = .regular,
+ .fallback = true,
+ // No size adjustment for emojis.
+ .size_adjustment = .none,
+ });
+ }
+ }
+
+ // Embedded emoji fallback for platforms without a system emoji font.
+ // macOS and Windows use their native emoji fonts (added above) when
+ // discovery is available.
+ if (comptime !(builtin.target.os.tag.isDarwin() or builtin.target.os.tag == .windows) or Discover == void) {
_ = try c.add(
self.alloc,
try .init(
diff --git a/src/font/backend.zig b/src/font/backend.zig
index 867421de7da..4d81db8aa7b 100644
--- a/src/font/backend.zig
+++ b/src/font/backend.zig
@@ -28,6 +28,10 @@ pub const Backend = enum {
/// CoreText for font discovery and rendering, no shaping.
coretext_noshape,
+ /// DirectWrite for font discovery, FreeType for rendering,
+ /// and HarfBuzz for shaping (Windows).
+ directwrite_freetype,
+
/// Use the browser font system and the Canvas API (wasm). This limits
/// the available fonts to browser fonts (anything Canvas natively
/// supports).
@@ -47,11 +51,10 @@ pub const Backend = enum {
}
if (target.os.tag == .windows) {
- // Avoid fontconfig on Windows because its libxml2 dependency
- // may not unpack due to symlinks. Use the FreeType-based
- // Windows font-directory scanner for discovery. A future
- // DirectWrite backend can replace this if needed.
- return .freetype_windows;
+ // DirectWrite gives OS-authoritative font enumeration,
+ // locale-aware fallback (CJK / emoji), and weight/italic
+ // scoring. freetype_windows is still available as an opt-in.
+ return .directwrite_freetype;
}
// macOS also supports "coretext_freetype" but there is no scenario
@@ -68,6 +71,7 @@ pub const Backend = enum {
.freetype,
.freetype_windows,
.fontconfig_freetype,
+ .directwrite_freetype,
.coretext_freetype,
=> true,
@@ -90,6 +94,7 @@ pub const Backend = enum {
.freetype,
.freetype_windows,
.fontconfig_freetype,
+ .directwrite_freetype,
.web_canvas,
=> false,
};
@@ -101,6 +106,7 @@ pub const Backend = enum {
.freetype,
.freetype_windows,
+ .directwrite_freetype,
.coretext,
.coretext_freetype,
.coretext_harfbuzz,
@@ -115,6 +121,7 @@ pub const Backend = enum {
.freetype,
.freetype_windows,
.fontconfig_freetype,
+ .directwrite_freetype,
.coretext_freetype,
.coretext_harfbuzz,
=> true,
@@ -125,4 +132,19 @@ pub const Backend = enum {
=> false,
};
}
+
+ pub fn hasDirectwrite(self: Backend) bool {
+ return switch (self) {
+ .directwrite_freetype => true,
+ .freetype,
+ .freetype_windows,
+ .fontconfig_freetype,
+ .coretext,
+ .coretext_freetype,
+ .coretext_harfbuzz,
+ .coretext_noshape,
+ .web_canvas,
+ => false,
+ };
+ }
};
diff --git a/src/font/directwrite.zig b/src/font/directwrite.zig
new file mode 100644
index 00000000000..8469168363c
--- /dev/null
+++ b/src/font/directwrite.zig
@@ -0,0 +1,875 @@
+const std = @import("std");
+const com = @import("../os/windows_com.zig");
+
+pub const GUID = com.GUID;
+pub const HRESULT = com.HRESULT;
+pub const SUCCEEDED = com.SUCCEEDED;
+pub const FAILED = com.FAILED;
+pub const S_OK = com.S_OK;
+pub const E_NOINTERFACE = com.E_NOINTERFACE;
+pub const IUnknown = com.IUnknown;
+pub const Reserved = com.Reserved;
+
+const BOOL = i32;
+const WCHAR = u16;
+const UINT32 = u32;
+const UINT16 = u16;
+const FLOAT = f32;
+
+// --- Enums ---
+
+pub const DWRITE_FACTORY_TYPE = enum(u32) {
+ SHARED = 0,
+ ISOLATED = 1,
+};
+
+pub const DWRITE_FONT_WEIGHT = enum(u32) {
+ THIN = 100,
+ EXTRA_LIGHT = 200,
+ LIGHT = 300,
+ SEMI_LIGHT = 350,
+ NORMAL = 400,
+ MEDIUM = 500,
+ SEMI_BOLD = 600,
+ BOLD = 700,
+ EXTRA_BOLD = 800,
+ BLACK = 900,
+ EXTRA_BLACK = 950,
+ _,
+};
+
+pub const DWRITE_FONT_STYLE = enum(u32) {
+ NORMAL = 0,
+ OBLIQUE = 1,
+ ITALIC = 2,
+};
+
+pub const DWRITE_FONT_STRETCH = enum(u32) {
+ UNDEFINED = 0,
+ ULTRA_CONDENSED = 1,
+ EXTRA_CONDENSED = 2,
+ CONDENSED = 3,
+ SEMI_CONDENSED = 4,
+ NORMAL = 5,
+ SEMI_EXPANDED = 6,
+ EXPANDED = 7,
+ EXTRA_EXPANDED = 8,
+ ULTRA_EXPANDED = 9,
+};
+
+pub const DWRITE_FONT_SIMULATIONS = enum(u32) {
+ NONE = 0,
+ BOLD = 1,
+ OBLIQUE = 2,
+ _,
+};
+
+pub const DWRITE_INFORMATIONAL_STRING_ID = enum(u32) {
+ NONE = 0,
+ COPYRIGHT_NOTICE = 1,
+ VERSION_STRINGS = 2,
+ TRADEMARK = 3,
+ MANUFACTURER = 4,
+ DESIGNER = 5,
+ DESIGNER_URL = 6,
+ DESCRIPTION = 7,
+ FONT_VENDOR_URL = 8,
+ LICENSE_DESCRIPTION = 9,
+ LICENSE_INFO_URL = 10,
+ WIN32_FAMILY_NAMES = 11,
+ WIN32_SUBFAMILY_NAMES = 12,
+ TYPOGRAPHIC_FAMILY_NAMES = 13,
+ TYPOGRAPHIC_SUBFAMILY_NAMES = 14,
+ SAMPLE_TEXT = 15,
+ FULL_NAME = 16,
+ POSTSCRIPT_NAME = 17,
+ POSTSCRIPT_CID_NAME = 18,
+};
+
+pub const DWRITE_READING_DIRECTION = enum(u32) {
+ LEFT_TO_RIGHT = 0,
+ RIGHT_TO_LEFT = 1,
+};
+
+// --- Structs ---
+
+pub const DWRITE_UNICODE_RANGE = extern struct {
+ first: UINT32,
+ last: UINT32,
+};
+
+// --- COM Interfaces ---
+
+// IDWriteNumberSubstitution -- IUnknown only, no extra methods.
+pub const IDWriteNumberSubstitution = extern struct {
+ vtable: *const VTable,
+
+ pub const VTable = extern struct {
+ // IUnknown
+ QueryInterface: *const fn (*IDWriteNumberSubstitution, *const GUID, *?*anyopaque) callconv(.winapi) HRESULT,
+ AddRef: *const fn (*IDWriteNumberSubstitution) callconv(.winapi) u32,
+ Release: *const fn (*IDWriteNumberSubstitution) callconv(.winapi) u32,
+ };
+
+ pub inline fn Release(self: *IDWriteNumberSubstitution) u32 {
+ return self.vtable.Release(self);
+ }
+};
+
+// IDWriteLocalizedStrings
+pub const IDWriteLocalizedStrings = extern struct {
+ vtable: *const VTable,
+
+ pub const VTable = extern struct {
+ // IUnknown
+ QueryInterface: Reserved,
+ AddRef: Reserved,
+ Release: *const fn (*IDWriteLocalizedStrings) callconv(.winapi) u32,
+ // IDWriteLocalizedStrings
+ GetCount: *const fn (*IDWriteLocalizedStrings) callconv(.winapi) UINT32,
+ FindLocaleName: *const fn (
+ *IDWriteLocalizedStrings,
+ localeName: [*:0]const WCHAR,
+ index: *UINT32,
+ exists: *BOOL,
+ ) callconv(.winapi) HRESULT,
+ GetLocaleNameLength: Reserved,
+ GetLocaleName: Reserved,
+ GetStringLength: *const fn (
+ *IDWriteLocalizedStrings,
+ index: UINT32,
+ length: *UINT32,
+ ) callconv(.winapi) HRESULT,
+ GetString: *const fn (
+ *IDWriteLocalizedStrings,
+ index: UINT32,
+ stringBuffer: [*]WCHAR,
+ size: UINT32,
+ ) callconv(.winapi) HRESULT,
+ };
+
+ pub inline fn Release(self: *IDWriteLocalizedStrings) u32 {
+ return self.vtable.Release(self);
+ }
+
+ pub inline fn GetCount(self: *IDWriteLocalizedStrings) UINT32 {
+ return self.vtable.GetCount(self);
+ }
+
+ pub inline fn FindLocaleName(
+ self: *IDWriteLocalizedStrings,
+ localeName: [*:0]const WCHAR,
+ index: *UINT32,
+ exists: *BOOL,
+ ) HRESULT {
+ return self.vtable.FindLocaleName(self, localeName, index, exists);
+ }
+
+ pub inline fn GetStringLength(self: *IDWriteLocalizedStrings, index: UINT32, length: *UINT32) HRESULT {
+ return self.vtable.GetStringLength(self, index, length);
+ }
+
+ pub inline fn GetString(self: *IDWriteLocalizedStrings, index: UINT32, stringBuffer: [*]WCHAR, size: UINT32) HRESULT {
+ return self.vtable.GetString(self, index, stringBuffer, size);
+ }
+};
+
+// IDWriteFontFace
+pub const IDWriteFontFace = extern struct {
+ vtable: *const VTable,
+
+ pub const VTable = extern struct {
+ // IUnknown
+ QueryInterface: Reserved,
+ AddRef: Reserved,
+ Release: *const fn (*IDWriteFontFace) callconv(.winapi) u32,
+ // IDWriteFontFace
+ GetType: Reserved,
+ GetFiles: *const fn (
+ *IDWriteFontFace,
+ numberOfFiles: *UINT32,
+ fontFiles: ?[*]?*IDWriteFontFile,
+ ) callconv(.winapi) HRESULT,
+ GetIndex: *const fn (*IDWriteFontFace) callconv(.winapi) UINT32,
+ GetSimulations: Reserved,
+ IsSymbolFont: Reserved,
+ GetMetrics: Reserved,
+ GetGlyphCount: Reserved,
+ GetDesignGlyphMetrics: Reserved,
+ GetGlyphIndices: Reserved,
+ TryGetFontTable: Reserved,
+ ReleaseFontTable: Reserved,
+ GetGlyphRunOutline: Reserved,
+ GetRecommendedRenderingMode: Reserved,
+ GetGdiCompatibleMetrics: Reserved,
+ GetGdiCompatibleGlyphMetrics: Reserved,
+ };
+
+ pub inline fn Release(self: *IDWriteFontFace) u32 {
+ return self.vtable.Release(self);
+ }
+
+ pub inline fn GetFiles(
+ self: *IDWriteFontFace,
+ numberOfFiles: *UINT32,
+ fontFiles: ?[*]?*IDWriteFontFile,
+ ) HRESULT {
+ return self.vtable.GetFiles(self, numberOfFiles, fontFiles);
+ }
+
+ pub inline fn GetIndex(self: *IDWriteFontFace) UINT32 {
+ return self.vtable.GetIndex(self);
+ }
+};
+
+// IDWriteFontFileLoader (IID needed to QI to IDWriteLocalFontFileLoader)
+pub const IDWriteFontFileLoader = extern struct {
+ vtable: *const VTable,
+
+ pub const IID = GUID{
+ .data1 = 0x727cad4e,
+ .data2 = 0xd6af,
+ .data3 = 0x4c9e,
+ .data4 = .{ 0x8a, 0x08, 0xd6, 0x95, 0xb1, 0x1c, 0xaa, 0x49 },
+ };
+
+ pub const VTable = extern struct {
+ // IUnknown
+ QueryInterface: *const fn (*IDWriteFontFileLoader, *const GUID, *?*anyopaque) callconv(.winapi) HRESULT,
+ AddRef: *const fn (*IDWriteFontFileLoader) callconv(.winapi) u32,
+ Release: *const fn (*IDWriteFontFileLoader) callconv(.winapi) u32,
+ // IDWriteFontFileLoader
+ CreateStreamFromKey: Reserved,
+ };
+
+ pub inline fn QueryInterface(self: *IDWriteFontFileLoader, riid: *const GUID, ppv: *?*anyopaque) HRESULT {
+ return self.vtable.QueryInterface(self, riid, ppv);
+ }
+
+ pub inline fn Release(self: *IDWriteFontFileLoader) u32 {
+ return self.vtable.Release(self);
+ }
+};
+
+// IDWriteLocalFontFileLoader
+pub const IDWriteLocalFontFileLoader = extern struct {
+ vtable: *const VTable,
+
+ pub const IID = GUID{
+ .data1 = 0xb2d9f3ec,
+ .data2 = 0xc9fe,
+ .data3 = 0x4a11,
+ .data4 = .{ 0xa2, 0xec, 0xd8, 0x62, 0x08, 0xf7, 0xc0, 0xa2 },
+ };
+
+ pub const VTable = extern struct {
+ // IUnknown
+ QueryInterface: Reserved,
+ AddRef: Reserved,
+ Release: *const fn (*IDWriteLocalFontFileLoader) callconv(.winapi) u32,
+ // IDWriteFontFileLoader
+ CreateStreamFromKey: Reserved,
+ // IDWriteLocalFontFileLoader
+ GetFilePathLengthFromKey: *const fn (
+ *IDWriteLocalFontFileLoader,
+ fontFileReferenceKey: *const anyopaque,
+ fontFileReferenceKeySize: UINT32,
+ filePathLength: *UINT32,
+ ) callconv(.winapi) HRESULT,
+ GetFilePathFromKey: *const fn (
+ *IDWriteLocalFontFileLoader,
+ fontFileReferenceKey: *const anyopaque,
+ fontFileReferenceKeySize: UINT32,
+ filePath: [*]WCHAR,
+ filePathSize: UINT32,
+ ) callconv(.winapi) HRESULT,
+ GetLastWriteTimeFromKey: Reserved,
+ };
+
+ pub inline fn Release(self: *IDWriteLocalFontFileLoader) u32 {
+ return self.vtable.Release(self);
+ }
+
+ pub inline fn GetFilePathLengthFromKey(
+ self: *IDWriteLocalFontFileLoader,
+ key: *const anyopaque,
+ key_size: UINT32,
+ path_len: *UINT32,
+ ) HRESULT {
+ return self.vtable.GetFilePathLengthFromKey(self, key, key_size, path_len);
+ }
+
+ pub inline fn GetFilePathFromKey(
+ self: *IDWriteLocalFontFileLoader,
+ key: *const anyopaque,
+ key_size: UINT32,
+ path: [*]WCHAR,
+ path_size: UINT32,
+ ) HRESULT {
+ return self.vtable.GetFilePathFromKey(self, key, key_size, path, path_size);
+ }
+};
+
+// IDWriteFontFile
+pub const IDWriteFontFile = extern struct {
+ vtable: *const VTable,
+
+ pub const VTable = extern struct {
+ // IUnknown
+ QueryInterface: Reserved,
+ AddRef: Reserved,
+ Release: *const fn (*IDWriteFontFile) callconv(.winapi) u32,
+ // IDWriteFontFile
+ GetReferenceKey: *const fn (
+ *IDWriteFontFile,
+ fontFileReferenceKey: *?*const anyopaque,
+ fontFileReferenceKeySize: *UINT32,
+ ) callconv(.winapi) HRESULT,
+ GetLoader: *const fn (
+ *IDWriteFontFile,
+ fontFileLoader: *?*IDWriteFontFileLoader,
+ ) callconv(.winapi) HRESULT,
+ Analyze: Reserved,
+ };
+
+ pub inline fn Release(self: *IDWriteFontFile) u32 {
+ return self.vtable.Release(self);
+ }
+
+ pub inline fn GetReferenceKey(
+ self: *IDWriteFontFile,
+ key: *?*const anyopaque,
+ key_size: *UINT32,
+ ) HRESULT {
+ return self.vtable.GetReferenceKey(self, key, key_size);
+ }
+
+ pub inline fn GetLoader(self: *IDWriteFontFile, loader: *?*IDWriteFontFileLoader) HRESULT {
+ return self.vtable.GetLoader(self, loader);
+ }
+};
+
+// IDWriteTextAnalysisSource -- callback interface we implement.
+// DWrite calls our methods through this vtable.
+pub const IDWriteTextAnalysisSource = extern struct {
+ vtable: *const VTable,
+
+ pub const VTable = extern struct {
+ // IUnknown
+ QueryInterface: *const fn (*IDWriteTextAnalysisSource, *const GUID, *?*anyopaque) callconv(.winapi) HRESULT,
+ AddRef: *const fn (*IDWriteTextAnalysisSource) callconv(.winapi) u32,
+ Release: *const fn (*IDWriteTextAnalysisSource) callconv(.winapi) u32,
+ // IDWriteTextAnalysisSource
+ GetTextAtPosition: *const fn (
+ *IDWriteTextAnalysisSource,
+ textPosition: UINT32,
+ textString: *?[*]const WCHAR,
+ textLength: *UINT32,
+ ) callconv(.winapi) HRESULT,
+ GetTextBeforePosition: *const fn (
+ *IDWriteTextAnalysisSource,
+ textPosition: UINT32,
+ textString: *?[*]const WCHAR,
+ textLength: *UINT32,
+ ) callconv(.winapi) HRESULT,
+ GetParagraphReadingDirection: *const fn (
+ *IDWriteTextAnalysisSource,
+ ) callconv(.winapi) DWRITE_READING_DIRECTION,
+ GetLocaleName: *const fn (
+ *IDWriteTextAnalysisSource,
+ textPosition: UINT32,
+ textLength: *UINT32,
+ localeName: *?[*:0]const WCHAR,
+ ) callconv(.winapi) HRESULT,
+ GetNumberSubstitution: *const fn (
+ *IDWriteTextAnalysisSource,
+ textPosition: UINT32,
+ textLength: *UINT32,
+ numberSubstitution: *?*IDWriteNumberSubstitution,
+ ) callconv(.winapi) HRESULT,
+ };
+};
+
+// IDWriteFontFallback
+pub const IDWriteFontFallback = extern struct {
+ vtable: *const VTable,
+
+ pub const VTable = extern struct {
+ // IUnknown
+ QueryInterface: Reserved,
+ AddRef: Reserved,
+ Release: *const fn (*IDWriteFontFallback) callconv(.winapi) u32,
+ // IDWriteFontFallback
+ MapCharacters: *const fn (
+ *IDWriteFontFallback,
+ analysisSource: *IDWriteTextAnalysisSource,
+ textPosition: UINT32,
+ textLength: UINT32,
+ baseFontCollection: ?*IDWriteFontCollection,
+ baseFamilyName: ?[*:0]const WCHAR,
+ baseWeight: DWRITE_FONT_WEIGHT,
+ baseStyle: DWRITE_FONT_STYLE,
+ baseStretch: DWRITE_FONT_STRETCH,
+ mappedLength: *UINT32,
+ mappedFont: *?*IDWriteFont,
+ scale: *FLOAT,
+ ) callconv(.winapi) HRESULT,
+ };
+
+ pub inline fn Release(self: *IDWriteFontFallback) u32 {
+ return self.vtable.Release(self);
+ }
+
+ pub inline fn MapCharacters(
+ self: *IDWriteFontFallback,
+ analysisSource: *IDWriteTextAnalysisSource,
+ textPosition: UINT32,
+ textLength: UINT32,
+ baseFontCollection: ?*IDWriteFontCollection,
+ baseFamilyName: ?[*:0]const WCHAR,
+ baseWeight: DWRITE_FONT_WEIGHT,
+ baseStyle: DWRITE_FONT_STYLE,
+ baseStretch: DWRITE_FONT_STRETCH,
+ mappedLength: *UINT32,
+ mappedFont: *?*IDWriteFont,
+ scale: *FLOAT,
+ ) HRESULT {
+ return self.vtable.MapCharacters(
+ self,
+ analysisSource,
+ textPosition,
+ textLength,
+ baseFontCollection,
+ baseFamilyName,
+ baseWeight,
+ baseStyle,
+ baseStretch,
+ mappedLength,
+ mappedFont,
+ scale,
+ );
+ }
+};
+
+// IDWriteFont (through IDWriteFont2 for IsColorFont)
+pub const IDWriteFont = extern struct {
+ vtable: *const VTable,
+
+ pub const VTable = extern struct {
+ // IUnknown
+ QueryInterface: Reserved,
+ AddRef: *const fn (*IDWriteFont) callconv(.winapi) u32,
+ Release: *const fn (*IDWriteFont) callconv(.winapi) u32,
+ // IDWriteFont
+ GetFontFamily: Reserved,
+ GetWeight: *const fn (*IDWriteFont) callconv(.winapi) DWRITE_FONT_WEIGHT,
+ GetStretch: *const fn (*IDWriteFont) callconv(.winapi) DWRITE_FONT_STRETCH,
+ GetStyle: *const fn (*IDWriteFont) callconv(.winapi) DWRITE_FONT_STYLE,
+ IsSymbolFont: Reserved,
+ GetFaceNames: *const fn (*IDWriteFont, names: *?*IDWriteLocalizedStrings) callconv(.winapi) HRESULT,
+ GetInformationalStrings: *const fn (
+ *IDWriteFont,
+ informationalStringID: DWRITE_INFORMATIONAL_STRING_ID,
+ informationalStrings: *?*IDWriteLocalizedStrings,
+ exists: *BOOL,
+ ) callconv(.winapi) HRESULT,
+ GetSimulations: *const fn (*IDWriteFont) callconv(.winapi) DWRITE_FONT_SIMULATIONS,
+ GetMetrics: Reserved,
+ HasCharacter: *const fn (*IDWriteFont, unicodeValue: UINT32, exists: *BOOL) callconv(.winapi) HRESULT,
+ CreateFontFace: *const fn (*IDWriteFont, fontFace: *?*IDWriteFontFace) callconv(.winapi) HRESULT,
+ // IDWriteFont1 reserved padding before IDWriteFont2.IsColorFont
+ _slot14: Reserved,
+ _slot15: Reserved,
+ _slot16: Reserved,
+ _slot17: Reserved,
+ // IDWriteFont2
+ IsColorFont: *const fn (*IDWriteFont) callconv(.winapi) BOOL,
+ };
+
+ pub inline fn AddRef(self: *IDWriteFont) u32 {
+ return self.vtable.AddRef(self);
+ }
+
+ pub inline fn Release(self: *IDWriteFont) u32 {
+ return self.vtable.Release(self);
+ }
+
+ pub inline fn GetWeight(self: *IDWriteFont) DWRITE_FONT_WEIGHT {
+ return self.vtable.GetWeight(self);
+ }
+
+ pub inline fn GetStretch(self: *IDWriteFont) DWRITE_FONT_STRETCH {
+ return self.vtable.GetStretch(self);
+ }
+
+ pub inline fn GetStyle(self: *IDWriteFont) DWRITE_FONT_STYLE {
+ return self.vtable.GetStyle(self);
+ }
+
+ pub inline fn GetFaceNames(self: *IDWriteFont, names: *?*IDWriteLocalizedStrings) HRESULT {
+ return self.vtable.GetFaceNames(self, names);
+ }
+
+ pub inline fn GetInformationalStrings(
+ self: *IDWriteFont,
+ id: DWRITE_INFORMATIONAL_STRING_ID,
+ strings: *?*IDWriteLocalizedStrings,
+ exists: *BOOL,
+ ) HRESULT {
+ return self.vtable.GetInformationalStrings(self, id, strings, exists);
+ }
+
+ pub inline fn GetSimulations(self: *IDWriteFont) DWRITE_FONT_SIMULATIONS {
+ return self.vtable.GetSimulations(self);
+ }
+
+ pub inline fn HasCharacter(self: *IDWriteFont, unicodeValue: UINT32, exists: *BOOL) HRESULT {
+ return self.vtable.HasCharacter(self, unicodeValue, exists);
+ }
+
+ pub inline fn CreateFontFace(self: *IDWriteFont, fontFace: *?*IDWriteFontFace) HRESULT {
+ return self.vtable.CreateFontFace(self, fontFace);
+ }
+
+ pub inline fn IsColorFont(self: *IDWriteFont) BOOL {
+ return self.vtable.IsColorFont(self);
+ }
+};
+
+// IDWriteFontFamily (extends IDWriteFontList)
+pub const IDWriteFontFamily = extern struct {
+ vtable: *const VTable,
+
+ pub const VTable = extern struct {
+ // IUnknown
+ QueryInterface: Reserved,
+ AddRef: Reserved,
+ Release: *const fn (*IDWriteFontFamily) callconv(.winapi) u32,
+ // IDWriteFontList
+ GetFontCollection: Reserved,
+ GetFontCount: *const fn (*IDWriteFontFamily) callconv(.winapi) UINT32,
+ GetFont: *const fn (*IDWriteFontFamily, index: UINT32, font: *?*IDWriteFont) callconv(.winapi) HRESULT,
+ // IDWriteFontFamily
+ GetFamilyNames: *const fn (*IDWriteFontFamily, names: *?*IDWriteLocalizedStrings) callconv(.winapi) HRESULT,
+ MatchClosestFont: Reserved,
+ };
+
+ pub inline fn Release(self: *IDWriteFontFamily) u32 {
+ return self.vtable.Release(self);
+ }
+
+ pub inline fn GetFontCount(self: *IDWriteFontFamily) UINT32 {
+ return self.vtable.GetFontCount(self);
+ }
+
+ pub inline fn GetFont(self: *IDWriteFontFamily, index: UINT32, font: *?*IDWriteFont) HRESULT {
+ return self.vtable.GetFont(self, index, font);
+ }
+
+ pub inline fn GetFamilyNames(self: *IDWriteFontFamily, names: *?*IDWriteLocalizedStrings) HRESULT {
+ return self.vtable.GetFamilyNames(self, names);
+ }
+};
+
+// IDWriteFontCollection
+// Slots: GetFontFamilyCount(3), GetFontFamily(4), FindFamilyName(5), GetFontFromFontFace(6)
+pub const IDWriteFontCollection = extern struct {
+ vtable: *const VTable,
+
+ pub const VTable = extern struct {
+ // IUnknown
+ QueryInterface: Reserved,
+ AddRef: Reserved,
+ Release: *const fn (*IDWriteFontCollection) callconv(.winapi) u32,
+ // IDWriteFontCollection
+ GetFontFamilyCount: *const fn (*IDWriteFontCollection) callconv(.winapi) UINT32,
+ GetFontFamily: *const fn (
+ *IDWriteFontCollection,
+ index: UINT32,
+ fontFamily: *?*IDWriteFontFamily,
+ ) callconv(.winapi) HRESULT,
+ FindFamilyName: *const fn (
+ *IDWriteFontCollection,
+ familyName: [*:0]const WCHAR,
+ index: *UINT32,
+ exists: *BOOL,
+ ) callconv(.winapi) HRESULT,
+ GetFontFromFontFace: Reserved,
+ };
+
+ pub inline fn Release(self: *IDWriteFontCollection) u32 {
+ return self.vtable.Release(self);
+ }
+
+ pub inline fn GetFontFamilyCount(self: *IDWriteFontCollection) UINT32 {
+ return self.vtable.GetFontFamilyCount(self);
+ }
+
+ pub inline fn GetFontFamily(self: *IDWriteFontCollection, index: UINT32, fontFamily: *?*IDWriteFontFamily) HRESULT {
+ return self.vtable.GetFontFamily(self, index, fontFamily);
+ }
+
+ pub inline fn FindFamilyName(
+ self: *IDWriteFontCollection,
+ familyName: [*:0]const WCHAR,
+ index: *UINT32,
+ exists: *BOOL,
+ ) HRESULT {
+ return self.vtable.FindFamilyName(self, familyName, index, exists);
+ }
+};
+
+// IDWriteFontCollection1 (extends IDWriteFontCollection)
+pub const IDWriteFontCollection1 = extern struct {
+ vtable: *const VTable,
+
+ pub const VTable = extern struct {
+ // IUnknown
+ QueryInterface: Reserved,
+ AddRef: Reserved,
+ Release: *const fn (*IDWriteFontCollection1) callconv(.winapi) u32,
+ // IDWriteFontCollection
+ GetFontFamilyCount: *const fn (*IDWriteFontCollection1) callconv(.winapi) UINT32,
+ GetFontFamily: *const fn (
+ *IDWriteFontCollection1,
+ index: UINT32,
+ fontFamily: *?*IDWriteFontFamily,
+ ) callconv(.winapi) HRESULT,
+ FindFamilyName: *const fn (
+ *IDWriteFontCollection1,
+ familyName: [*:0]const WCHAR,
+ index: *UINT32,
+ exists: *BOOL,
+ ) callconv(.winapi) HRESULT,
+ GetFontFromFontFace: Reserved,
+ // IDWriteFontCollection1
+ _slot7: Reserved,
+ _slot8: Reserved,
+ };
+
+ pub inline fn Release(self: *IDWriteFontCollection1) u32 {
+ return self.vtable.Release(self);
+ }
+
+ pub inline fn GetFontFamilyCount(self: *IDWriteFontCollection1) UINT32 {
+ return self.vtable.GetFontFamilyCount(self);
+ }
+
+ pub inline fn GetFontFamily(self: *IDWriteFontCollection1, index: UINT32, fontFamily: *?*IDWriteFontFamily) HRESULT {
+ return self.vtable.GetFontFamily(self, index, fontFamily);
+ }
+
+ pub inline fn FindFamilyName(
+ self: *IDWriteFontCollection1,
+ familyName: [*:0]const WCHAR,
+ index: *UINT32,
+ exists: *BOOL,
+ ) HRESULT {
+ return self.vtable.FindFamilyName(self, familyName, index, exists);
+ }
+};
+
+// IDWriteFactory3
+pub const IDWriteFactory3 = extern struct {
+ vtable: *const VTable,
+
+ pub const IID = GUID{
+ .data1 = 0x9A1B41C3,
+ .data2 = 0xD3BB,
+ .data3 = 0x466A,
+ .data4 = .{ 0x87, 0xFC, 0xFE, 0x67, 0x55, 0x6A, 0x3B, 0x65 },
+ };
+
+ pub const VTable = extern struct {
+ // IUnknown
+ QueryInterface: Reserved,
+ AddRef: Reserved,
+ Release: *const fn (*IDWriteFactory3) callconv(.winapi) u32,
+ // IDWriteFactory
+ GetSystemFontCollection: *const fn (
+ *IDWriteFactory3,
+ fontCollection: *?*IDWriteFontCollection,
+ checkForUpdates: BOOL,
+ ) callconv(.winapi) HRESULT,
+ CreateCustomFontCollection: Reserved,
+ RegisterFontCollectionLoader: Reserved,
+ UnregisterFontCollectionLoader: Reserved,
+ CreateFontFileReference: Reserved,
+ CreateCustomFontFileReference: Reserved,
+ CreateFontFace: Reserved,
+ CreateRenderingParams: Reserved,
+ CreateMonitorRenderingParams: Reserved,
+ CreateCustomRenderingParams: Reserved,
+ RegisterFontFileLoader: Reserved,
+ UnregisterFontFileLoader: Reserved,
+ CreateTextFormat: Reserved,
+ CreateTypography: Reserved,
+ GetGdiInterop: Reserved,
+ CreateTextLayout: Reserved,
+ CreateGdiCompatibleTextLayout: Reserved,
+ CreateEllipsisTrimmingSign: Reserved,
+ CreateTextAnalyzer: Reserved,
+ CreateNumberSubstitution: *const fn (
+ *IDWriteFactory3,
+ method: u32,
+ localeName: ?[*:0]const WCHAR,
+ ignoreUserOverride: BOOL,
+ numberSubstitution: *?*IDWriteNumberSubstitution,
+ ) callconv(.winapi) HRESULT,
+ CreateGlyphRunAnalysis: Reserved,
+ // IDWriteFactory1
+ _slot24: Reserved,
+ _slot25: Reserved,
+ // IDWriteFactory2
+ GetSystemFontFallback: *const fn (
+ *IDWriteFactory3,
+ fontFallback: *?*IDWriteFontFallback,
+ ) callconv(.winapi) HRESULT,
+ _slot27: Reserved,
+ _slot28: Reserved,
+ _slot29: Reserved,
+ _slot30: Reserved,
+ // IDWriteFactory3
+ _slot31: Reserved,
+ _slot32: Reserved,
+ _slot33: Reserved,
+ _slot34: Reserved,
+ _slot35: Reserved,
+ _slot36: Reserved,
+ _slot37: Reserved,
+ GetSystemFontCollection1: *const fn (
+ *IDWriteFactory3,
+ includeDownloadableFonts: BOOL,
+ fontCollection: *?*IDWriteFontCollection1,
+ checkForUpdates: BOOL,
+ ) callconv(.winapi) HRESULT,
+ _slot39: Reserved,
+ };
+
+ pub inline fn Release(self: *IDWriteFactory3) u32 {
+ return self.vtable.Release(self);
+ }
+
+ pub inline fn GetSystemFontCollection(
+ self: *IDWriteFactory3,
+ fontCollection: *?*IDWriteFontCollection,
+ checkForUpdates: BOOL,
+ ) HRESULT {
+ return self.vtable.GetSystemFontCollection(self, fontCollection, checkForUpdates);
+ }
+
+ pub inline fn CreateNumberSubstitution(
+ self: *IDWriteFactory3,
+ method: u32,
+ localeName: ?[*:0]const WCHAR,
+ ignoreUserOverride: BOOL,
+ numberSubstitution: *?*IDWriteNumberSubstitution,
+ ) HRESULT {
+ return self.vtable.CreateNumberSubstitution(self, method, localeName, ignoreUserOverride, numberSubstitution);
+ }
+
+ pub inline fn GetSystemFontFallback(
+ self: *IDWriteFactory3,
+ fontFallback: *?*IDWriteFontFallback,
+ ) HRESULT {
+ return self.vtable.GetSystemFontFallback(self, fontFallback);
+ }
+
+ pub inline fn GetSystemFontCollection1(
+ self: *IDWriteFactory3,
+ includeDownloadableFonts: BOOL,
+ fontCollection: *?*IDWriteFontCollection1,
+ checkForUpdates: BOOL,
+ ) HRESULT {
+ return self.vtable.GetSystemFontCollection1(self, includeDownloadableFonts, fontCollection, checkForUpdates);
+ }
+};
+
+// --- Helper Functions ---
+
+pub const DWriteCreateFactoryFn = *const fn (
+ DWRITE_FACTORY_TYPE,
+ *const GUID,
+ *?*anyopaque,
+) callconv(.winapi) HRESULT;
+
+pub fn loadDWriteCreateFactory() !DWriteCreateFactoryFn {
+ const dwrite_dll = std.os.windows.kernel32.LoadLibraryW(
+ std.unicode.utf8ToUtf16LeStringLiteral("dwrite.dll"),
+ ) orelse return error.DWriteNotAvailable;
+
+ const proc = std.os.windows.kernel32.GetProcAddress(
+ dwrite_dll,
+ "DWriteCreateFactory",
+ ) orelse return error.DWriteCreateFactoryNotFound;
+
+ return @ptrCast(proc);
+}
+
+/// Read the string at index 0 from an IDWriteLocalizedStrings into a
+/// UTF-8 slice backed by the provided buffer.
+pub fn getLocalizedString(
+ strings: *IDWriteLocalizedStrings,
+ buf: []u8,
+) ![]const u8 {
+ // Get the length of the string at index 0 (in WCHARs, not including null).
+ var wide_len: UINT32 = 0;
+ const hr = strings.GetStringLength(0, &wide_len);
+ if (FAILED(hr)) return error.GetStringLengthFailed;
+
+ // Stack-allocate a wide buffer (512 WCHAR max).
+ var wide_buf: [512]WCHAR = undefined;
+ if (wide_len + 1 > wide_buf.len) return error.StringTooLong;
+
+ const hr2 = strings.GetString(0, &wide_buf, wide_len + 1);
+ if (FAILED(hr2)) return error.GetStringFailed;
+
+ const out_len = std.unicode.utf16LeToUtf8(buf, wide_buf[0..wide_len]) catch
+ return error.BufferTooSmall;
+
+ return buf[0..out_len];
+}
+
+// --- Tests ---
+
+test "vtable pointer sizes" {
+ const ptr_size = @sizeOf(*anyopaque);
+ try std.testing.expectEqual(ptr_size, @sizeOf(IDWriteFactory3));
+ try std.testing.expectEqual(ptr_size, @sizeOf(IDWriteFontCollection));
+ try std.testing.expectEqual(ptr_size, @sizeOf(IDWriteFontCollection1));
+ try std.testing.expectEqual(ptr_size, @sizeOf(IDWriteFontFamily));
+ try std.testing.expectEqual(ptr_size, @sizeOf(IDWriteFont));
+ try std.testing.expectEqual(ptr_size, @sizeOf(IDWriteFontFace));
+ try std.testing.expectEqual(ptr_size, @sizeOf(IDWriteFontFile));
+ try std.testing.expectEqual(ptr_size, @sizeOf(IDWriteFontFileLoader));
+ try std.testing.expectEqual(ptr_size, @sizeOf(IDWriteLocalFontFileLoader));
+ try std.testing.expectEqual(ptr_size, @sizeOf(IDWriteFontFallback));
+ try std.testing.expectEqual(ptr_size, @sizeOf(IDWriteLocalizedStrings));
+ try std.testing.expectEqual(ptr_size, @sizeOf(IDWriteNumberSubstitution));
+ try std.testing.expectEqual(ptr_size, @sizeOf(IDWriteTextAnalysisSource));
+}
+
+test "IID values" {
+ // IDWriteFactory3: 9A1B41C3-D3BB-466A-87FC-FE67556A3B65
+ try std.testing.expectEqual(IDWriteFactory3.IID.data1, 0x9A1B41C3);
+ try std.testing.expectEqual(IDWriteFactory3.IID.data2, 0xD3BB);
+ try std.testing.expectEqual(IDWriteFactory3.IID.data3, 0x466A);
+ try std.testing.expectEqualSlices(u8, &IDWriteFactory3.IID.data4, &[8]u8{ 0x87, 0xFC, 0xFE, 0x67, 0x55, 0x6A, 0x3B, 0x65 });
+
+ // IDWriteLocalFontFileLoader: b2d9f3ec-c9fe-4a11-a2ec-d86208f7c0a2
+ try std.testing.expectEqual(IDWriteLocalFontFileLoader.IID.data1, 0xb2d9f3ec);
+ try std.testing.expectEqual(IDWriteLocalFontFileLoader.IID.data2, 0xc9fe);
+ try std.testing.expectEqual(IDWriteLocalFontFileLoader.IID.data3, 0x4a11);
+ try std.testing.expectEqualSlices(u8, &IDWriteLocalFontFileLoader.IID.data4, &[8]u8{ 0xa2, 0xec, 0xd8, 0x62, 0x08, 0xf7, 0xc0, 0xa2 });
+}
+
+test "enum values" {
+ try std.testing.expectEqual(@intFromEnum(DWRITE_FONT_WEIGHT.NORMAL), 400);
+ try std.testing.expectEqual(@intFromEnum(DWRITE_FONT_WEIGHT.BOLD), 700);
+ try std.testing.expectEqual(@intFromEnum(DWRITE_FONT_STYLE.NORMAL), 0);
+ try std.testing.expectEqual(@intFromEnum(DWRITE_FONT_STYLE.ITALIC), 2);
+}
+
+test "DWRITE_UNICODE_RANGE size" {
+ try std.testing.expectEqual(@sizeOf(DWRITE_UNICODE_RANGE), 8);
+}
diff --git a/src/font/discovery.zig b/src/font/discovery.zig
index b945aa01bb0..843d7061770 100644
--- a/src/font/discovery.zig
+++ b/src/font/discovery.zig
@@ -5,6 +5,7 @@ const assert = @import("../quirks.zig").inlineAssert;
const fontconfig = @import("fontconfig");
const macos = @import("macos");
const opentype = @import("opentype.zig");
+const dwrite = @import("directwrite.zig");
const options = @import("main.zig").options;
const Collection = @import("main.zig").Collection;
const DeferredFace = @import("main.zig").DeferredFace;
@@ -19,6 +20,7 @@ const log = std.log.scoped(.discovery);
pub const Discover = switch (options.backend) {
.freetype => void, // no discovery
.freetype_windows => Windows,
+ .directwrite_freetype => DirectWrite,
.fontconfig_freetype => Fontconfig,
.web_canvas => void, // no discovery
.coretext,
@@ -244,6 +246,367 @@ pub const Descriptor = struct {
}
};
+pub const DirectWrite = struct {
+ factory: *dwrite.IDWriteFactory3,
+ collection: *dwrite.IDWriteFontCollection,
+ fallback: *dwrite.IDWriteFontFallback,
+ number_sub: *dwrite.IDWriteNumberSubstitution,
+
+ pub fn init(lib: Library) DirectWrite {
+ // DirectWrite manages its own font enumeration via the OS;
+ // FreeType `lib` is unused but kept so Discover.init has a
+ // uniform signature across backends.
+ _ = lib;
+ const createFactory = dwrite.loadDWriteCreateFactory() catch
+ @panic("DirectWrite: failed to load DWriteCreateFactory");
+
+ var factory_raw: ?*anyopaque = null;
+ var hr = createFactory(.SHARED, &dwrite.IDWriteFactory3.IID, &factory_raw);
+ if (dwrite.FAILED(hr)) @panic("DirectWrite: failed to create factory");
+ const factory: *dwrite.IDWriteFactory3 = @ptrCast(@alignCast(factory_raw.?));
+
+ var collection: ?*dwrite.IDWriteFontCollection = null;
+ hr = factory.GetSystemFontCollection(&collection, 0);
+ if (dwrite.FAILED(hr)) @panic("DirectWrite: failed to get system font collection");
+
+ var fallback: ?*dwrite.IDWriteFontFallback = null;
+ hr = factory.GetSystemFontFallback(&fallback);
+ if (dwrite.FAILED(hr)) @panic("DirectWrite: failed to get system font fallback");
+
+ // We don't need number substitution for font discovery, but
+ // IDWriteTextAnalysisSource requires one for MapCharacters.
+ const DWRITE_NUMBER_SUBSTITUTION_METHOD_NONE: u32 = 2;
+ var number_sub: ?*dwrite.IDWriteNumberSubstitution = null;
+ hr = factory.CreateNumberSubstitution(DWRITE_NUMBER_SUBSTITUTION_METHOD_NONE, null, 0, &number_sub);
+ if (dwrite.FAILED(hr)) @panic("DirectWrite: failed to create number substitution");
+
+ return .{
+ .factory = factory,
+ .collection = collection.?,
+ .fallback = fallback.?,
+ .number_sub = number_sub.?,
+ };
+ }
+
+ pub fn deinit(self: *DirectWrite) void {
+ _ = self.number_sub.Release();
+ _ = self.fallback.Release();
+ _ = self.collection.Release();
+ _ = self.factory.Release();
+ }
+
+ pub fn discover(self: *const DirectWrite, alloc: Allocator, desc: Descriptor) !DiscoverIterator {
+ return if (desc.family) |family|
+ self.discoverFamily(alloc, desc, family)
+ else
+ self.discoverAll(alloc, desc);
+ }
+
+ fn discoverFamily(self: *const DirectWrite, alloc: Allocator, desc: Descriptor, family: [:0]const u8) !DiscoverIterator {
+ // Convert family name to UTF-16 for DirectWrite APIs
+ var wfamily_buf: [128]u16 = undefined;
+ const wfamily = utf8ToUtf16Le(&wfamily_buf, family) orelse
+ return DiscoverIterator.empty(alloc, desc.variations);
+
+ var family_index: u32 = 0;
+ var exists: i32 = 0;
+ var hr = self.collection.FindFamilyName(wfamily, &family_index, &exists);
+ if (dwrite.FAILED(hr) or exists == 0)
+ return DiscoverIterator.empty(alloc, desc.variations);
+
+ var dw_family: ?*dwrite.IDWriteFontFamily = null;
+ hr = self.collection.GetFontFamily(family_index, &dw_family);
+ if (dwrite.FAILED(hr)) return error.DirectWriteError;
+ defer _ = dw_family.?.Release();
+
+ const font_count = dw_family.?.GetFontCount();
+ var fonts = try alloc.alloc(ScoredFont, font_count);
+ var valid_count: usize = 0;
+
+ for (0..font_count) |i| {
+ var dw_font: ?*dwrite.IDWriteFont = null;
+ hr = dw_family.?.GetFont(@intCast(i), &dw_font);
+ if (dwrite.FAILED(hr)) continue;
+
+ if (dw_font.?.GetSimulations() != .NONE) {
+ _ = dw_font.?.Release();
+ continue;
+ }
+
+ fonts[valid_count] = .{ .font = dw_font.?, .score = scoreFont(&desc, dw_font.?) };
+ valid_count += 1;
+ }
+
+ const scored = fonts[0..valid_count];
+ std.mem.sortUnstable(ScoredFont, scored, {}, struct {
+ fn lessThan(_: void, a: ScoredFont, b: ScoredFont) bool {
+ return a.score.int() > b.score.int();
+ }
+ }.lessThan);
+
+ var result = try alloc.alloc(*dwrite.IDWriteFont, valid_count);
+ for (scored, 0..) |sf, j| result[j] = sf.font;
+ alloc.free(fonts);
+
+ return DiscoverIterator{
+ .fonts = result,
+ .alloc = alloc,
+ .variations = desc.variations,
+ .i = 0,
+ };
+ }
+
+ /// Enumerate all fonts across every family in the system collection.
+ /// Used by `+list-fonts` when no --family filter is specified.
+ fn discoverAll(self: *const DirectWrite, alloc: Allocator, desc: Descriptor) !DiscoverIterator {
+ const family_count = self.collection.GetFontFamilyCount();
+ var fonts = try std.ArrayList(*dwrite.IDWriteFont).initCapacity(alloc, 256);
+ errdefer {
+ for (fonts.items) |f| _ = f.Release();
+ fonts.deinit(alloc);
+ }
+
+ for (0..family_count) |fi| {
+ var dw_family: ?*dwrite.IDWriteFontFamily = null;
+ var hr = self.collection.GetFontFamily(@intCast(fi), &dw_family);
+ if (dwrite.FAILED(hr)) continue;
+ defer _ = dw_family.?.Release();
+
+ const font_count = dw_family.?.GetFontCount();
+ for (0..font_count) |i| {
+ var dw_font: ?*dwrite.IDWriteFont = null;
+ hr = dw_family.?.GetFont(@intCast(i), &dw_font);
+ if (dwrite.FAILED(hr)) continue;
+
+ if (dw_font.?.GetSimulations() != .NONE) {
+ _ = dw_font.?.Release();
+ continue;
+ }
+
+ try fonts.append(alloc, dw_font.?);
+ }
+ }
+
+ return DiscoverIterator{
+ .fonts = try fonts.toOwnedSlice(alloc),
+ .alloc = alloc,
+ .variations = desc.variations,
+ .i = 0,
+ };
+ }
+
+ pub fn discoverFallback(
+ self: *const DirectWrite,
+ alloc: Allocator,
+ collection: *Collection,
+ desc: Descriptor,
+ ) !DiscoverIterator {
+ _ = collection;
+ if (desc.codepoint == 0) return try self.discover(alloc, desc);
+
+ var utf16_buf: [2]u16 = undefined;
+ const utf16_len: u32 = if (desc.codepoint > 0xFFFF) blk: {
+ const cp = desc.codepoint - 0x10000;
+ utf16_buf[0] = @intCast(0xD800 + (cp >> 10));
+ utf16_buf[1] = @intCast(0xDC00 + (cp & 0x3FF));
+ break :blk 2;
+ } else blk: {
+ utf16_buf[0] = @intCast(desc.codepoint);
+ break :blk 1;
+ };
+
+ var source = TextAnalysisSource{
+ .vtable = &TextAnalysisSource.static_vtable,
+ .text = &utf16_buf,
+ .text_len = utf16_len,
+ .number_sub = self.number_sub,
+ };
+
+ var mapped_length: u32 = 0;
+ var mapped_font: ?*dwrite.IDWriteFont = null;
+ var scale: f32 = 0;
+
+ const base_weight: dwrite.DWRITE_FONT_WEIGHT = if (desc.bold) .BOLD else .NORMAL;
+ const base_style: dwrite.DWRITE_FONT_STYLE = if (desc.italic) .ITALIC else .NORMAL;
+
+ const fb_hr = self.fallback.MapCharacters(
+ @ptrCast(&source),
+ 0,
+ utf16_len,
+ self.collection,
+ null,
+ base_weight,
+ base_style,
+ .NORMAL,
+ &mapped_length,
+ &mapped_font,
+ &scale,
+ );
+
+ if (dwrite.SUCCEEDED(fb_hr)) {
+ if (mapped_font) |font| {
+ var result = try alloc.alloc(*dwrite.IDWriteFont, 1);
+ result[0] = font;
+ return DiscoverIterator{
+ .fonts = result,
+ .alloc = alloc,
+ .variations = desc.variations,
+ .i = 0,
+ };
+ }
+ }
+
+ return try self.discover(alloc, desc);
+ }
+
+ // Scoring
+
+ const Score = packed struct {
+ const Backing = @typeInfo(@This()).@"struct".backing_integer.?;
+ glyph_count: u16 = 0,
+ bold: bool = false,
+ italic: bool = false,
+ normal_stretch: bool = false,
+ codepoint: bool = false,
+
+ pub fn int(self: Score) Backing {
+ return @bitCast(self);
+ }
+ };
+
+ const ScoredFont = struct {
+ font: *dwrite.IDWriteFont,
+ score: Score,
+ };
+
+ fn scoreFont(desc: *const Descriptor, font: *dwrite.IDWriteFont) Score {
+ var score: Score = .{};
+
+ const weight = font.GetWeight();
+ const style = font.GetStyle();
+ const stretch = font.GetStretch();
+
+ const is_bold = @intFromEnum(weight) >= @intFromEnum(dwrite.DWRITE_FONT_WEIGHT.SEMI_BOLD);
+ score.bold = desc.bold == is_bold;
+
+ const is_italic = (style == .ITALIC or style == .OBLIQUE);
+ score.italic = desc.italic == is_italic;
+
+ score.normal_stretch = (stretch == .NORMAL);
+
+ if (desc.codepoint > 0) {
+ var cp_exists: i32 = 0;
+ const cp_hr = font.HasCharacter(desc.codepoint, &cp_exists);
+ if (dwrite.SUCCEEDED(cp_hr) and cp_exists != 0) score.codepoint = true;
+ }
+
+ return score;
+ }
+
+ // TextAnalysisSource -- minimal implementation for IDWriteFontFallback::MapCharacters
+
+ const TextAnalysisSource = extern struct {
+ vtable: *const dwrite.IDWriteTextAnalysisSource.VTable,
+ text: *const [2]u16,
+ text_len: u32,
+ number_sub: *dwrite.IDWriteNumberSubstitution,
+
+ const static_vtable = dwrite.IDWriteTextAnalysisSource.VTable{
+ .QueryInterface = @ptrCast(&queryInterface),
+ .AddRef = @ptrCast(&addRef),
+ .Release = @ptrCast(&release),
+ .GetTextAtPosition = @ptrCast(&getTextAtPosition),
+ .GetTextBeforePosition = @ptrCast(&getTextBeforePosition),
+ .GetParagraphReadingDirection = @ptrCast(&getParagraphReadingDirection),
+ .GetLocaleName = @ptrCast(&getLocaleName),
+ .GetNumberSubstitution = @ptrCast(&getNumberSubstitution),
+ };
+
+ fn queryInterface(_: *TextAnalysisSource, _: *const dwrite.GUID, _: *?*anyopaque) callconv(.winapi) dwrite.HRESULT {
+ return dwrite.E_NOINTERFACE;
+ }
+ fn addRef(_: *TextAnalysisSource) callconv(.winapi) u32 { return 1; }
+ fn release(_: *TextAnalysisSource) callconv(.winapi) u32 { return 1; }
+
+ fn getTextAtPosition(self: *TextAnalysisSource, pos: u32, text_out: *?[*]const u16, len_out: *u32) callconv(.winapi) dwrite.HRESULT {
+ if (pos >= self.text_len) {
+ text_out.* = null;
+ len_out.* = 0;
+ } else {
+ text_out.* = @as([*]const u16, self.text) + pos;
+ len_out.* = self.text_len - pos;
+ }
+ return dwrite.S_OK;
+ }
+
+ fn getTextBeforePosition(self: *TextAnalysisSource, pos: u32, text_out: *?[*]const u16, len_out: *u32) callconv(.winapi) dwrite.HRESULT {
+ if (pos == 0 or pos > self.text_len) {
+ text_out.* = null;
+ len_out.* = 0;
+ } else {
+ text_out.* = @as([*]const u16, self.text);
+ len_out.* = pos;
+ }
+ return dwrite.S_OK;
+ }
+
+ fn getParagraphReadingDirection(_: *TextAnalysisSource) callconv(.winapi) dwrite.DWRITE_READING_DIRECTION {
+ return .LEFT_TO_RIGHT;
+ }
+
+ fn getLocaleName(_: *TextAnalysisSource, _: u32, text_len: *u32, locale: *?[*:0]const u16) callconv(.winapi) dwrite.HRESULT {
+ const empty: [*:0]const u16 = &[_:0]u16{};
+ locale.* = empty;
+ text_len.* = 0;
+ return dwrite.S_OK;
+ }
+
+ fn getNumberSubstitution(self: *TextAnalysisSource, _: u32, text_len: *u32, sub: *?*dwrite.IDWriteNumberSubstitution) callconv(.winapi) dwrite.HRESULT {
+ sub.* = self.number_sub;
+ text_len.* = self.text_len;
+ return dwrite.S_OK;
+ }
+ };
+
+ // UTF-8 to null-terminated UTF-16LE for DirectWrite APIs.
+
+ fn utf8ToUtf16Le(buf: []u16, utf8: []const u8) ?[*:0]const u16 {
+ const len = std.unicode.utf8ToUtf16Le(buf[0 .. buf.len - 1], utf8) catch return null;
+ buf[len] = 0;
+ return @ptrCast(buf[0..len :0].ptr);
+ }
+
+ // Variation axes are not used for scoring because DirectWrite's
+ // GetWeight/GetStyle already return instance-level values. Variations
+ // are passed through to DeferredFace and applied when the font is
+ // loaded via FreeType (see DeferredFace.loadDirectWrite).
+
+ pub const DiscoverIterator = struct {
+ fonts: []*dwrite.IDWriteFont,
+ alloc: Allocator,
+ variations: []const Variation,
+ i: usize,
+
+ pub fn empty(alloc: Allocator, variations: []const Variation) DiscoverIterator {
+ return .{ .fonts = &.{}, .alloc = alloc, .variations = variations, .i = 0 };
+ }
+
+ pub fn deinit(self: *DiscoverIterator) void {
+ for (self.fonts) |font| _ = font.Release();
+ if (self.fonts.len > 0) self.alloc.free(self.fonts);
+ self.* = undefined;
+ }
+
+ pub fn next(self: *DiscoverIterator) !?DeferredFace {
+ if (self.i >= self.fonts.len) return null;
+ const font = self.fonts[self.i];
+ _ = font.AddRef();
+ defer self.i += 1;
+ return DeferredFace{ .dw = .{ .font = font, .variations = self.variations } };
+ }
+ };
+};
+
pub const Fontconfig = struct {
fc_config: *fontconfig.Config,
@@ -1347,3 +1710,130 @@ test "windows" {
defer face.deinit();
try testing.expect(face.hasCodepoint('A', null));
}
+
+test "directwrite" {
+ if (options.backend != .directwrite_freetype) return error.SkipZigTest;
+
+ const testing = std.testing;
+ const alloc = testing.allocator;
+
+ var dw = DirectWrite.init(undefined);
+ defer dw.deinit();
+ var it = try dw.discover(alloc, .{ .family = "Consolas", .size = 12 });
+ defer it.deinit();
+ var count: usize = 0;
+ while (try it.next()) |_| {
+ count += 1;
+ }
+ try testing.expect(count > 0);
+}
+
+test "directwrite codepoint" {
+ if (options.backend != .directwrite_freetype) return error.SkipZigTest;
+
+ const testing = std.testing;
+ const alloc = testing.allocator;
+
+ var dw = DirectWrite.init(undefined);
+ defer dw.deinit();
+ var it = try dw.discover(alloc, .{ .family = "Consolas", .codepoint = 'A', .size = 12 });
+ defer it.deinit();
+
+ var face = (try it.next()).?;
+ defer face.deinit();
+ try testing.expect(face.hasCodepoint('A', null));
+ try testing.expect(face.hasCodepoint('B', null));
+}
+
+test "directwrite bold" {
+ if (options.backend != .directwrite_freetype) return error.SkipZigTest;
+
+ const testing = std.testing;
+ const alloc = testing.allocator;
+
+ var dw = DirectWrite.init(undefined);
+ defer dw.deinit();
+ var it = try dw.discover(alloc, .{ .family = "Consolas", .bold = true, .size = 12 });
+ defer it.deinit();
+
+ var face = (try it.next()).?;
+ defer face.deinit();
+
+ var buf: [1024]u8 = undefined;
+ const name = try face.name(&buf);
+ try testing.expect(name.len > 0);
+}
+
+test "directwrite fallback" {
+ if (options.backend != .directwrite_freetype) return error.SkipZigTest;
+
+ const testing = std.testing;
+ const alloc = testing.allocator;
+
+ var dw = DirectWrite.init(undefined);
+ defer dw.deinit();
+
+ // U+1F600 = grinning face emoji -- should find a fallback font.
+ // DirectWrite.discoverFallback ignores the collection parameter
+ // (uses its own system collection), so undefined is safe here.
+ var dummy_collection: Collection = undefined;
+ var it = try dw.discoverFallback(alloc, &dummy_collection, .{ .codepoint = 0x1F600, .size = 12 });
+ defer it.deinit();
+
+ // It's OK if no emoji font is found on headless CI
+ if (try it.next()) |f| {
+ var f_mut = f;
+ defer f_mut.deinit();
+ try testing.expect(f_mut.hasCodepoint(0x1F600, null));
+ }
+}
+
+test "directwrite variations" {
+ if (options.backend != .directwrite_freetype) return error.SkipZigTest;
+
+ const testing = std.testing;
+ const alloc = testing.allocator;
+
+ const variations = [_]Variation{
+ .{ .id = Variation.Id.init("wght"), .value = 300 },
+ };
+
+ var dw = DirectWrite.init(undefined);
+ defer dw.deinit();
+ var it = try dw.discover(alloc, .{
+ .family = "Cascadia Code",
+ .variations = &variations,
+ .size = 12,
+ });
+ defer it.deinit();
+
+ // Variations are carried through to DeferredFace for application
+ // at load time (via FreeType's setVarDesignCoordinates).
+ var face = (try it.next()) orelse return;
+ defer face.deinit();
+ try testing.expectEqual(1, face.dw.?.variations.len);
+ try testing.expectEqual(Variation.Id.init("wght"), face.dw.?.variations[0].id);
+}
+
+test "directwrite discover all" {
+ if (options.backend != .directwrite_freetype) return error.SkipZigTest;
+
+ const testing = std.testing;
+ const alloc = testing.allocator;
+
+ var dw = DirectWrite.init(undefined);
+ defer dw.deinit();
+
+ // No family filter -- exercises discoverAll(), which enumerates
+ // every font in the system collection.
+ var it = try dw.discover(alloc, .{});
+ defer it.deinit();
+
+ var count: usize = 0;
+ while (try it.next()) |_| {
+ count += 1;
+ }
+
+ // A typical Windows install has hundreds of fonts.
+ try testing.expect(count > 0);
+}
diff --git a/src/font/face.zig b/src/font/face.zig
index d77253adff4..efd04adebba 100644
--- a/src/font/face.zig
+++ b/src/font/face.zig
@@ -13,6 +13,7 @@ pub const Face = switch (options.backend) {
.freetype,
.freetype_windows,
.fontconfig_freetype,
+ .directwrite_freetype,
.coretext_freetype,
=> freetype.Face,
diff --git a/src/font/face/freetype.zig b/src/font/face/freetype.zig
index 528f72d52c2..0077734e53d 100644
--- a/src/font/face/freetype.zig
+++ b/src/font/face/freetype.zig
@@ -352,6 +352,11 @@ pub const Face = struct {
/// Set the load flags to use when loading a glyph for measurement or
/// rendering.
+ ///
+ /// Light hinting (TARGET_LIGHT) is the default on all platforms,
+ /// including Windows. This preserves glyph shapes rather than
+ /// snapping to the pixel grid, matching the CoreText approach on
+ /// macOS.
fn glyphLoadFlags(self: Face, constrained: bool) freetype.LoadFlags {
// Hinting should only be enabled if the configured load flags specify
// it and the provided constraint doesn't actually do anything, since
@@ -1157,6 +1162,34 @@ test {
}
}
+test "default load flags" {
+ const testFont = font.embedded.inconsolata;
+ const alloc = testing.allocator;
+
+ var lib = try Library.init(alloc);
+ defer lib.deinit();
+
+ var ft_font = try Face.init(
+ lib,
+ testFont,
+ .{ .size = .{ .points = 12, .xdpi = 96, .ydpi = 96 } },
+ );
+ defer ft_font.deinit();
+
+ const flags = ft_font.glyphLoadFlags(false);
+
+ try testing.expectEqual(.light, flags.target);
+ try testing.expect(!flags.no_hinting);
+ try testing.expect(!flags.force_autohint);
+ try testing.expect(!flags.no_autohint);
+
+ // When constrained, hinting is disabled since the constraint
+ // transform would undo it anyway.
+ const constrained_flags = ft_font.glyphLoadFlags(true);
+ try testing.expect(constrained_flags.no_hinting);
+ try testing.expectEqual(.light, constrained_flags.target);
+}
+
test "color emoji" {
const alloc = testing.allocator;
const testFont = font.embedded.emoji;
diff --git a/src/font/library.zig b/src/font/library.zig
index 56946343ebc..a2d92d16436 100644
--- a/src/font/library.zig
+++ b/src/font/library.zig
@@ -12,6 +12,7 @@ pub const Library = switch (options.backend) {
.freetype,
.freetype_windows,
.fontconfig_freetype,
+ .directwrite_freetype,
.coretext_freetype,
=> FreetypeLibrary,
diff --git a/src/font/main.zig b/src/font/main.zig
index a8522afe16a..311df8edada 100644
--- a/src/font/main.zig
+++ b/src/font/main.zig
@@ -25,6 +25,7 @@ pub const SharedGridSet = @import("SharedGridSet.zig");
pub const sprite = @import("sprite.zig");
pub const Sprite = sprite.Sprite;
pub const SpriteFace = sprite.Face;
+pub const directwrite = if (options.backend.hasDirectwrite()) @import("directwrite.zig") else struct {};
pub const Descriptor = discovery.Descriptor;
pub const Discover = discovery.Discover;
pub const Library = library.Library;
diff --git a/src/font/shape.zig b/src/font/shape.zig
index bc19c1f2334..723e83d2c64 100644
--- a/src/font/shape.zig
+++ b/src/font/shape.zig
@@ -21,6 +21,7 @@ pub const Shaper = switch (options.backend) {
.freetype,
.freetype_windows,
.fontconfig_freetype,
+ .directwrite_freetype,
.coretext_freetype,
.coretext_harfbuzz,
=> harfbuzz.Shaper,
diff --git a/src/global.zig b/src/global.zig
index 29eaf5f367e..9245a57712b 100644
--- a/src/global.zig
+++ b/src/global.zig
@@ -4,7 +4,6 @@ const build_config = @import("build_config.zig");
const cli = @import("cli.zig");
const internal_os = @import("os/main.zig");
const fontconfig = @import("fontconfig");
-const glslang = @import("glslang");
const harfbuzz = @import("harfbuzz");
const oni = @import("oniguruma");
const crash = @import("crash/main.zig");
@@ -163,9 +162,6 @@ pub const GlobalState = struct {
// affects a lot of behaviors in a shell.
try internal_os.ensureLocale(self.alloc);
- // Initialize glslang for shader compilation
- try glslang.init();
-
// Initialize oniguruma for regex
try oni.init(&.{oni.Encoding.utf8});
diff --git a/src/input/key_encode.zig b/src/input/key_encode.zig
index 6ab5a4cc858..4eefee224d1 100644
--- a/src/input/key_encode.zig
+++ b/src/input/key_encode.zig
@@ -2079,6 +2079,18 @@ test "legacy: ctrl+c" {
try testing.expectEqualStrings("\x03", writer.buffered());
}
+test "legacy: ctrl+c no text" {
+ var buf: [128]u8 = undefined;
+ var writer: std.Io.Writer = .fixed(&buf);
+ try legacy(&writer, .{
+ .key = .key_c,
+ .mods = .{ .ctrl = true },
+ .utf8 = "",
+ .unshifted_codepoint = 'c',
+ }, .{});
+ try testing.expectEqualStrings("\x03", writer.buffered());
+}
+
test "legacy: ctrl+space" {
var buf: [128]u8 = undefined;
var writer: std.Io.Writer = .fixed(&buf);
@@ -2538,3 +2550,9 @@ test "ctrlseq: right ctrl c" {
});
try testing.expectEqual(@as(u8, 0x03), seq.?);
}
+
+test "ctrlseq: ctrl c with no text uses logical key" {
+ const seq = ctrlSeq(.key_c, "", 'c', .{ .ctrl = true });
+ try testing.expectEqual(@as(u8, 0x03), seq.?);
+}
+
diff --git a/src/log_bridge.zig b/src/log_bridge.zig
new file mode 100644
index 00000000000..4d760a37948
--- /dev/null
+++ b/src/log_bridge.zig
@@ -0,0 +1,355 @@
+//! Embedder log bridge for libghostty.
+//!
+//! Exports a C API that lets the embedder (e.g. the Windows C# shell)
+//! register a callback to receive every std.log message produced by
+//! libghostty. The callback is invoked from inside logFn in
+//! main_ghostty.zig, alongside the existing macOS-unified-log and stderr
+//! sinks. On Windows this is the only sink that actually reaches the
+//! user: the WinUI 3 GUI-subsystem exe has no console and macOS unified
+//! log is not available, so Zig logs otherwise vanish.
+//!
+//! Level mapping contract (stable, do not change these integers once
+//! embedders exist):
+//! 0 -> debug
+//! 1 -> info
+//! 2 -> warn
+//! 3 -> err
+//!
+//! Thread safety: logFn may be called from any thread, so the global
+//! callback pointer is loaded with an atomic read. The callback itself
+//! must be reentrant-safe and must not call back into libghostty's
+//! logger in a way that would deadlock.
+//!
+//! Encoding: scope bytes are ASCII (they come from Zig enum tag names).
+//! Message bytes may be arbitrary UTF-8 (paths, shell command values,
+//! etc.). We pass raw bytes in both cases; the embedder decodes.
+
+const std = @import("std");
+const builtin = @import("builtin");
+
+/// Stable integer mapping for std.log.Level. std.log.Level's own
+/// ordinals are not part of any public contract and could shift across
+/// Zig versions; by going through this enum the ABI seen by embedders
+/// is pinned regardless of std internals. Keep the values synchronized
+/// with the embedder (see the level mapping contract in the file doc
+/// comment above).
+pub const Level = enum(u32) {
+ debug = 0,
+ info = 1,
+ warn = 2,
+ err = 3,
+};
+
+/// Callback signature the embedder registers. The scope and message
+/// bytes are NOT null-terminated; use the companion length. user_data
+/// is echoed verbatim from the registration call.
+pub const LogCallback = ?*const fn (
+ level: u32,
+ scope_ptr: [*]const u8,
+ scope_len: usize,
+ message_ptr: [*]const u8,
+ message_len: usize,
+ user_data: ?*anyopaque,
+) callconv(.c) void;
+
+/// Maximum rendered message length in bytes. Messages longer than this
+/// are truncated and get a suffix appended (see truncated_suffix).
+/// The bound exists so we can render the formatted message into a
+/// fixed stack buffer per log call and never allocate. 64 KiB is far
+/// larger than any real log line but small enough to fit comfortably on
+/// the stack of any Zig-created thread.
+pub const max_message_bytes: usize = 64 * 1024;
+
+/// Appended to the end of a truncated message so consumers can tell.
+/// Chosen short and ASCII so it always fits in the final bytes of the
+/// buffer without further truncation considerations.
+pub const truncated_suffix: []const u8 = " [truncated]";
+
+// Global callback state. Split across two atomics so the function
+// pointer and user_data stay in sync at the moment of registration:
+// both are stored atomically, and the dispatch path reads the callback
+// pointer first, then user_data. A benign race is possible where a
+// caller tears down the callback between the two loads and we pass
+// stale user_data into a null callback - but we always check the
+// callback pointer for null before invoking, so no harm occurs.
+//
+// We store the function pointer as a usize (its bit pattern) rather
+// than a typed pointer because std.atomic.Value does not support
+// function-pointer generic args. A zero value means "no callback".
+var cb_bits: std.atomic.Value(usize) = std.atomic.Value(usize).init(0);
+var cb_user_data: std.atomic.Value(usize) = std.atomic.Value(usize).init(0);
+
+/// Install (or clear) the embedder log callback. Pass null to clear.
+/// Safe to call from any thread.
+pub fn setCallback(cb: LogCallback, user_data: ?*anyopaque) void {
+ const cb_as_int: usize = if (cb) |fn_ptr| @intFromPtr(fn_ptr) else 0;
+ const ud_as_int: usize = if (user_data) |ud| @intFromPtr(ud) else 0;
+ // Release ordering on the store pair so any writes the embedder
+ // did to memory it will hand back via user_data happen-before the
+ // callback sees them.
+ cb_user_data.store(ud_as_int, .release);
+ cb_bits.store(cb_as_int, .release);
+}
+
+/// Dispatch a log event to the embedder callback, if one is set.
+/// Called from logFn. The format+args are rendered into a stack
+/// buffer so the callback receives a fully-formatted message without
+/// any Zig formatter reentering the logger.
+pub fn dispatch(
+ level: std.log.Level,
+ comptime scope: @TypeOf(.EnumLiteral),
+ comptime format: []const u8,
+ args: anytype,
+) void {
+ // Fast path: no embedder, nothing to do. One atomic load per log
+ // call is a few nanoseconds, which is negligible compared to the
+ // cost of formatting the message.
+ const cb_int = cb_bits.load(.acquire);
+ if (cb_int == 0) return;
+
+ const cb: *const fn (
+ u32,
+ [*]const u8,
+ usize,
+ [*]const u8,
+ usize,
+ ?*anyopaque,
+ ) callconv(.c) void = @ptrFromInt(cb_int);
+
+ const ud_int = cb_user_data.load(.acquire);
+ const ud: ?*anyopaque = if (ud_int == 0) null else @ptrFromInt(ud_int);
+
+ const level_int: u32 = @intFromEnum(levelToExport(level));
+
+ // Render the message into a stack buffer using a fixed Writer.
+ // std.Io.Writer.fixed keeps the bytes it successfully wrote in
+ // `writer.end` even when a subsequent write fails with
+ // error.WriteFailed (the zig 0.15 fixed writer's overflow error),
+ // so we can salvage a partial render and append a truncation
+ // suffix instead of dropping the whole message.
+ var buf: [max_message_bytes]u8 = undefined;
+ var writer: std.Io.Writer = .fixed(&buf);
+ const overflowed: bool = blk: {
+ writer.print(format, args) catch break :blk true;
+ break :blk false;
+ };
+ const rendered: []const u8 = if (overflowed) overflow: {
+ // On overflow writer.end is at or near buf.len. Reserve room
+ // for the suffix by moving the end back by suffix.len (this
+ // may drop a handful of bytes off the tail of the partial
+ // message, which is an acceptable trade for a clear marker).
+ const keep = if (buf.len >= truncated_suffix.len) buf.len - truncated_suffix.len else 0;
+ @memcpy(buf[keep..][0..truncated_suffix.len], truncated_suffix);
+ break :overflow buf[0..buf.len];
+ } else buf[0..writer.end];
+
+ // Scope bytes are the enum tag name. For the default scope we pass
+ // an empty byte slice so the embedder can decide how to render it.
+ const scope_name: []const u8 = if (scope == .default) "" else @tagName(scope);
+
+ // Normalize: avoid passing a null base pointer when len is 0. Some
+ // C# marshalers refuse to decode a (null, 0) pair.
+ const scope_ptr: [*]const u8 = if (scope_name.len == 0) &empty_sentinel else scope_name.ptr;
+ const msg_ptr: [*]const u8 = if (rendered.len == 0) &empty_sentinel else rendered.ptr;
+
+ cb(level_int, scope_ptr, scope_name.len, msg_ptr, rendered.len, ud);
+}
+
+/// One-byte sentinel so we never hand a null base pointer to the
+/// embedder, even for zero-length slices. Using a file-scope const
+/// gives it a stable address.
+const empty_sentinel: [1]u8 = .{0};
+
+fn levelToExport(level: std.log.Level) Level {
+ return switch (level) {
+ .debug => .debug,
+ .info => .info,
+ .warn => .warn,
+ .err => .err,
+ };
+}
+
+// --- C API ------------------------------------------------------------
+// Exported into libghostty from src/main_c.zig.
+
+/// Register or clear the embedder log callback.
+///
+/// Passing null for `cb` clears any previously-installed callback. The
+/// callback is invoked from whichever thread emits a std.log call, so
+/// embedders must handle multi-threaded invocation.
+///
+/// Level integers are stable: see the Level enum in this file.
+///
+/// Scope and message bytes are NOT null-terminated. Use the companion
+/// length argument.
+pub export fn ghostty_log_set_callback(
+ cb: LogCallback,
+ user_data: ?*anyopaque,
+) void {
+ setCallback(cb, user_data);
+}
+
+// --- Tests ------------------------------------------------------------
+
+const testing = std.testing;
+
+const CaptureState = struct {
+ level: u32 = 0,
+ scope: [64]u8 = undefined,
+ scope_len: usize = 0,
+ message: [256]u8 = undefined,
+ message_len: usize = 0,
+ called: usize = 0,
+};
+
+fn captureCallback(
+ level: u32,
+ scope_ptr: [*]const u8,
+ scope_len: usize,
+ message_ptr: [*]const u8,
+ message_len: usize,
+ user_data: ?*anyopaque,
+) callconv(.c) void {
+ const state: *CaptureState = @ptrCast(@alignCast(user_data.?));
+ state.level = level;
+ state.scope_len = @min(scope_len, state.scope.len);
+ @memcpy(state.scope[0..state.scope_len], scope_ptr[0..state.scope_len]);
+ state.message_len = @min(message_len, state.message.len);
+ @memcpy(state.message[0..state.message_len], message_ptr[0..state.message_len]);
+ state.called += 1;
+}
+
+test "dispatch with no callback set is a no-op" {
+ setCallback(null, null);
+ // Must not crash even though no callback is registered.
+ dispatch(.info, .some_scope, "hello {d}", .{42});
+}
+
+test "dispatch routes level, scope, and formatted message" {
+ var state: CaptureState = .{};
+ setCallback(captureCallback, &state);
+ defer setCallback(null, null);
+
+ dispatch(.info, .test_scope, "hello {d}", .{42});
+
+ try testing.expectEqual(@as(usize, 1), state.called);
+ try testing.expectEqual(@as(u32, @intFromEnum(Level.info)), state.level);
+ try testing.expectEqualStrings("test_scope", state.scope[0..state.scope_len]);
+ try testing.expectEqualStrings("hello 42", state.message[0..state.message_len]);
+}
+
+test "dispatch forwards user_data verbatim to the callback" {
+ const Probe = struct {
+ var seen_user_data: ?*anyopaque = null;
+ var expected: u32 = 0xdeadbeef;
+ fn cb(
+ _: u32,
+ _: [*]const u8,
+ _: usize,
+ _: [*]const u8,
+ _: usize,
+ user_data: ?*anyopaque,
+ ) callconv(.c) void {
+ seen_user_data = user_data;
+ }
+ };
+ Probe.seen_user_data = null;
+
+ setCallback(Probe.cb, @ptrCast(&Probe.expected));
+ defer setCallback(null, null);
+
+ dispatch(.info, .s, "x", .{});
+
+ try testing.expect(Probe.seen_user_data != null);
+ try testing.expectEqual(
+ @as(?*anyopaque, @ptrCast(&Probe.expected)),
+ Probe.seen_user_data,
+ );
+}
+
+test "dispatch maps each std.log.Level to the documented integer" {
+ var state: CaptureState = .{};
+ setCallback(captureCallback, &state);
+ defer setCallback(null, null);
+
+ dispatch(.debug, .s, "d", .{});
+ try testing.expectEqual(@as(u32, 0), state.level);
+
+ dispatch(.info, .s, "i", .{});
+ try testing.expectEqual(@as(u32, 1), state.level);
+
+ dispatch(.warn, .s, "w", .{});
+ try testing.expectEqual(@as(u32, 2), state.level);
+
+ dispatch(.err, .s, "e", .{});
+ try testing.expectEqual(@as(u32, 3), state.level);
+}
+
+test "dispatch with default scope passes an empty scope slice" {
+ var state: CaptureState = .{};
+ setCallback(captureCallback, &state);
+ defer setCallback(null, null);
+
+ dispatch(.info, .default, "x", .{});
+ try testing.expectEqual(@as(usize, 0), state.scope_len);
+}
+
+test "dispatch truncates an oversized message with a suffix" {
+ // Capture into a buffer large enough to hold the full
+ // max_message_bytes + suffix so we can inspect the tail directly.
+ const Big = struct {
+ var called: usize = 0;
+ var seen_len: usize = 0;
+ var buf: [max_message_bytes]u8 = undefined;
+ };
+ Big.called = 0;
+ Big.seen_len = 0;
+
+ const Cb = struct {
+ fn cb(
+ _: u32,
+ _: [*]const u8,
+ _: usize,
+ message_ptr: [*]const u8,
+ message_len: usize,
+ _: ?*anyopaque,
+ ) callconv(.c) void {
+ Big.called += 1;
+ Big.seen_len = @min(message_len, Big.buf.len);
+ @memcpy(Big.buf[0..Big.seen_len], message_ptr[0..Big.seen_len]);
+ }
+ };
+
+ setCallback(Cb.cb, null);
+ defer setCallback(null, null);
+
+ // Build a format string that is guaranteed to overflow the stack
+ // buffer: max_message_bytes + 16 copies of 'A'. After truncation
+ // the rendered length must equal buf.len and the tail must be
+ // truncated_suffix.
+ const big = "A" ** (max_message_bytes + 16);
+ dispatch(.info, .s, big, .{});
+
+ try testing.expectEqual(@as(usize, 1), Big.called);
+ try testing.expectEqual(max_message_bytes, Big.seen_len);
+ // Tail is the truncation suffix.
+ const tail_start = max_message_bytes - truncated_suffix.len;
+ try testing.expectEqualStrings(truncated_suffix, Big.buf[tail_start..max_message_bytes]);
+ // Everything before the suffix is still 'A'.
+ for (Big.buf[0..tail_start]) |c| {
+ try testing.expectEqual(@as(u8, 'A'), c);
+ }
+}
+
+test "clearing the callback stops dispatch" {
+ var state: CaptureState = .{};
+ setCallback(captureCallback, &state);
+
+ dispatch(.info, .s, "first", .{});
+ try testing.expectEqual(@as(usize, 1), state.called);
+
+ setCallback(null, null);
+ dispatch(.info, .s, "second", .{});
+ // Still 1 - the clear prevented the second call.
+ try testing.expectEqual(@as(usize, 1), state.called);
+}
diff --git a/src/main_c.zig b/src/main_c.zig
index f61565f262c..0906475f57f 100644
--- a/src/main_c.zig
+++ b/src/main_c.zig
@@ -43,6 +43,11 @@ comptime {
// Our benchmark API. We probably want to gate this on a build
// config in the future but for now we always just export it.
_ = @import("benchmark/main.zig").CApi;
+
+ // Embedder log callback bridge. Referenced here so the
+ // ghostty_log_set_callback export lands in libghostty even when
+ // main_ghostty.zig imports log_bridge only via logFn.
+ _ = @import("log_bridge.zig");
}
/// ghostty_info_s
@@ -127,6 +132,71 @@ pub export fn ghostty_cli_try_action() void {
posix.exit(0);
}
+/// Runs the CLI action (if any) and returns the exit code instead of
+/// calling exit. Returns -1 if no action was specified. Lets the host
+/// (e.g. a C# WinUI app) handle process termination cleanly, avoiding
+/// DLL_PROCESS_DETACH crashes from posix.exit/ExitProcess.
+pub export fn ghostty_cli_run_action() c_int {
+ const action = state.action orelse return -1;
+ std.log.info("executing CLI action={}", .{action});
+ return @intCast(action.run(state.alloc) catch |err| {
+ std.log.err("CLI action failed error={}", .{err});
+ return 1;
+ });
+}
+
+/// ghostty_build_info_s
+pub const BuildInfo = extern struct {
+ version: [*:0]const u8,
+ version_string: [*:0]const u8,
+ commit: [*:0]const u8,
+ channel: [*:0]const u8,
+ zig_version: [*:0]const u8,
+ build_mode: [*:0]const u8,
+};
+
+/// Fill `out` with build information about the loaded libghostty. Returned
+/// strings are NUL-terminated UTF-8 with static lifetime; the caller must
+/// not free them. `commit` is empty when no build commit is present. Safe
+/// to call before `ghostty_init`.
+pub export fn ghostty_build_info(out: *BuildInfo) void {
+ out.* = .{
+ .version = build_config_version_cstr,
+ .version_string = build_config.version_string,
+ .commit = build_config_commit_cstr,
+ .channel = @tagName(build_config.release_channel),
+ .zig_version = builtin.zig_version_string,
+ .build_mode = @tagName(builtin.mode),
+ };
+}
+
+const build_config_version_cstr: [*:0]const u8 = std.fmt.comptimePrint(
+ "{d}.{d}.{d}",
+ .{ build_config.version.major, build_config.version.minor, build_config.version.patch },
+);
+
+const build_config_commit_cstr: [*:0]const u8 = blk: {
+ const b = build_config.version.build orelse break :blk "";
+ break :blk std.fmt.comptimePrint("{s}", .{b});
+};
+
+/// Set an optional callback that the +list-themes TUI invokes when the
+/// selected theme changes (preview) or is accepted (confirmed). This
+/// lets embedders update their app chrome (title bar, tabs, etc.) to
+/// match the previewed theme without writing to the config file.
+///
+/// The callback receives:
+/// - name: null-terminated UTF-8 theme name
+/// - confirmed: false while browsing (preview), true on accept
+///
+/// Pass null to clear the callback. Must be called before
+/// ghostty_cli_run_action.
+pub export fn ghostty_cli_set_theme_callback(
+ cb: ?*const fn ([*:0]const u8, bool) callconv(.c) void,
+) void {
+ @import("cli/list_themes.zig").setThemeCallback(cb);
+}
+
/// Return metadata about Ghostty, such as version, build mode, etc.
pub export fn ghostty_info() Info {
return .{
@@ -159,7 +229,7 @@ pub export fn ghostty_string_free(str: String) void {
// On Windows, Zig's _DllMainCRTStartup does not initialize the MSVC C
// runtime when targeting MSVC ABI. Without initialization, any C library
// function that depends on CRT internal state (setlocale, malloc from C
-// dependencies, C++ constructors in glslang) crashes with null pointer
+// dependencies, C++ constructors) crashes with null pointer
// dereferences. Declaring DllMain causes Zig's start.zig to call it
// during DLL_PROCESS_ATTACH/DETACH, and for MSVC we forward to the CRT
// bootstrap functions from libvcruntime and libucrt (already linked).
diff --git a/src/main_ghostty.zig b/src/main_ghostty.zig
index 531a0646134..379772186ff 100644
--- a/src/main_ghostty.zig
+++ b/src/main_ghostty.zig
@@ -13,6 +13,7 @@ const apprt = @import("apprt.zig");
const App = @import("App.zig");
const Ghostty = @import("main_c.zig").Ghostty;
+const log_bridge = @import("log_bridge.zig");
const state = &@import("global.zig").state;
/// The return type for main() depends on the build artifact. The lib build
@@ -118,6 +119,14 @@ fn logFn(
comptime format: []const u8,
args: anytype,
) void {
+ // Embedder bridge. When the embedder (e.g. the Windows C# shell)
+ // has registered a callback via ghostty_log_set_callback, render
+ // the formatted message once and hand it over. Runs on every
+ // platform when a callback is set; on Windows this is the only
+ // sink that reaches a reader because the GUI-subsystem exe has no
+ // console and macOS unified log is unavailable.
+ log_bridge.dispatch(level, scope, format, args);
+
// On Mac, we use unified logging. To view this:
//
// sudo log stream --level debug --predicate 'subsystem=="com.mitchellh.ghostty"'
@@ -190,6 +199,7 @@ test {
_ = @import("surface_mouse.zig");
// Libraries
+ _ = @import("log_bridge.zig");
_ = @import("tripwire.zig");
_ = @import("benchmark/main.zig");
_ = @import("crash/main.zig");
diff --git a/src/os/main.zig b/src/os/main.zig
index b3018274041..ccd20d6c7ad 100644
--- a/src/os/main.zig
+++ b/src/os/main.zig
@@ -28,6 +28,7 @@ pub const path = @import("path.zig");
pub const passwd = @import("passwd.zig");
pub const xdg = @import("xdg.zig");
pub const windows = @import("windows.zig");
+pub const windows_shell = @import("windows_shell.zig");
pub const macos = @import("macos.zig");
pub const shell = @import("shell.zig");
pub const uri = @import("uri.zig");
@@ -73,6 +74,7 @@ test {
_ = path;
_ = uri;
_ = shell;
+ _ = windows_shell;
if (comptime builtin.os.tag == .linux) {
_ = kernel_info;
diff --git a/src/os/passwd.zig b/src/os/passwd.zig
index e9bbff066b8..96443d3687f 100644
--- a/src/os/passwd.zig
+++ b/src/os/passwd.zig
@@ -66,7 +66,7 @@ pub fn get(alloc: Allocator) !Entry {
// some operating systems (NixOS tested) don't set the PATH for various
// utilities properly until we get a login shell.
const Pty = @import("../pty.zig").Pty;
- var pty = try Pty.open(.{});
+ var pty = try Pty.open(.{ .size = .{} });
defer pty.deinit();
var cmd: internal_os.FlatpakHostCommand = .{
.argv = &[_][]const u8{
diff --git a/src/os/path.zig b/src/os/path.zig
index 730ae692d12..948bd28775b 100644
--- a/src/os/path.zig
+++ b/src/os/path.zig
@@ -6,10 +6,21 @@ const testing = std.testing;
/// Search for "cmd" in the PATH and return the absolute path. This will
/// always allocate if there is a non-null result. The caller must free the
/// resulting value.
+///
+/// On Windows, honors PATHEXT when searching for bare command names.
+/// If cmd is already a path or has an extension, tries it literally first
+/// before attempting PATHEXT extensions.
pub fn expand(alloc: Allocator, cmd: []const u8) !?[]u8 {
- // If the command already contains a slash, then we return it as-is
- // because it is assumed to be absolute or relative.
- if (std.mem.indexOfScalar(u8, cmd, '/') != null) {
+ // If the command already contains a path separator, return as-is.
+ // POSIX: '/'. Windows additionally accepts '\\' and drive-letter
+ // prefixes like 'X:'. Without the Windows extensions a path such
+ // as `C:\Windows\System32\cmd.exe` would miss the fast path and
+ // get joined onto every PATH dir, always producing null.
+ const already_path = std.mem.indexOfScalar(u8, cmd, '/') != null or
+ (builtin.os.tag == .windows and
+ (std.mem.indexOfScalar(u8, cmd, '\\') != null or
+ (cmd.len >= 2 and std.ascii.isAlphabetic(cmd[0]) and cmd[1] == ':')));
+ if (already_path) {
return try alloc.dupe(u8, cmd);
}
@@ -23,39 +34,67 @@ pub fn expand(alloc: Allocator, cmd: []const u8) !?[]u8 {
};
defer if (builtin.os.tag == .windows) alloc.free(PATH);
+ // Parse PATHEXT on Windows
+ var pathext_list: ?[][]const u8 = null;
+ var pathext_buf: ?[]u8 = null;
+ const has_extension = std.mem.indexOfScalar(u8, cmd, '.') != null;
+ defer {
+ if (pathext_buf) |pb| alloc.free(pb);
+ if (pathext_list) |pl| alloc.free(pl);
+ }
+
+ if (builtin.os.tag == .windows and !has_extension) {
+ const pathext_str = blk: {
+ if (std.process.getenvW(std.unicode.utf8ToUtf16LeStringLiteral("PATHEXT"))) |we| {
+ const utf8_pathext = try std.unicode.utf16LeToUtf8Alloc(alloc, we);
+ break :blk utf8_pathext;
+ } else {
+ // Fallback to default Windows extensions
+ break :blk try alloc.dupe(u8, ".COM;.EXE;.BAT;.CMD");
+ }
+ };
+ pathext_buf = pathext_str;
+
+ // Count semicolons to determine how many extensions
+ var ext_count: usize = 1;
+ for (pathext_str) |ch| {
+ if (ch == ';') ext_count += 1;
+ }
+
+ // Allocate and populate extension list
+ pathext_list = try alloc.alloc([]const u8, ext_count);
+ var idx: usize = 0;
+ var it = std.mem.tokenizeScalar(u8, pathext_str, ';');
+ while (it.next()) |ext| {
+ pathext_list.?[idx] = ext;
+ idx += 1;
+ }
+ }
+
var path_buf: [std.fs.max_path_bytes]u8 = undefined;
var it = std.mem.tokenizeScalar(u8, PATH, std.fs.path.delimiter);
var seen_eacces = false;
while (it.next()) |search_path| {
- // We need enough space in our path buffer to store this
- const path_len = search_path.len + cmd.len + 1;
- if (path_buf.len < path_len) return error.PathTooLong;
-
- // Copy in the full path
- @memcpy(path_buf[0..search_path.len], search_path);
- path_buf[search_path.len] = std.fs.path.sep;
- @memcpy(path_buf[search_path.len + 1 ..][0..cmd.len], cmd);
- path_buf[path_len] = 0;
- const full_path = path_buf[0..path_len :0];
-
- // Stat it
- const f = std.fs.cwd().openFile(
- full_path,
- .{},
- ) catch |err| switch (err) {
- error.FileNotFound => continue,
- error.AccessDenied => {
- // Accumulate this and return it later so we can try other
- // paths that we have access to.
- seen_eacces = true;
- continue;
- },
- else => return err,
- };
- defer f.close();
- const stat = try f.stat();
- if (stat.kind != .directory and isExecutable(stat.mode)) {
- return try alloc.dupe(u8, full_path);
+ // First, try the command as-is (literal match, or if it has an extension)
+ if (try tryPathImpl(alloc, search_path, cmd, &path_buf, &seen_eacces)) |result| {
+ return result;
+ }
+
+ // On Windows, if no extension, try with PATHEXT extensions
+ if (builtin.os.tag == .windows and !has_extension and pathext_list != null) {
+ for (pathext_list.?) |ext| {
+ // Build cmd + extension
+ const combined_len = cmd.len + ext.len;
+ if (combined_len > std.fs.max_path_bytes) return error.PathTooLong;
+ var cmd_with_ext: [std.fs.max_path_bytes]u8 = undefined;
+ @memcpy(cmd_with_ext[0..cmd.len], cmd);
+ @memcpy(cmd_with_ext[cmd.len..][0..ext.len], ext);
+ const cmd_ext_str = cmd_with_ext[0..combined_len];
+
+ if (try tryPathImpl(alloc, search_path, cmd_ext_str, &path_buf, &seen_eacces)) |result| {
+ return result;
+ }
+ }
}
}
@@ -64,6 +103,43 @@ pub fn expand(alloc: Allocator, cmd: []const u8) !?[]u8 {
return null;
}
+/// Helper function to try opening a file at search_path/cmd.
+/// Returns the allocated full path on success, null on FileNotFound,
+/// tracks AccessDenied in seen_eacces pointer, or error on other failures.
+fn tryPathImpl(alloc: Allocator, search_path: []const u8, cmd: []const u8, path_buf: *[std.fs.max_path_bytes]u8, seen_eacces: *bool) !?[]u8 {
+ const path_len = search_path.len + cmd.len + 1;
+ if (path_buf.len < path_len) return error.PathTooLong;
+
+ // Copy in the full path
+ @memcpy(path_buf[0..search_path.len], search_path);
+ path_buf[search_path.len] = std.fs.path.sep;
+ @memcpy(path_buf[search_path.len + 1 ..][0..cmd.len], cmd);
+ path_buf[path_len] = 0;
+ const full_path = path_buf[0..path_len :0];
+
+ // Try to open the file
+ const f = std.fs.cwd().openFile(
+ full_path,
+ .{},
+ ) catch |err| switch (err) {
+ error.FileNotFound => return null,
+ error.AccessDenied => {
+ // Accumulate this and return it later so we can try other
+ // paths that we have access to.
+ seen_eacces.* = true;
+ return null;
+ },
+ else => return err,
+ };
+ defer f.close();
+ const stat = try f.stat();
+ if (stat.kind != .directory and isExecutable(stat.mode)) {
+ return try alloc.dupe(u8, full_path);
+ }
+
+ return null;
+}
+
fn isExecutable(mode: std.fs.File.Mode) bool {
if (builtin.os.tag == .windows) return true;
return mode & 0o0111 != 0;
@@ -87,3 +163,60 @@ test "expand: slash" {
defer testing.allocator.free(path);
try testing.expect(path.len == 7);
}
+
+test "expand: windows backslash passes through" {
+ if (builtin.os.tag != .windows) return error.SkipZigTest;
+ const input = "C:\\Windows\\System32\\cmd.exe";
+ const path = (try expand(testing.allocator, input)).?;
+ defer testing.allocator.free(path);
+ try testing.expectEqualStrings(input, path);
+}
+
+test "expand: windows drive-letter only passes through" {
+ if (builtin.os.tag != .windows) return error.SkipZigTest;
+ // No separator, but the drive prefix means this is already a
+ // path (drive-relative) and expand() must not treat it as a
+ // bare name to search PATH for.
+ const input = "C:cmd.exe";
+ const path = (try expand(testing.allocator, input)).?;
+ defer testing.allocator.free(path);
+ try testing.expectEqualStrings(input, path);
+}
+
+test "expand: windows bare cmd.exe resolves on PATH" {
+ if (builtin.os.tag != .windows) return error.SkipZigTest;
+ const path = (try expand(testing.allocator, "cmd.exe")).?;
+ defer testing.allocator.free(path);
+ // System32\cmd.exe lives on the default Windows PATH.
+ try testing.expect(std.ascii.endsWithIgnoreCase(path, "cmd.exe"));
+ try testing.expect(path.len > "cmd.exe".len);
+}
+
+test "expand: windows bare pwsh resolves via PATHEXT" {
+ if (builtin.os.tag != .windows) return error.SkipZigTest;
+ // This test requires pwsh.exe to be on the system PATH.
+ // If not found, it returns null rather than erroring.
+ const path = try expand(testing.allocator, "pwsh");
+ if (path) |p| {
+ defer testing.allocator.free(p);
+ try testing.expect(std.ascii.endsWithIgnoreCase(p, "pwsh.exe") or std.ascii.endsWithIgnoreCase(p, "pwsh.com") or std.ascii.endsWithIgnoreCase(p, "pwsh.bat") or std.ascii.endsWithIgnoreCase(p, "pwsh.cmd"));
+ try testing.expect(p.len > "pwsh".len);
+ }
+}
+
+test "expand: windows name with extension does not PATHEXT hunt" {
+ if (builtin.os.tag != .windows) return error.SkipZigTest;
+ // If we provide a name with an extension (even unknown), it should
+ // try that literally, not attempt PATHEXT extensions.
+ const path = try expand(testing.allocator, "cmd.xyz");
+ try testing.expect(path == null);
+}
+
+test "expand: windows extension present bypasses PATHEXT" {
+ if (builtin.os.tag != .windows) return error.SkipZigTest;
+ // cmd.exe should resolve literally via the literal-first attempt,
+ // not via PATHEXT hunting.
+ const path = (try expand(testing.allocator, "cmd.exe")).?;
+ defer testing.allocator.free(path);
+ try testing.expect(std.ascii.endsWithIgnoreCase(path, "cmd.exe"));
+}
diff --git a/src/os/windows.zig b/src/os/windows.zig
index 6e452fb7339..dedab979f5b 100644
--- a/src/os/windows.zig
+++ b/src/os/windows.zig
@@ -10,6 +10,7 @@ pub const CloseHandle = windows.CloseHandle;
pub const GetCurrentProcessId = windows.GetCurrentProcessId;
pub const SetHandleInformation = windows.SetHandleInformation;
pub const DWORD = windows.DWORD;
+pub const BOOL = windows.BOOL;
pub const FILE_ATTRIBUTE_NORMAL = windows.FILE_ATTRIBUTE_NORMAL;
pub const FILE_FLAG_OVERLAPPED = windows.FILE_FLAG_OVERLAPPED;
pub const FILE_SHARE_READ = windows.FILE_SHARE_READ;
@@ -20,6 +21,7 @@ pub const INFINITE = windows.INFINITE;
pub const INVALID_HANDLE_VALUE = windows.INVALID_HANDLE_VALUE;
pub const MAX_PATH = windows.MAX_PATH;
pub const OPEN_EXISTING = windows.OPEN_EXISTING;
+pub const OVERLAPPED = windows.OVERLAPPED;
pub const PIPE_ACCESS_OUTBOUND = windows.PIPE_ACCESS_OUTBOUND;
pub const PIPE_TYPE_BYTE = windows.PIPE_TYPE_BYTE;
pub const PROCESS_INFORMATION = windows.PROCESS_INFORMATION;
@@ -29,13 +31,26 @@ pub const STARTUPINFOW = windows.STARTUPINFOW;
pub const STARTF_USESTDHANDLES = windows.STARTF_USESTDHANDLES;
pub const SYNCHRONIZE = windows.SYNCHRONIZE;
pub const WAIT_FAILED = windows.WAIT_FAILED;
+pub const WAIT_OBJECT_0 = windows.WAIT_OBJECT_0;
+pub const DUPLICATE_SAME_ACCESS = windows.DUPLICATE_SAME_ACCESS;
pub const FALSE = windows.FALSE;
pub const TRUE = windows.TRUE;
+/// GetHandleInformation is not wrapped in Zig std yet (std only wraps
+/// SetHandleInformation). Expose a small wrapper here so callers can
+/// verify handle inheritance flags without reaching into kernel32
+/// directly.
+pub fn GetHandleInformation(handle: windows.HANDLE, flags: *windows.DWORD) !void {
+ if (exp.kernel32.GetHandleInformation(handle, flags) == 0) {
+ return windows.unexpectedError(windows.kernel32.GetLastError());
+ }
+}
+
pub const exp = struct {
pub const HPCON = windows.LPVOID;
pub const CREATE_UNICODE_ENVIRONMENT = 0x00000400;
+ pub const CREATE_NO_WINDOW = 0x08000000;
pub const EXTENDED_STARTUPINFO_PRESENT = 0x00080000;
pub const LPPROC_THREAD_ATTRIBUTE_LIST = ?*anyopaque;
pub const FILE_FLAG_FIRST_PIPE_INSTANCE = 0x00080000;
@@ -55,6 +70,23 @@ pub const exp = struct {
lpPipeAttributes: ?*const windows.SECURITY_ATTRIBUTES,
nSize: windows.DWORD,
) callconv(.winapi) windows.BOOL;
+ // System ANSI code page (per-process default ACP, set by the
+ // user's locale). Used to detect legacy double-byte CJK locales
+ // where forcing UTF-8 on a spawned shell would mojibake legacy
+ // .bat scripts. std.os.windows does not wrap this.
+ pub extern "kernel32" fn GetACP() callconv(.winapi) windows.UINT;
+ // std.os.windows.kernel32 only exposes CreateEventExW; add the
+ // classic CreateEventW for overlapped I/O wait events.
+ pub extern "kernel32" fn CreateEventW(
+ lpEventAttributes: ?*windows.SECURITY_ATTRIBUTES,
+ bManualReset: windows.BOOL,
+ bInitialState: windows.BOOL,
+ lpName: ?windows.LPCWSTR,
+ ) callconv(.winapi) ?windows.HANDLE;
+ pub extern "kernel32" fn GetHandleInformation(
+ hObject: windows.HANDLE,
+ lpdwFlags: *windows.DWORD,
+ ) callconv(.winapi) windows.BOOL;
pub extern "kernel32" fn CreatePseudoConsole(
size: windows.COORD,
hInput: windows.HANDLE,
@@ -118,6 +150,7 @@ pub const exp = struct {
pub const PROC_THREAD_ATTRIBUTE_ADDITIVE = 0x00040000;
pub const ProcThreadAttributeNumber = enum(windows.DWORD) {
+ ProcThreadAttributeHandleList = 2,
ProcThreadAttributePseudoConsole = 22,
_,
};
@@ -136,4 +169,5 @@ pub const exp = struct {
}
pub const PROC_THREAD_ATTRIBUTE_PSEUDOCONSOLE = ProcThreadAttributeValue(.ProcThreadAttributePseudoConsole, false, true, false);
+ pub const PROC_THREAD_ATTRIBUTE_HANDLE_LIST = ProcThreadAttributeValue(.ProcThreadAttributeHandleList, false, true, false);
};
diff --git a/src/os/windows_com.zig b/src/os/windows_com.zig
new file mode 100644
index 00000000000..ea1546da4d1
--- /dev/null
+++ b/src/os/windows_com.zig
@@ -0,0 +1,81 @@
+const std = @import("std");
+
+/// COM GUID (Globally Unique Identifier).
+pub const GUID = extern struct {
+ data1: u32,
+ data2: u16,
+ data3: u16,
+ data4: [8]u8,
+};
+
+/// COM HRESULT return type.
+pub const HRESULT = i32;
+
+/// Returns true if the HRESULT indicates success (non-negative).
+pub inline fn SUCCEEDED(hr: HRESULT) bool {
+ return hr >= 0;
+}
+
+/// Returns true if the HRESULT indicates failure (negative).
+pub inline fn FAILED(hr: HRESULT) bool {
+ return hr < 0;
+}
+
+pub const S_OK: HRESULT = 0;
+pub const E_NOINTERFACE: HRESULT = @bitCast(@as(u32, 0x80004002));
+pub const E_FAIL: HRESULT = @bitCast(@as(u32, 0x80004005));
+
+/// IUnknown - base COM interface that all COM objects implement.
+pub const IUnknown = extern struct {
+ vtable: *const VTable,
+
+ pub const VTable = extern struct {
+ QueryInterface: *const fn (
+ self: *IUnknown,
+ riid: *const GUID,
+ ppvObject: *?*anyopaque,
+ ) callconv(.winapi) HRESULT,
+ AddRef: *const fn (self: *IUnknown) callconv(.winapi) u32,
+ Release: *const fn (self: *IUnknown) callconv(.winapi) u32,
+ };
+
+ pub inline fn Release(self: *IUnknown) u32 {
+ return self.vtable.Release(self);
+ }
+
+ pub inline fn AddRef(self: *IUnknown) u32 {
+ return self.vtable.AddRef(self);
+ }
+
+ pub inline fn QueryInterface(
+ self: *IUnknown,
+ riid: *const GUID,
+ ppvObject: *?*anyopaque,
+ ) HRESULT {
+ return self.vtable.QueryInterface(self, riid, ppvObject);
+ }
+};
+
+/// Stub vtable entry for COM methods not yet wrapped.
+pub const Reserved = *const fn () callconv(.winapi) void;
+
+test "GUID size and alignment" {
+ try std.testing.expectEqual(@sizeOf(GUID), 16);
+ try std.testing.expectEqual(@alignOf(GUID), 4);
+}
+
+test "HRESULT helpers" {
+ try std.testing.expect(SUCCEEDED(S_OK));
+ try std.testing.expect(!FAILED(S_OK));
+ try std.testing.expect(FAILED(E_FAIL));
+ try std.testing.expect(!SUCCEEDED(E_FAIL));
+ try std.testing.expect(FAILED(E_NOINTERFACE));
+}
+
+test "IUnknown vtable pointer size" {
+ try std.testing.expectEqual(@sizeOf(IUnknown), @sizeOf(*anyopaque));
+}
+
+test "Reserved size" {
+ try std.testing.expectEqual(@sizeOf(Reserved), @sizeOf(*anyopaque));
+}
diff --git a/src/os/windows_shell.zig b/src/os/windows_shell.zig
new file mode 100644
index 00000000000..8df43278808
--- /dev/null
+++ b/src/os/windows_shell.zig
@@ -0,0 +1,455 @@
+//! VT-awareness classification for Windows shell executables.
+//!
+//! This is orthogonal to src/termio/shell_integration.zig's `Shell`
+//! enum: `Shell` identifies bash/zsh/etc for RC-file injection;
+//! `Awareness` says whether the shell speaks VT natively or uses the
+//! Win32 Console API. A shell can be recognized here without being
+//! recognized there (e.g. `wsl.exe`) and vice versa.
+//!
+//! The C# port at windows/Ghostty.Core/Shell/ShellDetector.cs keeps
+//! the same table; edit both together until the C# side is retired.
+
+const std = @import("std");
+const builtin = @import("builtin");
+const windows = @import("windows.zig");
+const testing = std.testing;
+const log = std.log.scoped(.windows_shell);
+
+pub const Awareness = enum {
+ unknown,
+ vt_aware,
+ console_api,
+};
+
+/// UTF-8 preamble kind needed to make a shell's *initial* output land
+/// as UTF-8 when it runs under ConPTY. Separate from `Awareness`
+/// because we need to distinguish cmd from powershell (same awareness,
+/// different preamble) and pwsh from the other vt_aware shells
+/// (same awareness, but only powershell-family benefits from the
+/// setup under forced conpty-mode=never).
+///
+/// The setup runs once at shell startup inside ConPTY's conhost.exe,
+/// which does not inherit the caller's console codepage.
+pub const Preamble = enum {
+ /// No preamble: either the shell is unknown, or it already handles
+ /// its own encoding (e.g. wsl / bash / nu all decode their own
+ /// output regardless of the Windows console CP).
+ none,
+ /// cmd.exe: run `chcp 65001 >nul` at startup and stay interactive.
+ cmd,
+ /// PowerShell (pwsh.exe or Windows PowerShell 5.1): assign
+ /// `[Console]::OutputEncoding` and `InputEncoding` before the
+ /// prompt appears.
+ pwsh,
+
+ /// Argv elements to append after the user's existing argv so that
+ /// the configured shell runs the UTF-8 setup at startup. String
+ /// literals live in `.rodata`, so callers using an arena for argv
+ /// can append the returned slices directly without duping.
+ pub fn suffix(self: Preamble) []const [:0]const u8 {
+ return switch (self) {
+ .none => &.{},
+ .cmd => &cmd_suffix,
+ .pwsh => &pwsh_suffix,
+ };
+ }
+
+ /// Text to prepend to a user-supplied script when the user already
+ /// consumed the shell's "rest of command line" slot (e.g. `cmd /C
+ ///