Merge branch 'main' into grapheme-break

pull/9680/head
Jacob Sandlund 2025-12-09 08:45:42 -05:00
commit 7bddbfed1e
36 changed files with 2630 additions and 593 deletions

View File

@ -1,5 +1,10 @@
on: [push, pull_request]
name: Nix
concurrency:
group: ${{ github.workflow }}-${{ github.ref_name != 'main' && github.ref || github.run_id }}
cancel-in-progress: true
jobs:
required:
name: "Required Checks: Nix"
@ -34,7 +39,7 @@ jobs:
ZIG_GLOBAL_CACHE_DIR: /zig/global-cache
steps:
- name: Checkout code
uses: actions/checkout@1af3b93b6815bc44a9784bd300feb67ff0d1eeb3 # v6.0.0
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
- name: Setup Cache
uses: namespacelabs/nscloud-cache-action@446d8f390563cd54ca27e8de5bdb816f63c0b706 # v1.2.21
with:

View File

@ -56,7 +56,7 @@ jobs:
fi
- name: Checkout code
uses: actions/checkout@1af3b93b6815bc44a9784bd300feb67ff0d1eeb3 # v6.0.0
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
with:
# Important so that build number generation works
fetch-depth: 0
@ -80,7 +80,7 @@ jobs:
ZIG_LOCAL_CACHE_DIR: /zig/local-cache
ZIG_GLOBAL_CACHE_DIR: /zig/global-cache
steps:
- uses: actions/checkout@1af3b93b6815bc44a9784bd300feb67ff0d1eeb3 # v6.0.0
- uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
- name: Setup Cache
uses: namespacelabs/nscloud-cache-action@446d8f390563cd54ca27e8de5bdb816f63c0b706 # v1.2.21
@ -132,7 +132,7 @@ jobs:
GHOSTTY_COMMIT: ${{ needs.setup.outputs.commit }}
steps:
- name: Checkout code
uses: actions/checkout@1af3b93b6815bc44a9784bd300feb67ff0d1eeb3 # v6.0.0
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
- uses: DeterminateSystems/nix-installer-action@main
with:
@ -306,7 +306,7 @@ jobs:
GHOSTTY_COMMIT_LONG: ${{ needs.setup.outputs.commit_long }}
steps:
- name: Checkout code
uses: actions/checkout@1af3b93b6815bc44a9784bd300feb67ff0d1eeb3 # v6.0.0
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
- name: Download macOS Artifacts
uses: actions/download-artifact@018cc2cf5baa6db3ef3c5f8a56943fffe632ef53 # v6.0.0

View File

@ -29,7 +29,7 @@ jobs:
commit: ${{ steps.extract_build_info.outputs.commit }}
commit_long: ${{ steps.extract_build_info.outputs.commit_long }}
steps:
- uses: actions/checkout@1af3b93b6815bc44a9784bd300feb67ff0d1eeb3 # v6.0.0
- uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
with:
# Important so that build number generation works
fetch-depth: 0
@ -66,7 +66,7 @@ jobs:
needs: [setup, build-macos]
if: needs.setup.outputs.should_skip != 'true'
steps:
- uses: actions/checkout@1af3b93b6815bc44a9784bd300feb67ff0d1eeb3 # v6.0.0
- uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
- name: Tip Tag
run: |
git config user.name "github-actions[bot]"
@ -81,7 +81,7 @@ jobs:
env:
GHOSTTY_COMMIT_LONG: ${{ needs.setup.outputs.commit_long }}
steps:
- uses: actions/checkout@1af3b93b6815bc44a9784bd300feb67ff0d1eeb3 # v6.0.0
- uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
- name: Install sentry-cli
run: |
@ -104,7 +104,7 @@ jobs:
env:
GHOSTTY_COMMIT_LONG: ${{ needs.setup.outputs.commit_long }}
steps:
- uses: actions/checkout@1af3b93b6815bc44a9784bd300feb67ff0d1eeb3 # v6.0.0
- uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
- name: Install sentry-cli
run: |
@ -127,7 +127,7 @@ jobs:
env:
GHOSTTY_COMMIT_LONG: ${{ needs.setup.outputs.commit_long }}
steps:
- uses: actions/checkout@1af3b93b6815bc44a9784bd300feb67ff0d1eeb3 # v6.0.0
- uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
- name: Install sentry-cli
run: |
@ -159,7 +159,7 @@ jobs:
ZIG_LOCAL_CACHE_DIR: /zig/local-cache
ZIG_GLOBAL_CACHE_DIR: /zig/global-cache
steps:
- uses: actions/checkout@1af3b93b6815bc44a9784bd300feb67ff0d1eeb3 # v6.0.0
- uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
- name: Setup Cache
uses: namespacelabs/nscloud-cache-action@446d8f390563cd54ca27e8de5bdb816f63c0b706 # v1.2.21
with:
@ -186,7 +186,7 @@ jobs:
nix develop -c minisign -S -m ghostty-source.tar.gz -s minisign.key < minisign.password
- name: Update Release
uses: softprops/action-gh-release@5be0e66d93ac7ed76da52eca8bb058f665c3a5fe # v2.4.2
uses: softprops/action-gh-release@a06a81a03ee405af7f2048a818ed3f03bbf83c7b # v2.5.0
with:
name: 'Ghostty Tip ("Nightly")'
prerelease: true
@ -217,7 +217,7 @@ jobs:
GHOSTTY_COMMIT_LONG: ${{ needs.setup.outputs.commit_long }}
steps:
- name: Checkout code
uses: actions/checkout@1af3b93b6815bc44a9784bd300feb67ff0d1eeb3 # v6.0.0
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
with:
# Important so that build number generation works
fetch-depth: 0
@ -356,7 +356,7 @@ jobs:
# Update Release
- name: Release
uses: softprops/action-gh-release@5be0e66d93ac7ed76da52eca8bb058f665c3a5fe # v2.4.2
uses: softprops/action-gh-release@a06a81a03ee405af7f2048a818ed3f03bbf83c7b # v2.5.0
with:
name: 'Ghostty Tip ("Nightly")'
prerelease: true
@ -451,7 +451,7 @@ jobs:
GHOSTTY_COMMIT_LONG: ${{ needs.setup.outputs.commit_long }}
steps:
- name: Checkout code
uses: actions/checkout@1af3b93b6815bc44a9784bd300feb67ff0d1eeb3 # v6.0.0
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
with:
# Important so that build number generation works
fetch-depth: 0
@ -583,7 +583,7 @@ jobs:
# Update Release
- name: Release
uses: softprops/action-gh-release@5be0e66d93ac7ed76da52eca8bb058f665c3a5fe # v2.4.2
uses: softprops/action-gh-release@a06a81a03ee405af7f2048a818ed3f03bbf83c7b # v2.5.0
with:
name: 'Ghostty Tip ("Nightly")'
prerelease: true
@ -635,7 +635,7 @@ jobs:
GHOSTTY_COMMIT_LONG: ${{ needs.setup.outputs.commit_long }}
steps:
- name: Checkout code
uses: actions/checkout@1af3b93b6815bc44a9784bd300feb67ff0d1eeb3 # v6.0.0
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
with:
# Important so that build number generation works
fetch-depth: 0
@ -767,7 +767,7 @@ jobs:
# Update Release
- name: Release
uses: softprops/action-gh-release@5be0e66d93ac7ed76da52eca8bb058f665c3a5fe # v2.4.2
uses: softprops/action-gh-release@a06a81a03ee405af7f2048a818ed3f03bbf83c7b # v2.5.0
with:
name: 'Ghostty Tip ("Nightly")'
prerelease: true

View File

@ -5,6 +5,11 @@ on:
name: Test
# We only want the latest commit to test for any non-main ref.
concurrency:
group: ${{ github.workflow }}-${{ github.ref_name != 'main' && github.ref || github.run_id }}
cancel-in-progress: true
jobs:
required:
name: "Required Checks: Test"
@ -69,7 +74,7 @@ jobs:
ZIG_GLOBAL_CACHE_DIR: /zig/global-cache
steps:
- name: Checkout code
uses: actions/checkout@1af3b93b6815bc44a9784bd300feb67ff0d1eeb3 # v6.0.0
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
- name: Setup Cache
uses: namespacelabs/nscloud-cache-action@446d8f390563cd54ca27e8de5bdb816f63c0b706 # v1.2.21
@ -112,7 +117,7 @@ jobs:
ZIG_GLOBAL_CACHE_DIR: /zig/global-cache
steps:
- name: Checkout code
uses: actions/checkout@1af3b93b6815bc44a9784bd300feb67ff0d1eeb3 # v6.0.0
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
- name: Setup Cache
uses: namespacelabs/nscloud-cache-action@446d8f390563cd54ca27e8de5bdb816f63c0b706 # v1.2.21
@ -145,7 +150,7 @@ jobs:
ZIG_GLOBAL_CACHE_DIR: /zig/global-cache
steps:
- name: Checkout code
uses: actions/checkout@1af3b93b6815bc44a9784bd300feb67ff0d1eeb3 # v6.0.0
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
- name: Setup Cache
uses: namespacelabs/nscloud-cache-action@446d8f390563cd54ca27e8de5bdb816f63c0b706 # v1.2.21
@ -179,7 +184,7 @@ jobs:
ZIG_GLOBAL_CACHE_DIR: /zig/global-cache
steps:
- name: Checkout code
uses: actions/checkout@1af3b93b6815bc44a9784bd300feb67ff0d1eeb3 # v6.0.0
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
- name: Setup Cache
uses: namespacelabs/nscloud-cache-action@446d8f390563cd54ca27e8de5bdb816f63c0b706 # v1.2.21
@ -222,7 +227,7 @@ jobs:
ZIG_GLOBAL_CACHE_DIR: /zig/global-cache
steps:
- name: Checkout code
uses: actions/checkout@1af3b93b6815bc44a9784bd300feb67ff0d1eeb3 # v6.0.0
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
- name: Setup Cache
uses: namespacelabs/nscloud-cache-action@446d8f390563cd54ca27e8de5bdb816f63c0b706 # v1.2.21
@ -258,7 +263,7 @@ jobs:
ZIG_GLOBAL_CACHE_DIR: /zig/global-cache
steps:
- name: Checkout code
uses: actions/checkout@1af3b93b6815bc44a9784bd300feb67ff0d1eeb3 # v6.0.0
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
- name: Setup Cache
uses: namespacelabs/nscloud-cache-action@446d8f390563cd54ca27e8de5bdb816f63c0b706 # v1.2.21
@ -287,7 +292,7 @@ jobs:
ZIG_GLOBAL_CACHE_DIR: /zig/global-cache
steps:
- name: Checkout code
uses: actions/checkout@1af3b93b6815bc44a9784bd300feb67ff0d1eeb3 # v6.0.0
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
- name: Setup Cache
uses: namespacelabs/nscloud-cache-action@446d8f390563cd54ca27e8de5bdb816f63c0b706 # v1.2.21
@ -320,7 +325,7 @@ jobs:
ZIG_GLOBAL_CACHE_DIR: /zig/global-cache
steps:
- name: Checkout code
uses: actions/checkout@1af3b93b6815bc44a9784bd300feb67ff0d1eeb3 # v6.0.0
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
- name: Setup Cache
uses: namespacelabs/nscloud-cache-action@446d8f390563cd54ca27e8de5bdb816f63c0b706 # v1.2.21
@ -366,7 +371,7 @@ jobs:
ZIG_GLOBAL_CACHE_DIR: /zig/global-cache
steps:
- name: Checkout code
uses: actions/checkout@1af3b93b6815bc44a9784bd300feb67ff0d1eeb3 # v6.0.0
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
- name: Setup Cache
uses: namespacelabs/nscloud-cache-action@446d8f390563cd54ca27e8de5bdb816f63c0b706 # v1.2.21
@ -404,7 +409,7 @@ jobs:
needs: [build-dist, build-snap]
steps:
- name: Checkout code
uses: actions/checkout@1af3b93b6815bc44a9784bd300feb67ff0d1eeb3 # v6.0.0
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
- name: Trigger Snap workflow
run: |
@ -421,7 +426,7 @@ jobs:
needs: test
steps:
- name: Checkout code
uses: actions/checkout@1af3b93b6815bc44a9784bd300feb67ff0d1eeb3 # v6.0.0
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
# TODO(tahoe): https://github.com/NixOS/nix/issues/13342
- uses: DeterminateSystems/nix-installer-action@main
@ -464,7 +469,7 @@ jobs:
needs: test
steps:
- name: Checkout code
uses: actions/checkout@1af3b93b6815bc44a9784bd300feb67ff0d1eeb3 # v6.0.0
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
# TODO(tahoe): https://github.com/NixOS/nix/issues/13342
- uses: DeterminateSystems/nix-installer-action@main
@ -509,7 +514,7 @@ jobs:
needs: test
steps:
- name: Checkout code
uses: actions/checkout@1af3b93b6815bc44a9784bd300feb67ff0d1eeb3 # v6.0.0
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
# This could be from a script if we wanted to but inlining here for now
# in one place.
@ -580,7 +585,7 @@ jobs:
ZIG_GLOBAL_CACHE_DIR: /zig/global-cache
steps:
- name: Checkout code
uses: actions/checkout@1af3b93b6815bc44a9784bd300feb67ff0d1eeb3 # v6.0.0
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
- name: Get required Zig version
id: zig
@ -627,7 +632,7 @@ jobs:
ZIG_GLOBAL_CACHE_DIR: /zig/global-cache
steps:
- name: Checkout code
uses: actions/checkout@1af3b93b6815bc44a9784bd300feb67ff0d1eeb3 # v6.0.0
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
- name: Setup Cache
uses: namespacelabs/nscloud-cache-action@446d8f390563cd54ca27e8de5bdb816f63c0b706 # v1.2.21
@ -675,7 +680,7 @@ jobs:
ZIG_GLOBAL_CACHE_DIR: /zig/global-cache
steps:
- name: Checkout code
uses: actions/checkout@1af3b93b6815bc44a9784bd300feb67ff0d1eeb3 # v6.0.0
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
- name: Setup Cache
uses: namespacelabs/nscloud-cache-action@446d8f390563cd54ca27e8de5bdb816f63c0b706 # v1.2.21
@ -710,7 +715,7 @@ jobs:
ZIG_GLOBAL_CACHE_DIR: /zig/global-cache
steps:
- name: Checkout code
uses: actions/checkout@1af3b93b6815bc44a9784bd300feb67ff0d1eeb3 # v6.0.0
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
- name: Setup Cache
uses: namespacelabs/nscloud-cache-action@446d8f390563cd54ca27e8de5bdb816f63c0b706 # v1.2.21
@ -737,7 +742,7 @@ jobs:
needs: test
steps:
- name: Checkout code
uses: actions/checkout@1af3b93b6815bc44a9784bd300feb67ff0d1eeb3 # v6.0.0
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
# TODO(tahoe): https://github.com/NixOS/nix/issues/13342
- uses: DeterminateSystems/nix-installer-action@main
@ -774,7 +779,7 @@ jobs:
ZIG_GLOBAL_CACHE_DIR: /zig/global-cache
steps:
- name: Checkout code
uses: actions/checkout@1af3b93b6815bc44a9784bd300feb67ff0d1eeb3 # v6.0.0
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
- name: Setup Cache
uses: namespacelabs/nscloud-cache-action@446d8f390563cd54ca27e8de5bdb816f63c0b706 # v1.2.21
@ -804,7 +809,7 @@ jobs:
ZIG_LOCAL_CACHE_DIR: /zig/local-cache
ZIG_GLOBAL_CACHE_DIR: /zig/global-cache
steps:
- uses: actions/checkout@1af3b93b6815bc44a9784bd300feb67ff0d1eeb3 # v6.0.0
- uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
- name: Setup Cache
uses: namespacelabs/nscloud-cache-action@446d8f390563cd54ca27e8de5bdb816f63c0b706 # v1.2.21
with:
@ -832,7 +837,7 @@ jobs:
ZIG_LOCAL_CACHE_DIR: /zig/local-cache
ZIG_GLOBAL_CACHE_DIR: /zig/global-cache
steps:
- uses: actions/checkout@1af3b93b6815bc44a9784bd300feb67ff0d1eeb3 # v6.0.0
- uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
- name: Setup Cache
uses: namespacelabs/nscloud-cache-action@446d8f390563cd54ca27e8de5bdb816f63c0b706 # v1.2.21
with:
@ -859,7 +864,7 @@ jobs:
ZIG_LOCAL_CACHE_DIR: /zig/local-cache
ZIG_GLOBAL_CACHE_DIR: /zig/global-cache
steps:
- uses: actions/checkout@1af3b93b6815bc44a9784bd300feb67ff0d1eeb3 # v6.0.0
- uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
- name: Setup Cache
uses: namespacelabs/nscloud-cache-action@446d8f390563cd54ca27e8de5bdb816f63c0b706 # v1.2.21
with:
@ -886,7 +891,7 @@ jobs:
ZIG_LOCAL_CACHE_DIR: /zig/local-cache
ZIG_GLOBAL_CACHE_DIR: /zig/global-cache
steps:
- uses: actions/checkout@1af3b93b6815bc44a9784bd300feb67ff0d1eeb3 # v6.0.0
- uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
- name: Setup Cache
uses: namespacelabs/nscloud-cache-action@446d8f390563cd54ca27e8de5bdb816f63c0b706 # v1.2.21
with:
@ -913,7 +918,7 @@ jobs:
ZIG_LOCAL_CACHE_DIR: /zig/local-cache
ZIG_GLOBAL_CACHE_DIR: /zig/global-cache
steps:
- uses: actions/checkout@1af3b93b6815bc44a9784bd300feb67ff0d1eeb3 # v6.0.0
- uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
- name: Setup Cache
uses: namespacelabs/nscloud-cache-action@446d8f390563cd54ca27e8de5bdb816f63c0b706 # v1.2.21
with:
@ -940,7 +945,7 @@ jobs:
ZIG_LOCAL_CACHE_DIR: /zig/local-cache
ZIG_GLOBAL_CACHE_DIR: /zig/global-cache
steps:
- uses: actions/checkout@1af3b93b6815bc44a9784bd300feb67ff0d1eeb3 # v6.0.0
- uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
- name: Setup Cache
uses: namespacelabs/nscloud-cache-action@446d8f390563cd54ca27e8de5bdb816f63c0b706 # v1.2.21
with:
@ -974,7 +979,7 @@ jobs:
ZIG_LOCAL_CACHE_DIR: /zig/local-cache
ZIG_GLOBAL_CACHE_DIR: /zig/global-cache
steps:
- uses: actions/checkout@1af3b93b6815bc44a9784bd300feb67ff0d1eeb3 # v6.0.0
- uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
- name: Setup Cache
uses: namespacelabs/nscloud-cache-action@446d8f390563cd54ca27e8de5bdb816f63c0b706 # v1.2.21
with:
@ -1001,7 +1006,7 @@ jobs:
ZIG_LOCAL_CACHE_DIR: /zig/local-cache
ZIG_GLOBAL_CACHE_DIR: /zig/global-cache
steps:
- uses: actions/checkout@1af3b93b6815bc44a9784bd300feb67ff0d1eeb3 # v6.0.0
- uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
- name: Setup Cache
uses: namespacelabs/nscloud-cache-action@446d8f390563cd54ca27e8de5bdb816f63c0b706 # v1.2.21
with:
@ -1035,7 +1040,7 @@ jobs:
ZIG_GLOBAL_CACHE_DIR: /zig/global-cache
steps:
- name: Checkout code
uses: actions/checkout@1af3b93b6815bc44a9784bd300feb67ff0d1eeb3 # v6.0.0
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
- name: Setup Cache
uses: namespacelabs/nscloud-cache-action@446d8f390563cd54ca27e8de5bdb816f63c0b706 # v1.2.21
@ -1104,7 +1109,7 @@ jobs:
runs-on: ${{ matrix.variant.runner }}
needs: test
steps:
- uses: actions/checkout@1af3b93b6815bc44a9784bd300feb67ff0d1eeb3 # v6.0.0
- uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
- uses: flatpak/flatpak-github-actions/flatpak-builder@92ae9851ad316786193b1fd3f40c4b51eb5cb101 # v6.6
with:
bundle: com.mitchellh.ghostty
@ -1123,7 +1128,7 @@ jobs:
ZIG_GLOBAL_CACHE_DIR: /zig/global-cache
steps:
- name: Checkout code
uses: actions/checkout@1af3b93b6815bc44a9784bd300feb67ff0d1eeb3 # v6.0.0
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
- name: Setup Cache
uses: namespacelabs/nscloud-cache-action@446d8f390563cd54ca27e8de5bdb816f63c0b706 # v1.2.21
@ -1162,7 +1167,7 @@ jobs:
# timeout-minutes: 10
# steps:
# - name: Checkout Ghostty
# uses: actions/checkout@1af3b93b6815bc44a9784bd300feb67ff0d1eeb3 # v6.0.0
# uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
#
# - name: Start SSH
# run: |

