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 @@

- Logo -
Ghostty + Logo +
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 + ///