Merge branch 'main' into grapheme-break
commit
7bddbfed1e
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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: |
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
},
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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=";
|
||||
};
|
||||
}
|
||||
{
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -33,6 +33,7 @@ struct QuickTerminalSize {
|
|||
case GHOSTTY_QUICK_TERMINAL_SIZE_PIXELS:
|
||||
self = .pixels(cStruct.value.pixels)
|
||||
default:
|
||||
assertionFailure()
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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" },
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
};
|
||||
};
|
||||
|
|
@ -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, &.{
|
||||
|
|
|
|||
|
|
@ -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
|
||||
*/
|
||||
|
|
|
|||
|
|
@ -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";
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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},
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -69,6 +69,7 @@ test {
|
|||
_ = i18n;
|
||||
_ = path;
|
||||
_ = uri;
|
||||
_ = shell;
|
||||
|
||||
if (comptime builtin.os.tag == .linux) {
|
||||
_ = kernel_info;
|
||||
|
|
|
|||
|
|
@ -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());
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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+.
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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());
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
@ -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());
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
Loading…
Reference in New Issue