View File

@ -17,7 +17,7 @@ jobs:
ZIG_GLOBAL_CACHE_DIR: /zig/global-cache
steps:
- name: Checkout code
uses: actions/checkout@1af3b93b6815bc44a9784bd300feb67ff0d1eeb3 # v6.0.0
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
with:
fetch-depth: 0
@ -62,7 +62,7 @@ jobs:
run: nix build .#ghostty
- name: Create pull request
uses: peter-evans/create-pull-request@84ae59a2cdc2258d6fa0732dd66352dddae2a412 # v7.0.9
uses: peter-evans/create-pull-request@22a9089034f40e5a961c8808d113e2c98fb63676 # v7.0.11
with:
title: Update iTerm2 colorschemes
base: main

View File

@ -23,10 +23,20 @@ it, please check out our ["Developing Ghostty"](HACKING.md) document as well.
If you are using any kind of AI assistance while contributing to Ghostty,
**this must be disclosed in the pull request**, along with the extent to
which AI assistance was used (e.g. docs only vs. code generation).
If PR responses are being generated by an AI, disclose that as well.
As a small exception, trivial tab-completion doesn't need to be disclosed,
so long as it is limited to single keywords or short phrases.
The submitter must have also tested the pull request on all impacted
platforms, and it's **highly discouraged** to code for an unfamiliar platform
with AI assistance alone: if you only have a macOS machine, do **not** ask AI
to write the equivalent GTK code, and vice versa — someone else with more
expertise will eventually get to it and do it for you.
Even though using AI to generate responses on a PR is allowed when properly
disclosed, **we do not encourage you to do so**. Often, the positive impact
of genuine, responsive human interaction more than makes up for any language
barrier. ❤️
An example disclosure:
> This PR was written primarily by Claude Code.
@ -36,6 +46,11 @@ Or a more detailed disclosure:
> I consulted ChatGPT to understand the codebase but the solution
> was fully authored manually by myself.
An example of a **problematic** disclosure (not having tested all platforms):
> I used Amp to code both macOS and GTK UIs, but I have not yet tested
> the GTK UI as I don't have a Linux setup.
Failure to disclose this is first and foremost rude to the human operators
on the other end of the pull request, but it also makes it difficult to
determine how much scrutiny to apply to the contribution.
@ -45,10 +60,12 @@ work than any human. That isn't the world we live in today, and in most cases
it's generating slop. I say this despite being a fan of and using them
successfully myself (with heavy supervision)!
When using AI assistance, we expect contributors to understand the code
When using AI assistance, we expect a fairly high level of accountability
and responsibility from contributors, and expect them to understand the code
that is produced and be able to answer critical questions about it. It
isn't a maintainers job to review a PR so broken that it requires
significant rework to be acceptable.
significant rework to be acceptable, and we **reserve the right to close
these PRs without hesitation**.
Please be respectful to maintainers and disclose AI assistance.
@ -74,22 +91,47 @@ submission.
### I have a bug! / Something isn't working
1. Search the issue tracker and discussions for similar issues. Tip: also
search for [closed issues] and [discussions] — your issue might have already
been fixed!
2. If your issue hasn't been reported already, open an ["Issue Triage" discussion]
and make sure to fill in the template **completely**. They are vital for
maintainers to figure out important details about your setup. Because of
this, please make sure that you _only_ use the "Issue Triage" category for
reporting bugs — thank you!
First, search the issue tracker and discussions for similar issues. Tip: also
search for [closed issues] and [discussions] — your issue might have already
been fixed!
> [!NOTE]
>
> If there is an _open_ issue or discussion that matches your problem,
> **please do not comment on it unless you have valuable insight to add**.
>
> GitHub has a very _noisy_ set of default notification settings which
> sends an email to _every participant_ in an issue/discussion every time
> someone adds a comment. Instead, use the handy upvote button for discussions,
> and/or emoji reactions on both discussions and issues, which are a visible
> yet non-disruptive way to show your support.
If your issue hasn't been reported already, open an ["Issue Triage"] discussion
and make sure to fill in the template **completely**. They are vital for
maintainers to figure out important details about your setup.
> [!WARNING]
>
> A _very_ common mistake is to file a bug report either as a Q&A or a Feature
> Request. **Please don't do this.** Otherwise, maintainers would have to ask
> for your system information again manually, and sometimes they will even ask
> you to create a new discussion because of how few detailed information is
> required for other discussion types compared to Issue Triage.
>
> Because of this, please make sure that you _only_ use the "Issue Triage"
> category for reporting bugs — thank you!
[closed issues]: https://github.com/ghostty-org/ghostty/issues?q=is%3Aissue%20state%3Aclosed
[discussions]: https://github.com/ghostty-org/ghostty/discussions?discussions_q=is%3Aclosed
["Issue Triage" discussion]: https://github.com/ghostty-org/ghostty/discussions/new?category=issue-triage
["Issue Triage"]: https://github.com/ghostty-org/ghostty/discussions/new?category=issue-triage
### I have an idea for a feature
Open a discussion in the ["Feature Requests, Ideas" category](https://github.com/ghostty-org/ghostty/discussions/new?category=feature-requests-ideas).
Like bug reports, first search through both issues and discussions and try to
find if your feature has already been requested. Otherwise, open a discussion
in the ["Feature Requests, Ideas"] category.
["Feature Requests, Ideas"]: https://github.com/ghostty-org/ghostty/discussions/new?category=feature-requests-ideas
### I've implemented a feature
@ -98,10 +140,28 @@ Open a discussion in the ["Feature Requests, Ideas" category](https://github.com
3. If you want to live dangerously, open a pull request and
[hope for the best](#pull-requests-implement-an-issue).
### I have a question
### I have a question which is neither a bug report nor a feature request
Open an [Q&A discussion], or join our [Discord Server] and ask away in the
`#help` channel.
`#help` forum channel.
Do not use the `#terminals` or `#development` channels to ask for help —
those are for general discussion about terminals and Ghostty development
respectively. If you do ask a question there, you will be redirected to
`#help` instead.
> [!NOTE]
> If your question is about a missing feature, please open a discussion under
> the ["Feature Requests, Ideas"] category. If Ghostty is behaving
> unexpectedly, use the ["Issue Triage"] category.
>
> The "Q&A" category is strictly for other kinds of discussions and do not
> require detailed information unlike the two other categories, meaning that
> maintainers would have to spend the extra effort to ask for basic information
> if you submit a bug report under this category.
>
> Therefore, please **pay attention to the category** before opening
> discussions to save us all some time and energy. Thank you!
[Q&A discussion]: https://github.com/ghostty-org/ghostty/discussions/new?category=q-a
[Discord Server]: https://discord.gg/ghostty

View File

@ -116,8 +116,8 @@
// Other
.apple_sdk = .{ .path = "./pkg/apple-sdk" },
.iterm2_themes = .{
.url = "https://github.com/mbadolato/iTerm2-Color-Schemes/releases/download/release-20251124-150533-2b326a8/ghostty-themes.tgz",
.hash = "N-V-__8AAPZCAwDJ0OsIn2nbr3FMvBw68oiv-hC2pFuY1eLN",
.url = "https://github.com/mbadolato/iTerm2-Color-Schemes/releases/download/release-20251201-150531-bfb3ee1/ghostty-themes.tgz",
.hash = "N-V-__8AANFEAwCzzNzNs3Gaq8pzGNl2BbeyFBwTyO5iZJL-",
.lazy = true,
},
},

6
build.zig.zon.json generated
View File

@ -49,10 +49,10 @@
"url": "https://deps.files.ghostty.org/imgui-1220bc6b9daceaf7c8c60f3c3998058045ba0c5c5f48ae255ff97776d9cd8bfc6402.tar.gz",
"hash": "sha256-oF/QHgTPEat4Hig4fGIdLkIPHmBEyOJ6JeYD6pnveGA="
},
"N-V-__8AAPZCAwDJ0OsIn2nbr3FMvBw68oiv-hC2pFuY1eLN": {
"N-V-__8AANFEAwCzzNzNs3Gaq8pzGNl2BbeyFBwTyO5iZJL-": {
"name": "iterm2_themes",
"url": "https://github.com/mbadolato/iTerm2-Color-Schemes/releases/download/release-20251124-150533-2b326a8/ghostty-themes.tgz",
"hash": "sha256-5mmXW7d9SkesHyIwUBlWmyGtOWf6wu0S6zkHe93FVLM="
"url": "https://github.com/mbadolato/iTerm2-Color-Schemes/releases/download/release-20251201-150531-bfb3ee1/ghostty-themes.tgz",
"hash": "sha256-5QePBQlSsz9W2r4zTS3QD+cDAeyObhR51E2AkJ3ZIUk="
},
"N-V-__8AAIC5lwAVPJJzxnCAahSvZTIlG-HhtOvnM1uh-66x": {
"name": "jetbrains_mono",

6
build.zig.zon.nix generated
View File

@ -163,11 +163,11 @@ in
};
}
{
name = "N-V-__8AAPZCAwDJ0OsIn2nbr3FMvBw68oiv-hC2pFuY1eLN";
name = "N-V-__8AANFEAwCzzNzNs3Gaq8pzGNl2BbeyFBwTyO5iZJL-";
path = fetchZigArtifact {
name = "iterm2_themes";
url = "https://github.com/mbadolato/iTerm2-Color-Schemes/releases/download/release-20251124-150533-2b326a8/ghostty-themes.tgz";
hash = "sha256-5mmXW7d9SkesHyIwUBlWmyGtOWf6wu0S6zkHe93FVLM=";
url = "https://github.com/mbadolato/iTerm2-Color-Schemes/releases/download/release-20251201-150531-bfb3ee1/ghostty-themes.tgz";
hash = "sha256-5QePBQlSsz9W2r4zTS3QD+cDAeyObhR51E2AkJ3ZIUk=";
};
}
{

2
build.zig.zon.txt generated
View File

@ -29,7 +29,7 @@ https://deps.files.ghostty.org/zig_wayland-1b5c038ec10da20ed3a15b0b2a6db1c21383e
https://deps.files.ghostty.org/zlib-1220fed0c74e1019b3ee29edae2051788b080cd96e90d56836eea857b0b966742efb.tar.gz
https://github.com/ivanstepanovftw/zigimg/archive/d7b7ab0ba0899643831ef042bd73289510b39906.tar.gz
https://github.com/jacobsandlund/uucode/archive/31655fba3c638229989cc524363ef5e3c7b580c1.tar.gz
https://github.com/mbadolato/iTerm2-Color-Schemes/releases/download/release-20251124-150533-2b326a8/ghostty-themes.tgz
https://github.com/mbadolato/iTerm2-Color-Schemes/releases/download/release-20251201-150531-bfb3ee1/ghostty-themes.tgz
https://github.com/natecraddock/zf/archive/3c52637b7e937c5ae61fd679717da3e276765b23.tar.gz
https://github.com/rockorager/libvaxis/archive/7dbb9fd3122e4ffad262dd7c151d80d863b68558.tar.gz
https://github.com/vancluever/z2d/archive/refs/tags/v0.9.0.tar.gz

View File

@ -61,9 +61,9 @@
},
{
"type": "archive",
"url": "https://github.com/mbadolato/iTerm2-Color-Schemes/releases/download/release-20251124-150533-2b326a8/ghostty-themes.tgz",
"dest": "vendor/p/N-V-__8AAPZCAwDJ0OsIn2nbr3FMvBw68oiv-hC2pFuY1eLN",
"sha256": "e669975bb77d4a47ac1f22305019569b21ad3967fac2ed12eb39077bddc554b3"
"url": "https://github.com/mbadolato/iTerm2-Color-Schemes/releases/download/release-20251201-150531-bfb3ee1/ghostty-themes.tgz",
"dest": "vendor/p/N-V-__8AANFEAwCzzNzNs3Gaq8pzGNl2BbeyFBwTyO5iZJL-",
"sha256": "e5078f050952b33f56dabe334d2dd00fe70301ec8e6e1479d44d80909dd92149"
},
{
"type": "archive",

View File

@ -33,6 +33,7 @@ struct QuickTerminalSize {
case GHOSTTY_QUICK_TERMINAL_SIZE_PIXELS:
self = .pixels(cStruct.value.pixels)
default:
assertionFailure()
return nil
}
}

View File

@ -155,6 +155,9 @@ command_timer: ?std.time.Instant = null,
/// Search state
search: ?Search = null,
/// Used to rate limit BEL handling.
last_bell_time: ?std.time.Instant = null,
/// The effect of an input event. This can be used by callers to take
/// the appropriate action after an input event. For example, key
/// input can be forwarded to the OS for further processing if it
@ -1026,7 +1029,12 @@ pub fn handleMessage(self: *Surface, msg: Message) !void {
.password_input => |v| try self.passwordInput(v),
.ring_bell => {
.ring_bell => bell: {
const now = std.time.Instant.now() catch unreachable;
if (self.last_bell_time) |last| {
if (now.since(last) < 100 * std.time.ns_per_ms) break :bell;
}
self.last_bell_time = now;
_ = self.rt_app.performAction(
.{ .surface = self },
.ring_bell,
@ -2584,6 +2592,8 @@ pub fn keyCallback(
{
// Refresh our link state
const pos = self.rt_surface.getCursorPos() catch break :mouse_mods;
self.renderer_state.mutex.lock();
defer self.renderer_state.mutex.unlock();
self.mouseRefreshLinks(
pos,
self.posToViewport(pos.x, pos.y),
@ -3464,6 +3474,8 @@ fn mouseReport(
.five => 65,
.six => 66,
.seven => 67,
.eight => 128,
.nine => 129,
else => return, // unsupported
};
}
@ -5020,8 +5032,9 @@ pub fn performBindingAction(self: *Surface, action: input.Binding.Action) !bool
},
.copy_to_clipboard => |format| {
// We can read from the renderer state without holding
// the lock because only we will write to this field.
self.renderer_state.mutex.lock();
defer self.renderer_state.mutex.unlock();
if (self.io.terminal.screens.active.selection) |sel| {
try self.copySelectionToClipboards(
sel,
@ -5049,8 +5062,10 @@ pub fn performBindingAction(self: *Surface, action: input.Binding.Action) !bool
.copy_url_to_clipboard => {
// If the mouse isn't over a link, nothing we can do.
if (!self.mouse.over_link) return false;
const pos = try self.rt_surface.getCursorPos();
self.renderer_state.mutex.lock();
defer self.renderer_state.mutex.unlock();
if (try self.linkAtPos(pos)) |link_info| {
const url_text = switch (link_info[0]) {
.open => url_text: {
@ -5426,6 +5441,9 @@ pub fn performBindingAction(self: *Surface, action: input.Binding.Action) !bool
),
.select_all => {
self.renderer_state.mutex.lock();
defer self.renderer_state.mutex.unlock();
const sel = self.io.terminal.screens.active.selectAll();
if (sel) |s| {
try self.setSelection(s);

View File

@ -43,6 +43,7 @@ pub const blueprints: []const Blueprint = &.{
.{ .major = 1, .minor = 5, .name = "inspector-widget" },
.{ .major = 1, .minor = 5, .name = "inspector-window" },
.{ .major = 1, .minor = 2, .name = "resize-overlay" },
.{ .major = 1, .minor = 2, .name = "search-overlay" },
.{ .major = 1, .minor = 5, .name = "split-tree" },
.{ .major = 1, .minor = 5, .name = "split-tree-split" },
.{ .major = 1, .minor = 2, .name = "surface" },

View File

@ -727,6 +727,11 @@ pub const Application = extern struct {
.show_on_screen_keyboard => return Action.showOnScreenKeyboard(target),
.command_finished => return Action.commandFinished(target, value),
.start_search => Action.startSearch(target),
.end_search => Action.endSearch(target),
.search_total => Action.searchTotal(target, value),
.search_selected => Action.searchSelected(target, value),
// Unimplemented
.secure_input,
.close_all_windows,
@ -741,10 +746,6 @@ pub const Application = extern struct {
.check_for_updates,
.undo,
.redo,
.start_search,
.end_search,
.search_total,
.search_selected,
=> {
log.warn("unimplemented action={}", .{action});
return false;
@ -2341,6 +2342,34 @@ const Action = struct {
}
}
pub fn startSearch(target: apprt.Target) void {
switch (target) {
.app => {},
.surface => |v| v.rt_surface.surface.setSearchActive(true),
}
}
pub fn endSearch(target: apprt.Target) void {
switch (target) {
.app => {},
.surface => |v| v.rt_surface.surface.setSearchActive(false),
}
}
pub fn searchTotal(target: apprt.Target, value: apprt.action.SearchTotal) void {
switch (target) {
.app => {},
.surface => |v| v.rt_surface.surface.setSearchTotal(value.total),
}
}
pub fn searchSelected(target: apprt.Target, value: apprt.action.SearchSelected) void {
switch (target) {
.app => {},
.surface => |v| v.rt_surface.surface.setSearchSelected(value.selected),
}
}
pub fn setTitle(
target: apprt.Target,
value: apprt.action.SetTitle,

View File

@ -0,0 +1,486 @@
const std = @import("std");
const adw = @import("adw");
const glib = @import("glib");
const gobject = @import("gobject");
const gdk = @import("gdk");
const gtk = @import("gtk");
const gresource = @import("../build/gresource.zig");
const Common = @import("../class.zig").Common;
const log = std.log.scoped(.gtk_ghostty_search_overlay);
/// The overlay that shows the current size while a surface is resizing.
/// This can be used generically to show pretty much anything with a
/// disappearing overlay, but we have no other use at this point so it
/// is named specifically for what it does.
///
/// General usage:
///
/// 1. Add it to an overlay
/// 2. Set the label with `setLabel`
/// 3. Schedule to show it with `schedule`
///
/// Set any properties to change the behavior.
pub const SearchOverlay = extern struct {
const Self = @This();
parent_instance: Parent,
pub const Parent = adw.Bin;
pub const getGObjectType = gobject.ext.defineClass(Self, .{
.name = "GhosttySearchOverlay",
.instanceInit = &init,
.classInit = &Class.init,
.parent_class = &Class.parent,
.private = .{ .Type = Private, .offset = &Private.offset },
});
pub const properties = struct {
pub const active = struct {
pub const name = "active";
const impl = gobject.ext.defineProperty(
name,
Self,
bool,
.{
.default = false,
.accessor = gobject.ext.typedAccessor(
Self,
bool,
.{
.getter = getSearchActive,
.setter = setSearchActive,
},
),
},
);
};
pub const @"search-total" = struct {
pub const name = "search-total";
const impl = gobject.ext.defineProperty(
name,
Self,
u64,
.{
.default = 0,
.minimum = 0,
.maximum = std.math.maxInt(u64),
.accessor = gobject.ext.typedAccessor(
Self,
u64,
.{ .getter = getSearchTotal },
),
},
);
};
pub const @"has-search-total" = struct {
pub const name = "has-search-total";
const impl = gobject.ext.defineProperty(
name,
Self,
bool,
.{
.default = false,
.accessor = gobject.ext.typedAccessor(
Self,
bool,
.{ .getter = getHasSearchTotal },
),
},
);
};
pub const @"search-selected" = struct {
pub const name = "search-selected";
const impl = gobject.ext.defineProperty(
name,
Self,
u64,
.{
.default = 0,
.minimum = 0,
.maximum = std.math.maxInt(u64),
.accessor = gobject.ext.typedAccessor(
Self,
u64,
.{ .getter = getSearchSelected },
),
},
);
};
pub const @"has-search-selected" = struct {
pub const name = "has-search-selected";
const impl = gobject.ext.defineProperty(
name,
Self,
bool,
.{
.default = false,
.accessor = gobject.ext.typedAccessor(
Self,
bool,
.{ .getter = getHasSearchSelected },
),
},
);
};
pub const @"halign-target" = struct {
pub const name = "halign-target";
const impl = gobject.ext.defineProperty(
name,
Self,
gtk.Align,
.{
.default = .end,
.accessor = C.privateShallowFieldAccessor("halign_target"),
},
);
};
pub const @"valign-target" = struct {
pub const name = "valign-target";
const impl = gobject.ext.defineProperty(
name,
Self,
gtk.Align,
.{
.default = .start,
.accessor = C.privateShallowFieldAccessor("valign_target"),
},
);
};
};
pub const signals = struct {
/// Emitted when the search is stopped (e.g., Escape pressed).
pub const @"stop-search" = struct {
pub const name = "stop-search";
pub const connect = impl.connect;
const impl = gobject.ext.defineSignal(
name,
Self,
&.{},
void,
);
};
/// Emitted when the search text changes (debounced).
pub const @"search-changed" = struct {
pub const name = "search-changed";
pub const connect = impl.connect;
const impl = gobject.ext.defineSignal(
name,
Self,
&.{?[*:0]const u8},
void,
);
};
/// Emitted when navigating to the next match.
pub const @"next-match" = struct {
pub const name = "next-match";
pub const connect = impl.connect;
const impl = gobject.ext.defineSignal(
name,
Self,
&.{},
void,
);
};
/// Emitted when navigating to the previous match.
pub const @"previous-match" = struct {
pub const name = "previous-match";
pub const connect = impl.connect;
const impl = gobject.ext.defineSignal(
name,
Self,
&.{},
void,
);
};
};
const Private = struct {
/// The search entry widget.
search_entry: *gtk.SearchEntry,
/// True when a search is active, meaning we should show the overlay.
active: bool = false,
/// Total number of search matches (null means unknown/none).
search_total: ?usize = null,
/// Currently selected match index (null means none selected).
search_selected: ?usize = null,
/// Target horizontal alignment for the overlay.
halign_target: gtk.Align = .end,
/// Target vertical alignment for the overlay.
valign_target: gtk.Align = .start,
pub var offset: c_int = 0;
};
fn init(self: *Self, _: *Class) callconv(.c) void {
gtk.Widget.initTemplate(self.as(gtk.Widget));
}
/// Grab focus on the search entry and select all text.
pub fn grabFocus(self: *Self) void {
const priv = self.private();
_ = priv.search_entry.as(gtk.Widget).grabFocus();
// Select all text in the search entry field. -1 is distance from
// the end, causing the entire text to be selected.
priv.search_entry.as(gtk.Editable).selectRegion(0, -1);
}
// Set active status, and update search on activation
fn setSearchActive(self: *Self, active: bool) void {
const priv = self.private();
if (!priv.active and active) {
const text = priv.search_entry.as(gtk.Editable).getText();
signals.@"search-changed".impl.emit(self, null, .{text}, null);
}
priv.active = active;
}
/// Set the total number of search matches.
pub fn setSearchTotal(self: *Self, total: ?usize) void {
const priv = self.private();
const had_total = priv.search_total != null;
if (priv.search_total == total) return;
priv.search_total = total;
self.as(gobject.Object).notifyByPspec(properties.@"search-total".impl.param_spec);
if (had_total != (total != null)) {
self.as(gobject.Object).notifyByPspec(properties.@"has-search-total".impl.param_spec);
}
}
/// Set the currently selected match index.
pub fn setSearchSelected(self: *Self, selected: ?usize) void {
const priv = self.private();
const had_selected = priv.search_selected != null;
if (priv.search_selected == selected) return;
priv.search_selected = selected;
self.as(gobject.Object).notifyByPspec(properties.@"search-selected".impl.param_spec);
if (had_selected != (selected != null)) {
self.as(gobject.Object).notifyByPspec(properties.@"has-search-selected".impl.param_spec);
}
}
fn getSearchActive(self: *Self) bool {
return self.private().active;
}
fn getSearchTotal(self: *Self) u64 {
return self.private().search_total orelse 0;
}
fn getHasSearchTotal(self: *Self) bool {
return self.private().search_total != null;
}
fn getSearchSelected(self: *Self) u64 {
return self.private().search_selected orelse 0;
}
fn getHasSearchSelected(self: *Self) bool {
return self.private().search_selected != null;
}
fn closureMatchLabel(
_: *Self,
has_selected: bool,
selected: u64,
has_total: bool,
total: u64,
) callconv(.c) ?[*:0]const u8 {
if (!has_total or total == 0) return glib.ext.dupeZ(u8, "0/0");
var buf: [32]u8 = undefined;
const label = std.fmt.bufPrintZ(&buf, "{}/{}", .{
if (has_selected) selected + 1 else 0,
total,
}) catch return null;
return glib.ext.dupeZ(u8, label);
}
//---------------------------------------------------------------
// Template callbacks
fn searchChanged(entry: *gtk.SearchEntry, self: *Self) callconv(.c) void {
const text = entry.as(gtk.Editable).getText();
signals.@"search-changed".impl.emit(self, null, .{text}, null);
}
// NOTE: The callbacks below use anyopaque for the first parameter
// because they're shared with multiple widgets in the template.
fn stopSearch(_: *anyopaque, self: *Self) callconv(.c) void {
signals.@"stop-search".impl.emit(self, null, .{}, null);
}
fn nextMatch(_: *anyopaque, self: *Self) callconv(.c) void {
signals.@"next-match".impl.emit(self, null, .{}, null);
}
fn previousMatch(_: *anyopaque, self: *Self) callconv(.c) void {
signals.@"previous-match".impl.emit(self, null, .{}, null);
}
fn searchEntryKeyPressed(
_: *gtk.EventControllerKey,
keyval: c_uint,
_: c_uint,
gtk_mods: gdk.ModifierType,
self: *Self,
) callconv(.c) c_int {
if (keyval == gdk.KEY_Return or keyval == gdk.KEY_KP_Enter) {
if (gtk_mods.shift_mask) {
signals.@"previous-match".impl.emit(self, null, .{}, null);
} else {
signals.@"next-match".impl.emit(self, null, .{}, null);
}
return 1;
}
return 0;
}
fn onDragEnd(
_: *gtk.GestureDrag,
offset_x: f64,
offset_y: f64,
self: *Self,
) callconv(.c) void {
// On drag end, we want to move our halign/valign if we crossed
// the midpoint on either axis. This lets the search overlay be
// moved to different corners of the parent container.
const priv = self.private();
const widget = self.as(gtk.Widget);
const parent = widget.getParent() orelse return;
const parent_width: f64 = @floatFromInt(parent.getAllocatedWidth());
const parent_height: f64 = @floatFromInt(parent.getAllocatedHeight());
const self_width: f64 = @floatFromInt(widget.getAllocatedWidth());
const self_height: f64 = @floatFromInt(widget.getAllocatedHeight());
const self_x: f64 = if (priv.halign_target == .start) 0 else parent_width - self_width;
const self_y: f64 = if (priv.valign_target == .start) 0 else parent_height - self_height;
const new_x = self_x + offset_x + (self_width / 2);
const new_y = self_y + offset_y + (self_height / 2);
const new_halign: gtk.Align = if (new_x > parent_width / 2) .end else .start;
const new_valign: gtk.Align = if (new_y > parent_height / 2) .end else .start;
var changed = false;
if (new_halign != priv.halign_target) {
priv.halign_target = new_halign;
self.as(gobject.Object).notifyByPspec(properties.@"halign-target".impl.param_spec);
changed = true;
}
if (new_valign != priv.valign_target) {
priv.valign_target = new_valign;
self.as(gobject.Object).notifyByPspec(properties.@"valign-target".impl.param_spec);
changed = true;
}
if (changed) self.as(gtk.Widget).queueResize();
}
//---------------------------------------------------------------
// Virtual methods
fn dispose(self: *Self) callconv(.c) void {
const priv = self.private();
_ = priv;
gtk.Widget.disposeTemplate(
self.as(gtk.Widget),
getGObjectType(),
);
gobject.Object.virtual_methods.dispose.call(
Class.parent,
self.as(Parent),
);
}
fn finalize(self: *Self) callconv(.c) void {
const priv = self.private();
_ = priv;
gobject.Object.virtual_methods.finalize.call(
Class.parent,
self.as(Parent),
);
}
const C = Common(Self, Private);
pub const as = C.as;
pub const ref = C.ref;
pub const unref = C.unref;
const private = C.private;
pub const Class = extern struct {
parent_class: Parent.Class,
var parent: *Parent.Class = undefined;
pub const Instance = Self;
fn init(class: *Class) callconv(.c) void {
gtk.Widget.Class.setTemplateFromResource(
class.as(gtk.Widget.Class),
comptime gresource.blueprint(.{
.major = 1,
.minor = 2,
.name = "search-overlay",
}),
);
// Bindings
class.bindTemplateChildPrivate("search_entry", .{});
// Template Callbacks
class.bindTemplateCallback("stop_search", &stopSearch);
class.bindTemplateCallback("search_changed", &searchChanged);
class.bindTemplateCallback("match_label_closure", &closureMatchLabel);
class.bindTemplateCallback("next_match", &nextMatch);
class.bindTemplateCallback("previous_match", &previousMatch);
class.bindTemplateCallback("search_entry_key_pressed", &searchEntryKeyPressed);
class.bindTemplateCallback("on_drag_end", &onDragEnd);
// Properties
gobject.ext.registerProperties(class, &.{
properties.active.impl,
properties.@"search-total".impl,
properties.@"has-search-total".impl,
properties.@"search-selected".impl,
properties.@"has-search-selected".impl,
properties.@"halign-target".impl,
properties.@"valign-target".impl,
});
// Signals
signals.@"stop-search".impl.register(.{});
signals.@"search-changed".impl.register(.{});
signals.@"next-match".impl.register(.{});
signals.@"previous-match".impl.register(.{});
// Virtual methods
gobject.Object.virtual_methods.dispose.implement(class, &dispose);
gobject.Object.virtual_methods.finalize.implement(class, &finalize);
}
pub const as = C.Class.as;
pub const bindTemplateChildPrivate = C.Class.bindTemplateChildPrivate;
pub const bindTemplateCallback = C.Class.bindTemplateCallback;
};
};

View File

@ -25,6 +25,7 @@ const Common = @import("../class.zig").Common;
const Application = @import("application.zig").Application;
const Config = @import("config.zig").Config;
const ResizeOverlay = @import("resize_overlay.zig").ResizeOverlay;
const SearchOverlay = @import("search_overlay.zig").SearchOverlay;
const ChildExited = @import("surface_child_exited.zig").SurfaceChildExited;
const ClipboardConfirmationDialog = @import("clipboard_confirmation_dialog.zig").ClipboardConfirmationDialog;
const TitleDialog = @import("surface_title_dialog.zig").SurfaceTitleDialog;
@ -549,6 +550,9 @@ pub const Surface = extern struct {
/// The resize overlay
resize_overlay: *ResizeOverlay,
/// The search overlay
search_overlay: *SearchOverlay,
/// The apprt Surface.
rt_surface: ApprtSurface = undefined,
@ -1135,13 +1139,14 @@ pub const Surface = extern struct {
if (entry.native == keycode) break :w3c entry.key;
} else .unidentified;
// If the key should be remappable, then consult the pre-remapped
// XKB keyval/keysym to get the (possibly) remapped key.
// Consult the pre-remapped XKB keyval/keysym to get the (possibly)
// remapped key. If the W3C key or the remapped key
// is eligible for remapping, we use it.
//
// See the docs for `shouldBeRemappable` for why we even have to
// do this in the first place.
if (w3c_key.shouldBeRemappable()) {
if (gtk_key.keyFromKeyval(keyval)) |remapped|
if (gtk_key.keyFromKeyval(keyval)) |remapped| {
if (w3c_key.shouldBeRemappable() or remapped.shouldBeRemappable())
break :keycode remapped;
}
@ -1951,6 +1956,29 @@ pub const Surface = extern struct {
self.as(gobject.Object).notifyByPspec(properties.@"error".impl.param_spec);
}
pub fn setSearchActive(self: *Self, active: bool) void {
const priv = self.private();
var value = gobject.ext.Value.newFrom(active);
defer value.unset();
gobject.Object.setProperty(
priv.search_overlay.as(gobject.Object),
SearchOverlay.properties.active.name,
&value,
);
if (active) {
priv.search_overlay.grabFocus();
}
}
pub fn setSearchTotal(self: *Self, total: ?usize) void {
self.private().search_overlay.setSearchTotal(total);
}
pub fn setSearchSelected(self: *Self, selected: ?usize) void {
self.private().search_overlay.setSearchSelected(selected);
}
fn propConfig(
self: *Self,
_: *gobject.ParamSpec,
@ -3170,6 +3198,35 @@ pub const Surface = extern struct {
self.setTitleOverride(if (title.len == 0) null else title);
}
fn searchStop(_: *SearchOverlay, self: *Self) callconv(.c) void {
const surface = self.core() orelse return;
_ = surface.performBindingAction(.end_search) catch |err| {
log.warn("unable to perform end_search action err={}", .{err});
};
_ = self.private().gl_area.as(gtk.Widget).grabFocus();
}
fn searchChanged(_: *SearchOverlay, needle: ?[*:0]const u8, self: *Self) callconv(.c) void {
const surface = self.core() orelse return;
_ = surface.performBindingAction(.{ .search = std.mem.sliceTo(needle orelse "", 0) }) catch |err| {
log.warn("unable to perform search action err={}", .{err});
};
}
fn searchNextMatch(_: *SearchOverlay, self: *Self) callconv(.c) void {
const surface = self.core() orelse return;
_ = surface.performBindingAction(.{ .navigate_search = .next }) catch |err| {
log.warn("unable to perform navigate_search action err={}", .{err});
};
}
fn searchPreviousMatch(_: *SearchOverlay, self: *Self) callconv(.c) void {
const surface = self.core() orelse return;
_ = surface.performBindingAction(.{ .navigate_search = .previous }) catch |err| {
log.warn("unable to perform navigate_search action err={}", .{err});
};
}
const C = Common(Self, Private);
pub const as = C.as;
pub const ref = C.ref;
@ -3184,6 +3241,7 @@ pub const Surface = extern struct {
fn init(class: *Class) callconv(.c) void {
gobject.ext.ensureType(ResizeOverlay);
gobject.ext.ensureType(SearchOverlay);
gobject.ext.ensureType(ChildExited);
gtk.Widget.Class.setTemplateFromResource(
class.as(gtk.Widget.Class),
@ -3203,6 +3261,7 @@ pub const Surface = extern struct {
class.bindTemplateChildPrivate("error_page", .{});
class.bindTemplateChildPrivate("progress_bar_overlay", .{});
class.bindTemplateChildPrivate("resize_overlay", .{});
class.bindTemplateChildPrivate("search_overlay", .{});
class.bindTemplateChildPrivate("terminal_page", .{});
class.bindTemplateChildPrivate("drop_target", .{});
class.bindTemplateChildPrivate("im_context", .{});
@ -3240,6 +3299,10 @@ pub const Surface = extern struct {
class.bindTemplateCallback("notify_vadjustment", &propVAdjustment);
class.bindTemplateCallback("should_border_be_shown", &closureShouldBorderBeShown);
class.bindTemplateCallback("should_unfocused_split_be_shown", &closureShouldUnfocusedSplitBeShown);
class.bindTemplateCallback("search_stop", &searchStop);
class.bindTemplateCallback("search_changed", &searchChanged);
class.bindTemplateCallback("search_next_match", &searchNextMatch);
class.bindTemplateCallback("search_previous_match", &searchPreviousMatch);
// Properties
gobject.ext.registerProperties(class, &.{

View File

@ -34,6 +34,18 @@ label.url-overlay.right {
border-radius: 6px 0px 0px 0px;
}
/*
* GhosttySurface search overlay
*/
.search-overlay {
padding: 6px 8px;
margin: 8px;
border-radius: 8px;
outline-style: solid;
outline-color: #555555;
outline-width: 1px;
}
/*
* GhosttySurface resize overlay
*/

View File

@ -0,0 +1,94 @@
using Gtk 4.0;
using Gdk 4.0;
using Adw 1;
template $GhosttySearchOverlay: Adw.Bin {
visible: bind template.active;
halign-target: end;
valign-target: start;
halign: bind template.halign-target;
valign: bind template.valign-target;
GestureDrag {
button: 1;
propagation-phase: capture;
drag-end => $on_drag_end();
}
Adw.Bin {
Box container {
styles [
"background",
"search-overlay",
]
orientation: horizontal;
spacing: 6;
SearchEntry search_entry {
placeholder-text: _("Find…");
width-chars: 20;
hexpand: true;
stop-search => $stop_search();
search-changed => $search_changed();
next-match => $next_match();
previous-match => $previous_match();
EventControllerKey {
// We need this so we capture before the SearchEntry.
propagation-phase: capture;
key-pressed => $search_entry_key_pressed();
}
}
Label {
styles [
"dim-label",
]
label: bind $match_label_closure(template.has-search-selected, template.search-selected, template.has-search-total, template.search-total) as <string>;
width-chars: 6;
xalign: 1.0;
}
Box button_box {
orientation: horizontal;
spacing: 1;
styles [
"linked",
]
Button prev_button {
icon-name: "go-up-symbolic";
tooltip-text: _("Previous Match");
clicked => $next_match();
cursor: Gdk.Cursor {
name: "pointer";
};
}
Button next_button {
icon-name: "go-down-symbolic";
tooltip-text: _("Next Match");
clicked => $previous_match();
cursor: Gdk.Cursor {
name: "pointer";
};
}
}
Button close_button {
icon-name: "window-close-symbolic";
tooltip-text: _("Close");
clicked => $stop_search();
cursor: Gdk.Cursor {
name: "pointer";
};
}
}
}
}

View File

@ -41,6 +41,34 @@ Overlay terminal_page {
halign: start;
has-arrow: false;
}
EventControllerFocus {
enter => $focus_enter();
leave => $focus_leave();
}
EventControllerKey {
key-pressed => $key_pressed();
key-released => $key_released();
}
EventControllerScroll {
scroll => $scroll();
scroll-begin => $scroll_begin();
scroll-end => $scroll_end();
flags: both_axes;
}
EventControllerMotion {
motion => $mouse_motion();
leave => $mouse_leave();
}
GestureClick {
pressed => $mouse_down();
released => $mouse_up();
button: 0;
}
};
[overlay]
@ -64,6 +92,10 @@ Overlay terminal_page {
reveal-child: bind $should_border_be_shown(template.config, template.bell-ringing) as <bool>;
transition-type: crossfade;
transition-duration: 500;
// Revealers take up the full size, we need this to not capture events.
can-focus: false;
can-target: false;
focusable: false;
Box bell_overlay {
styles [
@ -115,12 +147,26 @@ Overlay terminal_page {
label: bind template.mouse-hover-url;
}
[overlay]
$GhosttySearchOverlay search_overlay {
stop-search => $search_stop();
search-changed => $search_changed();
next-match => $search_next_match();
previous-match => $search_previous_match();
}
[overlay]
// Apply unfocused-split-fill and unfocused-split-opacity to current surface
// this is only applied when a tab has more than one surface
Revealer {
reveal-child: bind $should_unfocused_split_be_shown(template.focused, template.is-split) as <bool>;
transition-duration: 0;
// This is all necessary so that the Revealer itself doesn't override
// any input events from the other overlays. Namely, if you don't have
// these then the search overlay won't get mouse events.
can-focus: false;
can-target: false;
focusable: false;
DrawingArea {
styles [
@ -129,35 +175,6 @@ Overlay terminal_page {
}
}
// Event controllers for interactivity
EventControllerFocus {
enter => $focus_enter();
leave => $focus_leave();
}
EventControllerKey {
key-pressed => $key_pressed();
key-released => $key_released();
}
EventControllerMotion {
motion => $mouse_motion();
leave => $mouse_leave();
}
EventControllerScroll {
scroll => $scroll();
scroll-begin => $scroll_begin();
scroll-end => $scroll_end();
flags: both_axes;
}
GestureClick {
pressed => $mouse_down();
released => $mouse_up();
button: 0;
}
DropTarget drop_target {
drop => $drop();
actions: copy;

View File

@ -170,11 +170,11 @@ pub const Resource = struct {
/// Returns true if the dist path exists at build time.
pub fn exists(self: *const Resource, b: *std.Build) bool {
if (std.fs.accessAbsolute(b.pathFromRoot(self.dist), .{})) {
if (b.build_root.handle.access(self.dist, .{})) {
// If we have a ".git" directory then we're a git checkout
// and we never want to use the dist path. This shouldn't happen
// so show a warning to the user.
if (std.fs.accessAbsolute(b.pathFromRoot(".git"), .{})) {
if (b.build_root.handle.access(".git", .{})) {
std.log.warn(
"dist resource '{s}' should not be in a git checkout",
.{self.dist},

View File

@ -6098,6 +6098,20 @@ pub const Keybinds = struct {
.{ .jump_to_prompt = 1 },
);
// Search
try self.set.putFlags(
alloc,
.{ .key = .{ .unicode = 'f' }, .mods = .{ .ctrl = true, .shift = true } },
.start_search,
.{ .performable = true },
);
try self.set.putFlags(
alloc,
.{ .key = .{ .physical = .escape } },
.end_search,
.{ .performable = true },
);
// Inspector, matching Chromium
try self.set.put(
alloc,
@ -7973,7 +7987,8 @@ pub const QuickTerminalSize = struct {
tag: Tag,
value: Value,
pub const Tag = enum(u8) { none, percentage, pixels };
/// c_int because it needs to be extern compatible
pub const Tag = enum(c_int) { none, percentage, pixels };
pub const Value = extern union {
percentage: f32,

View File

@ -69,6 +69,7 @@ test {
_ = i18n;
_ = path;
_ = uri;
_ = shell;
if (comptime builtin.os.tag == .linux) {
_ = kernel_info;

View File

@ -5,8 +5,6 @@ const Writer = std.Io.Writer;
/// Writer that escapes characters that shells treat specially to reduce the
/// risk of injection attacks or other such weirdness. Specifically excludes
/// linefeeds so that they can be used to delineate lists of file paths.
///
/// T should be a Zig type that follows the `std.Io.Writer` interface.
pub const ShellEscapeWriter = struct {
writer: Writer,
child: *Writer,
@ -33,7 +31,7 @@ pub const ShellEscapeWriter = struct {
var count: usize = 0;
for (data[0 .. data.len - 1]) |chunk| try self.writeEscaped(chunk, &count);
for (0..splat) |_| try self.writeEscaped(data[data.len], &count);
for (0..splat) |_| try self.writeEscaped(data[data.len - 1], &count);
return count;
}
@ -67,7 +65,7 @@ pub const ShellEscapeWriter = struct {
test "shell escape 1" {
var buf: [128]u8 = undefined;
var writer: std.Io.Writer = .fixed(&buf);
var shell: ShellEscapeWriter = .{ .child_writer = &writer };
var shell: ShellEscapeWriter = .init(&writer);
try shell.writer.writeAll("abc");
try testing.expectEqualStrings("abc", writer.buffered());
}
@ -75,7 +73,7 @@ test "shell escape 1" {
test "shell escape 2" {
var buf: [128]u8 = undefined;
var writer: std.Io.Writer = .fixed(&buf);
var shell: ShellEscapeWriter = .{ .child_writer = &writer };
var shell: ShellEscapeWriter = .init(&writer);
try shell.writer.writeAll("a c");
try testing.expectEqualStrings("a\\ c", writer.buffered());
}
@ -83,7 +81,7 @@ test "shell escape 2" {
test "shell escape 3" {
var buf: [128]u8 = undefined;
var writer: std.Io.Writer = .fixed(&buf);
var shell: ShellEscapeWriter = .{ .child_writer = &writer };
var shell: ShellEscapeWriter = .init(&writer);
try shell.writer.writeAll("a?c");
try testing.expectEqualStrings("a\\?c", writer.buffered());
}
@ -91,7 +89,7 @@ test "shell escape 3" {
test "shell escape 4" {
var buf: [128]u8 = undefined;
var writer: std.Io.Writer = .fixed(&buf);
var shell: ShellEscapeWriter = .{ .child_writer = &writer };
var shell: ShellEscapeWriter = .init(&writer);
try shell.writer.writeAll("a\\c");
try testing.expectEqualStrings("a\\\\c", writer.buffered());
}
@ -99,7 +97,7 @@ test "shell escape 4" {
test "shell escape 5" {
var buf: [128]u8 = undefined;
var writer: std.Io.Writer = .fixed(&buf);
var shell: ShellEscapeWriter = .{ .child_writer = &writer };
var shell: ShellEscapeWriter = .init(&writer);
try shell.writer.writeAll("a|c");
try testing.expectEqualStrings("a\\|c", writer.buffered());
}
@ -107,7 +105,7 @@ test "shell escape 5" {
test "shell escape 6" {
var buf: [128]u8 = undefined;
var writer: std.Io.Writer = .fixed(&buf);
var shell: ShellEscapeWriter = .{ .child_writer = &writer };
var shell: ShellEscapeWriter = .init(&writer);
try shell.writer.writeAll("a\"c");
try testing.expectEqualStrings("a\\\"c", writer.buffered());
}
@ -115,7 +113,7 @@ test "shell escape 6" {
test "shell escape 7" {
var buf: [128]u8 = undefined;
var writer: std.Io.Writer = .fixed(&buf);
var shell: ShellEscapeWriter = .{ .child_writer = &writer };
var shell: ShellEscapeWriter = .init(&writer);
try shell.writer.writeAll("a(1)");
try testing.expectEqualStrings("a\\(1\\)", writer.buffered());
}

View File

@ -7,6 +7,7 @@
//! [1]: https://github.com/WebKit/WebKit/blob/main/Source/WebCore/page/Quirks.cpp
const std = @import("std");
const builtin = @import("builtin");
const font = @import("font/main.zig");
@ -41,6 +42,16 @@ pub fn disableDefaultFontFeatures(face: *const font.Face) bool {
/// is negligible, but we have some asserts inside tight loops and hotpaths
/// that cause significant overhead (as much as 15-20%) when they don't get
/// optimized out.
pub inline fn inlineAssert(ok: bool) void {
if (!ok) unreachable;
}
pub const inlineAssert = switch (builtin.mode) {
// In debug builds we just use std.debug.assert because this
// fixes up stack traces. `inline` causes broken stack traces. This
// is probably a Zig compiler bug but until it is fixed we have to
// do this for development sanity.
.Debug => std.debug.assert,
.ReleaseSmall, .ReleaseSafe, .ReleaseFast => (struct {
inline fn assert(ok: bool) void {
if (!ok) unreachable;
}
}).assert,
};

View File

@ -83,8 +83,10 @@ from the `zsh` directory. The existing `ZDOTDIR` is retained so that
after loading the Ghostty shell integration the normal Zsh loading
sequence occurs.
```bash
```zsh
if [[ -n $GHOSTTY_RESOURCES_DIR ]]; then
source "$GHOSTTY_RESOURCES_DIR"/shell-integration/zsh/ghostty-integration
fi
```
Shell integration requires Zsh 5.1+.

View File

@ -15,11 +15,16 @@
# You should have received a copy of the GNU General Public License
# along with this program. If not, see <http://www.gnu.org/licenses/>.
# This script is sourced automatically by zsh when ZDOTDIR is set to this
# directory. It therefore assumes it's running within our shell integration
# environment and should not be sourced manually (unlike ghostty-integration).
#
# This file can get sourced with aliases enabled. To avoid alias expansion
# we quote everything that can be quoted. Some aliases will still break us
# though.
# Restore the original ZDOTDIR value.
# Restore the original ZDOTDIR value if GHOSTTY_ZSH_ZDOTDIR is set.
# Otherwise, unset the ZDOTDIR that was set during shell injection.
if [[ -n "${GHOSTTY_ZSH_ZDOTDIR+X}" ]]; then
'builtin' 'export' ZDOTDIR="$GHOSTTY_ZSH_ZDOTDIR"
'builtin' 'unset' 'GHOSTTY_ZSH_ZDOTDIR'
@ -43,12 +48,6 @@ fi
[[ ! -r "$_ghostty_file" ]] || 'builtin' 'source' '--' "$_ghostty_file"
} always {
if [[ -o 'interactive' ]]; then
'builtin' 'autoload' '--' 'is-at-least'
'is-at-least' "5.1" || {
builtin echo "ZSH ${ZSH_VERSION} is too old for ghostty shell integration" > /dev/stderr
'builtin' 'unset' '_ghostty_file'
return
}
# ${(%):-%x} is the path to the current file.
# On top of it we add :A:h to get the directory.
'builtin' 'typeset' _ghostty_file="${${(%):-%x}:A:h}"/ghostty-integration

View File

@ -1,3 +1,5 @@
# vim:ft=zsh
#
# Based on (started as) a copy of Kitty's zsh integration. Kitty is
# distributed under GPLv3, so this file is also distributed under GPLv3.
# The license header is reproduced below:
@ -41,6 +43,13 @@ _entrypoint() {
[[ -o interactive ]] || builtin return 0 # non-interactive shell
(( ! $+_ghostty_state )) || builtin return 0 # already initialized
# We require zsh 5.1+ (released Sept 2015) for features like functions_source,
# introspection arrays, and array pattern substitution.
if ! { builtin autoload -- is-at-least 2>/dev/null && is-at-least 5.1; }; then
builtin echo "Zsh ${ZSH_VERSION} is too old for ghostty shell integration (5.1+ required)" >&2
builtin return 1
fi
# 0: no OSC 133 [AC] marks have been written yet.
# 1: the last written OSC 133 C has not been closed with D yet.
# 2: none of the above.

View File

@ -2608,7 +2608,9 @@ pub fn adjustCapacity(
errdefer self.destroyNode(new_node);
const new_page: *Page = &new_node.data;
assert(new_page.capacity.rows >= page.capacity.rows);
assert(new_page.capacity.cols >= page.capacity.cols);
new_page.size.rows = page.size.rows;
new_page.size.cols = page.size.cols;
try new_page.cloneFrom(page, 0, page.size.rows);
// Fix up all our tracked pins to point to the new page.
@ -6257,6 +6259,39 @@ test "PageList adjustCapacity to increase hyperlinks" {
}
}
test "PageList adjustCapacity after col shrink" {
const testing = std.testing;
const alloc = testing.allocator;
var s = try init(alloc, 10, 2, 0);
defer s.deinit();
// Shrink columns - this updates size.cols but not capacity.cols
try s.resize(.{ .cols = 5, .reflow = false });
try testing.expectEqual(5, s.cols);
{
const page = &s.pages.first.?.data;
// capacity.cols is still 10, but size.cols should be 5
try testing.expectEqual(5, page.size.cols);
try testing.expect(page.capacity.cols >= 10);
}
// Now adjust capacity (e.g., to increase styles)
// This should preserve the current size.cols, not revert to capacity.cols
_ = try s.adjustCapacity(
s.pages.first.?,
.{ .styles = std_capacity.styles * 2 },
);
{
const page = &s.pages.first.?.data;
// After adjustCapacity, size.cols should still be 5, not 10
try testing.expectEqual(5, page.size.cols);
try testing.expectEqual(5, s.cols);
}
}
test "PageList pageIterator single page" {
const testing = std.testing;
const alloc = testing.allocator;

View File

@ -26,7 +26,7 @@ pub const Handler = struct {
assert(self.state == .inactive);
// Initialize our state to ignore in case of error
self.state = .{ .ignore = {} };
self.state = .ignore;
// Try to parse the hook.
const hk_ = self.tryHook(alloc, dcs) catch |err| {
@ -70,7 +70,7 @@ pub const Handler = struct {
),
},
},
.command = .{ .tmux = .{ .enter = {} } },
.command = .{ .tmux = .enter },
};
},
@ -116,7 +116,7 @@ pub const Handler = struct {
// On error we just discard our state and ignore the rest
log.info("error putting byte into DCS handler err={}", .{err});
self.discard();
self.state = .{ .ignore = {} };
self.state = .ignore;
return null;
};
}
@ -158,7 +158,7 @@ pub const Handler = struct {
// Note: we do NOT call deinit here on purpose because some commands
// transfer memory ownership. If state needs cleanup, the switch
// prong below should handle it.
defer self.state = .{ .inactive = {} };
defer self.state = .inactive;
return switch (self.state) {
.inactive,
@ -167,7 +167,7 @@ pub const Handler = struct {
.tmux => if (comptime build_options.tmux_control_mode) tmux: {
self.state.deinit();
break :tmux .{ .tmux = .{ .exit = {} } };
break :tmux .{ .tmux = .exit };
} else unreachable,
.xtgettcap => |*list| xtgettcap: {
@ -200,7 +200,7 @@ pub const Handler = struct {
fn discard(self: *Handler) void {
self.state.deinit();
self.state = .{ .inactive = {} };
self.state = .inactive;
}
};
@ -213,7 +213,7 @@ pub const Command = union(enum) {
/// Tmux control mode
tmux: if (build_options.tmux_control_mode)
terminal.tmux.Notification
terminal.tmux.ControlNotification
else
void,
@ -255,21 +255,15 @@ pub const Command = union(enum) {
decstbm,
decslrm,
};
/// Tmux control mode
pub const Tmux = union(enum) {
enter: void,
exit: void,
};
};
const State = union(enum) {
/// We're not in a DCS state at the moment.
inactive: void,
inactive,
/// We're hooked, but its an unknown DCS command or one that went
/// invalid due to some bad input, so we're ignoring the rest.
ignore: void,
ignore,
/// XTGETTCAP
xtgettcap: std.Io.Writer.Allocating,
@ -282,7 +276,7 @@ const State = union(enum) {
/// Tmux control mode: https://github.com/tmux/tmux/wiki/Control-Mode
tmux: if (build_options.tmux_control_mode)
terminal.tmux.Client
terminal.tmux.ControlParser
else
void,

View File

@ -385,6 +385,7 @@ pub const RenderState = struct {
const row_rows = row_data.items(.raw);
const row_cells = row_data.items(.cells);
const row_sels = row_data.items(.selection);
const row_highlights = row_data.items(.highlights);
const row_dirties = row_data.items(.dirty);
// Track the last page that we know was dirty. This lets us
@ -468,6 +469,7 @@ pub const RenderState = struct {
_ = arena.reset(.retain_capacity);
row_cells[y].clearRetainingCapacity();
row_sels[y] = null;
row_highlights[y] = .empty;
}
row_dirties[y] = true;
@ -1314,3 +1316,62 @@ test "string" {
const expected = "AB\x00\x00\x00\n\x00\x00\x00\x00\x00\n";
try testing.expectEqualStrings(expected, result);
}
test "dirty row resets highlights" {
const testing = std.testing;
const alloc = testing.allocator;
var t = try Terminal.init(alloc, .{
.cols = 10,
.rows = 3,
});
defer t.deinit(alloc);
var s = t.vtStream();
defer s.deinit();
try s.nextSlice("ABC");
var state: RenderState = .empty;
defer state.deinit(alloc);
try state.update(alloc, &t);
// Reset dirty state
state.dirty = .false;
{
const row_data = state.row_data.slice();
const dirty = row_data.items(.dirty);
@memset(dirty, false);
}
// Manually add a highlight to row 0
{
const row_data = state.row_data.slice();
const row_arenas = row_data.items(.arena);
const row_highlights = row_data.items(.highlights);
var arena = row_arenas[0].promote(alloc);
defer row_arenas[0] = arena.state;
try row_highlights[0].append(arena.allocator(), .{
.tag = 1,
.range = .{ 0, 2 },
});
}
// Verify we have a highlight
{
const row_data = state.row_data.slice();
const row_highlights = row_data.items(.highlights);
try testing.expectEqual(1, row_highlights[0].items.len);
}
// Write to row 0 to make it dirty
try s.nextSlice("\x1b[H"); // Move to home
try s.nextSlice("X");
try state.update(alloc, &t);
// Verify the highlight was reset on the dirty row
{
const row_data = state.row_data.slice();
const row_highlights = row_data.items(.highlights);
try testing.expectEqual(0, row_highlights[0].items.len);
}
}

View File

@ -1,435 +1,12 @@
//! This file contains the implementation for tmux control mode. See
//! tmux(1) for more information on control mode. Some basics are documented
//! here but this is not meant to be a comprehensive source of protocol
//! documentation.
//! Types and functions related to tmux protocols.
const std = @import("std");
const assert = @import("../quirks.zig").inlineAssert;
const oni = @import("oniguruma");
const control = @import("tmux/control.zig");
const layout = @import("tmux/layout.zig");
pub const output = @import("tmux/output.zig");
pub const ControlParser = control.Parser;
pub const ControlNotification = control.Notification;
pub const Layout = layout.Layout;
const log = std.log.scoped(.terminal_tmux);
/// A tmux control mode client. It is expected that the caller establishes
/// the connection in some way (i.e. detects the opening DCS sequence). This
/// just works on a byte stream.
pub const Client = struct {
/// Current state of the client.
state: State = .idle,
/// The buffer used to store in-progress notifications, output, etc.
buffer: std.Io.Writer.Allocating,
/// The maximum size in bytes of the buffer. This is used to limit
/// memory usage. If the buffer exceeds this size, the client will
/// enter a broken state (the control mode session will be forcibly
/// exited and future data dropped).
max_bytes: usize = 1024 * 1024,
const State = enum {
/// Outside of any active notifications. This should drop any output
/// unless it is '%' on the first byte of a line. The buffer will be
/// cleared when it sees '%', this is so that the previous notification
/// data is valid until we receive/process new data.
idle,
/// We experienced unexpected input and are in a broken state
/// so we cannot continue processing. When this state is set,
/// the buffer has been deinited and must not be accessed.
broken,
/// Inside an active notification (started with '%').
notification,
/// Inside a begin/end block.
block,
};
pub fn deinit(self: *Client) void {
// If we're in a broken state, we already deinited
// the buffer, so we don't need to do anything.
if (self.state == .broken) return;
self.buffer.deinit();
}
// Handle a byte of input.
pub fn put(self: *Client, byte: u8) !?Notification {
// If we're in a broken state, just do nothing.
//
// We have to do this check here before we check the buffer, because if
// we're in a broken state then we'd have already deinited the buffer.
if (self.state == .broken) return null;
if (self.buffer.written().len >= self.max_bytes) {
self.broken();
return error.OutOfMemory;
}
switch (self.state) {
// Drop because we're in a broken state.
.broken => return null,
// Waiting for a notification so if the byte is not '%' then
// we're in a broken state. Control mode output should always
// be wrapped in '%begin/%end' orelse we expect a notification.
// Return an exit notification.
.idle => if (byte != '%') {
self.broken();
return .{ .exit = {} };
} else {
self.buffer.clearRetainingCapacity();
self.state = .notification;
},
// If we're in a notification and its not a newline then
// we accumulate. If it is a newline then we have a
// complete notification we need to parse.
.notification => if (byte == '\n') {
// We have a complete notification, parse it.
return try self.parseNotification();
},
// If we're in a block then we accumulate until we see a newline
// and then we check to see if that line ended the block.
.block => if (byte == '\n') {
const written = self.buffer.written();
const idx = if (std.mem.lastIndexOfScalar(
u8,
written,
'\n',
)) |v| v + 1 else 0;
const line = written[idx..];
if (std.mem.startsWith(u8, line, "%end") or
std.mem.startsWith(u8, line, "%error"))
{
const err = std.mem.startsWith(u8, line, "%error");
const output = std.mem.trimRight(u8, written[0..idx], "\r\n");
// If it is an error then log it.
if (err) log.warn("tmux control mode error={s}", .{output});
// Important: do not clear buffer since the notification
// contains it.
self.state = .idle;
return if (err) .{ .block_err = output } else .{ .block_end = output };
}
// Didn't end the block, continue accumulating.
},
}
try self.buffer.writer.writeByte(byte);
return null;
}
fn parseNotification(self: *Client) !?Notification {
assert(self.state == .notification);
const line = line: {
var line = self.buffer.written();
if (line[line.len - 1] == '\r') line = line[0 .. line.len - 1];
break :line line;
};
const cmd = cmd: {
const idx = std.mem.indexOfScalar(u8, line, ' ') orelse line.len;
break :cmd line[0..idx];
};
// The notification MUST exist because we guard entering the notification
// state on seeing at least a '%'.
if (std.mem.eql(u8, cmd, "%begin")) {
// We don't use the rest of the tokens for now because tmux
// claims to guarantee that begin/end are always in order and
// never intermixed. In the future, we should probably validate
// this.
// TODO(tmuxcc): do this before merge?
// Move to block state because we expect a corresponding end/error
// and want to accumulate the data.
self.state = .block;
self.buffer.clearRetainingCapacity();
return null;
} else if (std.mem.eql(u8, cmd, "%output")) cmd: {
var re = try oni.Regex.init(
"^%output %([0-9]+) (.+)$",
.{ .capture_group = true },
oni.Encoding.utf8,
oni.Syntax.default,
null,
);
defer re.deinit();
var region = re.search(line, .{}) catch |err| {
log.warn("failed to match notification cmd={s} line=\"{s}\" err={}", .{ cmd, line, err });
break :cmd;
};
defer region.deinit();
const starts = region.starts();
const ends = region.ends();
const id = std.fmt.parseInt(
usize,
line[@intCast(starts[1])..@intCast(ends[1])],
10,
) catch unreachable;
const data = line[@intCast(starts[2])..@intCast(ends[2])];
// Important: do not clear buffer here since name points to it
self.state = .idle;
return .{ .output = .{ .pane_id = id, .data = data } };
} else if (std.mem.eql(u8, cmd, "%session-changed")) cmd: {
var re = try oni.Regex.init(
"^%session-changed \\$([0-9]+) (.+)$",
.{ .capture_group = true },
oni.Encoding.utf8,
oni.Syntax.default,
null,
);
defer re.deinit();
var region = re.search(line, .{}) catch |err| {
log.warn("failed to match notification cmd={s} line=\"{s}\" err={}", .{ cmd, line, err });
break :cmd;
};
defer region.deinit();
const starts = region.starts();
const ends = region.ends();
const id = std.fmt.parseInt(
usize,
line[@intCast(starts[1])..@intCast(ends[1])],
10,
) catch unreachable;
const name = line[@intCast(starts[2])..@intCast(ends[2])];
// Important: do not clear buffer here since name points to it
self.state = .idle;
return .{ .session_changed = .{ .id = id, .name = name } };
} else if (std.mem.eql(u8, cmd, "%sessions-changed")) cmd: {
if (!std.mem.eql(u8, line, "%sessions-changed")) {
log.warn("failed to match notification cmd={s} line=\"{s}\"", .{ cmd, line });
break :cmd;
}
self.buffer.clearRetainingCapacity();
self.state = .idle;
return .{ .sessions_changed = {} };
} else if (std.mem.eql(u8, cmd, "%window-add")) cmd: {
var re = try oni.Regex.init(
"^%window-add @([0-9]+)$",
.{ .capture_group = true },
oni.Encoding.utf8,
oni.Syntax.default,
null,
);
defer re.deinit();
var region = re.search(line, .{}) catch |err| {
log.warn("failed to match notification cmd={s} line=\"{s}\" err={}", .{ cmd, line, err });
break :cmd;
};
defer region.deinit();
const starts = region.starts();
const ends = region.ends();
const id = std.fmt.parseInt(
usize,
line[@intCast(starts[1])..@intCast(ends[1])],
10,
) catch unreachable;
self.buffer.clearRetainingCapacity();
self.state = .idle;
return .{ .window_add = .{ .id = id } };
} else if (std.mem.eql(u8, cmd, "%window-renamed")) cmd: {
var re = try oni.Regex.init(
"^%window-renamed @([0-9]+) (.+)$",
.{ .capture_group = true },
oni.Encoding.utf8,
oni.Syntax.default,
null,
);
defer re.deinit();
var region = re.search(line, .{}) catch |err| {
log.warn("failed to match notification cmd={s} line=\"{s}\" err={}", .{ cmd, line, err });
break :cmd;
};
defer region.deinit();
const starts = region.starts();
const ends = region.ends();
const id = std.fmt.parseInt(
usize,
line[@intCast(starts[1])..@intCast(ends[1])],
10,
) catch unreachable;
const name = line[@intCast(starts[2])..@intCast(ends[2])];
// Important: do not clear buffer here since name points to it
self.state = .idle;
return .{ .window_renamed = .{ .id = id, .name = name } };
} else {
// Unknown notification, log it and return to idle state.
log.warn("unknown tmux control mode notification={s}", .{cmd});
}
// Unknown command. Clear the buffer and return to idle state.
self.buffer.clearRetainingCapacity();
self.state = .idle;
return null;
}
// Mark the tmux state as broken.
fn broken(self: *Client) void {
self.state = .broken;
self.buffer.deinit();
}
};
/// Possible notification types from tmux control mode. These are documented
/// in tmux(1).
pub const Notification = union(enum) {
enter: void,
exit: void,
block_end: []const u8,
block_err: []const u8,
output: struct {
pane_id: usize,
data: []const u8, // unescaped
},
session_changed: struct {
id: usize,
name: []const u8,
},
sessions_changed: void,
window_add: struct {
id: usize,
},
window_renamed: struct {
id: usize,
name: []const u8,
},
};
test "tmux begin/end empty" {
const testing = std.testing;
const alloc = testing.allocator;
var c: Client = .{ .buffer = .init(alloc) };
defer c.deinit();
for ("%begin 1578922740 269 1\n") |byte| try testing.expect(try c.put(byte) == null);
for ("%end 1578922740 269 1") |byte| try testing.expect(try c.put(byte) == null);
const n = (try c.put('\n')).?;
try testing.expect(n == .block_end);
try testing.expectEqualStrings("", n.block_end);
}
test "tmux begin/error empty" {
const testing = std.testing;
const alloc = testing.allocator;
var c: Client = .{ .buffer = .init(alloc) };
defer c.deinit();
for ("%begin 1578922740 269 1\n") |byte| try testing.expect(try c.put(byte) == null);
for ("%error 1578922740 269 1") |byte| try testing.expect(try c.put(byte) == null);
const n = (try c.put('\n')).?;
try testing.expect(n == .block_err);
try testing.expectEqualStrings("", n.block_err);
}
test "tmux begin/end data" {
const testing = std.testing;
const alloc = testing.allocator;
var c: Client = .{ .buffer = .init(alloc) };
defer c.deinit();
for ("%begin 1578922740 269 1\n") |byte| try testing.expect(try c.put(byte) == null);
for ("hello\nworld\n") |byte| try testing.expect(try c.put(byte) == null);
for ("%end 1578922740 269 1") |byte| try testing.expect(try c.put(byte) == null);
const n = (try c.put('\n')).?;
try testing.expect(n == .block_end);
try testing.expectEqualStrings("hello\nworld", n.block_end);
}
test "tmux output" {
const testing = std.testing;
const alloc = testing.allocator;
var c: Client = .{ .buffer = .init(alloc) };
defer c.deinit();
for ("%output %42 foo bar baz") |byte| try testing.expect(try c.put(byte) == null);
const n = (try c.put('\n')).?;
try testing.expect(n == .output);
try testing.expectEqual(42, n.output.pane_id);
try testing.expectEqualStrings("foo bar baz", n.output.data);
}
test "tmux session-changed" {
const testing = std.testing;
const alloc = testing.allocator;
var c: Client = .{ .buffer = .init(alloc) };
defer c.deinit();
for ("%session-changed $42 foo") |byte| try testing.expect(try c.put(byte) == null);
const n = (try c.put('\n')).?;
try testing.expect(n == .session_changed);
try testing.expectEqual(42, n.session_changed.id);
try testing.expectEqualStrings("foo", n.session_changed.name);
}
test "tmux sessions-changed" {
const testing = std.testing;
const alloc = testing.allocator;
var c: Client = .{ .buffer = .init(alloc) };
defer c.deinit();
for ("%sessions-changed") |byte| try testing.expect(try c.put(byte) == null);
const n = (try c.put('\n')).?;
try testing.expect(n == .sessions_changed);
}
test "tmux sessions-changed carriage return" {
const testing = std.testing;
const alloc = testing.allocator;
var c: Client = .{ .buffer = .init(alloc) };
defer c.deinit();
for ("%sessions-changed\r") |byte| try testing.expect(try c.put(byte) == null);
const n = (try c.put('\n')).?;
try testing.expect(n == .sessions_changed);
}
test "tmux window-add" {
const testing = std.testing;
const alloc = testing.allocator;
var c: Client = .{ .buffer = .init(alloc) };
defer c.deinit();
for ("%window-add @14") |byte| try testing.expect(try c.put(byte) == null);
const n = (try c.put('\n')).?;
try testing.expect(n == .window_add);
try testing.expectEqual(14, n.window_add.id);
}
test "tmux window-renamed" {
const testing = std.testing;
const alloc = testing.allocator;
var c: Client = .{ .buffer = .init(alloc) };
defer c.deinit();
for ("%window-renamed @42 bar") |byte| try testing.expect(try c.put(byte) == null);
const n = (try c.put('\n')).?;
try testing.expect(n == .window_renamed);
try testing.expectEqual(42, n.window_renamed.id);
try testing.expectEqualStrings("bar", n.window_renamed.name);
test {
@import("std").testing.refAllDecls(@This());
}

View File

@ -0,0 +1,701 @@
//! This file contains the implementation for tmux control mode. See
//! tmux(1) for more information on control mode. Some basics are documented
//! here but this is not meant to be a comprehensive source of protocol
//! documentation.
const std = @import("std");
const Allocator = std.mem.Allocator;
const assert = @import("../../quirks.zig").inlineAssert;
const oni = @import("oniguruma");
const log = std.log.scoped(.terminal_tmux);
/// A tmux control mode parser. This takes in output from tmux control
/// mode and parses it into a structured notifications.
///
/// It is up to the caller to establish the connection to the tmux
/// control mode session in some way (e.g. via exec, a network socket,
/// whatever). This is fully agnostic to how the data is received and sent.
pub const Parser = struct {
/// Current state of the client.
state: State = .idle,
/// The buffer used to store in-progress notifications, output, etc.
buffer: std.Io.Writer.Allocating,
/// The maximum size in bytes of the buffer. This is used to limit
/// memory usage. If the buffer exceeds this size, the client will
/// enter a broken state (the control mode session will be forcibly
/// exited and future data dropped).
max_bytes: usize = 1024 * 1024,
const State = enum {
/// Outside of any active notifications. This should drop any output
/// unless it is '%' on the first byte of a line. The buffer will be
/// cleared when it sees '%', this is so that the previous notification
/// data is valid until we receive/process new data.
idle,
/// We experienced unexpected input and are in a broken state
/// so we cannot continue processing. When this state is set,
/// the buffer has been deinited and must not be accessed.
broken,
/// Inside an active notification (started with '%').
notification,
/// Inside a begin/end block.
block,
};
pub fn deinit(self: *Parser) void {
// If we're in a broken state, we already deinited
// the buffer, so we don't need to do anything.
if (self.state == .broken) return;
self.buffer.deinit();
}
// Handle a byte of input.
//
// If we reach our byte limit this will return OutOfMemory. It only
// does this on the first time we exceed the limit; subsequent calls
// will return null as we drop all input in a broken state.
pub fn put(self: *Parser, byte: u8) Allocator.Error!?Notification {
// If we're in a broken state, just do nothing.
//
// We have to do this check here before we check the buffer, because if
// we're in a broken state then we'd have already deinited the buffer.
if (self.state == .broken) return null;
if (self.buffer.written().len >= self.max_bytes) {
self.broken();
return error.OutOfMemory;
}
switch (self.state) {
// Drop because we're in a broken state.
.broken => return null,
// Waiting for a notification so if the byte is not '%' then
// we're in a broken state. Control mode output should always
// be wrapped in '%begin/%end' orelse we expect a notification.
// Return an exit notification.
.idle => if (byte != '%') {
self.broken();
return .{ .exit = {} };
} else {
self.buffer.clearRetainingCapacity();
self.state = .notification;
},
// If we're in a notification and its not a newline then
// we accumulate. If it is a newline then we have a
// complete notification we need to parse.
.notification => if (byte == '\n') {
// We have a complete notification, parse it.
return self.parseNotification() catch {
// If parsing failed, then we do not mark the state
// as broken because we may be able to continue parsing
// other types of notifications.
//
// In the future we may want to emit a notification
// here about unknown or unsupported notifications.
return null;
};
},
// If we're in a block then we accumulate until we see a newline
// and then we check to see if that line ended the block.
.block => if (byte == '\n') {
const written = self.buffer.written();
const idx = if (std.mem.lastIndexOfScalar(
u8,
written,
'\n',
)) |v| v + 1 else 0;
const line = written[idx..];
if (std.mem.startsWith(u8, line, "%end") or
std.mem.startsWith(u8, line, "%error"))
{
const err = std.mem.startsWith(u8, line, "%error");
const output = std.mem.trimRight(u8, written[0..idx], "\r\n");
// If it is an error then log it.
if (err) log.warn("tmux control mode error={s}", .{output});
// Important: do not clear buffer since the notification
// contains it.
self.state = .idle;
return if (err) .{ .block_err = output } else .{ .block_end = output };
}
// Didn't end the block, continue accumulating.
},
}
self.buffer.writer.writeByte(byte) catch |err| switch (err) {
error.WriteFailed => return error.OutOfMemory,
};
return null;
}
const ParseError = error{RegexError};
fn parseNotification(self: *Parser) ParseError!?Notification {
assert(self.state == .notification);
const line = line: {
var line = self.buffer.written();
if (line[line.len - 1] == '\r') line = line[0 .. line.len - 1];
break :line line;
};
const cmd = cmd: {
const idx = std.mem.indexOfScalar(u8, line, ' ') orelse line.len;
break :cmd line[0..idx];
};
// The notification MUST exist because we guard entering the notification
// state on seeing at least a '%'.
if (std.mem.eql(u8, cmd, "%begin")) {
// We don't use the rest of the tokens for now because tmux
// claims to guarantee that begin/end are always in order and
// never intermixed. In the future, we should probably validate
// this.
// TODO(tmuxcc): do this before merge?
// Move to block state because we expect a corresponding end/error
// and want to accumulate the data.
self.state = .block;
self.buffer.clearRetainingCapacity();
return null;
} else if (std.mem.eql(u8, cmd, "%output")) cmd: {
var re = oni.Regex.init(
"^%output %([0-9]+) (.+)$",
.{ .capture_group = true },
oni.Encoding.utf8,
oni.Syntax.default,
null,
) catch |err| {
log.warn("regex init failed error={}", .{err});
return error.RegexError;
};
defer re.deinit();
var region = re.search(line, .{}) catch |err| {
log.warn("failed to match notification cmd={s} line=\"{s}\" err={}", .{ cmd, line, err });
break :cmd;
};
defer region.deinit();
const starts = region.starts();
const ends = region.ends();
const id = std.fmt.parseInt(
usize,
line[@intCast(starts[1])..@intCast(ends[1])],
10,
) catch unreachable;
const data = line[@intCast(starts[2])..@intCast(ends[2])];
// Important: do not clear buffer here since name points to it
self.state = .idle;
return .{ .output = .{ .pane_id = id, .data = data } };
} else if (std.mem.eql(u8, cmd, "%session-changed")) cmd: {
var re = oni.Regex.init(
"^%session-changed \\$([0-9]+) (.+)$",
.{ .capture_group = true },
oni.Encoding.utf8,
oni.Syntax.default,
null,
) catch |err| {
log.warn("regex init failed error={}", .{err});
return error.RegexError;
};
defer re.deinit();
var region = re.search(line, .{}) catch |err| {
log.warn("failed to match notification cmd={s} line=\"{s}\" err={}", .{ cmd, line, err });
break :cmd;
};
defer region.deinit();
const starts = region.starts();
const ends = region.ends();
const id = std.fmt.parseInt(
usize,
line[@intCast(starts[1])..@intCast(ends[1])],
10,
) catch unreachable;
const name = line[@intCast(starts[2])..@intCast(ends[2])];
// Important: do not clear buffer here since name points to it
self.state = .idle;
return .{ .session_changed = .{ .id = id, .name = name } };
} else if (std.mem.eql(u8, cmd, "%sessions-changed")) cmd: {
if (!std.mem.eql(u8, line, "%sessions-changed")) {
log.warn("failed to match notification cmd={s} line=\"{s}\"", .{ cmd, line });
break :cmd;
}
self.buffer.clearRetainingCapacity();
self.state = .idle;
return .{ .sessions_changed = {} };
} else if (std.mem.eql(u8, cmd, "%layout-change")) cmd: {
var re = oni.Regex.init(
"^%layout-change @([0-9]+) (.+) (.+) (.*)$",
.{ .capture_group = true },
oni.Encoding.utf8,
oni.Syntax.default,
null,
) catch |err| {
log.warn("regex init failed error={}", .{err});
return error.RegexError;
};
defer re.deinit();
var region = re.search(line, .{}) catch |err| {
log.warn("failed to match notification cmd={s} line=\"{s}\" err={}", .{ cmd, line, err });
break :cmd;
};
defer region.deinit();
const starts = region.starts();
const ends = region.ends();
const id = std.fmt.parseInt(
usize,
line[@intCast(starts[1])..@intCast(ends[1])],
10,
) catch unreachable;
const layout = line[@intCast(starts[2])..@intCast(ends[2])];
const visible_layout = line[@intCast(starts[3])..@intCast(ends[3])];
const raw_flags = line[@intCast(starts[4])..@intCast(ends[4])];
// Important: do not clear buffer here since layout strings point to it
self.state = .idle;
return .{ .layout_change = .{
.window_id = id,
.layout = layout,
.visible_layout = visible_layout,
.raw_flags = raw_flags,
} };
} else if (std.mem.eql(u8, cmd, "%window-add")) cmd: {
var re = oni.Regex.init(
"^%window-add @([0-9]+)$",
.{ .capture_group = true },
oni.Encoding.utf8,
oni.Syntax.default,
null,
) catch |err| {
log.warn("regex init failed error={}", .{err});
return error.RegexError;
};
defer re.deinit();
var region = re.search(line, .{}) catch |err| {
log.warn("failed to match notification cmd={s} line=\"{s}\" err={}", .{ cmd, line, err });
break :cmd;
};
defer region.deinit();
const starts = region.starts();
const ends = region.ends();
const id = std.fmt.parseInt(
usize,
line[@intCast(starts[1])..@intCast(ends[1])],
10,
) catch unreachable;
self.buffer.clearRetainingCapacity();
self.state = .idle;
return .{ .window_add = .{ .id = id } };
} else if (std.mem.eql(u8, cmd, "%window-renamed")) cmd: {
var re = oni.Regex.init(
"^%window-renamed @([0-9]+) (.+)$",
.{ .capture_group = true },
oni.Encoding.utf8,
oni.Syntax.default,
null,
) catch |err| {
log.warn("regex init failed error={}", .{err});
return error.RegexError;
};
defer re.deinit();
var region = re.search(line, .{}) catch |err| {
log.warn("failed to match notification cmd={s} line=\"{s}\" err={}", .{ cmd, line, err });
break :cmd;
};
defer region.deinit();
const starts = region.starts();
const ends = region.ends();
const id = std.fmt.parseInt(
usize,
line[@intCast(starts[1])..@intCast(ends[1])],
10,
) catch unreachable;
const name = line[@intCast(starts[2])..@intCast(ends[2])];
// Important: do not clear buffer here since name points to it
self.state = .idle;
return .{ .window_renamed = .{ .id = id, .name = name } };
} else if (std.mem.eql(u8, cmd, "%window-pane-changed")) cmd: {
var re = oni.Regex.init(
"^%window-pane-changed @([0-9]+) %([0-9]+)$",
.{ .capture_group = true },
oni.Encoding.utf8,
oni.Syntax.default,
null,
) catch |err| {
log.warn("regex init failed error={}", .{err});
return error.RegexError;
};
defer re.deinit();
var region = re.search(line, .{}) catch |err| {
log.warn("failed to match notification cmd={s} line=\"{s}\" err={}", .{ cmd, line, err });
break :cmd;
};
defer region.deinit();
const starts = region.starts();
const ends = region.ends();
const window_id = std.fmt.parseInt(
usize,
line[@intCast(starts[1])..@intCast(ends[1])],
10,
) catch unreachable;
const pane_id = std.fmt.parseInt(
usize,
line[@intCast(starts[2])..@intCast(ends[2])],
10,
) catch unreachable;
self.buffer.clearRetainingCapacity();
self.state = .idle;
return .{ .window_pane_changed = .{ .window_id = window_id, .pane_id = pane_id } };
} else if (std.mem.eql(u8, cmd, "%client-detached")) cmd: {
var re = oni.Regex.init(
"^%client-detached (.+)$",
.{ .capture_group = true },
oni.Encoding.utf8,
oni.Syntax.default,
null,
) catch |err| {
log.warn("regex init failed error={}", .{err});
return error.RegexError;
};
defer re.deinit();
var region = re.search(line, .{}) catch |err| {
log.warn("failed to match notification cmd={s} line=\"{s}\" err={}", .{ cmd, line, err });
break :cmd;
};
defer region.deinit();
const starts = region.starts();
const ends = region.ends();
const client = line[@intCast(starts[1])..@intCast(ends[1])];
// Important: do not clear buffer here since client points to it
self.state = .idle;
return .{ .client_detached = .{ .client = client } };
} else if (std.mem.eql(u8, cmd, "%client-session-changed")) cmd: {
var re = oni.Regex.init(
"^%client-session-changed (.+) \\$([0-9]+) (.+)$",
.{ .capture_group = true },
oni.Encoding.utf8,
oni.Syntax.default,
null,
) catch |err| {
log.warn("regex init failed error={}", .{err});
return error.RegexError;
};
defer re.deinit();
var region = re.search(line, .{}) catch |err| {
log.warn("failed to match notification cmd={s} line=\"{s}\" err={}", .{ cmd, line, err });
break :cmd;
};
defer region.deinit();
const starts = region.starts();
const ends = region.ends();
const client = line[@intCast(starts[1])..@intCast(ends[1])];
const session_id = std.fmt.parseInt(
usize,
line[@intCast(starts[2])..@intCast(ends[2])],
10,
) catch unreachable;
const name = line[@intCast(starts[3])..@intCast(ends[3])];
// Important: do not clear buffer here since client/name point to it
self.state = .idle;
return .{ .client_session_changed = .{ .client = client, .session_id = session_id, .name = name } };
} else {
// Unknown notification, log it and return to idle state.
log.warn("unknown tmux control mode notification={s}", .{cmd});
}
// Unknown command. Clear the buffer and return to idle state.
self.buffer.clearRetainingCapacity();
self.state = .idle;
return null;
}
// Mark the tmux state as broken.
fn broken(self: *Parser) void {
self.state = .broken;
self.buffer.deinit();
}
};
/// Possible notification types from tmux control mode. These are documented
/// in tmux(1). A lot of the simple documentation was copied from that man
/// page here.
pub const Notification = union(enum) {
/// Entering tmux control mode. This isn't an actual event sent by
/// tmux but is one sent by us to indicate that we have detected that
/// tmux control mode is starting.
enter,
/// Exit.
///
/// NOTE: The tmux protocol contains a "reason" string (human friendly)
/// associated with this. We currently drop it because we don't need it
/// but this may be something we want to add later. If we do add it,
/// we have to consider buffer limits and how we handle those (dropping
/// vs truncating, etc.).
exit,
/// Dispatched at the end of a begin/end block with the raw data.
/// The control mode parser can't parse the data because it is unaware
/// of the command that was sent to trigger this output.
block_end: []const u8,
block_err: []const u8,
/// Raw output from a pane.
output: struct {
pane_id: usize,
data: []const u8, // unescaped
},
/// The client is now attached to the session with ID session-id, which is
/// named name.
session_changed: struct {
id: usize,
name: []const u8,
},
/// A session was created or destroyed.
sessions_changed,
/// The layout of the window with ID window-id changed.
layout_change: struct {
window_id: usize,
layout: []const u8,
visible_layout: []const u8,
raw_flags: []const u8,
},
/// The window with ID window-id was linked to the current session.
window_add: struct {
id: usize,
},
/// The window with ID window-id was renamed to name.
window_renamed: struct {
id: usize,
name: []const u8,
},
/// The active pane in the window with ID window-id changed to the pane
/// with ID pane-id.
window_pane_changed: struct {
window_id: usize,
pane_id: usize,
},
/// The client has detached.
client_detached: struct {
client: []const u8,
},
/// The client is now attached to the session with ID session-id, which is
/// named name.
client_session_changed: struct {
client: []const u8,
session_id: usize,
name: []const u8,
},
};
test "tmux begin/end empty" {
const testing = std.testing;
const alloc = testing.allocator;
var c: Parser = .{ .buffer = .init(alloc) };
defer c.deinit();
for ("%begin 1578922740 269 1\n") |byte| try testing.expect(try c.put(byte) == null);
for ("%end 1578922740 269 1") |byte| try testing.expect(try c.put(byte) == null);
const n = (try c.put('\n')).?;
try testing.expect(n == .block_end);
try testing.expectEqualStrings("", n.block_end);
}
test "tmux begin/error empty" {
const testing = std.testing;
const alloc = testing.allocator;
var c: Parser = .{ .buffer = .init(alloc) };
defer c.deinit();
for ("%begin 1578922740 269 1\n") |byte| try testing.expect(try c.put(byte) == null);
for ("%error 1578922740 269 1") |byte| try testing.expect(try c.put(byte) == null);
const n = (try c.put('\n')).?;
try testing.expect(n == .block_err);
try testing.expectEqualStrings("", n.block_err);
}
test "tmux begin/end data" {
const testing = std.testing;
const alloc = testing.allocator;
var c: Parser = .{ .buffer = .init(alloc) };
defer c.deinit();
for ("%begin 1578922740 269 1\n") |byte| try testing.expect(try c.put(byte) == null);
for ("hello\nworld\n") |byte| try testing.expect(try c.put(byte) == null);
for ("%end 1578922740 269 1") |byte| try testing.expect(try c.put(byte) == null);
const n = (try c.put('\n')).?;
try testing.expect(n == .block_end);
try testing.expectEqualStrings("hello\nworld", n.block_end);
}
test "tmux output" {
const testing = std.testing;
const alloc = testing.allocator;
var c: Parser = .{ .buffer = .init(alloc) };
defer c.deinit();
for ("%output %42 foo bar baz") |byte| try testing.expect(try c.put(byte) == null);
const n = (try c.put('\n')).?;
try testing.expect(n == .output);
try testing.expectEqual(42, n.output.pane_id);
try testing.expectEqualStrings("foo bar baz", n.output.data);
}
test "tmux session-changed" {
const testing = std.testing;
const alloc = testing.allocator;
var c: Parser = .{ .buffer = .init(alloc) };
defer c.deinit();
for ("%session-changed $42 foo") |byte| try testing.expect(try c.put(byte) == null);
const n = (try c.put('\n')).?;
try testing.expect(n == .session_changed);
try testing.expectEqual(42, n.session_changed.id);
try testing.expectEqualStrings("foo", n.session_changed.name);
}
test "tmux sessions-changed" {
const testing = std.testing;
const alloc = testing.allocator;
var c: Parser = .{ .buffer = .init(alloc) };
defer c.deinit();
for ("%sessions-changed") |byte| try testing.expect(try c.put(byte) == null);
const n = (try c.put('\n')).?;
try testing.expect(n == .sessions_changed);
}
test "tmux sessions-changed carriage return" {
const testing = std.testing;
const alloc = testing.allocator;
var c: Parser = .{ .buffer = .init(alloc) };
defer c.deinit();
for ("%sessions-changed\r") |byte| try testing.expect(try c.put(byte) == null);
const n = (try c.put('\n')).?;
try testing.expect(n == .sessions_changed);
}
test "tmux layout-change" {
const testing = std.testing;
const alloc = testing.allocator;
var c: Parser = .{ .buffer = .init(alloc) };
defer c.deinit();
for ("%layout-change @2 1234x791,0,0{617x791,0,0,0,617x791,618,0,1} 1234x791,0,0{617x791,0,0,0,617x791,618,0,1} *-") |byte| try testing.expect(try c.put(byte) == null);
const n = (try c.put('\n')).?;
try testing.expect(n == .layout_change);
try testing.expectEqual(2, n.layout_change.window_id);
try testing.expectEqualStrings("1234x791,0,0{617x791,0,0,0,617x791,618,0,1}", n.layout_change.layout);
try testing.expectEqualStrings("1234x791,0,0{617x791,0,0,0,617x791,618,0,1}", n.layout_change.visible_layout);
try testing.expectEqualStrings("*-", n.layout_change.raw_flags);
}
test "tmux window-add" {
const testing = std.testing;
const alloc = testing.allocator;
var c: Parser = .{ .buffer = .init(alloc) };
defer c.deinit();
for ("%window-add @14") |byte| try testing.expect(try c.put(byte) == null);
const n = (try c.put('\n')).?;
try testing.expect(n == .window_add);
try testing.expectEqual(14, n.window_add.id);
}
test "tmux window-renamed" {
const testing = std.testing;
const alloc = testing.allocator;
var c: Parser = .{ .buffer = .init(alloc) };
defer c.deinit();
for ("%window-renamed @42 bar") |byte| try testing.expect(try c.put(byte) == null);
const n = (try c.put('\n')).?;
try testing.expect(n == .window_renamed);
try testing.expectEqual(42, n.window_renamed.id);
try testing.expectEqualStrings("bar", n.window_renamed.name);
}
test "tmux window-pane-changed" {
const testing = std.testing;
const alloc = testing.allocator;
var c: Parser = .{ .buffer = .init(alloc) };
defer c.deinit();
for ("%window-pane-changed @42 %2") |byte| try testing.expect(try c.put(byte) == null);
const n = (try c.put('\n')).?;
try testing.expect(n == .window_pane_changed);
try testing.expectEqual(42, n.window_pane_changed.window_id);
try testing.expectEqual(2, n.window_pane_changed.pane_id);
}
test "tmux client-detached" {
const testing = std.testing;
const alloc = testing.allocator;
var c: Parser = .{ .buffer = .init(alloc) };
defer c.deinit();
for ("%client-detached /dev/pts/1") |byte| try testing.expect(try c.put(byte) == null);
const n = (try c.put('\n')).?;
try testing.expect(n == .client_detached);
try testing.expectEqualStrings("/dev/pts/1", n.client_detached.client);
}
test "tmux client-session-changed" {
const testing = std.testing;
const alloc = testing.allocator;
var c: Parser = .{ .buffer = .init(alloc) };
defer c.deinit();
for ("%client-session-changed /dev/pts/1 $2 mysession") |byte| try testing.expect(try c.put(byte) == null);
const n = (try c.put('\n')).?;
try testing.expect(n == .client_session_changed);
try testing.expectEqualStrings("/dev/pts/1", n.client_session_changed.client);
try testing.expectEqual(2, n.client_session_changed.session_id);
try testing.expectEqualStrings("mysession", n.client_session_changed.name);
}

View File

@ -0,0 +1,638 @@
const std = @import("std");
const testing = std.testing;
const Allocator = std.mem.Allocator;
const ArenaAllocator = std.heap.ArenaAllocator;
/// A tmux layout.
///
/// This is a tree structure so by definition it pretty much needs to be
/// allocated. We leave allocation up to the user of this struct, but
/// a general recommendation is to use an arena allocator for simplicity
/// in freeing the entire layout at once.
pub const Layout = struct {
/// Width, height of the node
width: usize,
height: usize,
/// X and Y offset from the top-left corner of the window.
x: usize,
y: usize,
/// The content of this node, either a pane (leaf) or more nodes
/// (split) horizontally or vertically.
content: Content,
pub const Content = union(enum) {
pane: usize,
horizontal: []const Layout,
vertical: []const Layout,
};
pub const ParseError = Allocator.Error || error{SyntaxError};
/// Parse a layout string that includes a 4-character checksum prefix.
///
/// The expected format is: `XXXX,layout_string` where XXXX is the
/// 4-character hexadecimal checksum and the layout string follows
/// after the comma. For example: `f8f9,80x24,0,0{40x24,0,0,1,40x24,40,0,2}`.
///
/// Returns `ChecksumMismatch` if the checksum doesn't match the layout.
/// Returns `SyntaxError` if the format is invalid.
pub fn parseWithChecksum(
alloc: Allocator,
str: []const u8,
) (ParseError || error{ChecksumMismatch})!Layout {
// If the string is less than 5 characters, it can't possibly
// be correct. 4-char checksum + comma. In practice it should
// be even longer, but that'll fail parse later.
if (str.len < 5) return error.SyntaxError;
if (str[4] != ',') return error.SyntaxError;
// The layout string should start with a 4-character checksum.
const checksum: Checksum = .calculate(str[5..]);
if (!std.mem.startsWith(
u8,
str,
&checksum.asString(),
)) return error.ChecksumMismatch;
// Checksum matches, parse the rest.
return try parse(alloc, str[5..]);
}
/// Parse a layout string into a Layout structure. The given allocator
/// will be used for all allocations within the layout. Note that
/// individual nodes can't be freed so this allocator must be some
/// kind of arena allocator.
///
/// The layout string must be fully provided as a single string.
/// Layouts are generally small so this should not be a problem.
///
/// Tmux layout strings have the following format:
///
/// - WxH,X,Y,ID Leaf pane: width×height, x-offset, y-offset, pane ID
/// - WxH,X,Y{...} Horizontal split (left-right), children comma-separated
/// - WxH,X,Y[...] Vertical split (top-bottom), children comma-separated
pub fn parse(alloc: Allocator, str: []const u8) ParseError!Layout {
var offset: usize = 0;
const root = try parseNext(
alloc,
str,
&offset,
);
if (offset != str.len) return error.SyntaxError;
return root;
}
fn parseNext(
alloc: Allocator,
str: []const u8,
offset: *usize,
) ParseError!Layout {
// Find the first `x` to grab the width.
const width: usize = if (std.mem.indexOfScalar(
u8,
str[offset.*..],
'x',
)) |idx| width: {
defer offset.* += idx + 1; // Consume `x`
break :width std.fmt.parseInt(
usize,
str[offset.* .. offset.* + idx],
10,
) catch return error.SyntaxError;
} else return error.SyntaxError;
// Find the height, up to a comma.
const height: usize = if (std.mem.indexOfScalar(
u8,
str[offset.*..],
',',
)) |idx| height: {
defer offset.* += idx + 1; // Consume `,`
break :height std.fmt.parseInt(
usize,
str[offset.* .. offset.* + idx],
10,
) catch return error.SyntaxError;
} else return error.SyntaxError;
// Find X
const x: usize = if (std.mem.indexOfScalar(
u8,
str[offset.*..],
',',
)) |idx| x: {
defer offset.* += idx + 1; // Consume `,`
break :x std.fmt.parseInt(
usize,
str[offset.* .. offset.* + idx],
10,
) catch return error.SyntaxError;
} else return error.SyntaxError;
// Find Y, which can end in any of `,{,[`
const y: usize = if (std.mem.indexOfAny(
u8,
str[offset.*..],
",{[",
)) |idx| y: {
defer offset.* += idx; // Don't consume the delimiter!
break :y std.fmt.parseInt(
usize,
str[offset.* .. offset.* + idx],
10,
) catch return error.SyntaxError;
} else return error.SyntaxError;
// Determine our child node.
const content: Layout.Content = switch (str[offset.*]) {
',' => content: {
// Consume the delimiter
offset.* += 1;
// Leaf pane. Read up to `,}]` because we may be in
// a set of nodes. If none exist, end of string is fine.
const idx = std.mem.indexOfAny(
u8,
str[offset.*..],
",}]",
) orelse str.len - offset.*;
defer offset.* += idx; // Consume the pane ID, not the delimiter
const pane_id = std.fmt.parseInt(
usize,
str[offset.* .. offset.* + idx],
10,
) catch return error.SyntaxError;
break :content .{ .pane = pane_id };
},
'{', '[' => |opening| content: {
var nodes: std.ArrayList(Layout) = .empty;
defer nodes.deinit(alloc);
// Move beyond our opening
offset.* += 1;
while (true) {
try nodes.append(alloc, try parseNext(
alloc,
str,
offset,
));
// We should not reach the end of string here because
// we expect a closing bracket.
if (offset.* >= str.len) return error.SyntaxError;
// If it is a comma, we expect another node.
if (str[offset.*] == ',') {
offset.* += 1; // Consume
continue;
}
// We expect a closing bracket now.
switch (opening) {
'{' => if (str[offset.*] != '}') return error.SyntaxError,
'[' => if (str[offset.*] != ']') return error.SyntaxError,
else => return error.SyntaxError,
}
// Successfully parsed all children.
offset.* += 1; // Consume closing bracket
break :content switch (opening) {
'{' => .{ .horizontal = try nodes.toOwnedSlice(alloc) },
'[' => .{ .vertical = try nodes.toOwnedSlice(alloc) },
else => unreachable,
};
}
},
// indexOfAny above guarantees we have only the above
else => unreachable,
};
return .{
.width = width,
.height = height,
.x = x,
.y = y,
.content = content,
};
}
};
pub const Checksum = enum(u16) {
_,
/// Calculate the checksum of a tmux layout string.
/// The algorithm rotates the checksum right by 1 bit (with wraparound)
/// and adds the ASCII value of each character.
pub fn calculate(str: []const u8) Checksum {
var result: u16 = 0;
for (str) |c| {
// Rotate right by 1: (result >> 1) + ((result & 1) << 15)
result = (result >> 1) | ((result & 1) << 15);
result +%= c;
}
return @enumFromInt(result);
}
/// Convert the checksum to a 4-character hexadecimal string. This
/// is always zero-padded to match the tmux implementation
/// (in layout-custom.c).
pub fn asString(self: Checksum) [4]u8 {
const value = @intFromEnum(self);
const charset = "0123456789abcdef";
return .{
charset[(value >> 12) & 0xf],
charset[(value >> 8) & 0xf],
charset[(value >> 4) & 0xf],
charset[value & 0xf],
};
}
};
test "simple single pane" {
var arena: ArenaAllocator = .init(testing.allocator);
defer arena.deinit();
const layout: Layout = try .parse(arena.allocator(), "80x24,0,0,42");
try testing.expectEqual(80, layout.width);
try testing.expectEqual(24, layout.height);
try testing.expectEqual(0, layout.x);
try testing.expectEqual(0, layout.y);
try testing.expectEqual(42, layout.content.pane);
}
test "single pane with offset" {
var arena: ArenaAllocator = .init(testing.allocator);
defer arena.deinit();
const layout: Layout = try .parse(arena.allocator(), "40x12,10,5,7");
try testing.expectEqual(40, layout.width);
try testing.expectEqual(12, layout.height);
try testing.expectEqual(10, layout.x);
try testing.expectEqual(5, layout.y);
try testing.expectEqual(7, layout.content.pane);
}
test "single pane large values" {
var arena: ArenaAllocator = .init(testing.allocator);
defer arena.deinit();
const layout: Layout = try .parse(arena.allocator(), "1920x1080,100,200,999");
try testing.expectEqual(1920, layout.width);
try testing.expectEqual(1080, layout.height);
try testing.expectEqual(100, layout.x);
try testing.expectEqual(200, layout.y);
try testing.expectEqual(999, layout.content.pane);
}
test "horizontal split two panes" {
var arena: ArenaAllocator = .init(testing.allocator);
defer arena.deinit();
const layout: Layout = try .parse(arena.allocator(), "80x24,0,0{40x24,0,0,1,40x24,40,0,2}");
try testing.expectEqual(80, layout.width);
try testing.expectEqual(24, layout.height);
try testing.expectEqual(0, layout.x);
try testing.expectEqual(0, layout.y);
const children = layout.content.horizontal;
try testing.expectEqual(2, children.len);
try testing.expectEqual(40, children[0].width);
try testing.expectEqual(24, children[0].height);
try testing.expectEqual(0, children[0].x);
try testing.expectEqual(0, children[0].y);
try testing.expectEqual(1, children[0].content.pane);
try testing.expectEqual(40, children[1].width);
try testing.expectEqual(24, children[1].height);
try testing.expectEqual(40, children[1].x);
try testing.expectEqual(0, children[1].y);
try testing.expectEqual(2, children[1].content.pane);
}
test "vertical split two panes" {
var arena: ArenaAllocator = .init(testing.allocator);
defer arena.deinit();
const layout: Layout = try .parse(arena.allocator(), "80x24,0,0[80x12,0,0,1,80x12,0,12,2]");
try testing.expectEqual(80, layout.width);
try testing.expectEqual(24, layout.height);
try testing.expectEqual(0, layout.x);
try testing.expectEqual(0, layout.y);
const children = layout.content.vertical;
try testing.expectEqual(2, children.len);
try testing.expectEqual(80, children[0].width);
try testing.expectEqual(12, children[0].height);
try testing.expectEqual(0, children[0].x);
try testing.expectEqual(0, children[0].y);
try testing.expectEqual(1, children[0].content.pane);
try testing.expectEqual(80, children[1].width);
try testing.expectEqual(12, children[1].height);
try testing.expectEqual(0, children[1].x);
try testing.expectEqual(12, children[1].y);
try testing.expectEqual(2, children[1].content.pane);
}
test "horizontal split three panes" {
var arena: ArenaAllocator = .init(testing.allocator);
defer arena.deinit();
const layout: Layout = try .parse(arena.allocator(), "120x24,0,0{40x24,0,0,1,40x24,40,0,2,40x24,80,0,3}");
try testing.expectEqual(120, layout.width);
try testing.expectEqual(24, layout.height);
const children = layout.content.horizontal;
try testing.expectEqual(3, children.len);
try testing.expectEqual(1, children[0].content.pane);
try testing.expectEqual(2, children[1].content.pane);
try testing.expectEqual(3, children[2].content.pane);
}
test "nested horizontal in vertical" {
var arena: ArenaAllocator = .init(testing.allocator);
defer arena.deinit();
// Vertical split with top pane and bottom horizontal split
const layout: Layout = try .parse(arena.allocator(), "80x24,0,0[80x12,0,0,1,80x12,0,12{40x12,0,12,2,40x12,40,12,3}]");
try testing.expectEqual(80, layout.width);
try testing.expectEqual(24, layout.height);
const vert_children = layout.content.vertical;
try testing.expectEqual(2, vert_children.len);
// First child is a simple pane
try testing.expectEqual(1, vert_children[0].content.pane);
// Second child is a horizontal split
const horiz_children = vert_children[1].content.horizontal;
try testing.expectEqual(2, horiz_children.len);
try testing.expectEqual(2, horiz_children[0].content.pane);
try testing.expectEqual(3, horiz_children[1].content.pane);
}
test "nested vertical in horizontal" {
var arena: ArenaAllocator = .init(testing.allocator);
defer arena.deinit();
// Horizontal split with left pane and right vertical split
const layout: Layout = try .parse(arena.allocator(), "80x24,0,0{40x24,0,0,1,40x24,40,0[40x12,40,0,2,40x12,40,12,3]}");
try testing.expectEqual(80, layout.width);
try testing.expectEqual(24, layout.height);
const horiz_children = layout.content.horizontal;
try testing.expectEqual(2, horiz_children.len);
// First child is a simple pane
try testing.expectEqual(1, horiz_children[0].content.pane);
// Second child is a vertical split
const vert_children = horiz_children[1].content.vertical;
try testing.expectEqual(2, vert_children.len);
try testing.expectEqual(2, vert_children[0].content.pane);
try testing.expectEqual(3, vert_children[1].content.pane);
}
test "deeply nested layout" {
var arena: ArenaAllocator = .init(testing.allocator);
defer arena.deinit();
// Three levels deep
const layout: Layout = try .parse(arena.allocator(), "80x24,0,0{40x24,0,0[40x12,0,0,1,40x12,0,12,2],40x24,40,0,3}");
const horiz = layout.content.horizontal;
try testing.expectEqual(2, horiz.len);
const vert = horiz[0].content.vertical;
try testing.expectEqual(2, vert.len);
try testing.expectEqual(1, vert[0].content.pane);
try testing.expectEqual(2, vert[1].content.pane);
try testing.expectEqual(3, horiz[1].content.pane);
}
test "syntax error empty string" {
var arena: ArenaAllocator = .init(testing.allocator);
defer arena.deinit();
try testing.expectError(error.SyntaxError, Layout.parse(arena.allocator(), ""));
}
test "syntax error missing width" {
var arena: ArenaAllocator = .init(testing.allocator);
defer arena.deinit();
try testing.expectError(error.SyntaxError, Layout.parse(arena.allocator(), "x24,0,0,1"));
}
test "syntax error missing height" {
var arena: ArenaAllocator = .init(testing.allocator);
defer arena.deinit();
try testing.expectError(error.SyntaxError, Layout.parse(arena.allocator(), "80x,0,0,1"));
}
test "syntax error missing x" {
var arena: ArenaAllocator = .init(testing.allocator);
defer arena.deinit();
try testing.expectError(error.SyntaxError, Layout.parse(arena.allocator(), "80x24,,0,1"));
}
test "syntax error missing y" {
var arena: ArenaAllocator = .init(testing.allocator);
defer arena.deinit();
try testing.expectError(error.SyntaxError, Layout.parse(arena.allocator(), "80x24,0,,1"));
}
test "syntax error missing pane id" {
var arena: ArenaAllocator = .init(testing.allocator);
defer arena.deinit();
try testing.expectError(error.SyntaxError, Layout.parse(arena.allocator(), "80x24,0,0,"));
}
test "syntax error non-numeric width" {
var arena: ArenaAllocator = .init(testing.allocator);
defer arena.deinit();
try testing.expectError(error.SyntaxError, Layout.parse(arena.allocator(), "abcx24,0,0,1"));
}
test "syntax error non-numeric pane id" {
var arena: ArenaAllocator = .init(testing.allocator);
defer arena.deinit();
try testing.expectError(error.SyntaxError, Layout.parse(arena.allocator(), "80x24,0,0,abc"));
}
test "syntax error unclosed horizontal bracket" {
var arena: ArenaAllocator = .init(testing.allocator);
defer arena.deinit();
try testing.expectError(error.SyntaxError, Layout.parse(arena.allocator(), "80x24,0,0{40x24,0,0,1"));
}
test "syntax error unclosed vertical bracket" {
var arena: ArenaAllocator = .init(testing.allocator);
defer arena.deinit();
try testing.expectError(error.SyntaxError, Layout.parse(arena.allocator(), "80x24,0,0[40x24,0,0,1"));
}
test "syntax error mismatched brackets" {
var arena: ArenaAllocator = .init(testing.allocator);
defer arena.deinit();
try testing.expectError(error.SyntaxError, Layout.parse(arena.allocator(), "80x24,0,0{40x24,0,0,1]"));
try testing.expectError(error.SyntaxError, Layout.parse(arena.allocator(), "80x24,0,0[40x24,0,0,1}"));
}
test "syntax error trailing data" {
var arena: ArenaAllocator = .init(testing.allocator);
defer arena.deinit();
try testing.expectError(error.SyntaxError, Layout.parse(arena.allocator(), "80x24,0,0,1extra"));
}
test "syntax error no x separator" {
var arena: ArenaAllocator = .init(testing.allocator);
defer arena.deinit();
try testing.expectError(error.SyntaxError, Layout.parse(arena.allocator(), "8024,0,0,1"));
}
test "syntax error no content delimiter" {
var arena: ArenaAllocator = .init(testing.allocator);
defer arena.deinit();
try testing.expectError(error.SyntaxError, Layout.parse(arena.allocator(), "80x24,0,0"));
}
// parseWithChecksum tests
test "parseWithChecksum valid" {
var arena: ArenaAllocator = .init(testing.allocator);
defer arena.deinit();
const layout: Layout = try .parseWithChecksum(arena.allocator(), "f8f9,80x24,0,0{40x24,0,0,1,40x24,40,0,2}");
try testing.expectEqual(80, layout.width);
try testing.expectEqual(24, layout.height);
}
test "parseWithChecksum mismatch" {
var arena: ArenaAllocator = .init(testing.allocator);
defer arena.deinit();
try testing.expectError(error.ChecksumMismatch, Layout.parseWithChecksum(arena.allocator(), "0000,80x24,0,0{40x24,0,0,1,40x24,40,0,2}"));
}
test "parseWithChecksum too short" {
var arena: ArenaAllocator = .init(testing.allocator);
defer arena.deinit();
try testing.expectError(error.SyntaxError, Layout.parseWithChecksum(arena.allocator(), "bb62"));
try testing.expectError(error.SyntaxError, Layout.parseWithChecksum(arena.allocator(), ""));
}
test "parseWithChecksum missing comma" {
var arena: ArenaAllocator = .init(testing.allocator);
defer arena.deinit();
try testing.expectError(error.SyntaxError, Layout.parseWithChecksum(arena.allocator(), "bb62x159x48,0,0"));
}
// Checksum tests
test "checksum empty string" {
const checksum = Checksum.calculate("");
try testing.expectEqual(@as(u16, 0), @intFromEnum(checksum));
try testing.expectEqualStrings("0000", &checksum.asString());
}
test "checksum single character" {
// 'A' = 65, first iteration: csum = 0 >> 1 | 0 = 0, then 0 + 65 = 65
const checksum = Checksum.calculate("A");
try testing.expectEqual(@as(u16, 65), @intFromEnum(checksum));
try testing.expectEqualStrings("0041", &checksum.asString());
}
test "checksum two characters" {
// 'A' (65): csum = 0, rotate = 0, add 65 => 65
// 'B' (66): csum = 65, rotate => (65 >> 1) | ((65 & 1) << 15) = 32 | 32768 = 32800
// add 66 => 32800 + 66 = 32866
const checksum = Checksum.calculate("AB");
try testing.expectEqual(@as(u16, 32866), @intFromEnum(checksum));
try testing.expectEqualStrings("8062", &checksum.asString());
}
test "checksum simple layout" {
const checksum = Checksum.calculate("80x24,0,0,42");
try testing.expectEqualStrings("d962", &checksum.asString());
}
test "checksum horizontal split layout" {
const checksum = Checksum.calculate("80x24,0,0{40x24,0,0,1,40x24,40,0,2}");
try testing.expectEqualStrings("f8f9", &checksum.asString());
}
test "checksum asString zero padding" {
// Value 0x000f should produce "000f"
const checksum: Checksum = @enumFromInt(0x000f);
try testing.expectEqualStrings("000f", &checksum.asString());
}
test "checksum asString all digits" {
// Value 0x1234 should produce "1234"
const checksum: Checksum = @enumFromInt(0x1234);
try testing.expectEqualStrings("1234", &checksum.asString());
}
test "checksum asString with letters" {
// Value 0xabcd should produce "abcd"
const checksum: Checksum = @enumFromInt(0xabcd);
try testing.expectEqualStrings("abcd", &checksum.asString());
}
test "checksum asString max value" {
// Value 0xffff should produce "ffff"
const checksum: Checksum = @enumFromInt(0xffff);
try testing.expectEqualStrings("ffff", &checksum.asString());
}
test "checksum wraparound" {
const checksum = Checksum.calculate("\xff\xff\xff\xff\xff\xff\xff\xff");
try testing.expectEqualStrings("03fc", &checksum.asString());
}
test "checksum deterministic" {
// Same input should always produce same output
const str = "159x48,0,0{79x48,0,0,79x48,80,0}";
const checksum1 = Checksum.calculate(str);
const checksum2 = Checksum.calculate(str);
try testing.expectEqual(checksum1, checksum2);
}
test "checksum different inputs different outputs" {
const checksum1 = Checksum.calculate("80x24,0,0,1");
const checksum2 = Checksum.calculate("80x24,0,0,2");
try testing.expect(@intFromEnum(checksum1) != @intFromEnum(checksum2));
}
test "checksum known tmux layout bb62" {
// From tmux documentation: "bb62,159x48,0,0{79x48,0,0,79x48,80,0}"
// The checksum "bb62" corresponds to the layout "159x48,0,0{79x48,0,0,79x48,80,0}"
const checksum = Checksum.calculate("159x48,0,0{79x48,0,0,79x48,80,0}");
try testing.expectEqualStrings("bb62", &checksum.asString());
}

View File

@ -0,0 +1,205 @@
const std = @import("std");
const testing = std.testing;
pub const ParseError = error{
MissingEntry,
ExtraEntry,
FormatError,
};
/// Parse the output from a command with the given format struct
/// (returned usually by FormatStruct). The format struct is expected
/// to be in the order of the variables used in the format string and
/// the variables are expected to be plain variables (no conditionals,
/// extra formatting, etc.). Each variable is expected to be separated
/// by a single `delimiter` character.
pub fn parseFormatStruct(
comptime T: type,
str: []const u8,
delimiter: u8,
) ParseError!T {
// Parse all our fields
const fields = @typeInfo(T).@"struct".fields;
var it = std.mem.splitScalar(u8, str, delimiter);
var result: T = undefined;
inline for (fields) |field| {
const part = it.next() orelse return error.MissingEntry;
@field(result, field.name) = Variable.parse(
@field(Variable, field.name),
part,
) catch return error.FormatError;
}
// We should have consumed all parts now.
if (it.next() != null) return error.ExtraEntry;
return result;
}
/// Returns a struct type that contains fields for each of the given
/// format variables. This can be used with `parseFormatStruct` to
/// parse an output string into a format struct.
pub fn FormatStruct(comptime vars: []const Variable) type {
var fields: [vars.len]std.builtin.Type.StructField = undefined;
for (vars, &fields) |variable, *field| {
field.* = .{
.name = @tagName(variable),
.type = variable.Type(),
.default_value_ptr = null,
.is_comptime = false,
.alignment = @alignOf(variable.Type()),
};
}
return @Type(.{ .@"struct" = .{
.layout = .auto,
.fields = &fields,
.decls = &.{},
.is_tuple = false,
} });
}
/// Possible variables in a tmux format string that we support.
///
/// Tmux supports a large number of variables, but we only implement
/// a subset of them here that are relevant to the use case of implementing
/// control mode for terminal emulators.
pub const Variable = enum {
session_id,
window_id,
window_width,
window_height,
window_layout,
/// Parse the given string value into the appropriate resulting
/// type for this variable.
pub fn parse(comptime self: Variable, value: []const u8) !Type(self) {
return switch (self) {
.session_id => if (value.len >= 2 and value[0] == '$')
try std.fmt.parseInt(usize, value[1..], 10)
else
return error.FormatError,
.window_id => if (value.len >= 2 and value[0] == '@')
try std.fmt.parseInt(usize, value[1..], 10)
else
return error.FormatError,
.window_width => try std.fmt.parseInt(usize, value, 10),
.window_height => try std.fmt.parseInt(usize, value, 10),
.window_layout => value,
};
}
/// The type of the parsed value for this variable type.
pub fn Type(comptime self: Variable) type {
return switch (self) {
.session_id => usize,
.window_id => usize,
.window_width => usize,
.window_height => usize,
.window_layout => []const u8,
};
}
};
test "parse session id" {
try testing.expectEqual(42, try Variable.parse(.session_id, "$42"));
try testing.expectEqual(0, try Variable.parse(.session_id, "$0"));
try testing.expectError(error.FormatError, Variable.parse(.session_id, "0"));
try testing.expectError(error.FormatError, Variable.parse(.session_id, "@0"));
try testing.expectError(error.FormatError, Variable.parse(.session_id, "$"));
try testing.expectError(error.FormatError, Variable.parse(.session_id, ""));
try testing.expectError(error.InvalidCharacter, Variable.parse(.session_id, "$abc"));
}
test "parse window id" {
try testing.expectEqual(42, try Variable.parse(.window_id, "@42"));
try testing.expectEqual(0, try Variable.parse(.window_id, "@0"));
try testing.expectEqual(12345, try Variable.parse(.window_id, "@12345"));
try testing.expectError(error.FormatError, Variable.parse(.window_id, "0"));
try testing.expectError(error.FormatError, Variable.parse(.window_id, "$0"));
try testing.expectError(error.FormatError, Variable.parse(.window_id, "@"));
try testing.expectError(error.FormatError, Variable.parse(.window_id, ""));
try testing.expectError(error.InvalidCharacter, Variable.parse(.window_id, "@abc"));
}
test "parse window width" {
try testing.expectEqual(80, try Variable.parse(.window_width, "80"));
try testing.expectEqual(0, try Variable.parse(.window_width, "0"));
try testing.expectEqual(12345, try Variable.parse(.window_width, "12345"));
try testing.expectError(error.InvalidCharacter, Variable.parse(.window_width, "abc"));
try testing.expectError(error.InvalidCharacter, Variable.parse(.window_width, "80px"));
try testing.expectError(error.Overflow, Variable.parse(.window_width, "-1"));
}
test "parse window height" {
try testing.expectEqual(24, try Variable.parse(.window_height, "24"));
try testing.expectEqual(0, try Variable.parse(.window_height, "0"));
try testing.expectEqual(12345, try Variable.parse(.window_height, "12345"));
try testing.expectError(error.InvalidCharacter, Variable.parse(.window_height, "abc"));
try testing.expectError(error.InvalidCharacter, Variable.parse(.window_height, "24px"));
try testing.expectError(error.Overflow, Variable.parse(.window_height, "-1"));
}
test "parse window layout" {
try testing.expectEqualStrings("abc123", try Variable.parse(.window_layout, "abc123"));
try testing.expectEqualStrings("", try Variable.parse(.window_layout, ""));
try testing.expectEqualStrings("a]b,c{d}e(f)", try Variable.parse(.window_layout, "a]b,c{d}e(f)"));
}
test "parseFormatStruct single field" {
const T = FormatStruct(&.{.session_id});
const result = try parseFormatStruct(T, "$42", ' ');
try testing.expectEqual(42, result.session_id);
}
test "parseFormatStruct multiple fields" {
const T = FormatStruct(&.{ .session_id, .window_id, .window_width, .window_height });
const result = try parseFormatStruct(T, "$1 @2 80 24", ' ');
try testing.expectEqual(1, result.session_id);
try testing.expectEqual(2, result.window_id);
try testing.expectEqual(80, result.window_width);
try testing.expectEqual(24, result.window_height);
}
test "parseFormatStruct with string field" {
const T = FormatStruct(&.{ .window_id, .window_layout });
const result = try parseFormatStruct(T, "@5,abc123", ',');
try testing.expectEqual(5, result.window_id);
try testing.expectEqualStrings("abc123", result.window_layout);
}
test "parseFormatStruct different delimiter" {
const T = FormatStruct(&.{ .window_width, .window_height });
const result = try parseFormatStruct(T, "120\t40", '\t');
try testing.expectEqual(120, result.window_width);
try testing.expectEqual(40, result.window_height);
}
test "parseFormatStruct missing entry" {
const T = FormatStruct(&.{ .session_id, .window_id });
try testing.expectError(error.MissingEntry, parseFormatStruct(T, "$1", ' '));
}
test "parseFormatStruct extra entry" {
const T = FormatStruct(&.{.session_id});
try testing.expectError(error.ExtraEntry, parseFormatStruct(T, "$1 @2", ' '));
}
test "parseFormatStruct format error" {
const T = FormatStruct(&.{.session_id});
try testing.expectError(error.FormatError, parseFormatStruct(T, "42", ' '));
try testing.expectError(error.FormatError, parseFormatStruct(T, "@42", ' '));
try testing.expectError(error.FormatError, parseFormatStruct(T, "$abc", ' '));
}
test "parseFormatStruct empty string" {
const T = FormatStruct(&.{.session_id});
try testing.expectError(error.FormatError, parseFormatStruct(T, "", ' '));
}
test "parseFormatStruct with empty layout field" {
const T = FormatStruct(&.{ .session_id, .window_layout });
const result = try parseFormatStruct(T, "$1,", ',');
try testing.expectEqual(1, result.session_id);
try testing.expectEqualStrings("", result.window_layout);
}

View File

@ -659,12 +659,12 @@ fn setupZsh(
resource_dir: []const u8,
env: *EnvMap,
) !void {
// Preserve the old zdotdir value so we can recover it.
// Preserve an existing ZDOTDIR value. We're about to overwrite it.
if (env.get("ZDOTDIR")) |old| {
try env.put("GHOSTTY_ZSH_ZDOTDIR", old);
}
// Set our new ZDOTDIR
// Set our new ZDOTDIR to point to our shell resource directory.
var path_buf: [std.fs.max_path_bytes]u8 = undefined;
const integ_dir = try std.fmt.bufPrint(
&path_buf,