diff options
| author | user <user@clank> | 2026-09-02 15:45:32 +0200 |
|---|---|---|
| committer | user <user@clank> | 2026-09-02 15:45:32 +0200 |
| commit | 311979f10132697b990a670c6c99b19c1c33d623 (patch) | |
| tree | 4d96e1a3c74e1a95594e83d8a175ad47d5d51584 /vendor | |
| parent | contrib/vendor-check: watching upstream is one command now (diff) | |
| download | slosh-311979f10132697b990a670c6c99b19c1c33d623.tar.gz slosh-311979f10132697b990a670c6c99b19c1c33d623.tar.xz slosh-311979f10132697b990a670c6c99b19c1c33d623.zip | |
vendor: libghostty-vt bedb6f2 -> 3c1ef5b3 (2026-09-01)
Three weeks of upstream: 504 commits, 120 touching the terminal core
-- OSC 5522, kitty relative placements, C0 fixes, the wuffs pixel
path. Rebuilt from ghostty's own dist step (zig build dist
-Demit-lib-vt=true), zig floor unchanged at 0.16.0, and slosh's C
usage compiled unmodified.
Patch 0001 is re-implemented, not retired: upstream rewrote the area
into ImageStorage.clearScreen but kept the behaviour we patch out --
a terminal clear still reclaims transmitted-but-unplaced images,
which breaks the upload-once place-per-frame pattern the protocol's
two verbs exist for. The patch now removes just that sweep; the
placement clearing and orphan reaping stay upstream's. Their recent
'preserve data on unmatched delete' fixes the same class of bug, so
sending ours upstream remains the way to actually retire it.
Diffstat (limited to 'vendor')
199 files changed, 34033 insertions, 3900 deletions
diff --git a/vendor/libghostty-vt.vendor.json b/vendor/libghostty-vt.vendor.json index bfe92a5..a3ab1a3 100644 --- a/vendor/libghostty-vt.vendor.json +++ b/vendor/libghostty-vt.vendor.json @@ -1,9 +1,9 @@ { "upstream": "https://github.com/ghostty-org/ghostty", - "source_commit": "bedb6f2ff9d2c65456a7b1e98a5a04a702b77f27", - "source_date": "2026-08-13T21:36:32+02:00", - "dist_archive": "libghostty-vt-1.3.2-main-+bedb6f2.tar.gz", - "version": "1.3.2-main-+bedb6f2", + "source_commit": "3c1ef5b32fc5ea6b93d28493fabf193f595139cf", + "source_date": "2026-09-01T10:25:35-07:00", + "dist_archive": "libghostty-vt-1.3.2-HEAD-+3c1ef5b32.tar.gz", + "version": "1.3.2-HEAD-+3c1ef5b32", "built_with_zig": "0.16.0", "patches": [ "patches/0001-keep-kitty-images-across-a-screen-clear.patch" diff --git a/vendor/libghostty-vt/.github/VOUCHED.td b/vendor/libghostty-vt/.github/VOUCHED.td index 63aaea0..88f9519 100644 --- a/vendor/libghostty-vt/.github/VOUCHED.td +++ b/vendor/libghostty-vt/.github/VOUCHED.td @@ -46,11 +46,13 @@ alexfeijoo44 alexjuca alosarjos amadeus +and-rs andrejdaskalov anhthang anmitalidev anthonyzhoon aprilnea +arneshbanerjee arvin7liu athaapa atomk @@ -60,6 +62,7 @@ b0uks b1nar10 balazs-szucs barutsrb +basteez bch bennettp123 benodiwal @@ -72,6 +75,7 @@ bo2themax bousii brentschroeter brianc442 +by-nelson c0x0o carldaws carlvillads @@ -97,6 +101,9 @@ deblasis dervedro devsunb diaaeddin +-didouougha slingshotting LLM output +diego-moment +diegoarmstrong dkinzler dmehala dmunozv04 @@ -110,6 +117,7 @@ drepper dzhlobo ekaterinepapava elias8 +elitex45 -enkr1 enzowilliam ephemera @@ -149,9 +157,11 @@ hulet i999rri icodesign illiakrauchanka +j-c-m j0hnm4r5 jacobsandlund jake-stewart +jakeriksen -jamesarch Bad AI etiquette jamylak jarred-sumner @@ -166,6 +176,7 @@ josephmart jparise juniqlim justonia +jzillmann karesansui-u kataokatsuki kawarimidoll @@ -175,6 +186,7 @@ khipp kierancanter kirwiisp kjvdven +kleshzz kloneets knu -kody-w @@ -203,6 +215,7 @@ marrocco-simone masterflitzer matkotiric mattn +mgsloan micaeljarniac michielvk miguelelgallo @@ -237,6 +250,7 @@ nolinmcfarland nouritsu nwehg ocean6954 +ollioddi oshdubh otomn paaloeye @@ -253,8 +267,10 @@ pluiedev pouwerkerk poweruser64 prakhar54-byte +preiter93 prihlop priyans-hu +pssalman puzza007 qappell quinnypig @@ -267,6 +283,7 @@ rgehan rhodes-b rightaditya rjwittams +rkoten rmengelbrecht rmunn rockorager @@ -289,17 +306,24 @@ simonbcn slsrepo steven-tk sunshine-syz +svector-anu svmhdvn +tacherasasi +-tangivis tasselx tbrundige tdgroot tdslot thirstycrow +-thomas-trijindev No human in the loop thoutbeckers ticclick tnagatomi +tothedarktowercame trag1c tristan957 +tsacha +tuananh turbolent tweedbeetle uhojin @@ -307,6 +331,7 @@ unphased unsaltedscholar uzaaft vancluever +vasilmytsyk vaughanandrews -vectorpeak Stupid bot cosplaying as a maintainer vegerot @@ -323,4 +348,5 @@ ydah zenangst zenyr zeshi09 +zlitus zubb diff --git a/vendor/libghostty-vt/.github/workflows/milestone.yml b/vendor/libghostty-vt/.github/workflows/milestone.yml index 34e7652..725c664 100644 --- a/vendor/libghostty-vt/.github/workflows/milestone.yml +++ b/vendor/libghostty-vt/.github/workflows/milestone.yml @@ -18,7 +18,7 @@ jobs: name: Milestone Update steps: - name: Set Milestone for PR - uses: hustcer/milestone-action@ebed8d5daafd855a600d7e665c1b130f06d24130 # v3.1 + uses: hustcer/milestone-action@2f38355153344ccaaa44b5b5fcff9f604dff1b45 # v3.2 if: github.event.pull_request.merged == true && !contains(github.event.pull_request.title, 'VOUCHED') && !startsWith(github.event.pull_request.title, 'ci:') with: action: bind-pr # `bind-pr` is the default action @@ -26,7 +26,7 @@ jobs: # Bind milestone to closed issue that has a merged PR fix - name: Set Milestone for Issue - uses: hustcer/milestone-action@ebed8d5daafd855a600d7e665c1b130f06d24130 # v3.1 + uses: hustcer/milestone-action@2f38355153344ccaaa44b5b5fcff9f604dff1b45 # v3.2 if: github.event.issue.state == 'closed' with: action: bind-issue diff --git a/vendor/libghostty-vt/.github/workflows/nix.yml b/vendor/libghostty-vt/.github/workflows/nix.yml index b7beaa6..87b1ecf 100644 --- a/vendor/libghostty-vt/.github/workflows/nix.yml +++ b/vendor/libghostty-vt/.github/workflows/nix.yml @@ -47,7 +47,7 @@ jobs: /nix /zig - name: Setup Nix - uses: cachix/install-nix-action@630ae543ea3a38a9a4166f03376c02c50f408342 # v31.11.0 + uses: cachix/install-nix-action@13d8dd58da0234aa297dedd986986ccb8e7f3e24 # v31.11.1 with: nix_path: nixpkgs=channel:nixos-unstable - uses: cachix/cachix-action@5f2d7c5294214f71b873db4b969586b980625e71 # v17 diff --git a/vendor/libghostty-vt/.github/workflows/release-tag.yml b/vendor/libghostty-vt/.github/workflows/release-tag.yml index 1f80040..42f6f32 100644 --- a/vendor/libghostty-vt/.github/workflows/release-tag.yml +++ b/vendor/libghostty-vt/.github/workflows/release-tag.yml @@ -89,7 +89,7 @@ jobs: /nix /zig - - uses: cachix/install-nix-action@630ae543ea3a38a9a4166f03376c02c50f408342 # v31.11.0 + - uses: cachix/install-nix-action@13d8dd58da0234aa297dedd986986ccb8e7f3e24 # v31.11.1 with: nix_path: nixpkgs=channel:nixos-unstable @@ -160,7 +160,7 @@ jobs: - name: Setup Sparkle env: - SPARKLE_VERSION: 2.9.0 + SPARKLE_VERSION: 2.9.6 run: | mkdir -p .action/sparkle cd .action/sparkle @@ -328,7 +328,7 @@ jobs: - name: Setup Sparkle env: - SPARKLE_VERSION: 2.9.0 + SPARKLE_VERSION: 2.9.6 run: | mkdir -p .action/sparkle cd .action/sparkle diff --git a/vendor/libghostty-vt/.github/workflows/release-tip.yml b/vendor/libghostty-vt/.github/workflows/release-tip.yml index dd1a23c..3a24512 100644 --- a/vendor/libghostty-vt/.github/workflows/release-tip.yml +++ b/vendor/libghostty-vt/.github/workflows/release-tip.yml @@ -42,7 +42,7 @@ jobs: with: path: | /nix - - uses: cachix/install-nix-action@630ae543ea3a38a9a4166f03376c02c50f408342 # v31.11.0 + - uses: cachix/install-nix-action@13d8dd58da0234aa297dedd986986ccb8e7f3e24 # v31.11.1 with: nix_path: nixpkgs=channel:nixos-unstable - uses: cachix/cachix-action@5f2d7c5294214f71b873db4b969586b980625e71 # v17 @@ -219,7 +219,7 @@ jobs: path: | /nix /zig - - uses: cachix/install-nix-action@630ae543ea3a38a9a4166f03376c02c50f408342 # v31.11.0 + - uses: cachix/install-nix-action@13d8dd58da0234aa297dedd986986ccb8e7f3e24 # v31.11.1 with: nix_path: nixpkgs=channel:nixos-unstable - uses: cachix/cachix-action@5f2d7c5294214f71b873db4b969586b980625e71 # v17 @@ -239,7 +239,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@3d0d9888cb7fd7b750713d6e236d1fcb99157228 # v3.0.2 + uses: softprops/action-gh-release@efb35369e0ad2afab669f228072c1b0d510eae64 # v3.0.3 with: name: 'Ghostty Tip ("Nightly")' prerelease: true @@ -274,7 +274,7 @@ jobs: path: | /nix /zig - - uses: cachix/install-nix-action@630ae543ea3a38a9a4166f03376c02c50f408342 # v31.11.0 + - uses: cachix/install-nix-action@13d8dd58da0234aa297dedd986986ccb8e7f3e24 # v31.11.1 with: nix_path: nixpkgs=channel:nixos-unstable - uses: cachix/cachix-action@5f2d7c5294214f71b873db4b969586b980625e71 # v17 @@ -294,7 +294,7 @@ jobs: nix develop -c minisign -S -m libghostty-vt-source.tar.gz -s minisign.key < minisign.password - name: Update Release - uses: softprops/action-gh-release@3d0d9888cb7fd7b750713d6e236d1fcb99157228 # v3.0.2 + uses: softprops/action-gh-release@efb35369e0ad2afab669f228072c1b0d510eae64 # v3.0.3 with: name: 'Ghostty Tip ("Nightly")' prerelease: true @@ -379,7 +379,7 @@ jobs: nix develop -c minisign -S -m ghostty-vt.xcframework.zip -s minisign.key < minisign.password - name: Update Release - uses: softprops/action-gh-release@3d0d9888cb7fd7b750713d6e236d1fcb99157228 # v3.0.2 + uses: softprops/action-gh-release@efb35369e0ad2afab669f228072c1b0d510eae64 # v3.0.3 with: name: 'Ghostty Tip ("Nightly")' prerelease: true @@ -409,6 +409,135 @@ jobs: echo "Release URLs:" echo " XCFramework: https://tip.files.ghostty.org/${GHOSTTY_COMMIT_LONG}/ghostty-vt.xcframework.zip" + build-lib-vt-wasm: + needs: [setup] + if: | + needs.setup.outputs.should_skip != 'true' && + ( + github.event_name == 'workflow_dispatch' || + ( + github.repository_owner == 'ghostty-org' && + github.ref_name == 'main' + ) + ) + runs-on: namespace-profile-ghostty-sm + env: + GHOSTTY_COMMIT_LONG: ${{ needs.setup.outputs.commit_long }} + ZIG_LOCAL_CACHE_DIR: /zig/local-cache + ZIG_GLOBAL_CACHE_DIR: /zig/global-cache + steps: + - name: Checkout code + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + + - name: Setup Cache + uses: namespacelabs/nscloud-cache-action@c5f8dab7560444c4bf8dbc64f1b203431873c547 # v1.6.1 + with: + path: | + /nix + /zig + + - uses: cachix/install-nix-action@13d8dd58da0234aa297dedd986986ccb8e7f3e24 # v31.11.1 + with: + nix_path: nixpkgs=channel:nixos-unstable + - uses: cachix/cachix-action@5f2d7c5294214f71b873db4b969586b980625e71 # v17 + with: + name: ghostty + authToken: "${{ secrets.CACHIX_AUTH_TOKEN }}" + + - name: Build ReleaseFast WASM + run: | + nix develop -c zig build \ + -Demit-lib-vt \ + -Dtarget=wasm32-freestanding \ + -Doptimize=ReleaseFast + + - name: Optimize ReleaseFast WASM + run: | + nix develop -c wasm-opt -O3 --strip-dwarf \ + --enable-simd \ + --enable-bulk-memory \ + --enable-sign-ext \ + --enable-nontrapping-float-to-int \ + --enable-multivalue \ + --enable-reference-types \ + zig-out/bin/ghostty-vt.wasm \ + -o ghostty-vt.wasm + + - name: Build ReleaseSmall WASM + run: | + nix develop -c zig build \ + -Demit-lib-vt \ + -Dtarget=wasm32-freestanding \ + -Doptimize=ReleaseSmall + + - name: Optimize ReleaseSmall WASM + run: | + nix develop -c wasm-opt -O3 \ + --enable-simd \ + --enable-bulk-memory \ + --enable-sign-ext \ + --enable-nontrapping-float-to-int \ + --enable-multivalue \ + --enable-reference-types \ + zig-out/bin/ghostty-vt.wasm \ + -o ghostty-vt-small.wasm + + - name: Verify WASM simd128 + run: | + # wasm-tools names the simd128 proposal "simd". The artifact must + # fail validation when that feature is disabled. + for artifact in ghostty-vt.wasm ghostty-vt-small.wasm; do + nix develop -c wasm-tools validate "${artifact}" + if nix develop -c wasm-tools validate \ + --features=-simd "${artifact}"; then + echo "${artifact} unexpectedly validates with simd128 disabled" >&2 + exit 1 + fi + echo "Verified ${artifact} requires simd128" + done + + - name: Sign WASM + run: | + echo -n "${{ secrets.MINISIGN_KEY }}" > minisign.key + echo -n "${{ secrets.MINISIGN_PASSWORD }}" > minisign.password + nix develop -c minisign -S -m ghostty-vt.wasm -s minisign.key < minisign.password + nix develop -c minisign -S -m ghostty-vt-small.wasm -s minisign.key < minisign.password + + - name: Update Release + uses: softprops/action-gh-release@efb35369e0ad2afab669f228072c1b0d510eae64 # v3.0.3 + with: + name: 'Ghostty Tip ("Nightly")' + prerelease: true + tag_name: tip + target_commitish: ${{ github.sha }} + files: | + ghostty-vt.wasm + ghostty-vt.wasm.minisig + ghostty-vt-small.wasm + ghostty-vt-small.wasm.minisig + token: ${{ secrets.GH_RELEASE_TOKEN }} + + - name: Prep R2 Storage + run: | + mkdir -p blob/${GHOSTTY_COMMIT_LONG} + cp ghostty-vt.wasm blob/${GHOSTTY_COMMIT_LONG}/ghostty-vt.wasm + cp ghostty-vt-small.wasm blob/${GHOSTTY_COMMIT_LONG}/ghostty-vt-small.wasm + - name: Upload to R2 + uses: ryand56/r2-upload-action@b801a390acbdeb034c5e684ff5e1361c06639e7c # v1.4 + with: + r2-account-id: ${{ secrets.CF_R2_TIP_ACCOUNT_ID }} + r2-access-key-id: ${{ secrets.CF_R2_TIP_AWS_KEY }} + r2-secret-access-key: ${{ secrets.CF_R2_TIP_SECRET_KEY }} + r2-bucket: ghostty-tip + source-dir: blob + destination-dir: ./ + + - name: Echo Release URLs + run: | + echo "Release URLs:" + echo " WASM (ReleaseFast): https://tip.files.ghostty.org/${GHOSTTY_COMMIT_LONG}/ghostty-vt.wasm" + echo " WASM (ReleaseSmall): https://tip.files.ghostty.org/${GHOSTTY_COMMIT_LONG}/ghostty-vt-small.wasm" + build-macos: needs: [setup] if: | @@ -462,7 +591,7 @@ jobs: # Setup Sparkle - name: Setup Sparkle env: - SPARKLE_VERSION: 2.9.0 + SPARKLE_VERSION: 2.9.6 run: | mkdir -p .action/sparkle cd .action/sparkle @@ -581,7 +710,7 @@ jobs: # Update Release - name: Release - uses: softprops/action-gh-release@3d0d9888cb7fd7b750713d6e236d1fcb99157228 # v3.0.2 + uses: softprops/action-gh-release@efb35369e0ad2afab669f228072c1b0d510eae64 # v3.0.3 with: name: 'Ghostty Tip ("Nightly")' prerelease: true @@ -659,7 +788,7 @@ jobs: EOF - name: Upload Release URLs - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v6.0 + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 with: name: release-urls-${{ inputs.pr || '0' }} path: release-urls.txt @@ -718,7 +847,7 @@ jobs: # Setup Sparkle - name: Setup Sparkle env: - SPARKLE_VERSION: 2.9.0 + SPARKLE_VERSION: 2.9.6 run: | mkdir -p .action/sparkle cd .action/sparkle @@ -830,7 +959,7 @@ jobs: # Update Release - name: Release - uses: softprops/action-gh-release@3d0d9888cb7fd7b750713d6e236d1fcb99157228 # v3.0.2 + uses: softprops/action-gh-release@efb35369e0ad2afab669f228072c1b0d510eae64 # v3.0.3 with: name: 'Ghostty Tip ("Nightly")' prerelease: true @@ -915,7 +1044,7 @@ jobs: # Setup Sparkle - name: Setup Sparkle env: - SPARKLE_VERSION: 2.9.0 + SPARKLE_VERSION: 2.9.6 run: | mkdir -p .action/sparkle cd .action/sparkle @@ -1027,7 +1156,7 @@ jobs: # Update Release - name: Release - uses: softprops/action-gh-release@3d0d9888cb7fd7b750713d6e236d1fcb99157228 # v3.0.2 + uses: softprops/action-gh-release@efb35369e0ad2afab669f228072c1b0d510eae64 # v3.0.3 with: name: 'Ghostty Tip ("Nightly")' prerelease: true diff --git a/vendor/libghostty-vt/.github/workflows/test.yml b/vendor/libghostty-vt/.github/workflows/test.yml index ec8b884..cf43c67 100644 --- a/vendor/libghostty-vt/.github/workflows/test.yml +++ b/vendor/libghostty-vt/.github/workflows/test.yml @@ -94,6 +94,9 @@ jobs: - build-cmake - build-flatpak - build-libghostty-vt + - build-libghostty-vt-freestanding + - build-libghostty-vt-wasm + - build-libghostty-vt-features - build-libghostty-vt-android - build-libghostty-vt-macos - build-libghostty-vt-windows @@ -169,7 +172,7 @@ jobs: /zig # Install Nix and use that to run our tests so our environment matches exactly. - - uses: cachix/install-nix-action@630ae543ea3a38a9a4166f03376c02c50f408342 # v31.11.0 + - uses: cachix/install-nix-action@13d8dd58da0234aa297dedd986986ccb8e7f3e24 # v31.11.1 with: nix_path: nixpkgs=channel:nixos-unstable - uses: cachix/cachix-action@5f2d7c5294214f71b873db4b969586b980625e71 # v17 @@ -227,7 +230,7 @@ jobs: /zig # Install Nix and use that to run our tests so our environment matches exactly. - - uses: cachix/install-nix-action@630ae543ea3a38a9a4166f03376c02c50f408342 # v31.11.0 + - uses: cachix/install-nix-action@13d8dd58da0234aa297dedd986986ccb8e7f3e24 # v31.11.1 with: nix_path: nixpkgs=channel:nixos-unstable - uses: cachix/cachix-action@5f2d7c5294214f71b873db4b969586b980625e71 # v17 @@ -295,7 +298,7 @@ jobs: /zig # Install Nix and use that to run our tests so our environment matches exactly. - - uses: cachix/install-nix-action@630ae543ea3a38a9a4166f03376c02c50f408342 # v31.11.0 + - uses: cachix/install-nix-action@13d8dd58da0234aa297dedd986986ccb8e7f3e24 # v31.11.1 with: nix_path: nixpkgs=channel:nixos-unstable - uses: cachix/cachix-action@5f2d7c5294214f71b873db4b969586b980625e71 # v17 @@ -401,7 +404,7 @@ jobs: /zig # Install Nix and use that to run our tests so our environment matches exactly. - - uses: cachix/install-nix-action@630ae543ea3a38a9a4166f03376c02c50f408342 # v31.11.0 + - uses: cachix/install-nix-action@13d8dd58da0234aa297dedd986986ccb8e7f3e24 # v31.11.1 with: nix_path: nixpkgs=channel:nixos-unstable - uses: cachix/cachix-action@5f2d7c5294214f71b873db4b969586b980625e71 # v17 @@ -439,7 +442,7 @@ jobs: /zig # Install Nix and use that to run our tests so our environment matches exactly. - - uses: cachix/install-nix-action@630ae543ea3a38a9a4166f03376c02c50f408342 # v31.11.0 + - uses: cachix/install-nix-action@13d8dd58da0234aa297dedd986986ccb8e7f3e24 # v31.11.1 with: nix_path: nixpkgs=channel:nixos-unstable - uses: cachix/cachix-action@5f2d7c5294214f71b873db4b969586b980625e71 # v17 @@ -556,7 +559,7 @@ jobs: /zig # Install Nix and use that to run our tests so our environment matches exactly. - - uses: cachix/install-nix-action@630ae543ea3a38a9a4166f03376c02c50f408342 # v31.11.0 + - uses: cachix/install-nix-action@13d8dd58da0234aa297dedd986986ccb8e7f3e24 # v31.11.1 with: nix_path: nixpkgs=channel:nixos-unstable - uses: cachix/cachix-action@5f2d7c5294214f71b873db4b969586b980625e71 # v17 @@ -590,7 +593,7 @@ jobs: /zig # Install Nix and use that to run our tests so our environment matches exactly. - - uses: cachix/install-nix-action@630ae543ea3a38a9a4166f03376c02c50f408342 # v31.11.0 + - uses: cachix/install-nix-action@13d8dd58da0234aa297dedd986986ccb8e7f3e24 # v31.11.1 with: nix_path: nixpkgs=channel:nixos-unstable - uses: cachix/cachix-action@5f2d7c5294214f71b873db4b969586b980625e71 # v17 @@ -616,7 +619,6 @@ jobs: x86_64-windows-gnu, # doesn't work yet, we need a way to find msvc libc/c++ headers # x86_64-windows-msvc - wasm32-freestanding, ] runs-on: namespace-profile-ghostty-sm needs: test @@ -635,7 +637,7 @@ jobs: /zig # Install Nix and use that to run our tests so our environment matches exactly. - - uses: cachix/install-nix-action@630ae543ea3a38a9a4166f03376c02c50f408342 # v31.11.0 + - uses: cachix/install-nix-action@13d8dd58da0234aa297dedd986986ccb8e7f3e24 # v31.11.1 with: nix_path: nixpkgs=channel:nixos-unstable - uses: cachix/cachix-action@5f2d7c5294214f71b873db4b969586b980625e71 # v17 @@ -648,6 +650,180 @@ jobs: nix develop -c zig build -Demit-lib-vt \ -Dtarget=${{ matrix.target }} + build-libghostty-vt-freestanding: + strategy: + matrix: + include: + - target: riscv32-freestanding-eabi + cpu: baseline + - target: thumb-freestanding-eabi + cpu: cortex_m4 + runs-on: namespace-profile-ghostty-sm + needs: test + env: + ZIG_LOCAL_CACHE_DIR: /zig/local-cache + ZIG_GLOBAL_CACHE_DIR: /zig/global-cache + steps: + - name: Checkout code + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + + - name: Setup Cache + uses: namespacelabs/nscloud-cache-action@c5f8dab7560444c4bf8dbc64f1b203431873c547 # v1.6.1 + with: + path: | + /nix + /zig + + - uses: cachix/install-nix-action@13d8dd58da0234aa297dedd986986ccb8e7f3e24 # v31.11.1 + with: + nix_path: nixpkgs=channel:nixos-unstable + - uses: cachix/cachix-action@5f2d7c5294214f71b873db4b969586b980625e71 # v17 + with: + name: ghostty + authToken: "${{ secrets.CACHIX_AUTH_TOKEN }}" + + - name: Build + run: | + nix develop -c zig build -Demit-lib-vt \ + -Dtarget=${{ matrix.target }} \ + -Dcpu=${{ matrix.cpu }} \ + -Doptimize=ReleaseSafe + + build-libghostty-vt-wasm: + runs-on: namespace-profile-ghostty-sm + needs: test + env: + ZIG_LOCAL_CACHE_DIR: /zig/local-cache + ZIG_GLOBAL_CACHE_DIR: /zig/global-cache + steps: + - name: Checkout code + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + + - name: Setup Cache + uses: namespacelabs/nscloud-cache-action@c5f8dab7560444c4bf8dbc64f1b203431873c547 # v1.6.1 + with: + path: | + /nix + /zig + + # Install Nix and use that to run our tests so our environment matches exactly. + - uses: cachix/install-nix-action@13d8dd58da0234aa297dedd986986ccb8e7f3e24 # v31.11.1 + with: + nix_path: nixpkgs=channel:nixos-unstable + - uses: cachix/cachix-action@5f2d7c5294214f71b873db4b969586b980625e71 # v17 + with: + name: ghostty + authToken: "${{ secrets.CACHIX_AUTH_TOKEN }}" + + - name: Build ReleaseFast WASM + run: | + nix develop -c zig build \ + -Demit-lib-vt \ + -Dtarget=wasm32-freestanding \ + -Doptimize=ReleaseFast + + - name: Optimize ReleaseFast WASM + run: | + nix develop -c wasm-opt -O3 --strip-dwarf \ + --enable-simd \ + --enable-bulk-memory \ + --enable-sign-ext \ + --enable-nontrapping-float-to-int \ + --enable-multivalue \ + --enable-reference-types \ + zig-out/bin/ghostty-vt.wasm \ + -o ghostty-vt.wasm + + - name: Build ReleaseSmall WASM + run: | + nix develop -c zig build \ + -Demit-lib-vt \ + -Dtarget=wasm32-freestanding \ + -Doptimize=ReleaseSmall + + - name: Validate WASM ABI manifest + run: | + nix develop -c zig build test-lib-vt-schema \ + -Dtarget=wasm32-freestanding \ + -Doptimize=ReleaseSmall + + - name: Optimize ReleaseSmall WASM + run: | + nix develop -c wasm-opt -O3 \ + --enable-simd \ + --enable-bulk-memory \ + --enable-sign-ext \ + --enable-nontrapping-float-to-int \ + --enable-multivalue \ + --enable-reference-types \ + zig-out/bin/ghostty-vt.wasm \ + -o ghostty-vt-small.wasm + + - name: Verify WASM simd128 + run: | + # wasm-tools names the simd128 proposal "simd". The artifact must + # fail validation when that feature is disabled. + for artifact in ghostty-vt.wasm ghostty-vt-small.wasm; do + nix develop -c wasm-tools validate "${artifact}" + if nix develop -c wasm-tools validate \ + --features=-simd "${artifact}"; then + echo "${artifact} unexpectedly validates with simd128 disabled" >&2 + exit 1 + fi + echo "Verified ${artifact} requires simd128" + done + + - name: Test WASM allocator + run: | + nix develop -c node test/wasm-alloc.mjs ghostty-vt.wasm + nix develop -c node test/wasm-alloc.mjs ghostty-vt-small.wasm + + # Compile-only checks for the -Dvt-features flags so that future changes + # don't regress any feature combination. + build-libghostty-vt-features: + runs-on: namespace-profile-ghostty-sm + needs: test + env: + ZIG_LOCAL_CACHE_DIR: /zig/local-cache + ZIG_GLOBAL_CACHE_DIR: /zig/global-cache + steps: + - name: Checkout code + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + + - name: Setup Cache + uses: namespacelabs/nscloud-cache-action@c5f8dab7560444c4bf8dbc64f1b203431873c547 # v1.6.1 + with: + path: | + /nix + /zig + + # Install Nix and use that to run our tests so our environment matches exactly. + - uses: cachix/install-nix-action@13d8dd58da0234aa297dedd986986ccb8e7f3e24 # v31.11.1 + with: + nix_path: nixpkgs=channel:nixos-unstable + - uses: cachix/cachix-action@5f2d7c5294214f71b873db4b969586b980625e71 # v17 + with: + name: ghostty + authToken: "${{ secrets.CACHIX_AUTH_TOKEN }}" + + - name: Compile feature flag matrix + run: | + features="snapshot formatter selection render_state input_encode color grid_introspection glyph_protocol kitty_graphics" + configs="-all" + for f in $features; do configs="$configs -all,+$f"; done + + for cfg in $configs; do + echo "::group::-Dvt-features=$cfg" + # The wasm lib build compiles the C API export block; the + # test build compiles every module test without running any. + nix develop -c zig build -Demit-lib-vt \ + "-Dvt-features=$cfg" \ + -Dtarget=wasm32-freestanding + nix develop -c zig build test-lib-vt-build \ + "-Dvt-features=$cfg" + echo "::endgroup::" + done + # lib-vt requires macOS runner for macOS/iOS builds because it requires the `apple_sdk` path build-libghostty-vt-macos: strategy: @@ -735,7 +911,7 @@ jobs: /zig # Install Nix and use that to run our tests so our environment matches exactly. - - uses: cachix/install-nix-action@630ae543ea3a38a9a4166f03376c02c50f408342 # v31.11.0 + - uses: cachix/install-nix-action@13d8dd58da0234aa297dedd986986ccb8e7f3e24 # v31.11.1 with: nix_path: nixpkgs=channel:nixos-unstable - uses: cachix/cachix-action@5f2d7c5294214f71b873db4b969586b980625e71 # v17 @@ -816,7 +992,7 @@ jobs: /zig # Install Nix and use that to run our tests so our environment matches exactly. - - uses: cachix/install-nix-action@630ae543ea3a38a9a4166f03376c02c50f408342 # v31.11.0 + - uses: cachix/install-nix-action@13d8dd58da0234aa297dedd986986ccb8e7f3e24 # v31.11.1 with: nix_path: nixpkgs=channel:nixos-unstable - uses: cachix/cachix-action@5f2d7c5294214f71b873db4b969586b980625e71 # v17 @@ -845,7 +1021,7 @@ jobs: /zig # Install Nix and use that to run our tests so our environment matches exactly. - - uses: cachix/install-nix-action@630ae543ea3a38a9a4166f03376c02c50f408342 # v31.11.0 + - uses: cachix/install-nix-action@13d8dd58da0234aa297dedd986986ccb8e7f3e24 # v31.11.1 with: nix_path: nixpkgs=channel:nixos-unstable - uses: cachix/cachix-action@5f2d7c5294214f71b873db4b969586b980625e71 # v17 @@ -878,7 +1054,7 @@ jobs: /zig # Install Nix and use that to run our tests so our environment matches exactly. - - uses: cachix/install-nix-action@630ae543ea3a38a9a4166f03376c02c50f408342 # v31.11.0 + - uses: cachix/install-nix-action@13d8dd58da0234aa297dedd986986ccb8e7f3e24 # v31.11.1 with: nix_path: nixpkgs=channel:nixos-unstable - uses: cachix/cachix-action@5f2d7c5294214f71b873db4b969586b980625e71 # v17 @@ -960,7 +1136,7 @@ jobs: /zig # Install Nix and use that to run our tests so our environment matches exactly. - - uses: cachix/install-nix-action@630ae543ea3a38a9a4166f03376c02c50f408342 # v31.11.0 + - uses: cachix/install-nix-action@13d8dd58da0234aa297dedd986986ccb8e7f3e24 # v31.11.1 with: nix_path: nixpkgs=channel:nixos-unstable - uses: cachix/cachix-action@5f2d7c5294214f71b873db4b969586b980625e71 # v17 @@ -1000,7 +1176,7 @@ jobs: /zig # Install Nix and use that to run our tests so our environment matches exactly. - - uses: cachix/install-nix-action@630ae543ea3a38a9a4166f03376c02c50f408342 # v31.11.0 + - uses: cachix/install-nix-action@13d8dd58da0234aa297dedd986986ccb8e7f3e24 # v31.11.1 with: nix_path: nixpkgs=channel:nixos-unstable - uses: cachix/cachix-action@5f2d7c5294214f71b873db4b969586b980625e71 # v17 @@ -1068,7 +1244,7 @@ jobs: # uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 # # Install Nix and use that to run our tests so our environment matches exactly. - # - uses: cachix/install-nix-action@630ae543ea3a38a9a4166f03376c02c50f408342 # v31.11.0 + # - uses: cachix/install-nix-action@13d8dd58da0234aa297dedd986986ccb8e7f3e24 # v31.11.1 # with: # nix_path: nixpkgs=channel:nixos-unstable # - uses: cachix/cachix-action@5f2d7c5294214f71b873db4b969586b980625e71 # v17 @@ -1277,7 +1453,7 @@ jobs: /zig # Install Nix and use that to run our tests so our environment matches exactly. - - uses: cachix/install-nix-action@630ae543ea3a38a9a4166f03376c02c50f408342 # v31.11.0 + - uses: cachix/install-nix-action@13d8dd58da0234aa297dedd986986ccb8e7f3e24 # v31.11.1 with: nix_path: nixpkgs=channel:nixos-unstable - uses: cachix/cachix-action@5f2d7c5294214f71b873db4b969586b980625e71 # v17 @@ -1334,7 +1510,7 @@ jobs: /zig # Install Nix and use that to run our tests so our environment matches exactly. - - uses: cachix/install-nix-action@630ae543ea3a38a9a4166f03376c02c50f408342 # v31.11.0 + - uses: cachix/install-nix-action@13d8dd58da0234aa297dedd986986ccb8e7f3e24 # v31.11.1 with: nix_path: nixpkgs=channel:nixos-unstable - uses: cachix/cachix-action@5f2d7c5294214f71b873db4b969586b980625e71 # v17 @@ -1345,6 +1521,9 @@ jobs: - name: Test run: nix develop -c zig build test-lib-vt + - name: Validate native ABI manifest + run: nix develop -c zig build test-lib-vt-schema + test-kaitai: if: github.repository == 'ghostty-org/ghostty' && needs.skip.outputs.skip != 'true' needs: skip @@ -1359,7 +1538,7 @@ jobs: path: /nix # Install Nix so the verifier uses the pinned compiler and runtimes. - - uses: cachix/install-nix-action@630ae543ea3a38a9a4166f03376c02c50f408342 # v31.11.0 + - uses: cachix/install-nix-action@13d8dd58da0234aa297dedd986986ccb8e7f3e24 # v31.11.1 with: nix_path: nixpkgs=channel:nixos-unstable - uses: cachix/cachix-action@5f2d7c5294214f71b873db4b969586b980625e71 # v17 @@ -1394,7 +1573,7 @@ jobs: /zig # Install Nix and use that to run our tests so our environment matches exactly. - - uses: cachix/install-nix-action@630ae543ea3a38a9a4166f03376c02c50f408342 # v31.11.0 + - uses: cachix/install-nix-action@13d8dd58da0234aa297dedd986986ccb8e7f3e24 # v31.11.1 with: nix_path: nixpkgs=channel:nixos-unstable - uses: cachix/cachix-action@5f2d7c5294214f71b873db4b969586b980625e71 # v17 @@ -1442,7 +1621,7 @@ jobs: /zig # Install Nix and use that to run our tests so our environment matches exactly. - - uses: cachix/install-nix-action@630ae543ea3a38a9a4166f03376c02c50f408342 # v31.11.0 + - uses: cachix/install-nix-action@13d8dd58da0234aa297dedd986986ccb8e7f3e24 # v31.11.1 with: nix_path: nixpkgs=channel:nixos-unstable - uses: cachix/cachix-action@5f2d7c5294214f71b873db4b969586b980625e71 # v17 @@ -1477,7 +1656,7 @@ jobs: /zig # Install Nix and use that to run our tests so our environment matches exactly. - - uses: cachix/install-nix-action@630ae543ea3a38a9a4166f03376c02c50f408342 # v31.11.0 + - uses: cachix/install-nix-action@13d8dd58da0234aa297dedd986986ccb8e7f3e24 # v31.11.1 with: nix_path: nixpkgs=channel:nixos-unstable - uses: cachix/cachix-action@5f2d7c5294214f71b873db4b969586b980625e71 # v17 @@ -1567,7 +1746,7 @@ jobs: /zig # Install Nix and use that to run our tests so our environment matches exactly. - - uses: cachix/install-nix-action@630ae543ea3a38a9a4166f03376c02c50f408342 # v31.11.0 + - uses: cachix/install-nix-action@13d8dd58da0234aa297dedd986986ccb8e7f3e24 # v31.11.1 with: nix_path: nixpkgs=channel:nixos-unstable - uses: cachix/cachix-action@5f2d7c5294214f71b873db4b969586b980625e71 # v17 @@ -1598,7 +1777,7 @@ jobs: /zig # Install Nix and use that to run our tests so our environment matches exactly. - - uses: cachix/install-nix-action@630ae543ea3a38a9a4166f03376c02c50f408342 # v31.11.0 + - uses: cachix/install-nix-action@13d8dd58da0234aa297dedd986986ccb8e7f3e24 # v31.11.1 with: nix_path: nixpkgs=channel:nixos-unstable - uses: cachix/cachix-action@5f2d7c5294214f71b873db4b969586b980625e71 # v17 @@ -1640,7 +1819,7 @@ jobs: path: | /nix /zig - - uses: cachix/install-nix-action@630ae543ea3a38a9a4166f03376c02c50f408342 # v31.11.0 + - uses: cachix/install-nix-action@13d8dd58da0234aa297dedd986986ccb8e7f3e24 # v31.11.1 with: nix_path: nixpkgs=channel:nixos-unstable - uses: cachix/cachix-action@5f2d7c5294214f71b873db4b969586b980625e71 # v17 @@ -1671,7 +1850,7 @@ jobs: path: | /nix /zig - - uses: cachix/install-nix-action@630ae543ea3a38a9a4166f03376c02c50f408342 # v31.11.0 + - uses: cachix/install-nix-action@13d8dd58da0234aa297dedd986986ccb8e7f3e24 # v31.11.1 with: nix_path: nixpkgs=channel:nixos-unstable - uses: cachix/cachix-action@5f2d7c5294214f71b873db4b969586b980625e71 # v17 @@ -1701,7 +1880,7 @@ jobs: path: | /nix /zig - - uses: cachix/install-nix-action@630ae543ea3a38a9a4166f03376c02c50f408342 # v31.11.0 + - uses: cachix/install-nix-action@13d8dd58da0234aa297dedd986986ccb8e7f3e24 # v31.11.1 with: nix_path: nixpkgs=channel:nixos-unstable - uses: cachix/cachix-action@5f2d7c5294214f71b873db4b969586b980625e71 # v17 @@ -1759,7 +1938,7 @@ jobs: path: | /nix /zig - - uses: cachix/install-nix-action@630ae543ea3a38a9a4166f03376c02c50f408342 # v31.11.0 + - uses: cachix/install-nix-action@13d8dd58da0234aa297dedd986986ccb8e7f3e24 # v31.11.1 with: nix_path: nixpkgs=channel:nixos-unstable - uses: cachix/cachix-action@5f2d7c5294214f71b873db4b969586b980625e71 # v17 @@ -1787,7 +1966,7 @@ jobs: path: | /nix /zig - - uses: cachix/install-nix-action@630ae543ea3a38a9a4166f03376c02c50f408342 # v31.11.0 + - uses: cachix/install-nix-action@13d8dd58da0234aa297dedd986986ccb8e7f3e24 # v31.11.1 with: nix_path: nixpkgs=channel:nixos-unstable - uses: cachix/cachix-action@5f2d7c5294214f71b873db4b969586b980625e71 # v17 @@ -1815,7 +1994,7 @@ jobs: path: | /nix /zig - - uses: cachix/install-nix-action@630ae543ea3a38a9a4166f03376c02c50f408342 # v31.11.0 + - uses: cachix/install-nix-action@13d8dd58da0234aa297dedd986986ccb8e7f3e24 # v31.11.1 with: nix_path: nixpkgs=channel:nixos-unstable - uses: cachix/cachix-action@5f2d7c5294214f71b873db4b969586b980625e71 # v17 @@ -1848,7 +2027,7 @@ jobs: path: | /nix /zig - - uses: cachix/install-nix-action@630ae543ea3a38a9a4166f03376c02c50f408342 # v31.11.0 + - uses: cachix/install-nix-action@13d8dd58da0234aa297dedd986986ccb8e7f3e24 # v31.11.1 with: nix_path: nixpkgs=channel:nixos-unstable - uses: cachix/cachix-action@5f2d7c5294214f71b873db4b969586b980625e71 # v17 @@ -1876,7 +2055,7 @@ jobs: path: | /nix /zig - - uses: cachix/install-nix-action@630ae543ea3a38a9a4166f03376c02c50f408342 # v31.11.0 + - uses: cachix/install-nix-action@13d8dd58da0234aa297dedd986986ccb8e7f3e24 # v31.11.1 with: nix_path: nixpkgs=channel:nixos-unstable - uses: cachix/cachix-action@5f2d7c5294214f71b873db4b969586b980625e71 # v17 @@ -1913,7 +2092,7 @@ jobs: /zig # Install Nix and use that to run our tests so our environment matches exactly. - - uses: cachix/install-nix-action@630ae543ea3a38a9a4166f03376c02c50f408342 # v31.11.0 + - uses: cachix/install-nix-action@13d8dd58da0234aa297dedd986986ccb8e7f3e24 # v31.11.1 with: nix_path: nixpkgs=channel:nixos-unstable - uses: cachix/cachix-action@5f2d7c5294214f71b873db4b969586b980625e71 # v17 @@ -1975,7 +2154,7 @@ jobs: /zig # Install Nix and use that to run our tests so our environment matches exactly. - - uses: cachix/install-nix-action@630ae543ea3a38a9a4166f03376c02c50f408342 # v31.11.0 + - uses: cachix/install-nix-action@13d8dd58da0234aa297dedd986986ccb8e7f3e24 # v31.11.1 with: nix_path: nixpkgs=channel:nixos-unstable - uses: cachix/cachix-action@5f2d7c5294214f71b873db4b969586b980625e71 # v17 diff --git a/vendor/libghostty-vt/.github/workflows/update-colorschemes.yml b/vendor/libghostty-vt/.github/workflows/update-colorschemes.yml index 8426d4f..c16f186 100644 --- a/vendor/libghostty-vt/.github/workflows/update-colorschemes.yml +++ b/vendor/libghostty-vt/.github/workflows/update-colorschemes.yml @@ -29,7 +29,7 @@ jobs: /zig - name: Setup Nix - uses: cachix/install-nix-action@630ae543ea3a38a9a4166f03376c02c50f408342 # v31.11.0 + uses: cachix/install-nix-action@13d8dd58da0234aa297dedd986986ccb8e7f3e24 # v31.11.1 with: nix_path: nixpkgs=channel:nixos-unstable - uses: cachix/cachix-action@5f2d7c5294214f71b873db4b969586b980625e71 # v17 diff --git a/vendor/libghostty-vt/CODEOWNERS b/vendor/libghostty-vt/CODEOWNERS index 65c2f6d..4fc08bc 100644 --- a/vendor/libghostty-vt/CODEOWNERS +++ b/vendor/libghostty-vt/CODEOWNERS @@ -192,6 +192,8 @@ /po/pl.po @ghostty-org/pl_PL /po/pt_BR.po @ghostty-org/pt_BR /po/ru.po @ghostty-org/ru_RU +/po/sr.po @ghostty-org/sr_RS +/po/sr@latin.po @ghostty-org/sr_RS /po/tr.po @ghostty-org/tr_TR /po/uk.po @ghostty-org/uk_UA /po/vi.po @ghostty-org/vi_VN diff --git a/vendor/libghostty-vt/Doxyfile b/vendor/libghostty-vt/Doxyfile index 1703e6f..77eeb3d 100644 --- a/vendor/libghostty-vt/Doxyfile +++ b/vendor/libghostty-vt/Doxyfile @@ -57,7 +57,8 @@ GENERATE_HTML = YES HTML_OUTPUT = zig-out/share/ghostty/doc/libghostty HTML_EXTRA_STYLESHEET = dist/doxygen/ghostty.css HTML_EXTRA_FILES = dist/doxygen/favicon.png \ - dist/doxygen/mobile-nav.js + dist/doxygen/mobile-nav.js \ + src/terminal/c/types.schema.json HTML_COLORSTYLE = DARK HTML_CODE_FOLDING = NO HTML_HEADER = dist/doxygen/header.html diff --git a/vendor/libghostty-vt/PACKAGING.md b/vendor/libghostty-vt/PACKAGING.md index 1483b85..8cb4225 100644 --- a/vendor/libghostty-vt/PACKAGING.md +++ b/vendor/libghostty-vt/PACKAGING.md @@ -122,3 +122,56 @@ relevant to package maintainers: often necessary for system packages to specify a specific minimum Linux version, glibc, etc. Run `zig targets` to a get a full list of available targets. + +## WebAssembly (libghostty-vt) + +libghostty-vt can be built for WebAssembly for use in browsers and other +wasm runtimes: + +```sh +zig build -Demit-lib-vt -Dtarget=wasm32-freestanding -Doptimize=ReleaseSmall +``` + +This produces `zig-out/bin/ghostty-vt.wasm`. + +Some notes for packaging the wasm module: + +- The build enables the `simd128` feature by default. Every browser engine + has supported it for years (Chrome 91, Firefox 89, Safari 16.4) and it is + a large performance win for VT parsing. If you target an unusual runtime + without SIMD support, opt out with `-Dcpu=generic`. + +- Optional feature areas can be compiled out with `-Dvt-features` to + significantly reduce binary size. The flag takes comma-separated + modifications applied to the default all-enabled feature set, + `-Dcpu`-style: `+feature` (or bare `feature`) enables, `-feature` + disables, and the special name `all` refers to every feature. Hyphens + and underscores are interchangeable in feature names. For example, a + read-only terminal viewer only needs the render state API: + + ```sh + zig build -Demit-lib-vt -Dtarget=wasm32-freestanding \ + -Doptimize=ReleaseSmall -Dvt-features=-all,+render-state + ``` + + This roughly halves the compressed module size versus the default + build. An interactive terminal typically wants + `-Dvt-features=-all,+render-state,+input-encode,+selection,+color`. + Disabled features drop both their C API exports and any escape + sequence handling (the sequences are still consumed and safely + ignored). See the `Features` struct in `src/terminal/build_options.zig` + for the full list of features and what each one covers. + +- `ReleaseSmall` is the recommended optimization mode for the web. Running + the result through [Binaryen's](https://github.com/WebAssembly/binaryen) + `wasm-opt -O3` shrinks it by roughly a further 10% without hurting + performance. + +- `ReleaseFast` measures 10-20% faster than `ReleaseSmall` on escape-heavy + terminal workloads, but the artifact is dominated by DWARF debug info. + If you want the speed, strip it: `wasm-opt -O3 --strip-dwarf` reduces a + ReleaseFast build from over 5MB to roughly 1.1MB (versus roughly 0.8MB + for ReleaseSmall). When invoking `wasm-opt`, pass the feature flags for + what the module uses, e.g. `--enable-simd --enable-bulk-memory +--enable-sign-ext --enable-nontrapping-float-to-int --enable-multivalue +--enable-reference-types`. diff --git a/vendor/libghostty-vt/VERSION b/vendor/libghostty-vt/VERSION index 3607331..0798ea0 100644 --- a/vendor/libghostty-vt/VERSION +++ b/vendor/libghostty-vt/VERSION @@ -1 +1 @@ -1.3.2-main-+bedb6f2
\ No newline at end of file +1.3.2-HEAD-+3c1ef5b32
\ No newline at end of file diff --git a/vendor/libghostty-vt/build.zig b/vendor/libghostty-vt/build.zig index acf19ac..5204b3d 100644 --- a/vendor/libghostty-vt/build.zig +++ b/vendor/libghostty-vt/build.zig @@ -69,6 +69,14 @@ pub fn build(b: *std.Build) !void { "test-lib-vt", "Run libghostty-vt tests", ); + const test_lib_vt_build_step = b.step( + "test-lib-vt-build", + "Build libghostty-vt tests without running them (compile check)", + ); + const test_lib_vt_schema_step = b.step( + "test-lib-vt-schema", + "Validate the libghostty-vt ABI type manifest", + ); const test_valgrind_step = b.step( "test-valgrind", "Run tests under valgrind", @@ -117,20 +125,36 @@ pub fn build(b: *std.Build) !void { } // libghostty-vt - const libghostty_vt_shared = shared: { + const native_freestanding = config.target.result.os.tag == .freestanding and + !config.target.result.cpu.arch.isWasm(); + const libghostty_vt_shared: ?buildpkg.GhosttyLibVt = shared: { if (config.target.result.cpu.arch.isWasm()) { break :shared try buildpkg.GhosttyLibVt.initWasm( b, &mod, ); } + if (native_freestanding) break :shared null; break :shared try buildpkg.GhosttyLibVt.initShared( b, &mod, ); }; - libghostty_vt_shared.install(b.getInstallStep()); + if (libghostty_vt_shared) |shared| { + shared.install(b.getInstallStep()); + + const type_schema_test = b.addSystemCommand(&.{"python3"}); + type_schema_test.addFileArg(b.path("src/terminal/c/types-schema-verify.py")); + type_schema_test.addFileArg(b.path("src/terminal/c/types.schema.json")); + type_schema_test.addFileArg(shared.output); + test_lib_vt_schema_step.dependOn(&type_schema_test.step); + } else { + try test_lib_vt_schema_step.addError( + "cannot execute the ABI manifest for a native freestanding target", + .{}, + ); + } // libghostty-vt static lib const libghostty_vt_static = try buildpkg.GhosttyLibVt.initStatic( @@ -153,6 +177,15 @@ pub fn build(b: *std.Build) !void { libghostty_vt_static.output, static_lib_name, ).step); + + if (native_freestanding) { + b.getInstallStep().dependOn(&b.addInstallDirectory(.{ + .source_dir = b.path("include/ghostty"), + .install_dir = .header, + .install_subdir = "ghostty", + .include_extensions = &.{".h"}, + }).step); + } } // libghostty-vt xcframework (Apple only, universal binary). @@ -312,6 +345,7 @@ pub fn build(b: *std.Build) !void { const run_cmd = b.addSystemCommand(&.{ "valgrind", "--leak-check=full", + "--error-exitcode=1", "--num-callers=50", b.fmt("--suppressions={s}", .{b.pathFromRoot("valgrind.supp")}), "--gen-suppressions=all", @@ -329,6 +363,7 @@ pub fn build(b: *std.Build) !void { }); const mod_vt_test_run = b.addRunArtifact(mod_vt_test); test_lib_vt_step.dependOn(&mod_vt_test_run.step); + test_lib_vt_build_step.dependOn(&mod_vt_test.step); const mod_vt_c_test = b.addTest(.{ .root_module = mod.vt_c, @@ -336,6 +371,7 @@ pub fn build(b: *std.Build) !void { }); const mod_vt_c_test_run = b.addRunArtifact(mod_vt_c_test); test_lib_vt_step.dependOn(&mod_vt_c_test_run.step); + test_lib_vt_build_step.dependOn(&mod_vt_c_test.step); } // Tests (skip when building libghostty-vt) @@ -376,6 +412,7 @@ pub fn build(b: *std.Build) !void { const valgrind_run = b.addSystemCommand(&.{ "valgrind", "--leak-check=full", + "--error-exitcode=1", "--num-callers=50", b.fmt("--suppressions={s}", .{b.pathFromRoot("valgrind.supp")}), "--gen-suppressions=all", diff --git a/vendor/libghostty-vt/build.zig.zon b/vendor/libghostty-vt/build.zig.zon index f97fddc..e306505 100644 --- a/vendor/libghostty-vt/build.zig.zon +++ b/vendor/libghostty-vt/build.zig.zon @@ -66,7 +66,7 @@ .gobject = .{ // https://github.com/ghostty-org/zig-gobject based on zig_gobject // Temporary until we generate them at build time automatically. - .url = "https://github.com/ghostty-org/zig-gobject/releases/download/0.10.0-2026-07-28-36-1/ghostty-gobject-0.10.0-2026-07-28-36-1.tar.zst", + .url = "https://deps.files.ghostty.org/gobject-2026-07-28-36-1.tar.zst", .hash = "gobject-0.3.2-Skun7F6HogCMynX2JqeSHS7xr-8pK4ob-qRFIcEasVi3", .lazy = true, }, @@ -125,8 +125,8 @@ .apple_sdk = .{ .path = "./pkg/apple-sdk" }, .android_ndk = .{ .path = "./pkg/android-ndk" }, .iterm2_themes = .{ - .url = "https://deps.files.ghostty.org/ghostty-themes-release-20260803-155300-875a82f.tgz", - .hash = "N-V-__8AAPpcBAD4_75xLBbLiYqdojOwQP74eoWmpL3jPrBl", + .url = "https://deps.files.ghostty.org/ghostty-themes-release-20260824-153547-75c93ee.tgz", + .hash = "N-V-__8AAGZkBACH0haGC9R-hfNPYWxu16hr-ydEsolad9GV", .lazy = true, }, }, diff --git a/vendor/libghostty-vt/build.zig.zon.json b/vendor/libghostty-vt/build.zig.zon.json index 9c6e316..e3d3b20 100644 --- a/vendor/libghostty-vt/build.zig.zon.json +++ b/vendor/libghostty-vt/build.zig.zon.json @@ -36,7 +36,7 @@ }, "gobject-0.3.2-Skun7F6HogCMynX2JqeSHS7xr-8pK4ob-qRFIcEasVi3": { "name": "gobject", - "url": "https://github.com/ghostty-org/zig-gobject/releases/download/0.10.0-2026-07-28-36-1/ghostty-gobject-0.10.0-2026-07-28-36-1.tar.zst", + "url": "https://deps.files.ghostty.org/gobject-2026-07-28-36-1.tar.zst", "hash": "sha256-dyCfm2XjiAk30zccjD6AgKFBdE7IRsJuoqnscfvnWSQ=" }, "N-V-__8AALiNBAA-_0gprYr92CjrMj1I5bqNu0TSJOnjFNSr": { @@ -59,10 +59,10 @@ "url": "https://deps.files.ghostty.org/N-V-__8AAEbOfQBnvcFcCX2W5z7tDaN8vaNZGamEQtNOe0UI.tar.gz", "hash": "sha256-yBbCDox18+Fa6Gc1DnmSVQLRpqhZOLsac7iSfl8x+cs=" }, - "N-V-__8AAPpcBAD4_75xLBbLiYqdojOwQP74eoWmpL3jPrBl": { + "N-V-__8AAGZkBACH0haGC9R-hfNPYWxu16hr-ydEsolad9GV": { "name": "iterm2_themes", - "url": "https://deps.files.ghostty.org/ghostty-themes-release-20260803-155300-875a82f.tgz", - "hash": "sha256-/qxLa9S8bSg2NAbJzwEtZhGvOxaPTAVFNWnBCs/MFBs=" + "url": "https://deps.files.ghostty.org/ghostty-themes-release-20260824-153547-75c93ee.tgz", + "hash": "sha256-sf0fMQV/l8EnTbA8bORJYIN6GkH1InXLxkruLm40654=" }, "N-V-__8AAIC5lwAVPJJzxnCAahSvZTIlG-HhtOvnM1uh-66x": { "name": "jetbrains_mono", @@ -149,10 +149,10 @@ "url": "https://deps.files.ghostty.org/wayland-protocols-258d8f88f2c8c25a830c6316f87d23ce1a0f12d9.tar.gz", "hash": "sha256-XO3K3egbdeYPI+XoO13SuOtO+5+Peb16NH0UiusFMPg=" }, - "N-V-__8AAAzZywE3s51XfsLbP9eyEw57ae9swYB9aGB6fCMs": { + "N-V-__8AAP5JWgCGP_AD0teWpa4krRvE9VPZzvviGdbmN4jI": { "name": "wuffs", - "url": "https://deps.files.ghostty.org/wuffs-122037b39d577ec2db3fd7b2130e7b69ef6cc1807d68607a7c232c958315d381b5cd.tar.gz", - "hash": "sha256-nkzSCr6W5sTG7enDBXEIhgEm574uLD41UVR2wlC+HBM=" + "url": "https://deps.files.ghostty.org/wuffs-7411f488fe2e2c205c3d3b3d28638b7356522930.tar.gz", + "hash": "sha256-F4d9NG95iGUdbGLkWy47BchoCaZELF40YSN0sqlxmhw=" }, "z2d-0.12.1-j5P_Hsw8EQAKyZTQICCQnAH2xYkLDW8k9uefbsYdfPZ-": { "name": "z2d", diff --git a/vendor/libghostty-vt/build.zig.zon.nix b/vendor/libghostty-vt/build.zig.zon.nix index 01bc76f..58ce3fd 100644 --- a/vendor/libghostty-vt/build.zig.zon.nix +++ b/vendor/libghostty-vt/build.zig.zon.nix @@ -164,7 +164,7 @@ in name = "gobject-0.3.2-Skun7F6HogCMynX2JqeSHS7xr-8pK4ob-qRFIcEasVi3"; path = fetchZigArtifact { name = "gobject"; - url = "https://github.com/ghostty-org/zig-gobject/releases/download/0.10.0-2026-07-28-36-1/ghostty-gobject-0.10.0-2026-07-28-36-1.tar.zst"; + url = "https://deps.files.ghostty.org/gobject-2026-07-28-36-1.tar.zst"; hash = "sha256-dyCfm2XjiAk30zccjD6AgKFBdE7IRsJuoqnscfvnWSQ="; unpack = true; }; @@ -206,11 +206,11 @@ in }; } { - name = "N-V-__8AAPpcBAD4_75xLBbLiYqdojOwQP74eoWmpL3jPrBl"; + name = "N-V-__8AAGZkBACH0haGC9R-hfNPYWxu16hr-ydEsolad9GV"; path = fetchZigArtifact { name = "iterm2_themes"; - url = "https://deps.files.ghostty.org/ghostty-themes-release-20260803-155300-875a82f.tgz"; - hash = "sha256-/qxLa9S8bSg2NAbJzwEtZhGvOxaPTAVFNWnBCs/MFBs="; + url = "https://deps.files.ghostty.org/ghostty-themes-release-20260824-153547-75c93ee.tgz"; + hash = "sha256-sf0fMQV/l8EnTbA8bORJYIN6GkH1InXLxkruLm40654="; unpack = false; }; } @@ -368,11 +368,11 @@ in }; } { - name = "N-V-__8AAAzZywE3s51XfsLbP9eyEw57ae9swYB9aGB6fCMs"; + name = "N-V-__8AAP5JWgCGP_AD0teWpa4krRvE9VPZzvviGdbmN4jI"; path = fetchZigArtifact { name = "wuffs"; - url = "https://deps.files.ghostty.org/wuffs-122037b39d577ec2db3fd7b2130e7b69ef6cc1807d68607a7c232c958315d381b5cd.tar.gz"; - hash = "sha256-nkzSCr6W5sTG7enDBXEIhgEm574uLD41UVR2wlC+HBM="; + url = "https://deps.files.ghostty.org/wuffs-7411f488fe2e2c205c3d3b3d28638b7356522930.tar.gz"; + hash = "sha256-F4d9NG95iGUdbGLkWy47BchoCaZELF40YSN0sqlxmhw="; unpack = false; }; } diff --git a/vendor/libghostty-vt/build.zig.zon.txt b/vendor/libghostty-vt/build.zig.zon.txt index 9c726c1..77df51c 100644 --- a/vendor/libghostty-vt/build.zig.zon.txt +++ b/vendor/libghostty-vt/build.zig.zon.txt @@ -9,8 +9,9 @@ https://deps.files.ghostty.org/breakpad-b99f444ba5f6b98cac261cbb391d8766b34a5918 https://deps.files.ghostty.org/fontconfig-2.14.2.tar.gz https://deps.files.ghostty.org/freetype-1220b81f6ecfb3fd222f76cf9106fecfa6554ab07ec7fdc4124b9bb063ae2adf969d.tar.gz https://deps.files.ghostty.org/gettext-0.24.tar.gz -https://deps.files.ghostty.org/ghostty-themes-release-20260803-155300-875a82f.tgz +https://deps.files.ghostty.org/ghostty-themes-release-20260824-153547-75c93ee.tgz https://deps.files.ghostty.org/glslang-12201278a1a05c0ce0b6eb6026c65cd3e9247aa041b1c260324bf29cee559dd23ba1.tar.gz +https://deps.files.ghostty.org/gobject-2026-07-28-36-1.tar.zst https://deps.files.ghostty.org/gtk4-layer-shell-1.1.0.tar.gz https://deps.files.ghostty.org/harfbuzz-11.0.0.tar.xz https://deps.files.ghostty.org/highway-66486a10623fa0d72fe91260f96c892e41aceb06.tar.gz @@ -28,11 +29,10 @@ https://deps.files.ghostty.org/vaxis-1dbbe575dff4586fe51e3217aa5c3fecdcbb6089.ta https://deps.files.ghostty.org/wayland-0.6.0-lQa1kqz8AQADQmdNJsNhLoNHcnEGEUjrOaPV-dtEnEmX.tar.gz https://deps.files.ghostty.org/wayland-9cb3d7aa9dc995ffafdbdef7ab86a949d0fb0e7d.tar.gz https://deps.files.ghostty.org/wayland-protocols-258d8f88f2c8c25a830c6316f87d23ce1a0f12d9.tar.gz -https://deps.files.ghostty.org/wuffs-122037b39d577ec2db3fd7b2130e7b69ef6cc1807d68607a7c232c958315d381b5cd.tar.gz +https://deps.files.ghostty.org/wuffs-7411f488fe2e2c205c3d3b3d28638b7356522930.tar.gz https://deps.files.ghostty.org/z2d-7dbae85c81784dba9988320bf9543ed9a81350c8.tar.gz https://deps.files.ghostty.org/zf-c35c421f84895193246db06c40683c1a30e616ef.tar.gz https://deps.files.ghostty.org/zig_js-3c23860e47fdcdc5af805efb7fd0bdac5fd3e9bc.tar.gz https://deps.files.ghostty.org/zig_objc-c8de82ff80281215ad92900866dab7103a8efa8b.tar.gz https://deps.files.ghostty.org/zlib-1220fed0c74e1019b3ee29edae2051788b080cd96e90d56836eea857b0b966742efb.tar.gz -https://github.com/ghostty-org/zig-gobject/releases/download/0.10.0-2026-07-28-36-1/ghostty-gobject-0.10.0-2026-07-28-36-1.tar.zst https://github.com/vancluever/arocc/archive/ecbc5c799574e0da2758a961b12efa586007f03c.tar.gz diff --git a/vendor/libghostty-vt/flake.lock b/vendor/libghostty-vt/flake.lock index ee1ca0d..305cb2e 100644 --- a/vendor/libghostty-vt/flake.lock +++ b/vendor/libghostty-vt/flake.lock @@ -23,11 +23,11 @@ ] }, "locked": { - "lastModified": 1782657028, - "narHash": "sha256-PHTCpYZCMzJYS3phhywqRAZphKVr2zjvlGYa+H20ZZ4=", + "lastModified": 1787661347, + "narHash": "sha256-THmnBAdAIV+jT348f3r/vyoeb6ilhcVLwVXkBU7Pai8=", "owner": "nix-community", "repo": "home-manager", - "rev": "4ad9aaae70c9aaab504127f926c0fa9cfbc2b365", + "rev": "52c3a5881c821ad7367bbde075e52b76cdb378ab", "type": "github" }, "original": { @@ -38,15 +38,15 @@ }, "nixpkgs": { "locked": { - "lastModified": 1782545840, - "narHash": "sha256-CAi8oAZaE6pTkcYQBnnOlvmfMgG/p1AO0FohKKN3J7I=", - "rev": "3d46470bb3030020f7e1361f33514854f5bfa86d", + "lastModified": 1787424095, + "narHash": "sha256-SpMiSe9OSfWseGAupsdcED3He9mTYTbGMtWb7EVt6pE=", + "rev": "174eb786fb68e3a13e4e535a3deea479a0c07a6a", "type": "tarball", - "url": "https://releases.nixos.org/nixpkgs/nixpkgs-26.11pre1023445.3d46470bb303/nixexprs.tar.xz" + "url": "https://releases.nixos.org/nixpkgs/nixpkgs-26.11pre1059855.174eb786fb68/nixexprs.tar.zst" }, "original": { "type": "tarball", - "url": "https://channels.nixos.org/nixpkgs-unstable/nixexprs.tar.xz" + "url": "https://channels.nixos.org/nixpkgs-unstable/nixexprs.tar.zst" } }, "root": { @@ -88,11 +88,11 @@ ] }, "locked": { - "lastModified": 1782609341, - "narHash": "sha256-OfCPJFS/2Z2ZyjE4adR7PL+ZEqvDQFi2XNI1L8s6wKU=", + "lastModified": 1787617283, + "narHash": "sha256-jiM1glpDL5ASrqvcWyacI+c1wZo3g6AUhdo6M09AoiY=", "owner": "mitchellh", "repo": "zig-overlay", - "rev": "5386fea92c45b154bbeb52d14ef5504f82eda86c", + "rev": "da55b9fe9260fb3eb1456c3ee6064b8f738e4ba9", "type": "github" }, "original": { @@ -108,11 +108,11 @@ ] }, "locked": { - "lastModified": 1784117571, + "lastModified": 1784080626, "narHash": "sha256-Hvnmnuu0VpHiZl+8z+UkMoxC7JZJvRYBqu2Asb0ZJOE=", "owner": "jcollie", "repo": "zon2nix", - "rev": "0ce628fb78309cdb13d098ae9c6fdbf87c75d25b", + "rev": "776b5f6864a607c141d7966421b433fa1657ba9a", "type": "github" }, "original": { diff --git a/vendor/libghostty-vt/flake.nix b/vendor/libghostty-vt/flake.nix index 7591603..f951b13 100644 --- a/vendor/libghostty-vt/flake.nix +++ b/vendor/libghostty-vt/flake.nix @@ -6,10 +6,10 @@ # glibc versions used by our dependencies from Nix are compatible with the # system glibc that the user is building for. # - # We are currently on nixpkgs-unstable to get Zig 0.15 for our package.nix and - # Gnome 49/Gtk 4.20. + # We are currently on nixpkgs-unstable to get Zig 0.16 for our package.nix, + # Gnome 50/Gtk 4.22, and fontconfig 2.18. # - nixpkgs.url = "https://channels.nixos.org/nixpkgs-unstable/nixexprs.tar.xz"; + nixpkgs.url = "https://channels.nixos.org/nixpkgs-unstable/nixexprs.tar.zst"; # Used for shell.nix flake-compat = { @@ -76,25 +76,7 @@ devShells = forAllPlatforms (pkgs: { default = pkgs.callPackage ./nix/devShell.nix - (let - libfyaml = - if pkgs.stdenv.hostPlatform.isDarwin - then - pkgs.libfyaml.overrideAttrs (prev: { - # Manually fix libfyaml.pc until NixOS/nixpkgs#515614 is available - postInstall = - (prev.postInstall or "") - + '' - substituteInPlace "$dev/lib/pkgconfig/libfyaml.pc" \ - --replace-fail " none required" "" - ''; - }) - else pkgs.libfyaml; - - appstream = pkgs.appstream.override {libfyaml = libfyaml;}; - libadwaita = pkgs.libadwaita.override {appstream = appstream;}; - blueprint-compiler = pkgs.blueprint-compiler.override {libadwaita = libadwaita;}; - in { + { zig = zig.packages.${pkgs.stdenv.hostPlatform.system}."0.16.0"; wraptest = pkgs.callPackage ./nix/pkgs/wraptest.nix {}; zon2nix = zon2nix; @@ -107,9 +89,7 @@ wcwidth = pyfinal.callPackage ./nix/pkgs/wcwidth.nix {}; }; }; - - inherit appstream libadwaita blueprint-compiler; - }); + }; }); packages = diff --git a/vendor/libghostty-vt/include/ghostty.h b/vendor/libghostty-vt/include/ghostty.h index 5523654..518af3f 100644 --- a/vendor/libghostty-vt/include/ghostty.h +++ b/vendor/libghostty-vt/include/ghostty.h @@ -75,19 +75,56 @@ typedef enum { typedef enum { GHOSTTY_CLIPBOARD_STANDARD, GHOSTTY_CLIPBOARD_SELECTION, + GHOSTTY_CLIPBOARD_PRIMARY, } ghostty_clipboard_e; +// One representation of clipboard contents. The data is binary-safe with +// an explicit length; it is not necessarily null-terminated. typedef struct { const char *mime; const char *data; + size_t len; } ghostty_clipboard_content_s; +// The payload for completing a clipboard read request. See +// ghostty_surface_complete_clipboard_request. +typedef struct { + const ghostty_clipboard_content_s *contents; + size_t contents_len; + const char *const *available; + size_t available_len; + bool confirmed; + bool remember; +} ghostty_clipboard_complete_s; + +// The payload of a clipboard read confirmation request: the would-be +// completion contents plus the information shown in the permission +// prompt. See ghostty_runtime_confirm_read_clipboard_cb. +typedef struct { + const ghostty_clipboard_content_s *contents; + size_t contents_len; + const char *const *available; + size_t available_len; + const char *name; + bool can_remember; +} ghostty_clipboard_confirm_s; + typedef enum { GHOSTTY_CLIPBOARD_REQUEST_PASTE, GHOSTTY_CLIPBOARD_REQUEST_OSC_52_READ, GHOSTTY_CLIPBOARD_REQUEST_OSC_52_WRITE, + GHOSTTY_CLIPBOARD_REQUEST_KITTY_READ, + GHOSTTY_CLIPBOARD_REQUEST_KITTY_WRITE, + GHOSTTY_CLIPBOARD_REQUEST_LIST, } ghostty_clipboard_request_e; +// apprt.ClipboardReadResult +typedef enum { + GHOSTTY_CLIPBOARD_READ_STARTED, + GHOSTTY_CLIPBOARD_READ_UNAVAILABLE, + GHOSTTY_CLIPBOARD_READ_UNSUPPORTED, +} ghostty_clipboard_read_result_e; + typedef enum { GHOSTTY_MOUSE_RELEASE, GHOSTTY_MOUSE_PRESS, @@ -1023,12 +1060,16 @@ typedef struct { } ghostty_action_s; typedef void (*ghostty_runtime_wakeup_cb)(void*); -typedef bool (*ghostty_runtime_read_clipboard_cb)(void*, - ghostty_clipboard_e, - void*); +typedef ghostty_clipboard_read_result_e (*ghostty_runtime_read_clipboard_cb)( + void*, + ghostty_clipboard_e, + void*, + const char* const*, + size_t, + bool); typedef void (*ghostty_runtime_confirm_read_clipboard_cb)( void*, - const char*, + const ghostty_clipboard_confirm_s*, void*, ghostty_clipboard_request_e); typedef void (*ghostty_runtime_write_clipboard_cb)(void*, @@ -1178,10 +1219,12 @@ GHOSTTY_API void ghostty_surface_split_resize(ghostty_surface_t, uint16_t); GHOSTTY_API void ghostty_surface_split_equalize(ghostty_surface_t); GHOSTTY_API bool ghostty_surface_binding_action(ghostty_surface_t, const char*, uintptr_t); -GHOSTTY_API void ghostty_surface_complete_clipboard_request(ghostty_surface_t, - const char*, - void*, - bool); +GHOSTTY_API void ghostty_surface_complete_clipboard_request( + ghostty_surface_t, + const ghostty_clipboard_complete_s*, + void*); +GHOSTTY_API void ghostty_surface_deny_clipboard_request(ghostty_surface_t, + void*); GHOSTTY_API bool ghostty_surface_has_selection(ghostty_surface_t); GHOSTTY_API bool ghostty_surface_read_selection(ghostty_surface_t, ghostty_text_s*); GHOSTTY_API bool ghostty_surface_read_text(ghostty_surface_t, diff --git a/vendor/libghostty-vt/include/ghostty/vt.h b/vendor/libghostty-vt/include/ghostty/vt.h index 7519a3f..cfa1017 100644 --- a/vendor/libghostty-vt/include/ghostty/vt.h +++ b/vendor/libghostty-vt/include/ghostty/vt.h @@ -32,9 +32,10 @@ * - @ref render "Render State" - Incremental render state updates for custom renderers * - @ref formatter "Formatter" - Format terminal content as plain text, VT sequences, or HTML * - @ref snapshot "Terminal Snapshot" - Encode and incrementally restore terminal state + * - @ref search "Search" - Search terminal contents, including scrollback * - @ref osc "OSC Parser" - Parse OSC (Operating System Command) sequences * - @ref sgr "SGR Parser" - Parse SGR (Select Graphic Rendition) sequences - * - @ref paste "Paste Utilities" - Validate paste data safety + * - @ref paste "Paste" - Paste into a terminal, validate and encode paste data * - @ref unicode "Unicode Utilities" - Codepoint properties for text layout * - @ref build_info "Build Info" - Query compile-time build configuration * - @ref allocator "Memory Management" - Memory management and custom allocators @@ -53,12 +54,13 @@ * - @ref c-vt/src/main.c - OSC parser example * - @ref c-vt-encode-key/src/main.c - Key encoding example * - @ref c-vt-encode-mouse/src/main.c - Mouse encoding example - * - @ref c-vt-paste/src/main.c - Paste safety check example + * - @ref c-vt-paste/src/main.c - Paste example * - @ref c-vt-sgr/src/main.c - SGR parser example * - @ref c-vt-formatter/src/main.c - Terminal formatter example * - @ref c-vt-grid-traverse/src/main.c - Grid traversal example using grid refs * - @ref c-vt-grid-ref-tracked/src/main.c - Tracked grid ref example * - @ref c-vt-compression/src/main.c - Idle scrollback compression example + * - @ref c-vt-search/src/main.c - Terminal search example * */ @@ -83,8 +85,10 @@ */ /** @example c-vt-paste/src/main.c - * This example demonstrates how to use the paste utilities to check if - * paste data is safe before sending it to the terminal. + * This example demonstrates how to paste into a terminal, including the + * unsafe-paste confirmation flow and Kitty clipboard protocol paste events + * (mode 5522), as well as the terminal-free paste safety and encoding + * utilities. */ /** @example c-vt-sgr/src/main.c @@ -123,6 +127,12 @@ * PNG decoder callback and send a Kitty Graphics Protocol image. */ +/** @example c-vt-search/src/main.c + * This example demonstrates how to search terminal contents for a + * string, navigate between the matches like a find bar, and read the + * viewport matches used to draw highlights. + */ + #ifndef GHOSTTY_VT_H #define GHOSTTY_VT_H @@ -154,6 +164,7 @@ extern "C" { #include <ghostty/vt/paste.h> #include <ghostty/vt/point.h> #include <ghostty/vt/screen.h> +#include <ghostty/vt/search.h> #include <ghostty/vt/selection.h> #include <ghostty/vt/size_report.h> #include <ghostty/vt/snapshot.h> diff --git a/vendor/libghostty-vt/include/ghostty/vt/allocator.h b/vendor/libghostty-vt/include/ghostty/vt/allocator.h index 2e8685e..e12d10e 100644 --- a/vendor/libghostty-vt/include/ghostty/vt/allocator.h +++ b/vendor/libghostty-vt/include/ghostty/vt/allocator.h @@ -31,14 +31,17 @@ * function that accepts one,** and libghostty will use a default allocator. * The default allocator will be libc malloc/free if libc is linked. * Otherwise, a custom allocator is used (currently Zig's SMP allocator) - * that doesn't require any external dependencies. + * that doesn't require any external dependencies. On native freestanding + * targets, the default allocator always fails instead. * * ## Basic Usage * * For simple use cases, you can ignore this interface entirely by passing NULL * as the allocator parameter to functions that accept one. This will use the * default allocator (typically libc malloc/free, if libc is linked, but - * we provide our own default allocator if libc isn't linked). + * we provide our own default allocator if libc isn't linked). Native + * freestanding builds must provide a custom allocator for operations that + * allocate memory. * * To use a custom allocator: * 1. Implement the GhosttyAllocatorVtable function pointers @@ -76,7 +79,8 @@ * * If you're not going to use a custom allocator, you can ignore all of * this. All functions that take an allocator pointer allow NULL to use a - * default allocator. + * default allocator. Native freestanding builds must provide an allocator + * for operations that allocate memory. * * The interface is based on the Zig allocator interface. I'll say up front * that it is easy to look at this interface and think "wow, this is really @@ -183,7 +187,8 @@ typedef struct { * For functions that take an allocator pointer, a NULL pointer indicates * that the default allocator should be used. The default allocator will * be libc malloc/free if we're linking to libc. If libc isn't linked, - * a custom allocator is used (currently Zig's SMP allocator). + * a custom allocator is used (currently Zig's SMP allocator). On native + * freestanding targets, the default allocator always fails instead. * * @ingroup allocator * diff --git a/vendor/libghostty-vt/include/ghostty/vt/device.h b/vendor/libghostty-vt/include/ghostty/vt/device.h index 0a15672..419748a 100644 --- a/vendor/libghostty-vt/include/ghostty/vt/device.h +++ b/vendor/libghostty-vt/include/ghostty/vt/device.h @@ -11,6 +11,8 @@ #include <stddef.h> #include <stdint.h> +#include <ghostty/vt/types.h> + /* DA1 conformance levels (Pp parameter). */ #define GHOSTTY_DA_CONFORMANCE_VT100 1 #define GHOSTTY_DA_CONFORMANCE_VT101 1 diff --git a/vendor/libghostty-vt/include/ghostty/vt/formatter.h b/vendor/libghostty-vt/include/ghostty/vt/formatter.h index 5cdcd11..210f422 100644 --- a/vendor/libghostty-vt/include/ghostty/vt/formatter.h +++ b/vendor/libghostty-vt/include/ghostty/vt/formatter.h @@ -11,6 +11,7 @@ #include <stddef.h> #include <stdint.h> #include <ghostty/vt/allocator.h> +#include <ghostty/vt/io.h> #include <ghostty/vt/selection.h> #include <ghostty/vt/types.h> #include <ghostty/vt/terminal.h> @@ -138,6 +139,30 @@ GHOSTTY_API GhosttyResult ghostty_formatter_terminal_new( GhosttyFormatterTerminalOptions options); /** + * Run the formatter and stream output to a writer. + * + * Each call formats the current terminal state and invokes the writer + * synchronously as output becomes available. The callback may be called more + * than once and must not call formatter or terminal APIs using the same + * formatter or its terminal. + * + * If an error occurs, the writer may already contain a partial formatted + * output. The operation cannot be resumed from that partial output. This + * function does not flush or make the caller's destination durable. + * + * @param formatter The formatter handle (must not be NULL) + * @param writer Destination writer whose write callback must not be NULL + * @return GHOSTTY_SUCCESS on success, GHOSTTY_IO_ERROR if the writer rejects + * output, GHOSTTY_LIMIT_EXCEEDED if output accounting overflows, or + * GHOSTTY_INVALID_VALUE if an argument is invalid + * + * @ingroup formatter + */ +GHOSTTY_API GhosttyResult ghostty_formatter_format( + GhosttyFormatter formatter, + GhosttyWriter writer); + +/** * Run the formatter and produce output into the caller-provided buffer. * * Each call formats the current terminal state. Pass NULL for buf to diff --git a/vendor/libghostty-vt/include/ghostty/vt/io.h b/vendor/libghostty-vt/include/ghostty/vt/io.h index 2d8e670..ad2c57a 100644 --- a/vendor/libghostty-vt/include/ghostty/vt/io.h +++ b/vendor/libghostty-vt/include/ghostty/vt/io.h @@ -10,6 +10,7 @@ #include <stdbool.h> #include <stddef.h> #include <stdint.h> +#include <ghostty/vt/types.h> /** @defgroup io I/O * @@ -98,6 +99,49 @@ typedef struct { void* userdata; } GhosttyWriter; +/** + * Read one MIME-typed representation of some content, streaming its + * bytes to a writer. + * + * The library calls this with the MIME type of the representation it + * needs. The callback writes all of that representation's data to + * @p writer, in as many calls to `writer.write(writer.userdata, data, + * len)` as is convenient (one call with everything or many small + * pieces both work), and returns true. Nothing written is retained + * beyond each write call, so the data may be borrowed from anywhere: + * a pasteboard item, a file being read, a stream. + * + * Returning false reports that the data could not be read. If the + * writer refuses a write (returns false), stop and return false + * without writing more. + * + * All pointer arguments, the mime, and the writer are borrowed and + * valid only for the duration of the callback. The callback is + * invoked synchronously on the calling thread. The API receiving the + * GhosttyMimeReader defines which MIME types are requested, how many + * times, and any consistency requirements across repeated reads. + * + * @param userdata Opaque userdata from GhosttyMimeReader + * @param mime The MIME type of the representation to read + * @param writer Where to write the data; valid only during this call + * @return true once all the data was written, false if it could not + * be read or the writer refused a write + */ +typedef bool (*GhosttyMimeReaderFn)( + void* userdata, + GhosttyString mime, + GhosttyWriter writer); + +/** + * A MIME-typed content source callback and its opaque context. + * + * The struct is passed by value. @p read must be non-NULL. + */ +typedef struct { + GhosttyMimeReaderFn read; + void* userdata; +} GhosttyMimeReader; + #ifdef __cplusplus } #endif diff --git a/vendor/libghostty-vt/include/ghostty/vt/kitty_graphics.h b/vendor/libghostty-vt/include/ghostty/vt/kitty_graphics.h index 22f6ac8..99cee60 100644 --- a/vendor/libghostty-vt/include/ghostty/vt/kitty_graphics.h +++ b/vendor/libghostty-vt/include/ghostty/vt/kitty_graphics.h @@ -399,6 +399,12 @@ typedef enum GHOSTTY_ENUM_TYPED { * time, before the image is stored. Consumers can upload this * directly to the GPU without any decode step. * + * For an animated image (Kitty graphics animation, actions a=f/a=a) + * this is the pixel data of the current animation frame. The + * image's GHOSTTY_KITTY_IMAGE_DATA_GENERATION changes whenever the + * current frame changes, so generation-keyed caches remain + * coherent. + * * Output type: const uint8_t ** */ GHOSTTY_KITTY_IMAGE_DATA_DATA_PTR = 7, diff --git a/vendor/libghostty-vt/include/ghostty/vt/modes.h b/vendor/libghostty-vt/include/ghostty/vt/modes.h index fbef4e5..6786f00 100644 --- a/vendor/libghostty-vt/include/ghostty/vt/modes.h +++ b/vendor/libghostty-vt/include/ghostty/vt/modes.h @@ -94,6 +94,7 @@ extern "C" { #define GHOSTTY_MODE_COLOR_SCHEME_REPORT (ghostty_mode_new(2031, false)) /**< Report color scheme */ #define GHOSTTY_MODE_VISIBILITY_REPORT (ghostty_mode_new(2033, false)) /**< Report terminal visibility */ #define GHOSTTY_MODE_IN_BAND_RESIZE (ghostty_mode_new(2048, false)) /**< In-band size reports */ +#define GHOSTTY_MODE_PASTE_EVENTS (ghostty_mode_new(5522, false)) /**< Kitty clipboard protocol paste events */ /** @} */ /** diff --git a/vendor/libghostty-vt/include/ghostty/vt/osc.h b/vendor/libghostty-vt/include/ghostty/vt/osc.h index 9409ebc..7a681c3 100644 --- a/vendor/libghostty-vt/include/ghostty/vt/osc.h +++ b/vendor/libghostty-vt/include/ghostty/vt/osc.h @@ -63,6 +63,10 @@ typedef enum GHOSTTY_ENUM_TYPED { GHOSTTY_OSC_COMMAND_CONEMU_XTERM_EMULATION = 20, GHOSTTY_OSC_COMMAND_CONEMU_COMMENT = 21, GHOSTTY_OSC_COMMAND_KITTY_TEXT_SIZING = 22, + GHOSTTY_OSC_COMMAND_KITTY_CLIPBOARD_PROTOCOL = 23, + GHOSTTY_OSC_COMMAND_KITTY_DND_PROTOCOL = 24, + GHOSTTY_OSC_COMMAND_CONTEXT_SIGNAL = 25, + GHOSTTY_OSC_COMMAND_KITTY_DESKTOP_NOTIFICATION = 26, GHOSTTY_OSC_COMMAND_TYPE_MAX_VALUE = GHOSTTY_ENUM_MAX_VALUE, } GhosttyOscCommandType; diff --git a/vendor/libghostty-vt/include/ghostty/vt/paste.h b/vendor/libghostty-vt/include/ghostty/vt/paste.h index b3df5be..4ba21a9 100644 --- a/vendor/libghostty-vt/include/ghostty/vt/paste.h +++ b/vendor/libghostty-vt/include/ghostty/vt/paste.h @@ -1,25 +1,66 @@ /** * @file paste.h * - * Paste utilities - validate and encode paste data for terminal input. + * Paste - paste into a terminal, and validate and encode paste data. */ #ifndef GHOSTTY_VT_PASTE_H #define GHOSTTY_VT_PASTE_H -/** @defgroup paste Paste Utilities +/** @defgroup paste Paste * - * Utilities for validating and encoding paste data for terminal input. + * Pasting into a terminal, plus the terminal-free utilities for + * validating and encoding paste data. * - * ## Basic Usage + * ## Pasting into a Terminal * - * Use ghostty_paste_is_safe() to check if paste data contains potentially - * dangerous sequences before sending it to the terminal. + * What a paste writes to the pty depends on the terminal's state, so + * the recommended way to paste is ghostty_terminal_paste(). The embedder + * hands over the MIME types the clipboard holds (just `text/plain` for + * an ordinary paste), a GhosttyMimeReader that produces the data of + * any one of them, and where the paste came from, and the terminal + * decides how its current modes apply: * - * Use ghostty_paste_encode() to encode paste data for writing to the pty, - * including bracketed paste wrapping and unsafe byte stripping. + * - If Kitty clipboard protocol paste events (mode 5522, + * GHOSTTY_MODE_PASTE_EVENTS) are enabled, the paste was user-initiated + * (GHOSTTY_PASTE_SOURCE_CLIPBOARD), and a clipboard_read callback is + * installed, the terminal sends the program a paste event listing the + * clipboard's MIME types with a one-time password instead of the data. + * The program then reads what it wants through the clipboard_read + * callback, which arrives with `granted` set so no permission prompt + * is needed. No data is read for the event. + * - Otherwise the first text representation is written: unsafe control + * bytes are replaced with spaces, and it is wrapped in bracketed paste + * sequences if mode 2004 (GHOSTTY_MODE_BRACKETED_PASTE) is enabled, or + * has its newlines converted to carriage returns if not. + * + * The data is pulled through GhosttyPaste::reader only when a + * representation is actually pasted (so a clipboard holding a large + * image next to some text costs nothing), and the encoded bytes + * stream to the write_pty callback (GHOSTTY_TERMINAL_OPT_WRITE_PTY) + * in chunks as they are produced, never in one piece. The callback + * may be invoked several times for a single paste; the pieces must be + * written to the pty in order. + * + * Text that could inject commands (a newline when unbracketed, or the + * bracketed paste terminator when bracketed) is refused with + * GHOSTTY_REJECTED and nothing written unless GhosttyPaste::allow_unsafe + * is set. The usual flow is to call once, confirm with the user on + * GHOSTTY_REJECTED, and call again with `allow_unsafe` set. Each call + * reads the text at most once and buffers it whole while the rule is + * applied, so the source needs no stability across reads (the + * confirmed retry simply pastes whatever the source holds then) and a + * refused or failed paste writes nothing at all. + * + * @snippet c-vt-paste/src/main.c terminal-paste * - * ## Examples + * ## Building Blocks + * + * For embedders that encode without a terminal, ghostty_paste_is_safe() + * checks if paste data contains potentially dangerous sequences + * (conservatively, regardless of terminal state) and + * ghostty_paste_encode() encodes paste data for writing to the pty, + * including bracketed paste wrapping and unsafe byte stripping. * * ### Safety Check * @@ -35,12 +76,120 @@ #include <stdbool.h> #include <stddef.h> #include <ghostty/vt/types.h> +#include <ghostty/vt/io.h> +#include <ghostty/vt/terminal.h> #ifdef __cplusplus extern "C" { #endif /** + * Why a paste happened. + */ +typedef enum GHOSTTY_ENUM_TYPED { + /** The user pasted from a clipboard: keybind, menu, middle click. */ + GHOSTTY_PASTE_SOURCE_CLIPBOARD = 0, + + /** + * Text inserted some other way: IME commit, drag and drop, scripted + * input. Always written as text, never as a paste event, matching + * kitty. This is not a way to opt out of paste events; an embedder + * that doesn't want them doesn't install a clipboard_read callback. + */ + GHOSTTY_PASTE_SOURCE_TEXT = 1, + GHOSTTY_PASTE_SOURCE_MAX_VALUE = GHOSTTY_ENUM_MAX_VALUE, +} GhosttyPasteSource; + +/** + * A paste of clipboard contents into the terminal. + * + * This is a sized struct; set `size` to `sizeof(GhosttyPaste)`. The + * MIME type array and the strings it points to are borrowed only for + * the duration of the ghostty_terminal_paste() call, as is everything + * the reader produces. + */ +typedef struct { + /** Size of this struct in bytes. */ + size_t size; + + /** + * The clipboard the contents came from. Reported to the program on a + * paste event (the selection and primary locations are both reported + * as the primary selection, the protocol knows only two); no effect + * on a text paste. + */ + GhosttyClipboardLocation location; + + /** Why this paste happened. */ + GhosttyPasteSource source; + + /** + * Borrowed array of the MIME types of the representations available, + * in preferred order. A text paste reads and writes the first entry + * with a text MIME type such as "text/plain" and ignores the rest. A + * paste event lists every entry and reads none. May be NULL when + * mimes_len is zero, which is nothing to paste. + */ + const GhosttyString* mimes; + + /** Number of entries in mimes. */ + size_t mimes_len; + + /** + * Produces the data of a representation on demand. Required when + * mimes_len is nonzero. + * + * Called at most once per ghostty_terminal_paste() call: for the + * text representation being pasted, never for anything else and + * never for a paste event. The MIME type requested is always an + * entry of `mimes`, passed through exactly as given there (the same + * pointer and length), so the callback may identify the + * representation by pointer or by content. A false return fails the + * paste with GHOSTTY_IO_ERROR. + */ + GhosttyMimeReader reader; + + /** + * Write text that could inject commands. Call with false, confirm + * with the user on GHOSTTY_REJECTED, and call again with true. + */ + bool allow_unsafe; +} GhosttyPaste; + +/** + * Paste into the terminal according to its current state: a Kitty + * clipboard protocol paste event if mode 5522 is enabled and a + * clipboard_read callback is installed, otherwise the text framed per + * mode 2004. See the group documentation for the full behavior. Output + * streams through the write_pty callback in chunks. The viewport is not + * scrolled; that is up to the embedder, as for key input. + * + * A paste event records a session grant for its one-time password only + * once the event is written; a failed call never leaves a grant for an + * event that was never sent. + * + * @param terminal The terminal handle + * @param paste The paste request, borrowed for the duration of the call + * @param[out] out_written On success, whether anything was written to + * the pty (the encoded text or a paste event). False means + * there was nothing to paste: no non-empty text + * representation. May be NULL. + * @return GHOSTTY_SUCCESS on success (see @p out_written); + * GHOSTTY_REJECTED if the text could inject commands and + * GhosttyPaste::allow_unsafe is false (nothing was written); + * GHOSTTY_INVALID_VALUE for a NULL terminal or paste, MIME + * types without a reader, or when no write_pty callback is + * installed; GHOSTTY_OUT_OF_MEMORY; GHOSTTY_IO_ERROR if the + * reader failed or there is no secure entropy source to mint a + * paste event password (wasm32-freestanding without + * GHOSTTY_SYS_OPT_RANDOM_SECURE set). Errors write nothing. + */ +GHOSTTY_API GhosttyResult ghostty_terminal_paste( + GhosttyTerminal terminal, + const GhosttyPaste* paste, + bool* out_written); + +/** * Check if paste data is safe to paste into the terminal. * * Data is considered unsafe if it contains: @@ -49,7 +198,9 @@ extern "C" { * to exit bracketed paste mode and inject commands * * This check is conservative and considers data unsafe regardless of - * current terminal state. + * current terminal state. ghostty_terminal_paste() applies the + * terminal-state-aware rule itself (newlines are safe inside a + * bracketed paste); use this to apply the stricter rule on top. * * @param data The paste data to check (must not be NULL) * @param len The length of the data in bytes @@ -74,6 +225,9 @@ GHOSTTY_API bool ghostty_paste_is_safe(const char* data, size_t len); * GHOSTTY_OUT_OF_SPACE and sets the required size in @p out_written. * The caller can then retry with a sufficiently sized buffer. * + * This is the encoder ghostty_terminal_paste() uses for a text paste; + * use it directly when there is no terminal to paste into. + * * @param data The paste data to encode (modified in place, may be NULL) * @param data_len The length of the input data in bytes * @param bracketed Whether bracketed paste mode is active diff --git a/vendor/libghostty-vt/include/ghostty/vt/point.h b/vendor/libghostty-vt/include/ghostty/vt/point.h index 8b717f4..9cab275 100644 --- a/vendor/libghostty-vt/include/ghostty/vt/point.h +++ b/vendor/libghostty-vt/include/ghostty/vt/point.h @@ -9,6 +9,8 @@ #include <stdint.h> +#include <ghostty/vt/types.h> + #ifdef __cplusplus extern "C" { #endif diff --git a/vendor/libghostty-vt/include/ghostty/vt/render.h b/vendor/libghostty-vt/include/ghostty/vt/render.h index 2e3f2f7..f6b5e22 100644 --- a/vendor/libghostty-vt/include/ghostty/vt/render.h +++ b/vendor/libghostty-vt/include/ghostty/vt/render.h @@ -63,7 +63,10 @@ extern "C" { * tracking which rows in a partially dirty frame have changed. * * The user of the render state API is expected to unset both of these. - * The `update` call does not unset dirty state, it only updates it. + * The `update` call does not unset dirty state, it only updates it. After + * successfully rendering a complete frame, use ghostty_render_state_clean() + * to unset both layers in one call. The granular setters remain available + * for callers that only consume part of a frame. * * An extremely important detail: setting one dirty state doesn't unset * the other. For example, setting the global dirty state to false does not @@ -199,6 +202,14 @@ typedef enum GHOSTTY_ENUM_TYPED { /** Whether the cursor is on the tail of a wide character (bool). * Only valid when CURSOR_VIEWPORT_HAS_VALUE is true. */ GHOSTTY_RENDER_STATE_DATA_CURSOR_VIEWPORT_WIDE_TAIL = 17, + + /** All cursor state in one sized struct (GhosttyRenderStateCursor). + * Initialize the output with GHOSTTY_INIT_SIZED before querying. */ + GHOSTTY_RENDER_STATE_DATA_CURSOR = 18, + + /** All render-state colors in one sized struct (GhosttyRenderStateColors). + * Initialize the output with GHOSTTY_INIT_SIZED before querying. */ + GHOSTTY_RENDER_STATE_DATA_COLORS = 19, GHOSTTY_RENDER_STATE_DATA_MAX_VALUE = GHOSTTY_ENUM_MAX_VALUE, } GhosttyRenderStateData; @@ -236,6 +247,22 @@ typedef enum GHOSTTY_ENUM_TYPED { /** Row-local selected cell range (GhosttyRenderStateRowSelection). */ GHOSTTY_RENDER_STATE_ROW_DATA_SELECTION = 4, + + /** A borrowed view of the raw cell values for the current row + * (GhosttyCellsView). One value per column, identical to querying + * GHOSTTY_RENDER_STATE_ROW_CELLS_DATA_RAW for each cell. The view + * is only valid as long as the underlying render state is not + * updated; it is unsafe to use after updating the render state. + * + * This is the bulk alternative to iterating cells one at a time. + * It lets callers with expensive call boundaries (e.g. WebAssembly + * embedders) read an entire row with a single call. + * + * Bit positions aren't protected by ABI, so callers should parse them + * out of the manifest from `ghostty_type_json`. Callers with access + * to the C header or without high FFI costs should use `ghostty_cell_get`. + */ + GHOSTTY_RENDER_STATE_ROW_DATA_CELLS_RAW = 5, GHOSTTY_RENDER_STATE_ROW_DATA_MAX_VALUE = GHOSTTY_ENUM_MAX_VALUE, } GhosttyRenderStateRowData; @@ -274,16 +301,58 @@ typedef struct { } GhosttyRenderStateRowSelection; /** + * Render-state cursor information. + * + * This struct uses the sized-struct ABI pattern. Initialize with + * GHOSTTY_INIT_SIZED(GhosttyRenderStateCursor) before querying + * GHOSTTY_RENDER_STATE_DATA_CURSOR. + * + * When viewport_has_value is false, viewport_x, viewport_y, and wide_tail + * contain undefined data and must not be read. + * + * @ingroup render + */ +typedef struct { + /** Size of this struct in bytes. Must be set to sizeof(GhosttyRenderStateCursor). */ + size_t size; + + /** Whether the cursor is visible within the viewport. */ + bool viewport_has_value; + + /** Cursor viewport x position in cells. */ + uint16_t viewport_x; + + /** Cursor viewport y position in cells. */ + uint16_t viewport_y; + + /** Whether the cursor is on the tail of a wide character. */ + bool wide_tail; + + /** Whether the cursor is visible based on terminal modes. */ + bool visible; + + /** Whether the cursor should blink based on terminal modes. */ + bool blinking; + + /** Whether the cursor is at a password input field. */ + bool password_input; + + /** The visual style of the cursor. */ + GhosttyRenderStateCursorVisualStyle visual_style; +} GhosttyRenderStateCursor; + +/** * Render-state color information. * * This struct uses the sized-struct ABI pattern. Initialize with - * GHOSTTY_INIT_SIZED(GhosttyRenderStateColors) before calling - * ghostty_render_state_colors_get(). + * GHOSTTY_INIT_SIZED(GhosttyRenderStateColors) before querying + * GHOSTTY_RENDER_STATE_DATA_COLORS. * * Example: * @code * GhosttyRenderStateColors colors = GHOSTTY_INIT_SIZED(GhosttyRenderStateColors); - * GhosttyResult result = ghostty_render_state_colors_get(state, &colors); + * GhosttyResult result = ghostty_render_state_get( + * state, GHOSTTY_RENDER_STATE_DATA_COLORS, &colors); * @endcode * * @ingroup render @@ -409,6 +478,23 @@ GHOSTTY_API GhosttyResult ghostty_render_state_begin_update(GhosttyRenderState s GHOSTTY_API GhosttyResult ghostty_render_state_end_update(GhosttyRenderState state); /** + * Mark all dirty render-state data as consumed. + * + * This sets the global dirty state to GHOSTTY_RENDER_STATE_DIRTY_FALSE and + * clears every per-row dirty flag. It is idempotent and does not modify cell + * contents or dirty state owned by the terminal. Call this only after a + * complete frame has been rendered successfully; partial consumers should + * use ghostty_render_state_set() and ghostty_render_state_row_set() instead. + * + * @param state The render state handle (NULL returns GHOSTTY_INVALID_VALUE) + * @return GHOSTTY_SUCCESS on success, GHOSTTY_INVALID_VALUE if `state` is + * NULL + * + * @ingroup render + */ +GHOSTTY_API GhosttyResult ghostty_render_state_clean(GhosttyRenderState state); + +/** * Get a value from a render state. * * The `out` pointer must point to a value of the type corresponding to the @@ -417,8 +503,9 @@ GHOSTTY_API GhosttyResult ghostty_render_state_end_update(GhosttyRenderState sta * @param state The render state handle (NULL returns GHOSTTY_INVALID_VALUE) * @param data The data kind to query * @param[out] out Pointer to receive the queried value - * @return GHOSTTY_SUCCESS on success, GHOSTTY_INVALID_VALUE if `state` is - * NULL or `data` is not a recognized enum value + * @return GHOSTTY_SUCCESS on success, GHOSTTY_INVALID_VALUE if `state` or + * `out` is NULL, `data` is not a recognized enum value, or a sized + * output struct is smaller than `sizeof(size_t)` * * @ingroup render */ @@ -474,24 +561,6 @@ GHOSTTY_API GhosttyResult ghostty_render_state_set(GhosttyRenderState state, const void* value); /** - * Get the current color information from a render state. - * - * This writes as many fields as fit in the caller-provided sized struct. - * `out_colors->size` must be set by the caller (typically via - * GHOSTTY_INIT_SIZED(GhosttyRenderStateColors)). - * - * @param state The render state handle (NULL returns GHOSTTY_INVALID_VALUE) - * @param[out] out_colors Sized output struct to receive render-state colors - * @return GHOSTTY_SUCCESS on success, GHOSTTY_INVALID_VALUE if `state` or - * `out_colors` is NULL, or if `out_colors->size` is smaller than - * `sizeof(size_t)` - * - * @ingroup render - */ -GHOSTTY_API GhosttyResult ghostty_render_state_colors_get(GhosttyRenderState state, - GhosttyRenderStateColors* out_colors); - -/** * Create a new row iterator instance. * * All fields except the allocator are left undefined until populated @@ -521,7 +590,8 @@ GHOSTTY_API void ghostty_render_state_row_iterator_free(GhosttyRenderStateRowIte /** * Move a render-state row iterator to the next row. * - * Returns true if the iterator moved successfully and row data is + * Rows are visited contiguously in ascending viewport order, starting at + * y = 0. Returns true if the iterator moved successfully and row data is * available to read at the new position. * * @param iterator The iterator handle to advance (may be NULL) @@ -533,11 +603,35 @@ GHOSTTY_API void ghostty_render_state_row_iterator_free(GhosttyRenderStateRowIte GHOSTTY_API bool ghostty_render_state_row_iterator_next(GhosttyRenderStateRowIterator iterator); /** + * Move a render-state row iterator to the next row requiring a redraw. + * + * If the global dirty state is GHOSTTY_RENDER_STATE_DIRTY_FALSE, this returns + * false. If it is GHOSTTY_RENDER_STATE_DIRTY_PARTIAL, clean rows are skipped. + * If it is GHOSTTY_RENDER_STATE_DIRTY_FULL, every remaining row is returned + * regardless of its per-row dirty flag. Rows are returned in ascending + * viewport order. This function does not clear any dirty state. + * + * @param iterator The iterator handle to advance (NULL returns false) + * @param[out] out_y Receives the viewport y coordinate when true is returned + * (NULL returns false); it is not modified when false is + * returned + * @return true if advanced to a row requiring a redraw, false if an argument + * is NULL or the iterator has reached the end of the effective dirty + * rows + * + * @ingroup render + */ +GHOSTTY_API bool ghostty_render_state_row_iterator_next_dirty( + GhosttyRenderStateRowIterator iterator, + uint16_t* out_y); + +/** * Get a value from the current row in a render-state row iterator. * * The `out` pointer must point to a value of the type corresponding to the * requested data kind (see GhosttyRenderStateRowData). - * Call ghostty_render_state_row_iterator_next() at least once before + * Call ghostty_render_state_row_iterator_next() or + * ghostty_render_state_row_iterator_next_dirty() at least once before * calling this function. * * @param iterator The iterator handle to query (NULL returns GHOSTTY_INVALID_VALUE) @@ -586,7 +680,8 @@ GHOSTTY_API GhosttyResult ghostty_render_state_row_get_multi( * * The `value` pointer must point to a value of the type corresponding to the * requested option kind (see GhosttyRenderStateRowOption). - * Call ghostty_render_state_row_iterator_next() at least once before + * Call ghostty_render_state_row_iterator_next() or + * ghostty_render_state_row_iterator_next_dirty() at least once before * calling this function. * * @param iterator The iterator handle to update (NULL returns GHOSTTY_INVALID_VALUE) diff --git a/vendor/libghostty-vt/include/ghostty/vt/screen.h b/vendor/libghostty-vt/include/ghostty/vt/screen.h index 9f639b5..2899989 100644 --- a/vendor/libghostty-vt/include/ghostty/vt/screen.h +++ b/vendor/libghostty-vt/include/ghostty/vt/screen.h @@ -21,19 +21,21 @@ extern "C" { * Terminal screen cell and row types. * * These types represent the contents of a terminal screen. A GhosttyCell - * is a single grid cell and a GhosttyRow is a single row. Both are opaque - * values whose fields are accessed via ghostty_cell_get() and - * ghostty_row_get() respectively. + * is a single grid cell and a GhosttyRow is a single row. Cell fields can + * be accessed via ghostty_cell_get() or decoded from the packed layout in + * ghostty_type_json(). Rows are opaque and accessed via ghostty_row_get(). * * @{ */ /** - * Opaque cell value. + * Packed cell value. * - * Represents a single terminal cell. The internal layout is opaque and - * must be queried via ghostty_cell_get(). Obtain cell values from - * terminal query APIs. + * Represents a single terminal cell. Portable callers can query fields via + * ghostty_cell_get(). Boundary-sensitive callers can decode the packed value + * using the GhosttyCell descriptor returned by ghostty_type_json(). The + * manifest is authoritative for the linked build; hardcoding bit positions + * is unsupported. * * @ingroup screen */ @@ -51,6 +53,24 @@ typedef uint64_t GhosttyCell; typedef uint64_t GhosttyRow; /** + * A borrowed view of contiguous raw cell values. + * + * The memory is not owned by this struct. The pointer is only valid + * for the lifetime documented by the API that produces it. Each value + * can be queried via ghostty_cell_get() or decoded using the GhosttyCell + * packed descriptor returned by ghostty_type_json(). + * + * @ingroup screen + */ +typedef struct { + /** Pointer to len contiguous cell values. */ + const GhosttyCell* ptr; + + /** Number of cells. */ + size_t len; +} GhosttyCellsView; + +/** * Cell content tag. * * Describes what kind of content a cell holds. diff --git a/vendor/libghostty-vt/include/ghostty/vt/search.h b/vendor/libghostty-vt/include/ghostty/vt/search.h new file mode 100644 index 0000000..68aebcc --- /dev/null +++ b/vendor/libghostty-vt/include/ghostty/vt/search.h @@ -0,0 +1,501 @@ +/** + * @file search.h + * + * Search terminal contents, including scrollback, for a string. + */ + +#ifndef GHOSTTY_VT_SEARCH_H +#define GHOSTTY_VT_SEARCH_H + +#include <stddef.h> +#include <ghostty/vt/allocator.h> +#include <ghostty/vt/selection.h> +#include <ghostty/vt/types.h> + +#ifdef __cplusplus +extern "C" { +#endif + +/** @defgroup search Search + * + * Search a terminal for a string, covering the active area and + * scrollback of both the primary and alternate screens. + * + * A GhosttySearch searches the terminal it was created with for a + * needle set with GHOSTTY_SEARCH_OPT_NEEDLE. It handles the hard + * parts of terminal search internally: results stay in sync with the + * live screens, survive primary/alternate screen switches (entering + * and leaving a fullscreen app such as vim does not restart a + * scrollback search), and recover from resize, reflow, resets, and + * scrollback pruning. + * + * A search starts idle. Setting the needle starts the search, + * changing it restarts the search from scratch, and clearing it + * returns the search to idle. Matching is byte-exact except ASCII + * letters, which compare case-insensitively. + * + * ## Driving a search + * + * Searching a large scrollback takes time, so the work is split into + * small steps the caller drives so that the caller can control + * performance more directly: + * + * - ghostty_search_tick() makes a bounded amount of progress on data + * the search has already copied. It never touches the terminal, + * meaning it can be safely called from a thread. + * - ghostty_search_feed() reads the terminal to copy in more data and + * pick up terminal changes. Feeding is the only way the search + * learns that the terminal changed, so keep feeding periodically + * while the search is in use. This requires exclusive terminal access. + * - ghostty_search_run() is a blocking convenience that feeds and + * ticks until the search is caught up. + * + * GHOSTTY_SEARCH_STATUS_COMPLETE means the search is caught up with + * the terminal as of the last feed. It never means finished forever, + * since later terminal writes require another feed to be seen. + * + * ## Matches are selections + * + * Every match is returned as a GhosttySelection snapshot with + * rectangle set to false, so the existing selection APIs all work on + * matches: ghostty_terminal_selection_format_buf() to copy the + * matched text, ghostty_terminal_point_from_grid_ref() with + * GHOSTTY_POINT_TAG_VIEWPORT to position highlight rectangles, + * ghostty_terminal_selection_contains() for hit testing, and + * ghostty_terminal_set() with GHOSTTY_TERMINAL_OPT_SELECTION to make + * a match the terminal's selection. + * + * Returned matches follow the usual snapshot lifetime rules: they are + * only valid until the next operation that modifies the terminal, + * including ghostty_terminal_vt_write(), resize, reset, and free. + * Read matches after a feed, use them before the terminal changes + * again, and re-read them rather than caching them. The selected + * match is kept accurate internally across terminal changes, so the + * safe way to follow a match is to re-read + * GHOSTTY_SEARCH_DATA_SELECTED_MATCH after each feed. + * + * ## Lifetime + * + * The search borrows the terminal it was created with and never frees + * it. Any number of searches, alongside other terminal readers such + * as formatters and render states, may share one terminal. + * + * The search and its terminal can be freed in either order. Freeing + * the search first releases tracked state it holds within the + * terminal. If the terminal is freed first, the search detects this: + * calls that need the terminal return GHOSTTY_INVALID_VALUE, reads + * return whatever the search last saw, and ghostty_search_free() + * releases only search-owned memory. A search cannot be rebound, so + * searching another terminal means creating a new search. + * + * ## Threading + * + * The library creates no threads. Calls on one GhosttySearch are not + * safe to make concurrently with each other, so the caller must + * serialize them. + * + * Functions that touch the terminal (ghostty_search_new(), + * ghostty_search_feed(), ghostty_search_run(), ghostty_search_set() + * with the needle and select options, and ghostty_search_free()) must + * also be serialized with all other access to the same terminal. + * + * Everything else (ghostty_search_tick(), ghostty_search_get(), and + * ghostty_search_get_multi()) only touches memory owned by the search + * and is safe to call while another thread modifies the terminal. + * This split is how Ghostty runs search on a background thread: tick + * freely, and take the terminal lock only to feed. Reading returned + * match values is always safe, but passing them to APIs that take the + * terminal follows the terminal serialization rule above. + * + * ## Example + * + * @snippet c-vt-search/src/main.c search-main + * + * @{ + */ + +/** + * Progress state of a search. + * + * @ingroup search + */ +typedef enum GHOSTTY_ENUM_TYPED { + /** + * ghostty_search_tick() can make progress without terminal access. + */ + GHOSTTY_SEARCH_STATUS_RUNNING = 0, + + /** + * Blocked until ghostty_search_feed(). This is also the state right + * after a needle is set, since the search has not yet seen the + * terminal. + */ + GHOSTTY_SEARCH_STATUS_FEED_REQUIRED = 1, + + /** + * Caught up with the terminal state as of the last feed. This never + * means finished forever, since later terminal writes require + * another feed to be seen. A search with no needle set also reports + * complete, since there is nothing to look for. + */ + GHOSTTY_SEARCH_STATUS_COMPLETE = 2, + + GHOSTTY_SEARCH_STATUS_MAX_VALUE = GHOSTTY_ENUM_MAX_VALUE, +} GhosttySearchStatus; + +/** + * Scroll policy applied when a match becomes selected via + * GHOSTTY_SEARCH_OPT_SELECT_NEXT or GHOSTTY_SEARCH_OPT_SELECT_PREV. + * + * @ingroup search + */ +typedef enum GHOSTTY_ENUM_TYPED { + /** Scroll the viewport so the match is visible, only if it is not + * already visible. This is the default. */ + GHOSTTY_SEARCH_SCROLL_IF_NEEDED = 0, + + /** Never scroll the viewport. */ + GHOSTTY_SEARCH_SCROLL_NONE = 1, + + GHOSTTY_SEARCH_SCROLL_MAX_VALUE = GHOSTTY_ENUM_MAX_VALUE, +} GhosttySearchScroll; + +/** + * Data fields readable with ghostty_search_get(). The output value + * type is documented per field. + * + * All reads reflect the terminal's active screen as of the last feed. + * When the running application switches to the alternate screen, the + * next feed switches counts, matches, and selection to that screen's + * results. Primary screen results, including completed scrollback + * searches, are retained and restored on the way back. + * + * @ingroup search + */ +typedef enum GHOSTTY_ENUM_TYPED { + /** Current search status: GhosttySearchStatus*. */ + GHOSTTY_SEARCH_DATA_STATUS = 0, + + /** + * The needle this search is looking for: GhosttyString*. The bytes + * are borrowed from the search and remain valid until the needle is + * changed or the search is freed. Returns GHOSTTY_NO_VALUE when no + * needle is set. + */ + GHOSTTY_SEARCH_DATA_NEEDLE = 1, + + /** + * Total matches found so far on the active screen: size_t*. Zero + * until the first feed. + */ + GHOSTTY_SEARCH_DATA_TOTAL_MATCHES = 2, + + /** + * Index of the selected match: size_t*. This indexes the newest to + * oldest ordering of GHOSTTY_SEARCH_DATA_MATCHES, where 0 is the + * newest match, so a "k of n" find bar renders index + 1 of + * GHOSTTY_SEARCH_DATA_TOTAL_MATCHES. Returns GHOSTTY_NO_VALUE when + * nothing is selected. + */ + GHOSTTY_SEARCH_DATA_SELECTED_INDEX = 3, + + /** + * The selected match: GhosttySelection*. This is an untracked + * snapshot with standard GhosttySelection lifetime rules. Returns + * GHOSTTY_NO_VALUE when nothing is selected. + */ + GHOSTTY_SEARCH_DATA_SELECTED_MATCH = 4, + + /** + * All matches on the active screen, ordered newest to oldest, from + * the bottom of the active area up through scrollback: + * GhosttySelectionBuffer*. Set ptr to NULL with cap 0 to query the + * required capacity. An undersized buffer returns + * GHOSTTY_OUT_OF_SPACE with the required capacity in len. + */ + GHOSTTY_SEARCH_DATA_MATCHES = 5, + + /** + * Matches on the pages covering the viewport, for drawing highlight + * rectangles: GhosttySelectionBuffer*. The list is computed during + * feeds and cached, so it reflects the viewport as of the last + * feed. + * + * Matches are found a page at a time, so the list can include + * matches slightly outside the visible viewport when they share a + * page with it. Ghostty's own renderer behaves the same way. + * Converting each match to viewport coordinates with + * ghostty_terminal_point_from_grid_ref() clips this naturally: skip + * matches that fail the conversion or whose row is beyond the + * visible row count. + */ + GHOSTTY_SEARCH_DATA_VIEWPORT_MATCHES = 6, + + /** Current scroll policy: GhosttySearchScroll*. */ + GHOSTTY_SEARCH_DATA_SELECT_SCROLL = 7, + + GHOSTTY_SEARCH_DATA_MAX_VALUE = GHOSTTY_ENUM_MAX_VALUE, +} GhosttySearchData; + +/** + * Options writable with ghostty_search_set(). The value type, and + * what a NULL value means, is documented per option. + * + * @ingroup search + */ +typedef enum GHOSTTY_ENUM_TYPED { + /** + * Set the needle to search for: const GhosttyString*. The bytes are + * copied, so the caller's memory does not need to outlive the call. + * Matching is byte-exact except ASCII letters, which compare + * case-insensitively. + * + * Changing the needle restarts the search from scratch and drops + * all results. As an exception, setting a needle equal to the current + * one (compared the same way as matching) keeps existing results, + * so find bars can resubmit freely. A NULL or empty value clears + * the needle and returns the search to idle. + * + * Replacing or clearing a needle releases tracked state held + * within the terminal, so the caller must serialize this with all + * other access to the same terminal. Returns GHOSTTY_INVALID_VALUE + * after the terminal was freed. + */ + GHOSTTY_SEARCH_OPT_NEEDLE = 0, + + /** + * Select the next match, moving toward older content: from the + * bottom of the screen upward into history, the direction a search + * from the prompt usually wants. Wraps around past the oldest + * match. + * + * The value must be NULL. It is reserved for future use. + * + * This catches up with the terminal first, so it is safe to call at + * any time relative to feeds. The viewport scrolls to the newly + * selected match according to GHOSTTY_SEARCH_OPT_SELECT_SCROLL. + * This reads the terminal, so the caller must serialize it with all + * other access to the same terminal. Returns GHOSTTY_NO_VALUE when + * there are no matches. + */ + GHOSTTY_SEARCH_OPT_SELECT_NEXT = 1, + + /** + * Select the previous match, moving toward newer content, wrapping + * around past the newest match. Otherwise identical to + * GHOSTTY_SEARCH_OPT_SELECT_NEXT. + */ + GHOSTTY_SEARCH_OPT_SELECT_PREV = 2, + + /** + * Set the scroll policy applied by the select options: const + * GhosttySearchScroll*. The policy persists until changed. A NULL + * value resets it to GHOSTTY_SEARCH_SCROLL_IF_NEEDED. This only + * modifies search-owned state and never reads the terminal. + */ + GHOSTTY_SEARCH_OPT_SELECT_SCROLL = 3, + + GHOSTTY_SEARCH_OPT_MAX_VALUE = GHOSTTY_ENUM_MAX_VALUE, +} GhosttySearchOption; + +/** + * Create a search bound to a terminal. + * + * The search borrows the terminal and never frees it. The search and + * the terminal can be freed in either order; see ghostty_search_free(). + * + * The search starts idle with no needle: it reports + * GHOSTTY_SEARCH_STATUS_COMPLETE and finds nothing. Set + * GHOSTTY_SEARCH_OPT_NEEDLE to start searching. + * + * Creation is cheap and does not read terminal contents, but it + * registers the search with the terminal so the two can be freed in + * any order. The caller must serialize this call with all other + * access to the same terminal. + * + * @param allocator Allocator, or NULL for the default allocator + * @param out_search Receives the created search handle + * @param terminal Terminal to bind the search to + * @return GHOSTTY_SUCCESS on success, GHOSTTY_INVALID_VALUE if + * out_search or terminal is invalid, or GHOSTTY_OUT_OF_MEMORY + * if allocation fails + * + * @ingroup search + */ +GHOSTTY_API GhosttyResult ghostty_search_new( + const GhosttyAllocator* allocator, + GhosttySearch* out_search, + GhosttyTerminal terminal); + +/** + * Free a search. + * + * If the bound terminal is still alive, this releases tracked state + * the search holds within it, so the caller must serialize this call + * with all other access to the same terminal. If the terminal was + * already freed, the search has been detached and this releases only + * search-owned memory. Passing NULL is allowed and is a no-op. + * + * @param search Search handle to free + * + * @ingroup search + */ +GHOSTTY_API void ghostty_search_free(GhosttySearch search); + +/** + * Make a bounded amount of search progress. + * + * This only works on data the search has already copied and never + * reads the terminal, so it is safe to call while another thread + * modifies the terminal. Call it in a loop while the status is + * GHOSTTY_SEARCH_STATUS_RUNNING. When the status becomes + * GHOSTTY_SEARCH_STATUS_FEED_REQUIRED, call ghostty_search_feed() to + * unblock it. + * + * @param search Search handle (NULL returns GHOSTTY_INVALID_VALUE) + * @param[out] out_status Receives the status after the tick (may be NULL) + * @return GHOSTTY_SUCCESS on success, or GHOSTTY_INVALID_VALUE if + * search is NULL + * + * @ingroup search + */ +GHOSTTY_API GhosttyResult ghostty_search_tick( + GhosttySearch search, + GhosttySearchStatus* out_status); + +/** + * Read the terminal to update the search. + * + * Each feed catches the search up with the terminal: it reconciles + * the tracked screens against the live ones, re-scans the active + * area, refreshes the viewport match list, gives the scrollback + * searcher its next chunk of data, and prunes results that scrollback + * eviction invalidated. Feeding is also the only way the search + * learns about terminal changes, so keep feeding periodically while + * the search is in use, even after it reports complete. + * + * This reads the terminal, so the caller must serialize it with all + * other access to the same terminal. Each call does a bounded amount + * of work so that any caller-held terminal lock is held only briefly. + * + * @param search Search handle (NULL returns GHOSTTY_INVALID_VALUE) + * @return GHOSTTY_SUCCESS on success, or GHOSTTY_INVALID_VALUE if + * search is NULL or the terminal was freed + * + * @ingroup search + */ +GHOSTTY_API GhosttyResult ghostty_search_feed(GhosttySearch search); + +/** + * Feed and tick until the search is caught up with the terminal. + * + * This is a blocking convenience for one-shot and single-threaded + * embedders. It always performs at least one feed, so it also picks + * up any terminal changes since the last feed, then loops until the + * status is GHOSTTY_SEARCH_STATUS_COMPLETE. Searching a large + * scrollback can take a while, so interactive embedders should drive + * ghostty_search_tick() and ghostty_search_feed() themselves. + * + * This reads the terminal for the entire call, so the caller must + * serialize it with all other access to the same terminal. + * + * @param search Search handle (NULL returns GHOSTTY_INVALID_VALUE) + * @return GHOSTTY_SUCCESS on success, or GHOSTTY_INVALID_VALUE if + * search is NULL or the terminal was freed + * + * @ingroup search + */ +GHOSTTY_API GhosttyResult ghostty_search_run(GhosttySearch search); + +/** + * Write an option to a search. + * + * The value type, and what a NULL value means, depends on the option + * and is documented by GhosttySearchOption. The needle and select + * options touch the terminal, so the caller must serialize those + * calls with all other access to the same terminal. + * GHOSTTY_SEARCH_OPT_SELECT_SCROLL only modifies search-owned state. + * + * @param search Search handle (NULL returns GHOSTTY_INVALID_VALUE) + * @param option Option to write + * @param value Pointer to the input value for the option. The meaning + * of NULL is documented per option. + * @return GHOSTTY_SUCCESS on success, GHOSTTY_NO_VALUE if a select + * option found no matches, GHOSTTY_OUT_OF_MEMORY if + * allocation fails, or GHOSTTY_INVALID_VALUE if search, + * option, or value is invalid or the option needs a terminal + * that was already freed + * + * @ingroup search + */ +GHOSTTY_API GhosttyResult ghostty_search_set( + GhosttySearch search, + GhosttySearchOption option, + const void* value); + +/** + * Read a data field from a search. + * + * The output value type depends on data and is documented by + * GhosttySearchData. This never reads the terminal, so it is safe to + * call while another thread modifies the terminal. Returned + * selections are untracked snapshots with standard GhosttySelection + * lifetime rules. + * + * @param search Search handle (NULL returns GHOSTTY_INVALID_VALUE) + * @param data Data field to read + * @param value Output pointer whose type depends on data + * @return GHOSTTY_SUCCESS on success, GHOSTTY_NO_VALUE if the + * requested data has no value, GHOSTTY_OUT_OF_SPACE if a + * provided GhosttySelectionBuffer is too small (required + * capacity in its len), GHOSTTY_OUT_OF_MEMORY if collecting + * viewport matches fails, or GHOSTTY_INVALID_VALUE if search, + * data, or value is invalid + * + * @ingroup search + */ +GHOSTTY_API GhosttyResult ghostty_search_get( + GhosttySearch search, + GhosttySearchData data, + void* value); + +/** + * Read multiple data fields from a search in a single call. + * + * This is an optimization over calling ghostty_search_get() multiple + * times. Each entry in values must point to storage of the type + * documented by the corresponding GhosttySearchData key. + * + * If any individual read fails, the function returns that error and + * writes the index of the failing key to out_written when out_written + * is non-NULL. Earlier keys have already been written. On success, + * out_written receives count when non-NULL. A too-small + * GhosttySelectionBuffer stops the batch with GHOSTTY_OUT_OF_SPACE at + * that key's index with the required capacity in its len, so order + * buffer-valued keys after scalar keys. + * + * @param search Search handle (NULL returns GHOSTTY_INVALID_VALUE) + * @param count Number of data fields to read + * @param keys Data fields to read (must not be NULL) + * @param values Output pointers corresponding to keys (must not be NULL) + * @param out_written Optional number of fields read, or failing index + * on error + * @return GHOSTTY_SUCCESS on success, or the first failing read's + * result + * + * @ingroup search + */ +GHOSTTY_API GhosttyResult ghostty_search_get_multi( + GhosttySearch search, + size_t count, + const GhosttySearchData* keys, + void** values, + size_t* out_written); + +/** @} */ + +#ifdef __cplusplus +} +#endif + +#endif /* GHOSTTY_VT_SEARCH_H */ diff --git a/vendor/libghostty-vt/include/ghostty/vt/selection.h b/vendor/libghostty-vt/include/ghostty/vt/selection.h index 3b926aa..27d3c47 100644 --- a/vendor/libghostty-vt/include/ghostty/vt/selection.h +++ b/vendor/libghostty-vt/include/ghostty/vt/selection.h @@ -117,6 +117,29 @@ typedef struct { } GhosttySelection; /** + * A caller-provided buffer of selections. + * + * This follows the same conventions as GhosttyBuffer: ptr may be NULL with + * cap 0 to query the required capacity. APIs that fill this type set len to + * the number of entries written on GHOSTTY_SUCCESS, or to the required entry + * capacity on GHOSTTY_OUT_OF_SPACE. + * + * @ingroup selection + */ +typedef struct { + /** Destination buffer for selections. May be NULL when cap is 0 to query + * the required capacity. */ + GhosttySelection* ptr; + + /** Capacity of ptr in entries. */ + size_t cap; + + /** Entries written on success, or required entry capacity on + * GHOSTTY_OUT_OF_SPACE. */ + size_t len; +} GhosttySelectionBuffer; + +/** * Options for deriving a word selection from a terminal grid reference. * * This is a sized struct. Use GHOSTTY_INIT_SIZED() to initialize it. diff --git a/vendor/libghostty-vt/include/ghostty/vt/sgr.h b/vendor/libghostty-vt/include/ghostty/vt/sgr.h index 757c5ae..b8aecb6 100644 --- a/vendor/libghostty-vt/include/ghostty/vt/sgr.h +++ b/vendor/libghostty-vt/include/ghostty/vt/sgr.h @@ -319,31 +319,6 @@ GHOSTTY_API GhosttySgrAttributeTag ghostty_sgr_attribute_tag(GhosttySgrAttribute GHOSTTY_API GhosttySgrAttributeValue* ghostty_sgr_attribute_value( GhosttySgrAttribute* attr); -#ifdef __wasm__ -/** - * Allocate memory for an SGR attribute (WebAssembly only). - * - * This is a convenience function for WebAssembly environments to allocate - * memory for an SGR attribute structure that can be passed to ghostty_sgr_next. - * - * @return Pointer to the allocated attribute structure - * - * @ingroup wasm - */ -GHOSTTY_API GhosttySgrAttribute* ghostty_wasm_alloc_sgr_attribute(void); - -/** - * Free memory for an SGR attribute (WebAssembly only). - * - * Frees memory allocated by ghostty_wasm_alloc_sgr_attribute. - * - * @param attr Pointer to the attribute structure to free - * - * @ingroup wasm - */ -GHOSTTY_API void ghostty_wasm_free_sgr_attribute(GhosttySgrAttribute* attr); -#endif - #ifdef __cplusplus } #endif diff --git a/vendor/libghostty-vt/include/ghostty/vt/snapshot.h b/vendor/libghostty-vt/include/ghostty/vt/snapshot.h index fb09d24..89cb539 100644 --- a/vendor/libghostty-vt/include/ghostty/vt/snapshot.h +++ b/vendor/libghostty-vt/include/ghostty/vt/snapshot.h @@ -7,6 +7,7 @@ #ifndef GHOSTTY_VT_SNAPSHOT_H #define GHOSTTY_VT_SNAPSHOT_H +#include <stdbool.h> #include <stddef.h> #include <stdint.h> @@ -131,13 +132,34 @@ typedef enum GHOSTTY_ENUM_TYPED { * state. The decoder default matches the largest built-in APC protocol * buffer limit, currently 65 MiB. * - * This is an input validation limit only. It does not configure continuation - * tracking on a terminal returned by the decoder. + * This is primarily an input validation limit. When + * GHOSTTY_SNAPSHOT_DECODER_OPT_RETAIN_CONTINUATION is true, the same value + * also becomes the continuation tracking limit on the returned terminal. * * Input type: size_t * */ GHOSTTY_SNAPSHOT_DECODER_OPT_MAX_CONTINUATION_BYTES = 0, + /** + * Retain the decoded continuation on the returned terminal. + * + * When true, terminals returned by ghostty_snapshot_decoder_ready() and + * ghostty_snapshot_decoder_decode() use + * GHOSTTY_SNAPSHOT_DECODER_OPT_MAX_CONTINUATION_BYTES as their continuation + * tracking limit. The existing ghostty_terminal_continuation_* APIs can then + * export the exact unfinished VT or UTF-8 input restored from the snapshot. + * + * This is false by default. A maximum continuation size of zero leaves + * tracking disabled. With a nonzero maximum, tracking remains enabled even + * when the decoded continuation is empty. Exporting an empty continuation + * does not disable it. Callers that do not need ongoing tracking must still + * set GHOSTTY_TERMINAL_OPT_CONTINUATION_MAX_BYTES to zero after export and + * before writing post-snapshot input. + * + * Input type: bool * + */ + GHOSTTY_SNAPSHOT_DECODER_OPT_RETAIN_CONTINUATION = 1, + GHOSTTY_SNAPSHOT_DECODER_OPT_MAX_VALUE = GHOSTTY_ENUM_MAX_VALUE, } GhosttySnapshotDecoderOption; @@ -222,6 +244,15 @@ typedef enum GHOSTTY_ENUM_TYPED { */ GHOSTTY_SNAPSHOT_DECODER_DATA_PROGRESS_REMAINING = 7, + /** + * Whether decoded continuation tracking is retained on returned terminals. + * + * This value is available in every non-failed decoder state. + * + * Output type: bool * + */ + GHOSTTY_SNAPSHOT_DECODER_DATA_RETAIN_CONTINUATION = 8, + GHOSTTY_SNAPSHOT_DECODER_DATA_MAX_VALUE = GHOSTTY_ENUM_MAX_VALUE, } GhosttySnapshotDecoderData; @@ -397,10 +428,16 @@ GHOSTTY_API GhosttyResult ghostty_snapshot_decoder_set( * immediately usable for rendering and live input. Older scrollback remains * to be restored with ghostty_snapshot_decoder_next(). * - * The restored parser state may be unfinished, but terminal continuation - * tracking is disabled; GHOSTTY_TERMINAL_DATA_CONTINUATION_MAX_BYTES returns - * zero. The decoder's continuation option is an input limit, not terminal - * runtime policy. + * The restored parser state may be unfinished. By default, terminal + * continuation tracking is disabled and + * GHOSTTY_TERMINAL_DATA_CONTINUATION_MAX_BYTES returns zero. When + * GHOSTTY_SNAPSHOT_DECODER_OPT_RETAIN_CONTINUATION is true, the decoder's + * maximum continuation size is applied to the terminal, and the terminal + * continuation APIs export the exact current continuation when that limit is + * nonzero. Tracking remains enabled even if the exported continuation is + * empty. Callers that do not need ongoing tracking must set + * GHOSTTY_TERMINAL_OPT_CONTINUATION_MAX_BYTES to zero after export and before + * writing any post-snapshot bytes, because later input may change it. * * The caller must keep the returned terminal alive until FINISH validates or * the decoder is freed. The decoder borrows this terminal handle while it @@ -455,8 +492,15 @@ GHOSTTY_API GhosttyResult ghostty_snapshot_decoder_next( * FINISH. It may only be called before decoding starts. Bytes following FINISH * are left unread. On success terminal receives a caller-owned terminal with * its persistent VT stream restored. Continuation tracking on the returned - * terminal is disabled and GHOSTTY_TERMINAL_DATA_CONTINUATION_MAX_BYTES - * returns zero. terminal is set to NULL on every error. + * terminal is disabled by default. When + * GHOSTTY_SNAPSHOT_DECODER_OPT_RETAIN_CONTINUATION is true, the decoder's + * maximum continuation size is applied to the terminal, and the terminal + * continuation APIs export the exact current continuation when that limit is + * nonzero. Tracking remains enabled even if the exported continuation is + * empty. Callers that do not need ongoing tracking must set + * GHOSTTY_TERMINAL_OPT_CONTINUATION_MAX_BYTES to zero after export and before + * writing any post-snapshot bytes, because later input may change it. + * terminal is set to NULL on every error. * A decoding, I/O, or allocation error after input consumption begins poisons * the decoder, after which it must be freed. An invalid argument or * lifecycle error detected before the operation consumes input does not diff --git a/vendor/libghostty-vt/include/ghostty/vt/sys.h b/vendor/libghostty-vt/include/ghostty/vt/sys.h index ae90596..c5e5903 100644 --- a/vendor/libghostty-vt/include/ghostty/vt/sys.h +++ b/vendor/libghostty-vt/include/ghostty/vt/sys.h @@ -125,6 +125,24 @@ typedef bool (*GhosttySysDecodePngFn)( GhosttySysImage* out); /** + * Callback type for secure random bytes. + * + * Fills @p buf with @p len cryptographically secure random bytes. The + * library uses this for secrets, so it must be a real CSPRNG (getrandom, + * arc4random_buf, BCryptGenRandom, crypto.getRandomValues, ...); a + * predictable source is a security hole. + * + * @param userdata The userdata pointer set via GHOSTTY_SYS_OPT_USERDATA + * @param buf Buffer to fill + * @param len Number of bytes to fill + * @return true if the buffer was filled, false if no entropy is available + */ +typedef bool (*GhosttySysRandomSecureFn)( + void* userdata, + uint8_t* buf, + size_t len); + +/** * System option identifiers for ghostty_sys_set(). */ typedef enum GHOSTTY_ENUM_TYPED { @@ -165,6 +183,21 @@ typedef enum GHOSTTY_ENUM_TYPED { * Input type: GhosttySysLogFn (function pointer, or NULL) */ GHOSTTY_SYS_OPT_LOG = 2, + + /** + * Override the secure random source. + * + * By default the library draws secure random bytes from the + * platform (getrandom or arc4random_buf on POSIX, CNG on Windows). + * Targets without one, such as wasm32-freestanding, have no default + * and operations that need entropy fail with GHOSTTY_IO_ERROR until + * this is set. When set, + * it is used instead of the platform source on every target. When + * cleared (NULL value), the platform default is restored. + * + * Input type: GhosttySysRandomSecureFn (function pointer, or NULL) + */ + GHOSTTY_SYS_OPT_RANDOM_SECURE = 3, GHOSTTY_SYS_OPT_MAX_VALUE = GHOSTTY_ENUM_MAX_VALUE, } GhosttySysOption; diff --git a/vendor/libghostty-vt/include/ghostty/vt/terminal.h b/vendor/libghostty-vt/include/ghostty/vt/terminal.h index 57d0e5d..172674b 100644 --- a/vendor/libghostty-vt/include/ghostty/vt/terminal.h +++ b/vendor/libghostty-vt/include/ghostty/vt/terminal.h @@ -86,16 +86,17 @@ extern "C" { * * | Option | Callback Type | Trigger | * |-----------------------------------------|-----------------------------------|-------------------------------------------| - * | `GHOSTTY_TERMINAL_OPT_WRITE_PTY` | `GhosttyTerminalWritePtyFn` | Query responses written back to the pty | + * | `GHOSTTY_TERMINAL_OPT_WRITE_PTY` | `GhosttyTerminalWritePtyFn` | VT query and mode reports written back to the PTY | * | `GHOSTTY_TERMINAL_OPT_BELL` | `GhosttyTerminalBellFn` | BEL character (0x07) | * | `GHOSTTY_TERMINAL_OPT_TITLE_CHANGED` | `GhosttyTerminalTitleChangedFn` | Title change via OSC 0 / OSC 2 | * | `GHOSTTY_TERMINAL_OPT_PWD_CHANGED` | `GhosttyTerminalPwdChangedFn` | Pwd change via OSC 7 / OSC 9 / OSC 1337 | * | `GHOSTTY_TERMINAL_OPT_ENQUIRY` | `GhosttyTerminalEnquiryFn` | ENQ character (0x05) | * | `GHOSTTY_TERMINAL_OPT_XTVERSION` | `GhosttyTerminalXtversionFn` | XTVERSION query (CSI > q) | - * | `GHOSTTY_TERMINAL_OPT_SIZE` | `GhosttyTerminalSizeFn` | XTWINOPS size query (CSI 14/16/18 t) | + * | `GHOSTTY_TERMINAL_OPT_SIZE` | `GhosttyTerminalSizeFn` | XTWINOPS query (CSI 14/16/18 t) or mode 2048 enable | * | `GHOSTTY_TERMINAL_OPT_COLOR_SCHEME` | `GhosttyTerminalColorSchemeFn` | Color scheme query (CSI ? 996 n) | * | `GHOSTTY_TERMINAL_OPT_DEVICE_ATTRIBUTES`| `GhosttyTerminalDeviceAttributesFn`| Device attributes query (CSI c / > c / = c)| - * | `GHOSTTY_TERMINAL_OPT_CLIPBOARD_WRITE` | `GhosttyTerminalClipboardWriteFn` | Clipboard write via OSC 52 / OSC 1337 | + * | `GHOSTTY_TERMINAL_OPT_CLIPBOARD_WRITE` | `GhosttyTerminalClipboardWriteFn` | Clipboard write via OSC 52 / OSC 1337 / OSC 5522 | + * | `GHOSTTY_TERMINAL_OPT_CLIPBOARD_READ` | `GhosttyTerminalClipboardReadFn` | Clipboard read via OSC 52 "?" / OSC 5522 | * | `GHOSTTY_TERMINAL_OPT_DESKTOP_NOTIFICATION`| `GhosttyTerminalDesktopNotificationFn` | Desktop notification via OSC 9 / OSC 777 | * | `GHOSTTY_TERMINAL_OPT_PROGRESS_REPORT` | `GhosttyTerminalProgressReportFn` | Progress report via OSC 9;4 | * | `GHOSTTY_TERMINAL_OPT_UNKNOWN_SEQUENCE` | `GhosttyTerminalUnknownSequenceFn` | Unsupported sequence identifier | @@ -112,6 +113,9 @@ extern "C" { * ### Defining a clipboard_write callback * @snippet c-vt-effects/src/main.c effects-clipboard-write * + * ### Defining a clipboard_read callback + * @snippet c-vt-effects/src/main.c effects-clipboard-read + * * ### Defining an unknown_sequence callback * @snippet c-vt-effects/src/main.c effects-unknown-sequence * @@ -459,7 +463,78 @@ typedef struct { } GhosttyClipboardContent; /** - * A semantic, atomic clipboard write. + * Result of a clipboard write reply. + * + * @ingroup terminal + */ +typedef enum GHOSTTY_ENUM_TYPED { + /** The clipboard write completed successfully. */ + GHOSTTY_CLIPBOARD_WRITE_RESULT_SUCCESS = 0, + + /** The clipboard write was denied by policy or the user. */ + GHOSTTY_CLIPBOARD_WRITE_RESULT_DENIED = 1, + + /** The destination or one or more representations are unsupported. */ + GHOSTTY_CLIPBOARD_WRITE_RESULT_UNSUPPORTED = 2, + + /** The clipboard is temporarily unavailable. */ + GHOSTTY_CLIPBOARD_WRITE_RESULT_BUSY = 3, + + /** One or more representations contain invalid data. */ + GHOSTTY_CLIPBOARD_WRITE_RESULT_INVALID_DATA = 4, + + /** The clipboard write failed due to an I/O error. */ + GHOSTTY_CLIPBOARD_WRITE_RESULT_IO_ERROR = 5, + GHOSTTY_CLIPBOARD_WRITE_RESULT_MAX_VALUE = GHOSTTY_ENUM_MAX_VALUE, +} GhosttyClipboardWriteResult; + +/** + * The reply to a clipboard write request. + * + * This is a sized struct; set `size` to `sizeof(GhosttyClipboardWriteReply)`. + * The reply is borrowed only for the duration of the reply call and may be + * freed as soon as it returns. + * + * The result answers the program with the matching protocol status for + * protocols with a write acknowledgement (OSC 5522: DONE, EPERM, ENOSYS, + * EBUSY, EINVAL, EIO); protocols without one (OSC 52, OSC 1337 Copy) + * discard the reply. `remember` is ignored on any result other than + * GHOSTTY_CLIPBOARD_WRITE_RESULT_SUCCESS. + * + * @ingroup terminal + */ +typedef struct { + /** Size of this struct in bytes. */ + size_t size; + + /** Outcome of the write. */ + GhosttyClipboardWriteResult result; + + /** + * Record a session grant so future requests from the same program skip + * the permission prompt. Only honored on success when + * GhosttyClipboardWrite::can_remember is set. + */ + bool remember; +} GhosttyClipboardWriteReply; + +typedef struct GhosttyClipboardWrite GhosttyClipboardWrite; + +/** + * Function type used to answer a clipboard write request. Obtained from + * GhosttyClipboardWrite::reply; see that struct for the contract. + * + * @param write The request being answered + * @param reply The reply, borrowed only for the duration of this call + * + * @ingroup terminal + */ +typedef void (*GhosttyClipboardWriteReplyFn)( + const GhosttyClipboardWrite* write, + const GhosttyClipboardWriteReply* reply); + +/** + * A synchronous request to write clipboard contents. * * This is a sized struct. The callback must only access fields present in the * size reported by `size`. The request, contents array, MIME strings, and @@ -470,9 +545,15 @@ typedef struct { * the destination be cleared. This is distinct from a content entry whose data * has zero length. * + * The write is answered by calling `reply` with this request and a + * GhosttyClipboardWriteReply. This must happen within the clipboard write + * request callback. This struct is only valid during that time. Calling + * `reply` more than once is safely ignored. Returning without replying + * denies the write. + * * @ingroup terminal */ -typedef struct { +struct GhosttyClipboardWrite { /** Size of this struct in bytes. */ size_t size; @@ -484,58 +565,257 @@ typedef struct { /** Number of entries in contents; zero means clear the destination. */ size_t contents_len; -} GhosttyClipboardWrite; + + /** + * Name of the writing program for permission prompts, if the protocol + * carries one. Empty otherwise. + */ + GhosttyString name; + + /** + * True if the terminal already holds a session grant for this request + * The embedder should skip any permission prompt and perform the write. + */ + bool granted; + + /** + * True if the program supplied a session password, so the embedder may + * offer to remember the user's decision through + * GhosttyClipboardWriteReply::remember. When false, remember is ignored. + */ + bool can_remember; + + /** Terminal-owned reply state. Do not access. */ + const void* ctx; + + /** Answer the write; see the struct documentation. */ + GhosttyClipboardWriteReplyFn reply; +}; /** - * Result of a clipboard write callback. + * Callback function type for clipboard_write. + * + * The embedder may ask for permission to write or perform the write + * async, but the callback itself is synchronous and the reply function + * must be called during the lifetime of this function. While this callback + * is active the VT stream is paused. + * + * Answer by calling `write->reply(write, &reply)` before returning. See + * GhosttyClipboardWrite for the full contract. * - * Protocols without write acknowledgements, including OSC 52 and iTerm2 - * OSC 1337 Copy, ignore this result. + * The request may carry an optional program name requesting the write + * and the state of prior permission granted. If `can_remember` is set + * the response may set the `remember` flag and future requests from this + * same program will be "granted" and the embedder can skip permission + * requests. + * + * Clipboard read requests (OSC 52 "?" and OSC 5522 reads) are delivered + * to GhosttyTerminalClipboardReadFn instead. + * + * @param terminal The terminal handle + * @param userdata The userdata pointer set via GHOSTTY_TERMINAL_OPT_USERDATA + * @param write Borrowed atomic clipboard write request + * + * @ingroup terminal + */ +typedef void (*GhosttyTerminalClipboardWriteFn)( + GhosttyTerminal terminal, + void* userdata, + const GhosttyClipboardWrite* write); + +/** + * Result of a clipboard read reply. * * @ingroup terminal */ typedef enum GHOSTTY_ENUM_TYPED { - /** The clipboard write completed successfully. */ - GHOSTTY_CLIPBOARD_WRITE_RESULT_SUCCESS = 0, + /** The clipboard was read; the reply carries its contents. */ + GHOSTTY_CLIPBOARD_READ_RESULT_SUCCESS = 0, - /** The clipboard write was denied by policy or the user. */ - GHOSTTY_CLIPBOARD_WRITE_RESULT_DENIED = 1, + /** The clipboard read was denied by policy or the user. */ + GHOSTTY_CLIPBOARD_READ_RESULT_DENIED = 1, - /** The destination or one or more representations are unsupported. */ - GHOSTTY_CLIPBOARD_WRITE_RESULT_UNSUPPORTED = 2, + /** The embedder cannot read this clipboard. */ + GHOSTTY_CLIPBOARD_READ_RESULT_UNSUPPORTED = 2, /** The clipboard is temporarily unavailable. */ - GHOSTTY_CLIPBOARD_WRITE_RESULT_BUSY = 3, + GHOSTTY_CLIPBOARD_READ_RESULT_BUSY = 3, - /** One or more representations contain invalid data. */ - GHOSTTY_CLIPBOARD_WRITE_RESULT_INVALID_DATA = 4, + /** Reading the clipboard failed due to an I/O error. */ + GHOSTTY_CLIPBOARD_READ_RESULT_IO_ERROR = 4, + GHOSTTY_CLIPBOARD_READ_RESULT_MAX_VALUE = GHOSTTY_ENUM_MAX_VALUE, +} GhosttyClipboardReadResult; - /** The clipboard write failed due to an I/O error. */ - GHOSTTY_CLIPBOARD_WRITE_RESULT_IO_ERROR = 5, - GHOSTTY_CLIPBOARD_WRITE_RESULT_MAX_VALUE = GHOSTTY_ENUM_MAX_VALUE, -} GhosttyClipboardWriteResult; +/** + * The reply to a clipboard read request. + * + * This is a sized struct; set `size` to `sizeof(GhosttyClipboardReadReply)`. + * All arrays and the strings they point to are borrowed only for the + * duration of the reply call and may be freed as soon as it returns. + * + * Any result other than GHOSTTY_CLIPBOARD_READ_RESULT_SUCCESS answers the + * program with an empty clipboard (OSC 52) or the matching protocol status + * (OSC 5522: EPERM, ENOSYS, EBUSY, EIO); the other fields are ignored in + * that case. On success, `contents` should carry one representation per + * requested MIME type (GhosttyClipboardRead::mimes) that the clipboard + * has; unrequested representations are ignored. Protocols that carry a + * single text value (OSC 52) use the first entry with a text MIME type + * such as "text/plain". + * + * @ingroup terminal + */ +typedef struct { + /** Size of this struct in bytes. */ + size_t size; + + /** Outcome of the read. */ + GhosttyClipboardReadResult result; + + /** Borrowed array of MIME representations of the clipboard contents. */ + const GhosttyClipboardContent* contents; + + /** Number of entries in contents. */ + size_t contents_len; + + /** + * Borrowed array of all MIME types available on the clipboard. Only + * used when GhosttyClipboardRead::list is set; may be NULL otherwise. + */ + const GhosttyString* available; + + /** Number of entries in available. */ + size_t available_len; + + /** + * Record a session grant so future requests from the same program skip + * the permission prompt. Only honored on success when + * GhosttyClipboardRead::can_remember is set. + */ + bool remember; +} GhosttyClipboardReadReply; + +typedef struct GhosttyClipboardRead GhosttyClipboardRead; /** - * Callback function type for clipboard_write. + * Function type used to answer a clipboard read request. Obtained from + * GhosttyClipboardRead::reply; see that struct for the contract. + * + * @param read The request being answered + * @param reply The reply, borrowed only for the duration of this call + * + * @ingroup terminal + */ +typedef void (*GhosttyClipboardReadReplyFn)( + const GhosttyClipboardRead* read, + const GhosttyClipboardReadReply* reply); + +/** + * A synchronous request to read clipboard contents. + * + * This is a sized struct. The callback must only access fields present in the + * size reported by `size`. The request is borrowed and valid only for the + * callback duration. + * + * The read is answered by calling `reply` with this request and a + * GhosttyClipboardReadReply. This must happen before the callback returns; + * the request is invalid afterwards. Calling `reply` more than once is + * ignored. Returning without replying answers the program with an empty + * clipboard (OSC 52) or EPERM (OSC 5522). + * + * @ingroup terminal + */ +struct GhosttyClipboardRead { + /** Size of this struct in bytes. */ + size_t size; + + /** Clipboard to read. */ + GhosttyClipboardLocation location; + + /** + * Borrowed array of the MIME types the program wants, in order of + * preference. Protocols that only carry text (OSC 52) request + * "text/plain". NULL when mimes_len is zero. + */ + const GhosttyString* mimes; + + /** Number of entries in mimes. */ + size_t mimes_len; + + /** + * True if the program also wants the list of MIME types available on the + * clipboard, delivered through GhosttyClipboardReadReply::available. + */ + bool list; + + /** + * Name of the requesting program for permission prompts, if the protocol + * carries one. Empty otherwise. + */ + GhosttyString name; + + /** + * True if the terminal already holds a session grant for this request + * (kitty clipboard protocol passwords). The embedder should skip any + * permission prompt and serve the read. + * + * Always false when mimes_len is zero: such a request is served + * without a prompt (see the callback docs), so the terminal never + * consults grants for it and a one-time password is preserved for + * the follow-up data read. + */ + bool granted; + + /** + * True if the program supplied a session password, so the embedder may + * offer to remember the user's decision through + * GhosttyClipboardReadReply::remember. When false, remember is ignored. + */ + bool can_remember; + + /** Terminal-owned reply state. Do not access. */ + const void* ctx; + + /** Answer the read; see the struct documentation. */ + GhosttyClipboardReadReplyFn reply; +}; + +/** + * Callback function type for clipboard_read. + * + * Called synchronously when the running program requests clipboard contents + * via OSC 52 with a "?" payload or a Kitty clipboard (OSC 5522) read. + * Answering lets the program read the user's clipboard, so the embedder is + * expected to mediate consent. Because the read is synchronous, an embedder + * that needs to ask the user must block (for example by running a modal + * prompt) until it has an answer; the VT stream waits until the callback + * returns. + * + * Answer by calling `read->reply(read, &reply)` before returning. See + * GhosttyClipboardRead for the full contract. * - * Called synchronously for a complete logical clipboard write. Protocol - * details such as OSC 52 selectors, base64 encoding, multipart chunks, - * aliases, and terminators are normalized before this callback is invoked. - * OSC 52 and iTerm2 OSC 1337 Copy writes therefore use the same callback - * shape. OSC 52 clipboard read requests ("?") are always ignored and never - * forwarded to this callback. + * OSC 5522 requests carry the program's MIME list, name, and password grant + * state; a reply that sets `remember` records a session grant so later + * requests with the same password arrive with `granted` set. Kitty itself + * serves a request for only the targets listing (`list` with no `mimes`) + * without prompting, and embedders are expected to do the same; the + * terminal never consults grants for such requests (`granted` is false + * and one-time passwords are not consumed). + * + * Installing this callback also enables Kitty paste events (mode 5522): + * ghostty_terminal_paste() sends the program an event instead of the text, + * and the program's follow-up read arrives here with `granted` set since + * the user already pasted. See ghostty_terminal_paste(). * * @param terminal The terminal handle * @param userdata The userdata pointer set via GHOSTTY_TERMINAL_OPT_USERDATA - * @param write Borrowed atomic clipboard write request - * @return The result of attempting the clipboard write + * @param read Borrowed clipboard read request * * @ingroup terminal */ -typedef GhosttyClipboardWriteResult (*GhosttyTerminalClipboardWriteFn)( +typedef void (*GhosttyTerminalClipboardReadFn)( GhosttyTerminal terminal, void* userdata, - const GhosttyClipboardWrite* write); + const GhosttyClipboardRead* read); /** * A request to show a desktop notification. @@ -688,16 +968,18 @@ typedef GhosttyString (*GhosttyTerminalEnquiryFn)(GhosttyTerminal terminal, void* userdata); /** - * Callback function type for size queries (XTWINOPS). + * Callback function type for terminal size reports. * - * Called in response to XTWINOPS size queries (CSI 14/16/18 t). + * Called in response to XTWINOPS size queries (CSI 14/16/18 t) and when VT + * input enables in-band size reports (mode 2048). * Return true and fill *out_size with the current terminal geometry, - * or return false to silently ignore the query. + * or return false to suppress the report. * * @param terminal The terminal handle * @param userdata The userdata pointer set via GHOSTTY_TERMINAL_OPT_USERDATA * @param[out] out_size Pointer to store the terminal size information - * @return true if size was filled, false to ignore the query + * @return true if size was filled, false to suppress the XTWINOPS response or + * mode 2048 report * * @ingroup terminal */ @@ -749,9 +1031,9 @@ typedef void (*GhosttyTerminalPwdChangedFn)(GhosttyTerminal terminal, * Callback function type for write_pty. * * Called when the terminal needs to write data back to the pty, for - * example in response to a device status report or mode query. The - * data is only valid for the duration of the call; callers must copy - * it if it needs to persist. + * example in response to a device status report, mode query, or VT-driven + * mode 2048 enable. The data is only valid for the duration of the call; + * callers must copy it if it needs to persist. * * @param terminal The terminal handle * @param userdata The userdata pointer set via GHOSTTY_TERMINAL_OPT_USERDATA @@ -818,8 +1100,9 @@ typedef enum GHOSTTY_ENUM_TYPED { /** * Callback invoked when the terminal needs to write data back - * to the pty (e.g. in response to a DECRQM query or device - * status report). Set to NULL to ignore such sequences. + * to the pty (e.g. in response to a DECRQM query, device status + * report, or VT-driven mode 2048 enable). Set to NULL to ignore such + * sequences. * * Input type: GhosttyTerminalWritePtyFn */ @@ -1062,10 +1345,11 @@ typedef enum GHOSTTY_ENUM_TYPED { /** * Callback invoked when the running program performs a clipboard write. - * OSC 52 and iTerm2 OSC 1337 Copy writes are normalized to an atomic set - * of decoded MIME representations. Set to NULL to ignore clipboard writes. - * Clipboard read requests are always ignored; see - * GhosttyTerminalClipboardWriteFn. + * OSC 52, iTerm2 OSC 1337 Copy, and Kitty clipboard (OSC 5522) writes + * are normalized to an atomic set of decoded MIME representations. Set + * to NULL to ignore clipboard writes (Kitty clipboard writes are then + * refused with ENOSYS). Clipboard read requests are delivered to + * GHOSTTY_TERMINAL_OPT_CLIPBOARD_READ instead. * * Input type: GhosttyTerminalClipboardWriteFn */ @@ -1227,6 +1511,40 @@ typedef enum GHOSTTY_ENUM_TYPED { * Input type: GhosttyString* */ GHOSTTY_TERMINAL_OPT_TERMINFO_NAME = 37, + + /** + * Callback invoked when the running program requests clipboard contents + * via OSC 52 with a "?" payload or a Kitty clipboard (OSC 5522) read. The + * read is synchronous and must be answered before the callback returns. + * Set to NULL (the default) to ignore OSC 52 read requests and refuse + * OSC 5522 reads with EPERM. + * + * Input type: GhosttyTerminalClipboardReadFn + */ + GHOSTTY_TERMINAL_OPT_CLIPBOARD_READ = 38, + + /** + * Set the maximum total decoded bytes a single Kitty clipboard protocol + * (OSC 5522) write transaction may accumulate. The limit is captured + * when a transaction begins; an in-flight transaction keeps the limit + * it started with. + * + * Data beyond the limit fails the whole transaction with EFBIG. The + * transaction is discarded, later write-related packets are ignored + * until a new write begins, and nothing reaches the clipboard write + * callback. + * + * Transactions are buffered in memory, so this limit bounds how much + * memory a single write can make the terminal allocate. Pass SIZE_MAX + * to remove the limit. A NULL value pointer reverts to the built-in + * default of 64MiB, the minimum required by the protocol. + * + * This limit doesn't apply to OSC 52 writes, which are bounded by the + * maximum length of an escape sequence instead. + * + * Input type: size_t* + */ + GHOSTTY_TERMINAL_OPT_CLIPBOARD_WRITE_MAX_BYTES = 39, GHOSTTY_TERMINAL_OPT_MAX_VALUE = GHOSTTY_ENUM_MAX_VALUE, } GhosttyTerminalOption; @@ -1624,6 +1942,15 @@ typedef enum GHOSTTY_ENUM_TYPED { * Output type: bool * */ GHOSTTY_TERMINAL_DATA_CURSOR_AT_PROMPT = 39, + + /** + * The configured maximum decoded bytes per Kitty clipboard protocol + * (OSC 5522) write transaction. See + * GHOSTTY_TERMINAL_OPT_CLIPBOARD_WRITE_MAX_BYTES. + * + * Output type: size_t * + */ + GHOSTTY_TERMINAL_DATA_CLIPBOARD_WRITE_MAX_BYTES = 40, GHOSTTY_TERMINAL_DATA_MAX_VALUE = GHOSTTY_ENUM_MAX_VALUE, } GhosttyTerminalData; diff --git a/vendor/libghostty-vt/include/ghostty/vt/types.h b/vendor/libghostty-vt/include/ghostty/vt/types.h index 672faa6..8c5f35f 100644 --- a/vendor/libghostty-vt/include/ghostty/vt/types.h +++ b/vendor/libghostty-vt/include/ghostty/vt/types.h @@ -39,18 +39,21 @@ * The Zig side backs all C enums with c_int, so the C declarations * must use int as their underlying type to maintain ABI compatibility. * - * C23 (detected via __STDC_VERSION__ >= 202311L) supports explicit - * enum underlying types with `enum : int { ... }`. For pre-C23 - * compilers, which are free to choose any type that can represent - * all values (C11 §6.7.2.2), we add an INT_MAX sentinel as the last - * entry to force the compiler to use int. + * C++11 and C23 support explicit enum underlying types with + * `enum : int { ... }`. Clang and GCC 13+ also support this syntax as + * an extension in older C language modes, so use it when available. + * + * Other pre-C23 C compilers are free to choose any type that can + * represent all values (C11 §6.7.2.2). For those compilers, we add an + * INT_MAX sentinel as the last entry so the compatible type must be + * able to represent INT_MAX. The exact compatible type and its + * signedness remain implementation-defined in this fallback. * * INT_MAX is used rather than a fixed constant like 0xFFFFFFFF - * because enum constants must have type int (which is signed). - * Values above INT_MAX overflow signed int and are a constraint - * violation in standard C; compilers that accept them interpret them - * as negative values via two's complement, which can collide with - * legitimate negative enum values. + * because enum constants must have type int in pre-C23 C. Values above + * INT_MAX are a constraint violation there; compilers that accept them + * may interpret them as negative values via two's complement, which can + * collide with legitimate negative enum values. * * Usage: * @code @@ -61,7 +64,18 @@ * } Foo; * @endcode */ -#if defined(__STDC_VERSION__) && __STDC_VERSION__ >= 202311L +#if defined(__cplusplus) && \ + (__cplusplus >= 201103L || (defined(_MSC_VER) && _MSC_VER >= 1700)) +#define GHOSTTY_ENUM_TYPED : int +#elif defined(__STDC_VERSION__) && __STDC_VERSION__ >= 202311L +#define GHOSTTY_ENUM_TYPED : int +#elif defined(__clang__) + #if __has_extension(c_fixed_enum) + #define GHOSTTY_ENUM_TYPED : int + #else + #define GHOSTTY_ENUM_TYPED + #endif +#elif defined(__GNUC__) && __GNUC__ >= 13 #define GHOSTTY_ENUM_TYPED : int #else #define GHOSTTY_ENUM_TYPED @@ -86,6 +100,12 @@ typedef enum GHOSTTY_ENUM_TYPED { GHOSTTY_IO_ERROR = -5, /** Operation failed because encoded input exceeded a configured limit */ GHOSTTY_LIMIT_EXCEEDED = -6, + /** + * Operation was rejected by a safety check (e.g. pasted text that could + * inject commands). Nothing was done. Confirm with the user and retry + * with the operation's allow flag set. + */ + GHOSTTY_REJECTED = -7, GHOSTTY_RESULT_MAX_VALUE = GHOSTTY_ENUM_MAX_VALUE, } GhosttyResult; @@ -169,6 +189,19 @@ typedef struct GhosttyRenderStateRowIteratorImpl* GhosttyRenderStateRowIterator; typedef struct GhosttyRenderStateRowCellsImpl* GhosttyRenderStateRowCells; /** + * Opaque handle to a terminal search. + * + * A search is bound to the terminal it was created with. It borrows the + * terminal, so it never frees it, and the search must be freed with + * ghostty_search_free(). If the terminal is freed first, the search + * detects this: calls that need the terminal fail cleanly and the + * search can still be freed. + * + * @ingroup search + */ +typedef struct GhosttySearchImpl* GhosttySearch; + +/** * Opaque handle to an SGR parser instance. * * This handle represents an SGR (Select Graphic Rendition) parser that can @@ -305,33 +338,64 @@ typedef struct { * opts.trim = true; * @endcode */ +#ifdef __cplusplus +#define GHOSTTY_INIT_SIZED(type) \ + ([]() noexcept { \ + type value{}; \ + value.size = sizeof(value); \ + return value; \ + }()) +#else #define GHOSTTY_INIT_SIZED(type) \ ((type){ .size = sizeof(type) }) +#endif /** - * Return a pointer to a null-terminated JSON string describing the - * layout of every C API struct for the current target. + * Return the versioned libghostty-vt C type manifest for the current target. + * + * The manifest defines all the public types available in the linked + * build. The types contain their layouts, enum values, union fields, and more. + * + * Language bindings, such as WebAssembly hosts, should obtain offsets, + * sizes, alignments, array shapes, enum constants, and tagged-union arms from + * this manifest rather than hardcoding them. Consumers should reject unknown + * schema versions and verify the descriptors they require at initialization. + * + * Packed type descriptors define fields using `lsb` and `width`. `lsb` is + * relative to bit zero of the containing numerical value; for nested packed + * layouts it is relative to the immediate containing field. Tagged packed + * unions select an inline arm layout using the named tag field. These layouts + * describe the current linked build and are not a cross-version stability + * promise. * - * This is primarily useful for language bindings that can't easily - * set C struct fields and need to do so via byte offsets. For example, - * WebAssembly modules can't share struct definitions with the host. + * The formal format is defined by the + * <a href="types.schema.json">libghostty-vt ABI manifest JSON Schema</a>. * * Example (abbreviated): * @code{.json} * { - * "GhosttyMouseEncoderSize": { - * "size": 40, - * "align": 8, - * "fields": { - * "size": { "offset": 0, "size": 8, "type": "u64" }, - * "screen_width": { "offset": 8, "size": 4, "type": "u32" }, - * "screen_height": { "offset": 12, "size": 4, "type": "u32" }, - * "cell_width": { "offset": 16, "size": 4, "type": "u32" }, - * "cell_height": { "offset": 20, "size": 4, "type": "u32" }, - * "padding_top": { "offset": 24, "size": 4, "type": "u32" }, - * "padding_bottom": { "offset": 28, "size": 4, "type": "u32" }, - * "padding_right": { "offset": 32, "size": 4, "type": "u32" }, - * "padding_left": { "offset": 36, "size": 4, "type": "u32" } + * "schema": 1, + * "abi": { + * "target": "wasm32", "os": "freestanding", "environment": "none", + * "pointer_size": 4, "usize_size": 4, "max_alignment": 16, + * "endian": "little" + * }, + * "types": { + * "GhosttyRenderStateData": { + * "kind": "enum", "size": 4, "align": 4, + * "underlying": "i32", "prefix": "GHOSTTY_RENDER_STATE_DATA_", + * "values": { "INVALID": 0, "DIRTY": 3, "MAX_VALUE": 2147483647 } + * }, + * "GhosttyStyleColor": { + * "kind": "struct", "size": 16, "align": 8, + * "fields": { + * "tag": { "offset": 0, "size": 4, + * "type": "GhosttyStyleColorTag" }, + * "value": { "offset": 8, "size": 8, + * "type": "GhosttyStyleColorValue", "tag": "tag", + * "arms": { "NONE": null, "PALETTE": "palette", + * "RGB": "rgb" } } + * } * } * } * } diff --git a/vendor/libghostty-vt/include/ghostty/vt/wasm.h b/vendor/libghostty-vt/include/ghostty/vt/wasm.h index e2b63e2..2f9ff5d 100644 --- a/vendor/libghostty-vt/include/ghostty/vt/wasm.h +++ b/vendor/libghostty-vt/include/ghostty/vt/wasm.h @@ -10,12 +10,12 @@ #ifdef __wasm__ #include <stddef.h> -#include <stdint.h> #include <ghostty/vt/types.h> /** @defgroup wasm WebAssembly Utilities * - * Convenience functions for allocating various types in WebAssembly builds. + * Convenience functions for working with the low-level C ABI in WebAssembly + * builds. * **These are only available the libghostty-vt wasm module.** * * Ghostty relies on pointers to various types for ABI compatibility, and @@ -28,37 +28,64 @@ * your custom allocator. This is a very rare use case in the WebAssembly * world so these are optimized for simplicity. * + * Use ghostty_wasm_alloc() and ghostty_wasm_free() for host-owned scratch + * buffers and ABI values. Dynamic-language hosts can use ghostty_type_json() + * to discover pointer and size_t widths, maximum alignment, byte order, and + * the size and alignment of public C structs. Do not mix allocation families: + * buffers returned by libghostty-vt allocating APIs must still be released + * with ghostty_free(), and opaque handles must be released with their + * type-specific destructor. + * + * ## Memory growth + * + * An exported function may grow Wasm linear memory when it allocates. Numeric + * pointers and handles remain valid, but JavaScript ArrayBuffer, DataView, and + * typed-array objects created before the growth may no longer cover the live + * memory. Reacquire `exports.memory.buffer` immediately before every host-side + * memory access. A host that caches views should recreate them whenever either + * the buffer identity or its byte length changes. + * * ## Example Usage * - * Here's a simple example of using the Wasm utilities with the key encoder: + * Here's a simple example that creates a terminal, writes bytes, and safely + * handles memory growth: * * @code * const { exports } = wasmInstance; - * const view = new DataView(wasmMemory.buffer); + * const memory = exports.memory; + * let cachedBuffer = null; + * let cachedLength = 0; + * let cachedBytes = null; * - * // Create key encoder - * const encoderPtr = exports.ghostty_wasm_alloc_opaque(); - * exports.ghostty_key_encoder_new(null, encoderPtr); - * const encoder = view.getUint32(encoder, true); + * function bytes() { + * const buffer = memory.buffer; + * if (buffer !== cachedBuffer || buffer.byteLength !== cachedLength) { + * cachedBuffer = buffer; + * cachedLength = buffer.byteLength; + * cachedBytes = new Uint8Array(buffer); + * } + * return cachedBytes; + * } * - * // Configure encoder with Kitty protocol flags - * const flagsPtr = exports.ghostty_wasm_alloc_u8(); - * view.setUint8(flagsPtr, 0x1F); - * exports.ghostty_key_encoder_setopt(encoder, 5, flagsPtr); + * function check(result) { + * if (result !== 0) throw new Error(`libghostty-vt error: ${result}`); + * } * - * // Allocate output buffer and size pointer - * const bufferSize = 32; - * const bufPtr = exports.ghostty_wasm_alloc_u8_array(bufferSize); - * const writtenPtr = exports.ghostty_wasm_alloc_usize(); + * // One slot can be reused for every opaque-handle constructor. + * const slot = exports.ghostty_wasm_alloc_opaque(); + * if (slot === 0) throw new Error("out of memory"); + * check(exports.ghostty_terminal_new(0, slot, 80, 24)); + * const terminal = exports.ghostty_wasm_take_opaque(slot); * - * // Encode the key event - * exports.ghostty_key_encoder_encode( - * encoder, eventPtr, bufPtr, bufferSize, writtenPtr - * ); + * const input = new TextEncoder().encode("Hello, world!"); + * const inputPtr = exports.ghostty_wasm_alloc(input.length); + * if (inputPtr === 0) throw new Error("out of memory"); + * bytes().set(input, inputPtr); // Acquires the current memory after alloc. + * exports.ghostty_terminal_vt_write(terminal, inputPtr, input.length); * - * // Read encoded output - * const bytesWritten = view.getUint32(writtenPtr, true); - * const encoded = new Uint8Array(wasmMemory.buffer, bufPtr, bytesWritten); + * exports.ghostty_wasm_free(inputPtr, input.length); + * exports.ghostty_terminal_free(terminal); + * exports.ghostty_wasm_free_opaque(slot); * @endcode * * @remark The code above is pretty ugly! This is the lowest level interface @@ -69,89 +96,62 @@ */ /** - * Allocate an opaque pointer. This can be used for any opaque pointer - * types such as GhosttyKeyEncoder, GhosttyKeyEvent, etc. - * - * @return Pointer to allocated opaque pointer, or NULL if allocation failed - * @ingroup wasm - */ -GHOSTTY_API void** ghostty_wasm_alloc_opaque(void); - -/** - * Free an opaque pointer allocated by ghostty_wasm_alloc_opaque(). - * - * @param ptr Pointer to free, or NULL (NULL is safely ignored) - * @ingroup wasm - */ -GHOSTTY_API void ghostty_wasm_free_opaque(void **ptr); - -/** - * Allocate an array of uint8_t values. + * Allocate caller-owned storage for a Wasm ABI value or scratch buffer. * - * @param len Number of uint8_t elements to allocate - * @return Pointer to allocated array, or NULL if allocation failed - * @ingroup wasm - */ -GHOSTTY_API uint8_t* ghostty_wasm_alloc_u8_array(size_t len); - -/** - * Free an array allocated by ghostty_wasm_alloc_u8_array(). + * The returned address is aligned to the target's maximum C ABI alignment, + * reported as `abi.max_alignment` by ghostty_type_json(). The memory is + * uninitialized. A zero-length request returns NULL. * - * @param ptr Pointer to the array to free, or NULL (NULL is safely ignored) - * @param len Length of the array (must match the length passed to alloc) - * @ingroup wasm - */ -GHOSTTY_API void ghostty_wasm_free_u8_array(uint8_t *ptr, size_t len); - -/** - * Allocate an array of uint16_t values. + * The returned pointer must be released with ghostty_wasm_free() using the + * exact same length. * - * @param len Number of uint16_t elements to allocate - * @return Pointer to allocated array, or NULL if allocation failed + * @param len Number of bytes to allocate + * @return Pointer to allocated storage, or NULL if len is zero or allocation + * failed * @ingroup wasm */ -GHOSTTY_API uint16_t* ghostty_wasm_alloc_u16_array(size_t len); +GHOSTTY_API void* ghostty_wasm_alloc(size_t len); /** - * Free an array allocated by ghostty_wasm_alloc_u16_array(). + * Free storage allocated by ghostty_wasm_alloc(). * - * @param ptr Pointer to the array to free, or NULL (NULL is safely ignored) - * @param len Length of the array (must match the length passed to alloc) + * @param ptr Pointer to free, or NULL (NULL is safely ignored) + * @param len Original allocation length passed to ghostty_wasm_alloc() * @ingroup wasm */ -GHOSTTY_API void ghostty_wasm_free_u16_array(uint16_t *ptr, size_t len); +GHOSTTY_API void ghostty_wasm_free(void *ptr, size_t len); /** - * Allocate a single uint8_t value. + * Allocate an opaque pointer. This can be used for any opaque pointer + * types such as GhosttyKeyEncoder, GhosttyKeyEvent, etc. The allocated slot + * is initialized to NULL and may be reused across constructors. * - * @return Pointer to allocated uint8_t, or NULL if allocation failed + * @return Pointer to allocated opaque pointer, or NULL if allocation failed * @ingroup wasm */ -GHOSTTY_API uint8_t* ghostty_wasm_alloc_u8(void); +GHOSTTY_API void** ghostty_wasm_alloc_opaque(void); /** - * Free a uint8_t allocated by ghostty_wasm_alloc_u8(). + * Free an opaque pointer allocated by ghostty_wasm_alloc_opaque(). * * @param ptr Pointer to free, or NULL (NULL is safely ignored) * @ingroup wasm */ -GHOSTTY_API void ghostty_wasm_free_u8(uint8_t *ptr); +GHOSTTY_API void ghostty_wasm_free_opaque(void **ptr); /** - * Allocate a single size_t value. + * Take an opaque handle from an out-parameter slot. * - * @return Pointer to allocated size_t, or NULL if allocation failed - * @ingroup wasm - */ -GHOSTTY_API size_t* ghostty_wasm_alloc_usize(void); - -/** - * Free a size_t allocated by ghostty_wasm_alloc_usize(). + * Returns the handle currently stored in @p slot and resets the slot to NULL. + * This function does not allocate, free the returned handle, or free the slot. + * Always check the GhosttyResult returned by the function that populated the + * slot before calling this function. * - * @param ptr Pointer to free, or NULL (NULL is safely ignored) + * @param slot Pointer to an opaque out-parameter slot, or NULL + * @return Stored opaque handle, or NULL if slot or its value is NULL * @ingroup wasm */ -GHOSTTY_API void ghostty_wasm_free_usize(size_t *ptr); +GHOSTTY_API void* ghostty_wasm_take_opaque(void **slot); /** @} */ diff --git a/vendor/libghostty-vt/nix/build-support/update-mirror.nu b/vendor/libghostty-vt/nix/build-support/update-mirror.nu index b234172..974e9d0 100755 --- a/vendor/libghostty-vt/nix/build-support/update-mirror.nu +++ b/vendor/libghostty-vt/nix/build-support/update-mirror.nu @@ -5,12 +5,43 @@ # directory, and updates the build.zig.zon files (the root one as well as # every pkg/*/build.zig.zon) to point to the new mirror URLs. # -# The downloaded files are unmodified so their checksums and content hashes -# will match the originals. +# HTTP files are downloaded unmodified. Git dependencies use Zig's normalized +# cache archive so their content hashes match the originals. # # After running this script, the files in the output directory can be uploaded # to blob storage, and the build.zig.zon files will already be updated with # the new URLs. +def download-git-package [url: string, expected_hash: string, destination: string] { + let cache_dir = (mktemp --directory) + let result = (do { ^zig fetch --global-cache-dir $cache_dir $url } | complete) + + if $result.exit_code != 0 { + rm --recursive $cache_dir + error make { + msg: $"zig fetch failed for ($url): ($result.stderr | str trim)" + } + } + + let actual_hash = ($result.stdout | str trim) + if $actual_hash != $expected_hash { + rm --recursive $cache_dir + error make { + msg: $"zig fetch hash mismatch for ($url): expected ($expected_hash), got ($actual_hash)" + } + } + + let archive = ($cache_dir | path join "p" $"($actual_hash).tar.gz") + if not ($archive | path exists) { + rm --recursive $cache_dir + error make { + msg: $"zig fetch did not produce a cache archive for ($url)" + } + } + + cp $archive $destination + rm --recursive $cache_dir +} + def main [ --output: string = "tmp-mirror", # Output directory for the mirrored files --prefix: string = "https://deps.files.ghostty.org/", # Final URL prefix to ignore @@ -45,8 +76,11 @@ def main [ let name = $entry.value.name let url = $entry.value.url - # Skip URLs that don't start with http(s) - if not ($url | str starts-with "http") { + let is_git_url = ($url | str starts-with "git+http") + let is_http_url = ($url | str starts-with "http") + + # Skip URLs that aren't HTTP downloads or HTTP-backed Git repositories. + if not ($is_http_url or $is_git_url) { continue } @@ -55,8 +89,13 @@ def main [ continue } - # Extract the file extension from the URL - let extension = ($url | parse -r '(\.[a-z0-9]+(?:\.[a-z0-9]+)?)$' | get -o capture0.0 | default "") + # Git dependencies are mirrored using an archive of the normalized package + # tree produced by Zig. Fetching it over HTTP produces the same package hash. + let extension = if $is_git_url { + ".tar.gz" + } else { + $url | parse -r '(\.[a-z0-9]+(?:\.[a-z0-9]+)?)$' | get -o capture0.0 | default "" + } # Try to extract commit hash (40 hex chars) from URL let commit_hash = ($url | parse -r '([a-f0-9]{40})' | get -o capture0.0 | default "") @@ -80,7 +119,12 @@ def main [ # Download the file if not $dry_run { - http get $url | save -f ($output_dir | path join $filename) + let destination = ($output_dir | path join $filename) + if $is_git_url { + download-git-package $url $key $destination + } else { + http get $url | save -f $destination + } } } diff --git a/vendor/libghostty-vt/nix/devShell.nix b/vendor/libghostty-vt/nix/devShell.nix index 609a8a3..fba128a 100644 --- a/vendor/libghostty-vt/nix/devShell.nix +++ b/vendor/libghostty-vt/nix/devShell.nix @@ -24,7 +24,11 @@ #, vulkan-loader # unused vttest, wabt, + wasm-tools, wasmtime, + binaryen, + twiggy, + wizer, wraptest, zig, zip, @@ -87,8 +91,10 @@ inherit pkgs lib stdenv; }; python = python3.withPackages (python-pkgs: [ + python-pkgs.jsonschema python-pkgs.kaitaistruct python-pkgs.ucs-detect + python-pkgs.wasmtime ]); in mkShell { @@ -127,8 +133,12 @@ in kaitai-struct-compiler # wasm + binaryen + twiggy wabt + wasm-tools wasmtime + wizer # Localization gettext diff --git a/vendor/libghostty-vt/pkg/afl++/build.zig b/vendor/libghostty-vt/pkg/afl++/build.zig index 9f6f70e..42e514a 100644 --- a/vendor/libghostty-vt/pkg/afl++/build.zig +++ b/vendor/libghostty-vt/pkg/afl++/build.zig @@ -9,11 +9,6 @@ pub fn addInstrumentedExe( b: *std.Build, obj: *std.Build.Step.Compile, ) std.Build.LazyPath { - // Force the build system to produce the binary artifact even though we - // only consume the LLVM bitcode below. Without this, the dependency - // tracking doesn't wire up correctly. - _ = obj.getEmittedBin(); - const pkg = b.dependencyFromBuildZig( @This(), .{}, @@ -34,6 +29,25 @@ pub fn addInstrumentedExe( const fuzz_exe = afl_cc.addOutputFileArg(obj.name); afl_cc.addFileArg(pkg.path("afl.c")); afl_cc.addFileArg(obj.getEmittedLlvmBc()); + + // The LLVM bitcode only contains the Zig code in the compilation. + // C source files in the module graph are compiled to native objects + // that live only in the static archive, so link the archive after + // the bitcode to resolve those symbols. The archive members holding + // the Zig code are never pulled in (and so can't conflict) because + // the bitcode object already defines every symbol they provide. + // Those C objects are built with UBSan in debug modes and we link + // with an external compiler that doesn't provide Zig's ubsan + // runtime, so it must be bundled. The ubsan runtime uses f128 + // conversion builtins that the external compiler's runtime may not + // provide (e.g. Apple's), so Zig's compiler-rt must be bundled too. + // The archive members must also be built as PIC since external + // compilers typically default to PIE executables. + obj.bundle_ubsan_rt = true; + obj.bundle_compiler_rt = true; + obj.root_module.pic = true; + afl_cc.addFileArg(obj.getEmittedBin()); + return fuzz_exe; } diff --git a/vendor/libghostty-vt/pkg/wuffs/build.zig b/vendor/libghostty-vt/pkg/wuffs/build.zig index b90c7e9..2ae4558 100644 --- a/vendor/libghostty-vt/pkg/wuffs/build.zig +++ b/vendor/libghostty-vt/pkg/wuffs/build.zig @@ -41,7 +41,6 @@ pub fn build(b: *std.Build) !void { .root_source_file = b.path("src/main.zig"), .target = target, .optimize = optimize, - .link_libc = true, }); const unit_tests = b.addTest(.{ @@ -49,6 +48,9 @@ pub fn build(b: *std.Build) !void { .root_module = module, }); + // Windows always has a libc available. + const windows = target.result.os.tag == .windows; + translate: { const translate_c = b.lazyImport(@This(), "translate_c") orelse break :translate; const translate_c_dep = b.lazyDependency("translate_c", .{}) orelse break :translate; @@ -56,17 +58,26 @@ pub fn build(b: *std.Build) !void { .c_source_file = b.addWriteFiles().add("wuffs_c.h", &wuffs_c_source), .target = target, .optimize = optimize, - .libc_file = if (target.result.os.tag.isDarwin()) libc_file: { - switch (try @import("apple_sdk").pathsForTarget(b, target.result)) { - inline else => |paths| break :libc_file paths.libc, - } - } else null, + .link_libc = windows, }); + // Wuffs only needs stdlib.h and string.h from libc, and only for + // a handful of declarations. We provide minimal versions of these + // headers so that wuffs can be translated and compiled without + // libc, notably for freestanding targets (wasm32) but this also + // avoids requiring an Apple SDK for translate-c on macOS. + if (!windows) wuffs_c.addIncludePath(b.path("include")); + var flags: std.ArrayList([]const u8) = .empty; defer flags.deinit(b.allocator); try flags.append(b.allocator, "-DWUFFS_IMPLEMENTATION"); - if (target.result.abi == .msvc) { + + // Disable ubsan on Windows to avoid undefined __ubsan_handle_* + // references: Zig's ubsan runtime can't be bundled on Windows + // (its /exclude-symbols directives break the MSVC linker), so + // these handlers would go unresolved. This affects both the + // MSVC and GNU ABIs. + if (windows) { try flags.append(b.allocator, "-fno-sanitize=undefined"); try flags.append(b.allocator, "-fno-sanitize-trap=undefined"); } diff --git a/vendor/libghostty-vt/pkg/wuffs/build.zig.zon b/vendor/libghostty-vt/pkg/wuffs/build.zig.zon index 1397d89..3016da7 100644 --- a/vendor/libghostty-vt/pkg/wuffs/build.zig.zon +++ b/vendor/libghostty-vt/pkg/wuffs/build.zig.zon @@ -11,8 +11,8 @@ // google/wuffs .wuffs = .{ - .url = "https://deps.files.ghostty.org/wuffs-122037b39d577ec2db3fd7b2130e7b69ef6cc1807d68607a7c232c958315d381b5cd.tar.gz", - .hash = "N-V-__8AAAzZywE3s51XfsLbP9eyEw57ae9swYB9aGB6fCMs", + .url = "https://deps.files.ghostty.org/wuffs-7411f488fe2e2c205c3d3b3d28638b7356522930.tar.gz", + .hash = "N-V-__8AAP5JWgCGP_AD0teWpa4krRvE9VPZzvviGdbmN4jI", .lazy = true, }, @@ -22,12 +22,11 @@ .hash = "N-V-__8AADYiAAB_80AWnH1AxXC0tql9thT-R-DYO1gBqTLc", .lazy = true, }, - - .apple_sdk = .{ .path = "../apple-sdk" }, }, .paths = .{ "build.zig", "build.zig.zon", + "include", "src", }, } diff --git a/vendor/libghostty-vt/pkg/wuffs/include/stdlib.h b/vendor/libghostty-vt/pkg/wuffs/include/stdlib.h new file mode 100644 index 0000000..5c05074 --- /dev/null +++ b/vendor/libghostty-vt/pkg/wuffs/include/stdlib.h @@ -0,0 +1,13 @@ +// Minimal stdlib.h so that wuffs can be translated and compiled without +// requiring libc headers. See string.h in this directory for details. +#ifndef GHOSTTY_WUFFS_STDLIB_H +#define GHOSTTY_WUFFS_STDLIB_H + +#include <stddef.h> + +void *malloc(size_t size); +void *calloc(size_t count, size_t size); +void *realloc(void *ptr, size_t size); +void free(void *ptr); + +#endif diff --git a/vendor/libghostty-vt/pkg/wuffs/include/string.h b/vendor/libghostty-vt/pkg/wuffs/include/string.h new file mode 100644 index 0000000..39d405d --- /dev/null +++ b/vendor/libghostty-vt/pkg/wuffs/include/string.h @@ -0,0 +1,22 @@ +// Minimal string.h so that wuffs can be translated and compiled without +// requiring libc headers (e.g. for wasm32-freestanding targets, or to +// avoid needing an Apple SDK for translate-c on macOS). +// +// Wuffs only calls the mem* family, whose symbols are provided by Zig's +// compiler-rt on targets without libc and by libc everywhere else. The +// declarations here match the standard C ABI, so this header is safe to +// use on every target, including ones that do link libc. +#ifndef GHOSTTY_WUFFS_STRING_H +#define GHOSTTY_WUFFS_STRING_H + +#include <stddef.h> + +void *memcpy(void *dst, const void *src, size_t n); +void *memmove(void *dst, const void *src, size_t n); +void *memset(void *b, int c, size_t n); +int memcmp(const void *s1, const void *s2, size_t n); +size_t strlen(const char *s); +int strcmp(const char *s1, const char *s2); +int strncmp(const char *s1, const char *s2, size_t n); + +#endif diff --git a/vendor/libghostty-vt/pkg/wuffs/src/main.zig b/vendor/libghostty-vt/pkg/wuffs/src/main.zig index b4519a9..ec9f8da 100644 --- a/vendor/libghostty-vt/pkg/wuffs/src/main.zig +++ b/vendor/libghostty-vt/pkg/wuffs/src/main.zig @@ -16,6 +16,38 @@ pub const ImageData = struct { data: []u8, }; +// Wuffs' generated `wuffs_foo__bar__alloc()` convenience functions are the +// only code that references libc's calloc/free. We never call them +// and linker garbage collection strips them, but that isn't guaranteed. +// When libc isn't linked there would be nothing to provide calloc/free if +// they survive, so export stubs to satisfy the link. Weak so that any real +// definition wins. Hidden keeps them out of the export table where the +// format honors it (e.g. wasm). +comptime { + if (!builtin.link_libc) { + @export(&callocStub, .{ + .name = "calloc", + .linkage = .weak, + .visibility = .hidden, + }); + @export(&freeStub, .{ + .name = "free", + .linkage = .weak, + .visibility = .hidden, + }); + } +} + +fn callocStub(count: usize, size: usize) callconv(.c) ?*anyopaque { + _ = count; + _ = size; + return null; +} + +fn freeStub(ptr: ?*anyopaque) callconv(.c) void { + _ = ptr; +} + test { refAllDeclsRecursive(@This()); } diff --git a/vendor/libghostty-vt/pkg/wuffs/src/swizzle.zig b/vendor/libghostty-vt/pkg/wuffs/src/swizzle.zig index 9e7ffa5..d873efe 100644 --- a/vendor/libghostty-vt/pkg/wuffs/src/swizzle.zig +++ b/vendor/libghostty-vt/pkg/wuffs/src/swizzle.zig @@ -16,11 +16,15 @@ pub fn gToRgba(alloc: Allocator, src: []const u8) Error![]u8 { } pub fn gaToRgba(alloc: Allocator, src: []const u8) Error![]u8 { + // Wuffs doesn't support YA_PREMUL as a swizzle source. The nonpremul + // pair produces the same bytes (r=g=b=y, a=a, no alpha math), which + // is what we want: alpha semantics are preserved as-is, matching the + // other conversions here. return swizzle( alloc, src, - c.WUFFS_BASE__PIXEL_FORMAT__YA_PREMUL, - c.WUFFS_BASE__PIXEL_FORMAT__RGBA_PREMUL, + c.WUFFS_BASE__PIXEL_FORMAT__YA_NONPREMUL, + c.WUFFS_BASE__PIXEL_FORMAT__RGBA_NONPREMUL, ); } @@ -51,6 +55,69 @@ pub fn bgraToRgba(alloc: Allocator, src: []const u8) Error![]u8 { ); } +/// Composite `src` over `dst` in place. Both are straight +/// (non-premultiplied) alpha RGBA of the same length. A transparent +/// destination pixel takes the source pixel exactly; wuffs composites +/// everything else in 16-bit integer space. +pub fn rgbaSrcOver(dst: []u8, src: []const u8) void { + assert(dst.len == src.len); + assert(dst.len % 4 == 0); + + var swizzler: c.wuffs_base__pixel_swizzler = undefined; + const status = c.wuffs_base__pixel_swizzler__prepare( + &swizzler, + c.wuffs_base__make_pixel_format(c.WUFFS_BASE__PIXEL_FORMAT__RGBA_NONPREMUL), + c.wuffs_base__empty_slice_u8(), + c.wuffs_base__make_pixel_format(c.WUFFS_BASE__PIXEL_FORMAT__RGBA_NONPREMUL), + c.wuffs_base__empty_slice_u8(), + c.WUFFS_BASE__PIXEL_BLEND__SRC_OVER, + ); + // This format pair and blend mode is a supported swizzle, so + // preparation can only fail on a programming error. + assert(c.wuffs_base__status__is_ok(&status)); + + _ = c.wuffs_base__pixel_swizzler__swizzle_interleaved_from_slice( + &swizzler, + c.wuffs_base__make_slice_u8(dst.ptr, dst.len), + c.wuffs_base__empty_slice_u8(), + c.wuffs_base__make_slice_u8(@constCast(src.ptr), src.len), + ); +} + +test "gaToRgba" { + const rgba = try gaToRgba(std.testing.allocator, &.{ 7, 100, 8, 200 }); + defer std.testing.allocator.free(rgba); + + try std.testing.expectEqualSlices(u8, &.{ + 7, 7, 7, 100, + 8, 8, 8, 200, + }, rgba); +} + +test "rgbaSrcOver" { + // 50% white over opaque black, opaque over anything, transparent + // source over anything, and anything over a transparent + // destination (exact source passthrough). + var dst = [_]u8{ + 0, 0, 0, 255, + 10, 20, 30, 40, + 10, 20, 30, 40, + 0, 0, 0, 0, + }; + rgbaSrcOver(&dst, &.{ + 255, 255, 255, 128, + 100, 110, 120, 255, + 100, 110, 120, 0, + 200, 100, 50, 128, + }); + try std.testing.expectEqualSlices(u8, &.{ + 128, 128, 128, 255, + 100, 110, 120, 255, + 10, 20, 30, 40, + 200, 100, 50, 128, + }, &dst); +} + fn swizzle( alloc: Allocator, src: []const u8, diff --git a/vendor/libghostty-vt/src/Surface.zig b/vendor/libghostty-vt/src/Surface.zig index 7ef2038..7752921 100644 --- a/vendor/libghostty-vt/src/Surface.zig +++ b/vendor/libghostty-vt/src/Surface.zig @@ -22,6 +22,7 @@ const Allocator = std.mem.Allocator; const ArenaAllocator = std.heap.ArenaAllocator; const global = @import("global.zig"); const oni = @import("oniguruma"); +const simd = @import("simd/main.zig"); const crash = @import("crash/main.zig"); const unicode = @import("unicode/main.zig"); const rendererpkg = @import("renderer.zig"); @@ -1061,6 +1062,10 @@ pub fn handleMessage(self: *Surface, msg: Message) !void { _ = try self.startClipboardRequest(.standard, .{ .osc_52_read = clipboard }); }, + .kitty_clipboard_read => |req| try self.kittyClipboardRead(req), + + .kitty_clipboard_write => |req| try self.kittyClipboardWrite(req), + .clipboard_write => |w| switch (w.req) { .small => |v| try self.clipboardWrite(v.data[0..v.len], w.clipboard_type), .stable => |v| try self.clipboardWrite(v, w.clipboard_type), @@ -2190,34 +2195,18 @@ fn clipboardWrite(self: *const Surface, data: []const u8, loc: apprt.Clipboard) return; } - const dec = std.base64.standard.Decoder; - - // Build buffer - const size = dec.calcSizeForSlice(data) catch |err| switch (err) { - error.InvalidPadding => { - log.info("application sent invalid base64 data for OSC 52", .{}); - return; - }, - - // Should not be reachable but don't want to risk it. - else => return, - }; - var buf = try self.alloc.allocSentinel(u8, size, 0); + // Decode with the SIMD decoder, strict per the Kitty clipboard + // spec that also governs OSC 52 base64 handling: a request with + // characters outside the base64 alphabet is discarded entirely + // (never partially decoded), while a missing-padding tail is + // tolerated since OSC 52 has no way to report errors. + var buf = try self.alloc.allocSentinel(u8, simd.base64.maxLen(data), 0); defer self.alloc.free(buf); - buf[buf.len] = 0; - - // Decode - dec.decode(buf, data) catch |err| switch (err) { - // Ignore this. It is possible to actually have valid data and - // get this error, so we allow it. - error.InvalidPadding => {}, - - else => { - log.info("application sent invalid base64 data for OSC 52", .{}); - return; - }, + const decoded = simd.base64.decodeStrict(data, buf, .optional) catch { + log.info("application sent invalid base64 data for OSC 52", .{}); + return; }; - assert(buf[buf.len] == 0); + buf[decoded.len] = 0; // When clipboard-write is "ask" a prompt is displayed to the user asking // them to confirm the clipboard access. Each app runtime handles this @@ -2225,7 +2214,7 @@ fn clipboardWrite(self: *const Surface, data: []const u8, loc: apprt.Clipboard) const confirm = self.config.clipboard_write == .ask; self.rt_surface.setClipboard(loc, &.{.{ .mime = "text/plain", - .data = buf, + .data = buf[0..decoded.len :0], }}, confirm) catch |err| { log.err("error setting clipboard string err={}", .{err}); return; @@ -2257,13 +2246,14 @@ fn copySelectionToClipboards( const ScreenFormatter = terminal.formatter.ScreenFormatter; var aw: std.Io.Writer.Allocating = .init(alloc); - var contents: std.ArrayList(apprt.ClipboardContent) = .empty; + var contents_buf: [2]apprt.ClipboardContent = undefined; + var contents: std.ArrayList(apprt.ClipboardContent) = .initBuffer(&contents_buf); switch (format) { .plain => { var formatter: ScreenFormatter = .init(self.io.terminal.screens.active, opts); formatter.content = .{ .selection = sel }; try formatter.format(&aw.writer); - try contents.append(alloc, .{ + contents.appendAssumeCapacity(.{ .mime = "text/plain", .data = try aw.toOwnedSliceSentinel(0), }); @@ -2280,7 +2270,7 @@ fn copySelectionToClipboards( // Note: We don't apply codepoint mappings to VT format since it contains // escape sequences that should be preserved as-is - try contents.append(alloc, .{ + contents.appendAssumeCapacity(.{ .mime = "text/plain", .data = try aw.toOwnedSliceSentinel(0), }); @@ -2297,7 +2287,7 @@ fn copySelectionToClipboards( // Note: We don't apply codepoint mappings to HTML format since HTML // has its own character encoding and entity system - try contents.append(alloc, .{ + contents.appendAssumeCapacity(.{ .mime = "text/html", .data = try aw.toOwnedSliceSentinel(0), }); @@ -2308,7 +2298,7 @@ fn copySelectionToClipboards( var formatter: ScreenFormatter = .init(self.io.terminal.screens.active, opts); formatter.content = .{ .selection = sel }; try formatter.format(&aw.writer); - try contents.append(alloc, .{ + contents.appendAssumeCapacity(.{ .mime = "text/plain", .data = try aw.toOwnedSliceSentinel(0), }); @@ -2331,7 +2321,7 @@ fn copySelectionToClipboards( try formatter.format(&aw.writer); // Note: We don't apply codepoint mappings to HTML format - try contents.append(alloc, .{ + contents.appendAssumeCapacity(.{ .mime = "text/html", .data = try aw.toOwnedSliceSentinel(0), }); @@ -2388,31 +2378,29 @@ fn setSelection(self: *Surface, sel_: ?terminal.Selection) !void { fn setSelectionAndCopy(self: *Surface, sel: terminal.Selection) !void { try self.setSelection(sel); - // If copy on select is false then exit early. - if (self.config.copy_on_select == .false) return; - switch (self.config.copy_on_select) { - .false => unreachable, // handled above with an early exit + .none => {}, - // Both standard and selection clipboards are set. + // The selection clipboard is set if supported, otherwise nothing is copied. + .primary => try self.copySelectionToClipboards( + sel, + &.{.selection}, + .mixed, + ), + + // Only the standard clipboard is set. .clipboard => try self.copySelectionToClipboards( sel, - &.{ .standard, .selection }, + &.{.standard}, .mixed, ), - // The selection clipboard is set if supported, otherwise the standard. - .true => { - const clipboard: apprt.Clipboard = if (self.rt_surface.supportsClipboard(.selection)) - .selection - else - .standard; - try self.copySelectionToClipboards( - sel, - &.{clipboard}, - .mixed, - ); - }, + // Both standard and selection clipboards are set. + .both => try self.copySelectionToClipboards( + sel, + &.{ .standard, .selection }, + .mixed, + ), } } @@ -3901,7 +3889,7 @@ pub fn mouseButtonCallback( // The selection clipboard is only updated for left-click drag when // the left button is released. This is to avoid the clipboard // being updated on every mouse move which would be noisy. - if (self.config.copy_on_select != .false) { + if (self.config.copy_on_select != .none) { const prev_ = self.io.terminal.screens.active.selection; if (prev_) |prev| { try self.setSelectionAndCopy(terminal.Selection.init( @@ -4059,22 +4047,16 @@ pub fn mouseButtonCallback( } } - // Middle-click paste source follows copy-on-select: when copy-on-select - // targets the selection clipboard, middle-click reads from it; when - // copy-on-select targets the system clipboard, middle-click reads from - // that instead. Falls back to the standard clipboard on platforms that - // do not support the selection clipboard. + // Middle-click action, either ignore, or paste from clipboard or paste from the selection clipboard if supported. if (button == .middle and action == .press) switch (self.config.middle_click_action) { .ignore => {}, + .@"clipboard-paste" => { + _ = try self.startClipboardRequest(.standard, .{ .paste = .standard }); + }, .@"primary-paste" => { - const clipboard: apprt.Clipboard = switch (self.config.copy_on_select) { - .clipboard => .standard, - .true, .false => if (self.rt_surface.supportsClipboard(.selection)) - .selection - else - .standard, - }; - _ = try self.startClipboardRequest(clipboard, .{ .paste = {} }); + if (self.rt_surface.supportsClipboard(.selection)) { + _ = try self.startClipboardRequest(.selection, .{ .paste = .selection }); + } }, }; @@ -4158,7 +4140,7 @@ pub fn mouseButtonCallback( // request so we need to unlock. self.renderer_state.mutex.unlock(global.io()); defer self.renderer_state.mutex.lockUncancelable(global.io()); - _ = try self.startClipboardRequest(.standard, .paste); + _ = try self.startClipboardRequest(.standard, .{ .paste = .standard }); // We don't need to clear selection because we didn't have // one to begin with. @@ -4173,7 +4155,7 @@ pub fn mouseButtonCallback( // request so we need to unlock. self.renderer_state.mutex.unlock(global.io()); defer self.renderer_state.mutex.lockUncancelable(global.io()); - _ = try self.startClipboardRequest(.standard, .paste); + _ = try self.startClipboardRequest(.standard, .{ .paste = .standard }); }, } @@ -4368,12 +4350,10 @@ fn linkAtPin( .semantic_prompt_boundary = true, }) orelse return null; - var strmap: terminal.StringMap = undefined; - self.alloc.free(try screen.selectionString(self.alloc, .{ + const strmap = try screen.selectionStringMap(self.alloc, .{ .sel = line, .trim = false, - .map = &strmap, - })); + }); defer strmap.deinit(self.alloc); for (self.config.links) |link| { @@ -4871,10 +4851,10 @@ pub fn performBindingAction(self: *Surface, action: input.Binding.Action) !bool }, .text => |data| { - // For text we always allocate just because its easier to - // handle all cases that way. - const buf = try self.alloc.alloc(u8, data.len); - defer self.alloc.free(buf); + var stack = std.heap.stackFallback(256, self.alloc); + const alloc = stack.get(); + const buf = try alloc.alloc(u8, data.len); + defer alloc.free(buf); const text = configpkg.string.parse(buf, data) catch |err| { log.warn( "error parsing text binding text={s} err={}", @@ -5108,15 +5088,15 @@ pub fn performBindingAction(self: *Surface, action: input.Binding.Action) !bool {}, ), - .paste_from_clipboard => return try self.startClipboardRequest( + .paste_from_clipboard => return (try self.startClipboardRequest( .standard, - .{ .paste = {} }, - ), + .{ .paste = .standard }, + )) == .started, - .paste_from_selection => return try self.startClipboardRequest( + .paste_from_selection => return (try self.startClipboardRequest( .selection, - .{ .paste = {} }, - ), + .{ .paste = .selection }, + )) == .started, .increase_font_size => |delta| { // Max delta is somewhat arbitrary. @@ -5846,69 +5826,289 @@ fn writeScreenFile( retain_tmp_dir = true; } +/// The payload for completing a clipboard request with +/// completeClipboardRequest. +pub const CompleteClipboard = struct { + /// The representations the apprt could serve for the request's MIME + /// types. These are immediately copied as needed so they only need + /// to live for the duration of the completion call. Requesters that + /// only carry text (paste, OSC 52) use the first text-like + /// representation. + contents: []const terminal.clipboard.Content = &.{}, + + /// The listing of MIME types available on the clipboard, only + /// gathered when the request asked for it. + available: []const []const u8 = &.{}, + + /// True if any clipboard confirmation prompt was already answered + /// by the user, skipping further prompts: + /// + /// - For "regular" pasting this means that unsafe pastes are + /// allowed. Unsafe data is defined as data that contains + /// newlines, though this definition may change later to detect + /// other scenarios. + /// + /// - For OSC 52 and Kitty clipboard protocol reads and writes no + /// prompt is shown to the user when this is true. + confirmed: bool = false, + + /// True if the user asked to remember their decision. This is only + /// honored by request types that support session grants (Kitty + /// clipboard protocol requests carrying a password). + remember: bool = false, +}; + /// Call this to complete a clipboard request sent to apprt. This should -/// only be called once for each request. The data is immediately copied so -/// it is safe to free the data after this call. -/// -/// If `confirmed` is true then any clipboard confirmation prompts are skipped: -/// -/// - For "regular" pasting this means that unsafe pastes are allowed. Unsafe -/// data is defined as data that contains newlines, though this definition -/// may change later to detect other scenarios. -/// -/// - For OSC 52 reads and writes no prompt is shown to the user if -/// `confirmed` is true. +/// only be called once for each request. /// -/// If `confirmed` is false then this may return either an UnsafePaste or -/// UnauthorizedPaste error, depending on the type of clipboard request. +/// If `complete.confirmed` is false then this may return either an +/// UnsafePaste or UnauthorizedPaste error, depending on the type of +/// clipboard request. The request state remains alive in that case so +/// the apprt can run its confirmation flow. pub fn completeClipboardRequest( self: *Surface, req: apprt.ClipboardRequest, - data: [:0]const u8, - confirmed: bool, + complete: CompleteClipboard, ) !void { switch (req) { - .paste => try self.completeClipboardPaste(data, confirmed), + .paste => try self.completeClipboardPaste( + clipboardTextContent(complete.contents) orelse "", + complete.confirmed, + ), + + .list => |clipboard| if (!try self.completeClipboardPasteEvent( + clipboard, + complete.available, + )) { + log.debug("mode 5522 paste event was not written", .{}); + }, .osc_52_read => |clipboard| try self.completeClipboardReadOSC52( - data, + clipboardTextContent(complete.contents) orelse "", clipboard, - confirmed, + complete.confirmed, ), - .osc_52_write => |clipboard| try self.rt_surface.setClipboard(clipboard, &.{.{ - .mime = "text/plain", - .data = data, - }}, !confirmed), + .osc_52_write => |clipboard| { + // The write API wants sentinel-terminated data; the write + // text round-tripped through the apprt confirmation flow as + // a plain representation. + const data = try self.alloc.dupeZ( + u8, + clipboardTextContent(complete.contents) orelse "", + ); + defer self.alloc.free(data); + try self.rt_surface.setClipboard(clipboard, &.{.{ + .mime = "text/plain", + .data = data, + }}, !complete.confirmed); + }, + + .kitty_read => |kitty| { + // If we need confirmation we return an error without + // consuming the request state; the apprt keeps it alive + // for the confirmation flow. A session grant carried by + // the request skips the prompt, and a request with no + // data types is exempt from prompting entirely; see + // readPromptExempt. + if (self.config.clipboard_read == .ask and + !complete.confirmed and + !kitty.granted and + !terminal.kitty.clipboard.readPromptExempt(kitty.mimes.len)) + { + return error.UnauthorizedPaste; + } + + // Past the confirmation check the request is consumed: + // every path from here, including errors, must destroy it. + defer kitty.destroy(); + + // Record a session grant when the user asked to remember + // their decision and the request carried a usable + // password. The grants live with the terminal state on + // the IO thread. + if (complete.remember and kitty.pw.len > 0) { + const pw = try self.alloc.dupe(u8, kitty.pw); + self.queueIo(.{ .kitty_clipboard_grant_read = .{ + .alloc = self.alloc, + .pw = pw, + } }, .unlocked); + } + + try self.completeKittyClipboardRead( + kitty, + complete.contents, + complete.available, + ); + }, + + .kitty_write => |kitty| { + // If we need confirmation we return an error without + // consuming the request state; the apprt keeps it alive + // for the confirmation flow. A session grant carried by + // the request skips the prompt. + if (self.config.clipboard_write == .ask and + !complete.confirmed and + !kitty.granted) + { + return error.UnauthorizedPaste; + } + + // Past the confirmation check the request is consumed: + // every path from here, including errors, must destroy it. + defer kitty.destroy(); + + // Record a session grant when the user asked to remember + // their decision and the request carried a usable + // password. The grants live with the terminal state on + // the IO thread. + if (complete.remember and kitty.pw.len > 0) { + const pw = try self.alloc.dupe(u8, kitty.pw); + self.queueIo(.{ .kitty_clipboard_grant_write = .{ + .alloc = self.alloc, + .pw = pw, + } }, .unlocked); + } + + // Apply the committed representations carried by the + // request itself; any contents echoed back by the apprt + // are only what its confirmation prompt displayed. An + // empty commit clears the clipboard, which the apprt + // write API expresses as a single empty text entry. + self.rt_surface.setClipboard( + kitty.location, + if (kitty.contents.len > 0) kitty.contents else &.{.{ + .mime = "text/plain", + .data = "", + }}, + false, + ) catch |err| { + log.err("error setting clipboard err={}", .{err}); + try self.kittyClipboardStatus( + .write, + kitty.id, + kitty.terminator, + .EIO, + ); + return; + }; + + try self.kittyClipboardStatus( + .write, + kitty.id, + kitty.terminator, + .DONE, + ); + }, + } +} + +/// The first text-like representation of the contents, if any. +fn clipboardTextContent(contents: []const terminal.clipboard.Content) ?[]const u8 { + for (contents) |content| { + if (terminal.clipboard.isTextMime(content.mime)) return content.data; + } + return null; +} + +/// Deny an in-flight clipboard request. This consumes the request: for +/// request types whose protocol expects an answer, the denial reply is +/// written to the pty. +pub fn denyClipboardRequest(self: *Surface, req: apprt.ClipboardRequest) void { + switch (req) { + // A denied paste simply doesn't happen. + .paste, .list => {}, + + // OSC 52 has no error responses, but the client is waiting on + // a reply, so a denied read is answered with empty contents. + .osc_52_read => |clipboard| self.completeClipboardReadOSC52( + "", + clipboard, + true, + ) catch |err| { + log.warn("error replying to OSC 52 clipboard read err={}", .{err}); + }, + + // A denied write simply doesn't happen. + .osc_52_write => {}, + + // The Kitty clipboard protocol reports denial explicitly. + .kitty_read => |kitty| { + defer kitty.destroy(); + self.kittyClipboardStatus( + .read, + kitty.id, + kitty.terminator, + .EPERM, + ) catch |err| { + log.warn("error replying to kitty clipboard read err={}", .{err}); + }; + }, + + .kitty_write => |kitty| { + defer kitty.destroy(); + self.kittyClipboardStatus( + .write, + kitty.id, + kitty.terminator, + .EPERM, + ) catch |err| { + log.warn("error replying to kitty clipboard write err={}", .{err}); + }; + }, } } /// This starts a clipboard request, with some basic validation. For example, /// an OSC 52 request is not actually requested if OSC 52 is disabled. /// -/// Returns true if the request was started, false if it was not (e.g., clipboard -/// doesn't contain text for paste requests). This allows performable keybinds -/// to pass through when the action cannot be performed. +/// The result reports whether the request was started; requests that +/// weren't started never complete. Callers own reacting to that, e.g. +/// performable paste keybinds pass through and Kitty clipboard reads +/// answer the program. fn startClipboardRequest( self: *Surface, loc: apprt.Clipboard, req: apprt.ClipboardRequest, -) !bool { - switch (req) { - .paste => {}, // always allowed +) !apprt.ClipboardReadResult { + const effective_req: apprt.ClipboardRequest = switch (req) { + .paste => |clipboard| effective: { + // Snapshot the mode before asking the apprt for clipboard data. + // Event pastes request only a MIME listing, while ordinary + // pastes request the text representation as before. + self.renderer_state.mutex.lockUncancelable(global.io()); + const event = self.io.terminal.modes.get(.kitty_paste_events); + self.renderer_state.mutex.unlock(global.io()); + + break :effective if (event) + .{ .list = clipboard } + else + req; + }, + else => req, + }; + + switch (effective_req) { + .paste, .list => {}, // always allowed .osc_52_read => if (self.config.clipboard_read == .deny) { log.info( "application attempted to read clipboard, but 'clipboard-read' is set to deny", .{}, ); - return false; + return .unsupported; }, - // No clipboard write code paths travel through this function + // The clipboard access policies were already applied by + // kittyClipboardRead and kittyClipboardWrite, which own + // replying on denial. + .kitty_read, .kitty_write => {}, + + // OSC 52 writes don't travel through this function; they go + // straight to the apprt setClipboard API. .osc_52_write => unreachable, } - return try self.rt_surface.clipboardRequest(loc, req); + return try self.rt_surface.clipboardRequest(loc, effective_req); } fn completeClipboardPaste( @@ -5990,6 +6190,63 @@ fn completeClipboardPaste( }; } +/// Send a Kitty clipboard-protocol paste event when mode 5522 is enabled. +/// The event only lists the available MIME types; it does not read any of +/// their data. The shared terminal paste implementation generates and records +/// the one-time password used by the program's follow-up OSC 5522 read. +fn completeClipboardPasteEvent( + self: *Surface, + clipboard: apprt.Clipboard, + available: []const []const u8, +) !bool { + if (self.readonly) return false; + + const kitty_clipboard = terminal.kitty.clipboard; + const location: terminal.clipboard.Location = switch (clipboard) { + .standard => .standard, + .selection => .selection, + .primary => .primary, + }; + + // The protocol implementation caps listings at this size too. Cap here + // so the temporary Content array stays on the stack. + var contents_buf: [kitty_clipboard.max_listing_mimes]terminal.clipboard.Content = undefined; + const contents_len = @min(available.len, contents_buf.len); + for (available[0..contents_len], contents_buf[0..contents_len]) |mime, *content| { + content.* = .{ .mime = mime, .data = "" }; + } + + var aw: std.Io.Writer.Allocating = .init(self.alloc); + defer aw.deinit(); + + self.renderer_state.mutex.lockUncancelable(global.io()); + defer self.renderer_state.mutex.unlock(global.io()); + + const pasted = try terminal.paste.paste(.{ + .terminal = &self.io.terminal, + .alloc = self.alloc, + .writer = &aw.writer, + .kitty_clipboard = .{ + .grants = &self.io.terminal_stream.handler.kitty_clipboard_grants, + .io = global.io(), + }, + }, .{ + .source = .{ .clipboard = location }, + .contents = .{ .memory = contents_buf[0..contents_len] }, + // A paste event discloses no clipboard data, so unsafe-text + // confirmation does not apply. If mode 5522 is reset, the empty + // stand-in representations cause the shared helper to write nothing. + .allow_unsafe = true, + }); + if (!pasted) return false; + + self.queueIo(.{ .write_alloc = .{ + .alloc = self.alloc, + .data = try aw.toOwnedSlice(), + } }, .locked); + return true; +} + fn completeClipboardReadOSC52( self: *Surface, data: []const u8, @@ -6035,6 +6292,173 @@ fn completeClipboardReadOSC52( } }, .unlocked); } +/// Handle a Kitty clipboard protocol (OSC 5522) read request forwarded +/// by the IO thread. This takes ownership of the request state. +fn kittyClipboardRead( + self: *Surface, + req: *apprt.ClipboardRequest.KittyRead, +) !void { + // A read denied by policy answers EPERM so clients degrade + // gracefully instead of waiting on a response that never comes. + if (self.config.clipboard_read == .deny) { + defer req.destroy(); + log.info("application attempted to read clipboard, but 'clipboard-read' is set to deny", .{}); + try self.kittyClipboardStatus(.read, req.id, req.terminator, .EPERM); + return; + } + + const result = self.startClipboardRequest( + req.location, + .{ .kitty_read = req }, + ) catch |err| { + defer req.destroy(); + self.kittyClipboardStatus(.read, req.id, req.terminator, .EIO) catch {}; + return err; + }; + + switch (result) { + // The request completes asynchronously. + .started => {}, + + // The clipboard has nothing we can serve, which is a + // successful read that serves no representations. This never + // prompts even under an ask policy since there are no contents + // to disclose. + .unavailable => { + defer req.destroy(); + try self.completeKittyClipboardRead(req, &.{}, &.{}); + }, + + // The apprt can't serve this clipboard at all, e.g. an + // unsupported primary selection. + .unsupported => { + defer req.destroy(); + try self.kittyClipboardStatus(.read, req.id, req.terminator, .ENOSYS); + }, + } +} + +/// Handle a committed Kitty clipboard protocol (OSC 5522) write +/// transaction forwarded by the IO thread. This takes ownership of the +/// request state. +fn kittyClipboardWrite( + self: *Surface, + req: *apprt.ClipboardRequest.KittyWrite, +) !void { + // A write denied by policy answers EPERM so clients degrade + // gracefully instead of waiting on a response that never comes. + // The IO thread already fails transactions that begin under a + // deny policy, but the policy may have changed mid-transaction. + if (self.config.clipboard_write == .deny) { + defer req.destroy(); + log.info("application attempted to write clipboard, but 'clipboard-write' is set to deny", .{}); + try self.kittyClipboardStatus(.write, req.id, req.terminator, .EPERM); + return; + } + + const result = self.startClipboardRequest( + req.location, + .{ .kitty_write = req }, + ) catch |err| { + defer req.destroy(); + self.kittyClipboardStatus(.write, req.id, req.terminator, .EIO) catch {}; + return err; + }; + + switch (result) { + // The request completes asynchronously. + .started => {}, + + // The apprt can't write this clipboard at all, e.g. an + // unsupported primary selection. Writes carry their own + // contents so there is no meaningful unavailable state; treat + // it the same. + .unavailable, .unsupported => { + defer req.destroy(); + try self.kittyClipboardStatus(.write, req.id, req.terminator, .ENOSYS); + }, + } +} + +/// Reply to a Kitty clipboard request with a single status packet. +fn kittyClipboardStatus( + self: *Surface, + op: terminal.kitty.clipboard.Operation, + id: []const u8, + terminator: terminal.osc.Terminator, + status: terminal.kitty.clipboard.Status, +) error{ OutOfMemory, WriteFailed }!void { + var aw: std.Io.Writer.Allocating = .init(self.alloc); + defer aw.deinit(); + try (terminal.kitty.clipboard.Response{ + .op = op, + .status = status, + .id = id, + .terminator = terminator, + }).encode(&aw.writer); + + self.queueIo(.{ .write_alloc = .{ + .alloc = self.alloc, + .data = try aw.toOwnedSlice(), + } }, .unlocked); +} + +/// Complete a Kitty clipboard protocol read with the clipboard +/// contents. +fn completeKittyClipboardRead( + self: *Surface, + req: *const apprt.ClipboardRequest.KittyRead, + contents: []const terminal.clipboard.Content, + available: []const []const u8, +) !void { + const kitty_clipboard = terminal.kitty.clipboard; + + // Serve the requested representations in request order under their + // requested names. Text-like MIME aliases all match the canonical + // text representation, since that is the only name the apprt + // serves text under. Requested types without a representation are + // simply never served, which is how the protocol communicates an + // unavailable representation. + var contents_buf: [kitty_clipboard.max_read_mimes]terminal.clipboard.Content = undefined; + var contents_len: usize = 0; + for (req.mimes) |mime| { + const data: []const u8 = data: { + for (contents) |content| { + if (std.mem.eql(u8, content.mime, mime)) break :data content.data; + if (terminal.clipboard.isTextMime(mime) and + terminal.clipboard.isTextMime(content.mime)) + { + break :data content.data; + } + } + + continue; + }; + + contents_buf[contents_len] = .{ .mime = mime, .data = data }; + contents_len += 1; + } + + // Encode the full success sequence: the OK packet, the targets + // listing if it was requested, DATA chunks for each served + // representation, and the final DONE packet. + var aw: std.Io.Writer.Allocating = .init(self.alloc); + defer aw.deinit(); + try (kitty_clipboard.ReadSuccess{ + .primary = req.location == .primary, + .id = req.id, + .list = req.list, + .available = available, + .contents = contents_buf[0..contents_len], + .terminator = req.terminator, + }).encode(&aw.writer); + + self.queueIo(.{ .write_alloc = .{ + .alloc = self.alloc, + .data = try aw.toOwnedSlice(), + } }, .unlocked); +} + fn showDesktopNotification(self: *Surface, title: [:0]const u8, body: [:0]const u8) !void { // Wyhash is used to hash the contents of the desktop notification to limit // how fast identical notifications can be sent sequentially. diff --git a/vendor/libghostty-vt/src/apprt.zig b/vendor/libghostty-vt/src/apprt.zig index c467f18..22256cc 100644 --- a/vendor/libghostty-vt/src/apprt.zig +++ b/vendor/libghostty-vt/src/apprt.zig @@ -27,6 +27,7 @@ pub const Target = action.Target; pub const ContentScale = structs.ContentScale; pub const Clipboard = structs.Clipboard; pub const ClipboardContent = structs.ClipboardContent; +pub const ClipboardReadResult = structs.ClipboardReadResult; pub const ClipboardRequest = structs.ClipboardRequest; pub const ClipboardRequestType = structs.ClipboardRequestType; pub const ColorScheme = structs.ColorScheme; @@ -51,6 +52,15 @@ pub const runtime = switch (build_config.artifact) { pub const App = runtime.App; pub const Surface = runtime.Surface; +/// True if all GPU operations (drawing, resource creation and +/// destruction) must happen on the app thread. This is the case +/// when the graphics context is owned by the app thread (GTK's +/// GLArea). When false, the render thread performs GPU operations +/// itself. +pub const must_draw_from_app_thread = + @hasDecl(App, "must_draw_from_app_thread") and + App.must_draw_from_app_thread; + test { _ = Runtime; _ = runtime; diff --git a/vendor/libghostty-vt/src/apprt/embedded.zig b/vendor/libghostty-vt/src/apprt/embedded.zig index a380dc5..525bc85 100644 --- a/vendor/libghostty-vt/src/apprt/embedded.zig +++ b/vendor/libghostty-vt/src/apprt/embedded.zig @@ -52,18 +52,36 @@ pub const App = struct { /// Callback called to handle an action. action: *const fn (*App, apprt.Target.C, apprt.Action.C) callconv(.c) bool, - /// Read the clipboard value. Returns true if the clipboard request - /// was started and complete_clipboard_request may be called with the - /// given state pointer. Returns false if the clipboard request couldn't - /// be started (such as when no text is available for a paste request). - read_clipboard: *const fn (SurfaceUD, c_int, *apprt.ClipboardRequest) callconv(.c) bool, + /// Read the clipboard value. The result only reports facts about + /// the clipboard: whether the request was started (in which case + /// complete_clipboard_request or deny_clipboard_request must + /// eventually be called with the given state pointer), whether the + /// clipboard has no servable contents, or whether the clipboard + /// can't be read at all. How each non-started state is answered is + /// up to the core. + /// + /// The MIME types are exactly the representations the caller wants + /// served; the embedder should read only those. Text-like types + /// are always requested as the canonical "text/plain". The final + /// bool asks for the listing of all MIME types available on the + /// clipboard to be delivered with the completion. + read_clipboard: *const fn ( + SurfaceUD, + c_int, + *apprt.ClipboardRequest, + [*]const [*:0]const u8, + usize, + bool, + ) callconv(.c) apprt.ClipboardReadResult, /// This may be called after a read clipboard call to request - /// confirmation that the clipboard value is safe to read. The embedder - /// must call complete_clipboard_request with the given request. + /// confirmation that the clipboard value is safe to read. The + /// embedder must call complete_clipboard_request (usually with + /// the confirmation's contents, which are only borrowed for + /// this call) or deny_clipboard_request with the given request. confirm_read_clipboard: *const fn ( SurfaceUD, - [*:0]const u8, + *const CAPI.ClipboardConfirm, *apprt.ClipboardRequest, apprt.ClipboardRequestType, ) callconv(.c) void, @@ -684,7 +702,13 @@ pub const Surface = struct { ) bool { return switch (clipboard_type) { .standard => true, - .selection, .primary => self.app.opts.supports_selection_clipboard, + .selection => self.app.opts.supports_selection_clipboard, + + // No embedder can serve the primary selection: the embedding + // API has no option to declare support for it, and macOS (the + // only GUI embedder) has no primary selection. The selection + // flag above covers a distinct custom pasteboard, not this. + .primary => false, }; } @@ -692,7 +716,67 @@ pub const Surface = struct { self: *Surface, clipboard_type: apprt.Clipboard, state: apprt.ClipboardRequest, - ) !bool { + ) !apprt.ClipboardReadResult { + // Kitty clipboard writes carry their own contents and read + // nothing from the clipboard, so they skip the read callback + // entirely: the request is completed immediately, and a + // completion that requires confirmation diverts into the + // apprt confirmation flow just like a read. + if (state == .kitty_write) { + // A write to a location the embedder can't serve is + // rejected before the transaction completes so the caller + // answers ENOSYS without a permission prompt, matching + // reads, where the embedder reports an unsupported + // location from the read callback. The write callback only + // fires after any prompt, so it is too late to reject. + if (!self.supportsClipboard(clipboard_type)) return .unsupported; + + const alloc = self.app.core_app.alloc; + const state_ptr = try alloc.create(apprt.ClipboardRequest); + errdefer alloc.destroy(state_ptr); + state_ptr.* = state; + self.completeKittyClipboardWrite(state_ptr); + return .started; + } + + // The representations the read wants served. Text-only + // requesters ask for the canonical text type; Kitty clipboard + // reads ask for exactly what the program requested, with + // text-like aliases normalized so the embedder never has to + // know about them. + var mimes_buf: [terminal.kitty.clipboard.max_read_mimes][*:0]const u8 = undefined; + const mimes: []const [*:0]const u8 = switch (state) { + .paste, .osc_52_read => &.{"text/plain"}, + + // A mode 5522 paste event only lists types and must not read + // any clipboard data. + .list => &.{}, + + .kitty_read => |kitty| mimes: { + assert(kitty.mimes.len <= mimes_buf.len); + for (kitty.mimes, mimes_buf[0..kitty.mimes.len]) |mime, *dst| { + dst.* = if (terminal.clipboard.isTextMime(mime)) + "text/plain" + else + mime.ptr; + } + break :mimes mimes_buf[0..kitty.mimes.len]; + }, + + // Handled above. + .kitty_write => unreachable, + + // No clipboard write code paths travel through this function + .osc_52_write => unreachable, + }; + const list = switch (state) { + // Paste events need the full MIME listing without reading any + // representation. Kitty reads only ask for it when requested. + .list => true, + .kitty_read => |kitty| kitty.list, + else => false, + }; + // We need to allocate to get a pointer to store our clipboard request // so that it is stable until the read_clipboard callback and call // complete_clipboard_request. This sucks but clipboard requests aren't @@ -702,40 +786,156 @@ pub const Surface = struct { errdefer alloc.destroy(state_ptr); state_ptr.* = state; - const started = self.app.opts.read_clipboard( + const result = self.app.opts.read_clipboard( self.userdata, @intCast(@intFromEnum(clipboard_type)), state_ptr, + mimes.ptr, + mimes.len, + list, ); - if (!started) { - alloc.destroy(state_ptr); - return false; - } - return true; + // Only a started request completes later and keeps the state. + if (result != .started) alloc.destroy(state_ptr); + return result; } - fn completeClipboardRequest( + /// Complete a Kitty clipboard protocol write request. The apprt + /// contributes nothing to the completion (the authoritative + /// contents live in the request itself), but a completion that + /// requires confirmation diverts into the confirm callback, which + /// keeps the state alive for the asynchronous prompt. + fn completeKittyClipboardWrite( self: *Surface, - str: [:0]const u8, state: *apprt.ClipboardRequest, - confirmed: bool, ) void { const alloc = self.app.core_app.alloc; + const kitty = state.kitty_write; - // Attempt to complete the request, but we may request - // confirmation. self.core_surface.completeClipboardRequest( state.*, - str, - confirmed, + .{}, ) catch |err| switch (err) { + error.UnauthorizedPaste => { + // Convert the would-be contents so the permission + // prompt can display exactly what would be written. + var stack = std.heap.stackFallback(1024, alloc); + const conv_alloc = stack.get(); + const contents = conv_alloc.alloc( + CAPI.ClipboardContent, + kitty.contents.len, + ) catch |alloc_err| { + log.err("error confirming clipboard request err={}", .{alloc_err}); + self.core_surface.denyClipboardRequest(state.*); + alloc.destroy(state); + return; + }; + defer conv_alloc.free(contents); + for (kitty.contents, contents) |src, *dst| dst.* = .{ + .mime = src.mime, + .data = src.data.ptr, + .len = src.data.len, + }; + + self.app.opts.confirm_read_clipboard( + self.userdata, + &.{ + .contents = contents.ptr, + .contents_len = contents.len, + .available = null, + .available_len = 0, + .name = if (kitty.name.len > 0) kitty.name.ptr else null, + .can_remember = kitty.pw.len > 0, + }, + state, + state.*, + ); + + return; + }, + + else => log.err("error completing clipboard request err={}", .{err}), + }; + + // We don't defer this because the clipboard confirmation route + // preserves the clipboard request. + alloc.destroy(state); + } + + fn completeClipboardRequest( + self: *Surface, + complete: *const CAPI.ClipboardComplete, + state: *apprt.ClipboardRequest, + ) void { + const alloc = self.app.core_app.alloc; + + // Convert the C representations to the core types. Everything + // remains borrowed from the caller for the duration of the call. + var stack = std.heap.stackFallback(1024, alloc); + const conv_alloc = stack.get(); + + const raw_contents: []const CAPI.ClipboardContent = + if (complete.contents) |v| v[0..complete.contents_len] else &.{}; + const contents = conv_alloc.alloc( + terminal.clipboard.Content, + raw_contents.len, + ) catch |err| { + log.err("error completing clipboard request err={}", .{err}); + alloc.destroy(state); + return; + }; + defer conv_alloc.free(contents); + for (raw_contents, contents) |raw, *content| content.* = .{ + .mime = std.mem.sliceTo(raw.mime, 0), + .data = raw.data[0..raw.len], + }; + + const raw_available: []const [*:0]const u8 = + if (complete.available) |v| v[0..complete.available_len] else &.{}; + const available = conv_alloc.alloc( + []const u8, + raw_available.len, + ) catch |err| { + log.err("error completing clipboard request err={}", .{err}); + alloc.destroy(state); + return; + }; + defer conv_alloc.free(available); + for (raw_available, available) |raw, *mime| { + mime.* = std.mem.sliceTo(raw, 0); + } + + // Attempt to complete the request, but we may request + // confirmation. + self.core_surface.completeClipboardRequest(state.*, .{ + .contents = contents, + .available = available, + .confirmed = complete.confirmed, + .remember = complete.remember, + }) catch |err| switch (err) { error.UnsafePaste, error.UnauthorizedPaste, => { + // Session grant information for the permission prompt, + // carried only by Kitty clipboard protocol requests. + const name: ?[*:0]const u8, const can_remember: bool = switch (state.*) { + inline .kitty_read, .kitty_write => |kitty| .{ + if (kitty.name.len > 0) kitty.name.ptr else null, + kitty.pw.len > 0, + }, + else => .{ null, false }, + }; + self.app.opts.confirm_read_clipboard( self.userdata, - str.ptr, + &.{ + .contents = complete.contents, + .contents_len = complete.contents_len, + .available = complete.available, + .available_len = complete.available_len, + .name = name, + .can_remember = can_remember, + }, state, state.*, ); @@ -751,6 +951,14 @@ pub const Surface = struct { alloc.destroy(state); } + fn denyClipboardRequest( + self: *Surface, + state: *apprt.ClipboardRequest, + ) void { + self.core_surface.denyClipboardRequest(state.*); + self.app.core_app.alloc.destroy(state); + } + pub fn setClipboard( self: *const Surface, clipboard_type: apprt.Clipboard, @@ -763,7 +971,8 @@ pub const Surface = struct { for (contents, 0..) |content, i| { array[i] = .{ .mime = content.mime, - .data = content.data, + .data = content.data.ptr, + .len = content.data.len, }; } @@ -1301,9 +1510,48 @@ pub const CAPI = struct { }; // ghostty_clipboard_content_s + // + // One representation of clipboard contents. The data is binary-safe + // and its length is explicit; it is not sentinel-terminated. const ClipboardContent = extern struct { mime: [*:0]const u8, - data: [*:0]const u8, + data: [*]const u8, + len: usize, + }; + + // ghostty_clipboard_complete_s + // + // The payload for completing a clipboard read request. See + // Surface.CompleteClipboard for the field documentation. + const ClipboardComplete = extern struct { + contents: ?[*]const ClipboardContent, + contents_len: usize, + available: ?[*]const [*:0]const u8, + available_len: usize, + confirmed: bool, + remember: bool, + }; + + // ghostty_clipboard_confirm_s + // + // The payload of a clipboard read confirmation request: the + // would-be completion contents plus the information shown in the + // permission prompt. All memory is borrowed for the duration of + // the confirm_read_clipboard callback. + const ClipboardConfirm = extern struct { + contents: ?[*]const ClipboardContent, + contents_len: usize, + available: ?[*]const [*:0]const u8, + available_len: usize, + + /// The human friendly name of the requesting program for the + /// prompt, null when the protocol doesn't carry one. + name: ?[*:0]const u8, + + /// True when the user's decision may be remembered as a + /// session grant, reported back through the completion's + /// remember field. + can_remember: bool, }; // ghostty_text_s @@ -1995,20 +2243,32 @@ pub const CAPI = struct { }; } - /// Complete a clipboard read request started via the read callback. - /// This can only be called once for a given request. Once it is called - /// with a request the request pointer will be invalidated. + /// Complete a clipboard read request started via the read callback + /// with the representations that could be served and, if requested, + /// the listing of available MIME types. All memory is borrowed for + /// the duration of the call. This can only be called once for a given + /// request. Once it is called with a request the request pointer will + /// be invalidated. + /// + /// To deny a request use ghostty_surface_deny_clipboard_request + /// instead. export fn ghostty_surface_complete_clipboard_request( ptr: *Surface, - str: [*:0]const u8, + complete: *const ClipboardComplete, state: *apprt.ClipboardRequest, - confirmed: bool, ) void { - ptr.completeClipboardRequest( - std.mem.sliceTo(str, 0), - state, - confirmed, - ); + ptr.completeClipboardRequest(complete, state); + } + + /// Deny a clipboard read request started via the read callback, + /// e.g. because the user rejected a confirmation prompt. Request + /// types whose protocol expects an answer have their denial reply + /// written to the pty. The request pointer is invalidated. + export fn ghostty_surface_deny_clipboard_request( + ptr: *Surface, + state: *apprt.ClipboardRequest, + ) void { + ptr.denyClipboardRequest(state); } export fn ghostty_surface_inspector(ptr: *Surface) ?*Inspector { diff --git a/vendor/libghostty-vt/src/apprt/gtk/Surface.zig b/vendor/libghostty-vt/src/apprt/gtk/Surface.zig index c5ba392..4a59ab2 100644 --- a/vendor/libghostty-vt/src/apprt/gtk/Surface.zig +++ b/vendor/libghostty-vt/src/apprt/gtk/Surface.zig @@ -74,7 +74,7 @@ pub fn clipboardRequest( self: *Self, clipboard_type: apprt.Clipboard, state: apprt.ClipboardRequest, -) !bool { +) !apprt.ClipboardReadResult { return try self.surface.clipboardRequest( clipboard_type, state, diff --git a/vendor/libghostty-vt/src/apprt/gtk/class/clipboard_confirmation_dialog.zig b/vendor/libghostty-vt/src/apprt/gtk/class/clipboard_confirmation_dialog.zig index d44d38a..d993b55 100644 --- a/vendor/libghostty-vt/src/apprt/gtk/class/clipboard_confirmation_dialog.zig +++ b/vendor/libghostty-vt/src/apprt/gtk/class/clipboard_confirmation_dialog.zig @@ -193,11 +193,11 @@ pub const ClipboardConfirmationDialog = extern struct { const priv = self.private(); const req = priv.request orelse return; switch (req.*) { - .osc_52_write => { + .osc_52_write, .kitty_write => { self.as(Dialog.Parent).setHeading(i18n._("Authorize Clipboard Access")); self.as(Dialog.Parent).setBody(i18n._("An application is attempting to write to the clipboard. The current clipboard contents are shown below.")); }, - .osc_52_read => { + .osc_52_read, .kitty_read => { self.as(Dialog.Parent).setHeading(i18n._("Authorize Clipboard Access")); self.as(Dialog.Parent).setBody(i18n._("An application is attempting to read from the clipboard. The current clipboard contents are shown below.")); }, @@ -205,6 +205,7 @@ pub const ClipboardConfirmationDialog = extern struct { self.as(Dialog.Parent).setHeading(i18n._("Warning: Potentially Unsafe Paste")); self.as(Dialog.Parent).setBody(i18n._("Pasting this text into the terminal may be dangerous as it looks like some commands may be executed.")); }, + .list => unreachable, } } diff --git a/vendor/libghostty-vt/src/apprt/gtk/class/surface.zig b/vendor/libghostty-vt/src/apprt/gtk/class/surface.zig index b5e8b0b..9d45bc6 100644 --- a/vendor/libghostty-vt/src/apprt/gtk/class/surface.zig +++ b/vendor/libghostty-vt/src/apprt/gtk/class/surface.zig @@ -853,14 +853,24 @@ pub const Surface = extern struct { pub fn bindIsSplit(self: *Self, tree: *SplitTree) void { const priv = self.private(); - if (priv.is_split_binding) |bind| bind.unbind(); + if (priv.is_split_binding) |binding| { + binding.unbind(); + binding.unref(); + priv.is_split_binding = null; + } - priv.is_split_binding = tree.as(gobject.Object).bindProperty( + const binding = tree.as(gobject.Object).bindProperty( "is-split", self.as(gobject.Object), "is-split", .{ .sync_create = true }, ); + // The ref created by bindProperty is owned by the binding itself. + // We need another ref to prevent the binding object from being + // freed if the source object (SplitTree) is finalized. Otherwise + // our pointer to the binding could become stale. + binding.ref(); + priv.is_split_binding = binding; } /// Callback used to determine whether unfocused-split-fill / unfocused-split-opacity @@ -1378,19 +1388,11 @@ pub const Surface = extern struct { if (entry.native == keycode) break :w3c entry.key; } else .unidentified; - // 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 (gtk_key.keyFromKeyval(keyval)) |remapped| { - if (w3c_key.shouldBeRemappable() or remapped.shouldBeRemappable()) - break :keycode remapped; - } - - // Return the original physical key - break :keycode w3c_key; + break :keycode gtk_key.remapKey( + w3c_key, + keyval, + key_event.isModifier() != 0, + ); }; // Get our modifier for the event @@ -1575,7 +1577,11 @@ pub const Surface = extern struct { // https://gitlab.gnome.org/GNOME/libadwaita/-/commit/a7738a4d269bfdf4d8d5429ca73ccdd9b2450421 // https://gitlab.gnome.org/GNOME/libadwaita/-/commit/9759d3fd81129608dd78116001928f2aed974ead if (gtk_xft_dpi <= 0) { - log.warn("gtk-xft-dpi has invalid value ({}), using default", .{gtk_xft_dpi}); + // -1 is a valid value which specifies default scale. + // https://docs.gtk.org/gtk4/property.Settings.gtk-xft-dpi.html + if (gtk_xft_dpi != -1) { + log.warn("gtk-xft-dpi has invalid value ({}), using default", .{gtk_xft_dpi}); + } break :xft_scale 1.0; } @@ -1733,7 +1739,7 @@ pub const Surface = extern struct { self: *Self, clipboard_type: apprt.Clipboard, state: apprt.ClipboardRequest, - ) !bool { + ) !apprt.ClipboardReadResult { return try Clipboard.request( self, clipboard_type, @@ -1889,6 +1895,12 @@ pub const Surface = extern struct { priv.config = null; } + if (priv.is_split_binding) |binding| { + binding.unbind(); + binding.unref(); + priv.is_split_binding = null; + } + if (priv.vadj_signal_group) |group| { group.setTarget(null); group.as(gobject.Object).unref(); @@ -4119,22 +4131,25 @@ const Clipboard = struct { ); } - /// Request data from the clipboard (read the clipboard). This - /// completes asynchronously and will call the `completeClipboardRequest` - /// core surface API when done. - /// - /// Returns true if the request was started, false if the clipboard - /// doesn't contain text (allowing performable keybinds to pass through). + /// Request data from the clipboard (read the clipboard). A started + /// request completes asynchronously and will call the + /// `completeClipboardRequest` core surface API when done. pub fn request( self: *Surface, clipboard_type: apprt.Clipboard, state: apprt.ClipboardRequest, - ) Allocator.Error!bool { + ) Allocator.Error!apprt.ClipboardReadResult { + // The GTK apprt doesn't support the Kitty clipboard protocol + // yet. + if (state == .kitty_read or + state == .kitty_write or + state == .list) return .unsupported; + // Get our requested clipboard const clipboard = get( self.private().gl_area.as(gtk.Widget), clipboard_type, - ) orelse return false; + ) orelse return .unsupported; // For paste requests, check if clipboard has text format available. // This is a synchronous check that allows performable keybinds to @@ -4143,7 +4158,7 @@ const Clipboard = struct { const formats = clipboard.getFormats(); if (formats.containGtype(gobject.ext.types.string) == 0) { log.debug("clipboard has no text format, not starting paste request", .{}); - return false; + return .unavailable; } } @@ -4166,7 +4181,7 @@ const Clipboard = struct { ud, ); - return true; + return .started; } /// Paste explicit text directly into the surface, regardless of the @@ -4179,16 +4194,15 @@ const Clipboard = struct { const surface = self.private().core_surface orelse return; surface.completeClipboardRequest( - .paste, - text, - false, + .{ .paste = .standard }, + .{ .contents = &.{.{ .mime = "text/plain", .data = text }} }, ) catch |err| switch (err) { error.UnsafePaste, error.UnauthorizedPaste, => { showClipboardConfirmation( self, - .paste, + .{ .paste = .standard }, text, ); return; @@ -4232,7 +4246,7 @@ const Clipboard = struct { .request = &req, .@"can-remember" = switch (req) { .osc_52_read, .osc_52_write => true, - .paste => false, + .paste, .list, .kitty_read, .kitty_write => false, }, .@"clipboard-contents" = contents_buf, }, @@ -4269,7 +4283,7 @@ const Clipboard = struct { if (remember) switch (req.*) { .osc_52_read => surface.config.clipboard_read = .allow, .osc_52_write => surface.config.clipboard_write = .allow, - .paste => {}, + .paste, .list, .kitty_read, .kitty_write => {}, }; // Get our text @@ -4286,11 +4300,10 @@ const Clipboard = struct { ?[:0]const u8, ) orelse return; - surface.completeClipboardRequest( - req.*, - text, - true, - ) catch |err| { + surface.completeClipboardRequest(req.*, .{ + .contents = &.{.{ .mime = "text/plain", .data = text }}, + .confirmed = true, + }) catch |err| { log.warn("failed to complete clipboard request: {}", .{err}); }; } @@ -4308,7 +4321,7 @@ const Clipboard = struct { if (remember) switch (req.*) { .osc_52_read => surface.config.clipboard_read = .deny, .osc_52_write => surface.config.clipboard_write = .deny, - .paste => @panic("paste should not be able to be remembered"), + .paste, .list, .kitty_read, .kitty_write => @panic("request should not be able to be remembered"), }; } @@ -4346,8 +4359,7 @@ const Clipboard = struct { const surface = self.private().core_surface orelse return; surface.completeClipboardRequest( req.state, - str, - false, + .{ .contents = &.{.{ .mime = "text/plain", .data = str }} }, ) catch |err| switch (err) { error.UnsafePaste, error.UnauthorizedPaste, diff --git a/vendor/libghostty-vt/src/apprt/gtk/class/surface_scrolled_window.zig b/vendor/libghostty-vt/src/apprt/gtk/class/surface_scrolled_window.zig index 6ccb0a0..ad9a4cc 100644 --- a/vendor/libghostty-vt/src/apprt/gtk/class/surface_scrolled_window.zig +++ b/vendor/libghostty-vt/src/apprt/gtk/class/surface_scrolled_window.zig @@ -89,7 +89,8 @@ pub const SurfaceScrolledWindow = extern struct { const priv = self.private(); if (priv.config_binding) |binding| { - binding.as(gobject.Object).unref(); + binding.unbind(); + binding.unref(); priv.config_binding = null; } @@ -168,18 +169,23 @@ pub const SurfaceScrolledWindow = extern struct { // Unbind old config binding if it exists if (priv.config_binding) |binding| { - binding.as(gobject.Object).unref(); + binding.unbind(); + binding.unref(); priv.config_binding = null; } // Bind config from surface to our config property if (priv.surface) |surface| { - priv.config_binding = surface.as(gobject.Object).bindProperty( + const binding = surface.as(gobject.Object).bindProperty( properties.config.name, self.as(gobject.Object), properties.config.name, .{ .sync_create = true }, ); + // Keep another ref, otherwise the binding would be freed and + // our pointer become stale if the surface gets finalized. + binding.ref(); + priv.config_binding = binding; } } diff --git a/vendor/libghostty-vt/src/apprt/gtk/key.zig b/vendor/libghostty-vt/src/apprt/gtk/key.zig index 198c154..ff2064d 100644 --- a/vendor/libghostty-vt/src/apprt/gtk/key.zig +++ b/vendor/libghostty-vt/src/apprt/gtk/key.zig @@ -227,6 +227,94 @@ pub fn keyFromKeyval(keyval: c_uint) ?input.Key { return null; } +/// Returns the logical key after applying any eligible XKB remapping. +/// +/// Modifier keyvals that Ghostty doesn't represent must not fall back to the +/// physical key. XKB can turn any physical key into a modifier, and using the +/// original key in that case would encode an unrelated key press. +pub fn remapKey( + physical_key: input.Key, + keyval: c_uint, + is_modifier: bool, +) input.Key { + if (keyFromKeyval(keyval)) |remapped| { + if (physical_key.shouldBeRemappable() or remapped.shouldBeRemappable()) + return remapped; + } + + if (is_modifier or isModifierKeyval(keyval)) return .unidentified; + return physical_key; +} + +/// Returns whether a keyval has modifier semantics that GDK may not expose +/// through `gdk_key_event_is_modifier`. +/// +/// In particular, XKB can report ISO level and group modifiers as keyvals +/// such as `Mode_switch` while GDK reports that the event is not a modifier. +fn isModifierKeyval(keyval: c_uint) bool { + return switch (keyval) { + gdk.KEY_Shift_L, + gdk.KEY_Shift_R, + gdk.KEY_Shift_Lock, + gdk.KEY_Control_L, + gdk.KEY_Control_R, + gdk.KEY_Caps_Lock, + gdk.KEY_Meta_L, + gdk.KEY_Meta_R, + gdk.KEY_Alt_L, + gdk.KEY_Alt_R, + gdk.KEY_Super_L, + gdk.KEY_Super_R, + gdk.KEY_Hyper_L, + gdk.KEY_Hyper_R, + gdk.KEY_Num_Lock, + gdk.KEY_Mode_switch, + gdk.KEY_ISO_Lock, + gdk.KEY_ISO_Level2_Latch, + gdk.KEY_ISO_Level3_Shift, + gdk.KEY_ISO_Level3_Latch, + gdk.KEY_ISO_Level3_Lock, + gdk.KEY_ISO_Level5_Shift, + gdk.KEY_ISO_Level5_Latch, + gdk.KEY_ISO_Level5_Lock, + gdk.KEY_ISO_Group_Latch, + gdk.KEY_ISO_Group_Lock, + => true, + + else => false, + }; +} + +test "remap key" { + const testing = std.testing; + + // XKB remaps between non-writing-system keys are honored. + try testing.expectEqual( + input.Key.escape, + remapKey(.caps_lock, gdk.KEY_Escape, false), + ); + + // Writing-system keys retain their physical identity. + try testing.expectEqual( + input.Key.key_a, + remapKey(.key_a, gdk.KEY_b, false), + ); + + // GDK doesn't identify Mode_switch as a modifier, but it must not fall + // back to the physical key. + try testing.expectEqual( + input.Key.unidentified, + remapKey(.delete, gdk.KEY_Mode_switch, false), + ); + + // GDK's modifier flag also covers custom modifier actions whose keyval + // Ghostty doesn't represent. + try testing.expectEqual( + input.Key.unidentified, + remapKey(.delete, gdk.KEY_VoidSymbol, true), + ); +} + /// Returns a keyval from an input key or null if we don't have a mapping. pub fn keyvalFromKey(key: input.Key) ?c_uint { switch (key) { diff --git a/vendor/libghostty-vt/src/apprt/structs.zig b/vendor/libghostty-vt/src/apprt/structs.zig index 2c37dbd..d8510fb 100644 --- a/vendor/libghostty-vt/src/apprt/structs.zig +++ b/vendor/libghostty-vt/src/apprt/structs.zig @@ -1,4 +1,6 @@ +const std = @import("std"); const build_config = @import("../build_config.zig"); +const terminal = @import("../terminal/main.zig"); /// ContentScale is the ratio between the current DPI and the platform's /// default DPI. This is used to determine how much certain rendered elements @@ -67,13 +69,35 @@ pub const ClipboardRequestType = enum(u8) { paste, osc_52_read, osc_52_write, + kitty_read, + kitty_write, + list, +}; + +/// The result of starting a clipboard read request. This only reports +/// facts about the clipboard; how each state is answered on the wire +/// (if at all) is up to the protocol handling of the requester. +/// +/// If this is changed, you must also update ghostty.h +pub const ClipboardReadResult = enum(c_int) { + /// The request was started and will be completed asynchronously + /// via the core surface completeClipboardRequest API. + started = 0, + + /// The clipboard exists but has no contents the apprt can serve + /// (e.g. no text-like data). The request was not started. + unavailable = 1, + + /// The clipboard itself can't be read (e.g. a primary selection on + /// a platform without one). The request was not started. + unsupported = 2, }; /// Clipboard request. This is used to request clipboard contents and must /// be sent as a response to a ClipboardRequest event. pub const ClipboardRequest = union(ClipboardRequestType) { /// A direct paste of clipboard contents. - paste: void, + paste: Clipboard, /// A request to read clipboard contents via OSC 52. osc_52_read: Clipboard, @@ -81,6 +105,113 @@ pub const ClipboardRequest = union(ClipboardRequestType) { /// A request to write clipboard contents via OSC 52. osc_52_write: Clipboard, + /// A request to read clipboard contents via the Kitty clipboard + /// protocol (OSC 5522). + kitty_read: *KittyRead, + + /// A request to write clipboard contents via the Kitty clipboard + /// protocol (OSC 5522), carrying a fully committed transaction. + kitty_write: *KittyWrite, + + /// A request to list the available clipboard MIME types without + /// reading any of their data. + list: Clipboard, + + /// State for one in-flight Kitty clipboard protocol read. This is + /// created on the IO thread and completed on the app thread, so it + /// owns all of its memory: everything, including the struct itself, + /// is allocated from the arena. + pub const KittyRead = struct { + arena: std.heap.ArenaAllocator, + + /// The clipboard being read. The protocol can only name the + /// standard clipboard or the primary selection. + location: Clipboard, + + /// The requested MIME types in request order, already capped at + /// terminal.kitty.clipboard.max_read_mimes by the sender. Only + /// these representations may be served in the response. The + /// values are sentinel-terminated so they can cross a C apprt + /// boundary without copies. + mimes: []const [:0]const u8, + + /// True when the targets ('.') listing was requested. + list: bool, + + /// The sanitized request id, echoed in every response packet. + id: []const u8, + + /// The effective session password, empty when the request had + /// none. A non-empty password means the user's decision may be + /// remembered as a session grant. + pw: []const u8, + + /// The human friendly name of the requesting program, shown in + /// permission prompts. Empty when absent. Sentinel-terminated + /// so it can cross a C apprt boundary without copies. + name: [:0]const u8, + + /// True when a stored session grant already covers this + /// request, so any permission prompt is skipped. + granted: bool, + + /// The response terminator, matching the request's. + terminator: terminal.osc.Terminator, + + pub fn destroy(self: *KittyRead) void { + // The struct itself lives in the arena, so move the arena + // out before tearing it down. + var arena = self.arena; + arena.deinit(); + } + }; + + /// State for one committed Kitty clipboard protocol write + /// transaction. Like KittyRead, this is created on the IO thread + /// and completed on the app thread, so everything, including the + /// struct itself, is allocated from the arena. + pub const KittyWrite = struct { + arena: std.heap.ArenaAllocator, + + /// The clipboard being written. The protocol can only name the + /// standard clipboard or the primary selection. + location: Clipboard, + + /// The committed representations. These are the authoritative + /// contents of the write: completions apply these rather than + /// any contents echoed back by the apprt. The values are + /// sentinel-terminated so they can cross a C apprt boundary + /// without copies; data is binary-safe via its length. + contents: []const ClipboardContent, + + /// The sanitized request id, echoed in every response packet. + id: []const u8, + + /// The effective session password, empty when the request had + /// none. A non-empty password means the user's decision may be + /// remembered as a session grant. + pw: []const u8, + + /// The human friendly name of the requesting program, shown in + /// permission prompts. Empty when absent. Sentinel-terminated + /// so it can cross a C apprt boundary without copies. + name: [:0]const u8, + + /// True when a stored session grant already covers this + /// request, so any permission prompt is skipped. + granted: bool, + + /// The response terminator, matching the request's. + terminator: terminal.osc.Terminator, + + pub fn destroy(self: *KittyWrite) void { + // The struct itself lives in the arena, so move the arena + // out before tearing it down. + var arena = self.arena; + arena.deinit(); + } + }; + /// Make this a valid gobject if we're in a GTK environment. pub const getGObjectType = switch (build_config.app_runtime) { .gtk => @import("gobject").ext.defineBoxed( diff --git a/vendor/libghostty-vt/src/apprt/surface.zig b/vendor/libghostty-vt/src/apprt/surface.zig index 3cb0016..51da337 100644 --- a/vendor/libghostty-vt/src/apprt/surface.zig +++ b/vendor/libghostty-vt/src/apprt/surface.zig @@ -16,6 +16,47 @@ pub const Message = union(enum) { /// we want this union to be. pub const WriteReq = MessageData(u8, 255); + /// A fixed-size desktop notification payload sent to the app thread. + pub const DesktopNotification = struct { + /// Desktop notification title. + title: [63:0]u8, + + /// Desktop notification body. + body: [255:0]u8, + + pub fn init(title: []const u8, body: []const u8) DesktopNotification { + var result: DesktopNotification = undefined; + copyUtf8Z(result.title.len, &result.title, title); + copyUtf8Z(result.body.len, &result.body, body); + return result; + } + + /// UTF-8 continuation bytes occupy the range 0x80 through 0xBF. + fn isUtf8ContinuationByte(byte: u8) bool { + return switch (byte) { + 0x80...0xBF => true, + else => false, + }; + } + + /// Copy as much of `src` as fits, backing up from a UTF-8 continuation + /// byte so valid input is never truncated in the middle of a codepoint. + fn copyUtf8Z( + comptime capacity: usize, + dst: *[capacity:0]u8, + src: []const u8, + ) void { + var len = @min(src.len, capacity); + while (len > 0 and + len < src.len and + isUtf8ContinuationByte(src[len])) : (len -= 1) + {} + + @memcpy(dst[0..len], src[0..len]); + dst[len] = 0; + } + }; + /// Set the title of the surface. /// TODO: we should change this to a "WriteReq" style structure in /// the termio message so that we can more efficiently send strings @@ -31,6 +72,16 @@ pub const Message = union(enum) { /// Read the clipboard and write to the pty. clipboard_read: apprt.Clipboard, + /// A Kitty clipboard protocol (OSC 5522) read request. The receiver + /// takes ownership of the request state and must eventually destroy + /// it. + kitty_clipboard_read: *apprt.ClipboardRequest.KittyRead, + + /// A committed Kitty clipboard protocol (OSC 5522) write + /// transaction. The receiver takes ownership of the request state + /// and must eventually destroy it. + kitty_clipboard_write: *apprt.ClipboardRequest.KittyWrite, + /// Write the clipboard contents. clipboard_write: struct { clipboard_type: apprt.Clipboard, @@ -52,13 +103,7 @@ pub const Message = union(enum) { child_exited: ChildExited, /// Show a desktop notification. - desktop_notification: struct { - /// Desktop notification title. - title: [63:0]u8, - - /// Desktop notification body. - body: [255:0]u8, - }, + desktop_notification: DesktopNotification, /// Health status change for the renderer. renderer_health: renderer.Health, @@ -195,3 +240,78 @@ pub fn newConfig( return copy; } + +test "DesktopNotification init" { + const notification = Message.DesktopNotification.init("Title", "Body"); + + try std.testing.expectEqualStrings("Title", std.mem.sliceTo(¬ification.title, 0)); + try std.testing.expectEqualStrings("Body", std.mem.sliceTo(¬ification.body, 0)); +} + +test "copyUtf8Z handles len at the final byte of every UTF-8 sequence length" { + const DesktopNotification = Message.DesktopNotification; + + var dst_1_byte: [1:0]u8 = undefined; + const src_ending_in_1_byte_codepoint = "ab"; + try std.testing.expect(!DesktopNotification.isUtf8ContinuationByte( + src_ending_in_1_byte_codepoint[dst_1_byte.len], + )); + DesktopNotification.copyUtf8Z( + dst_1_byte.len, + &dst_1_byte, + src_ending_in_1_byte_codepoint, + ); + try std.testing.expectEqualStrings("a", std.mem.sliceTo(&dst_1_byte, 0)); + + var dst_2_bytes: [2:0]u8 = undefined; + const src_ending_in_2_byte_codepoint = "aЯ"; + try std.testing.expect(DesktopNotification.isUtf8ContinuationByte( + src_ending_in_2_byte_codepoint[dst_2_bytes.len], + )); + DesktopNotification.copyUtf8Z( + dst_2_bytes.len, + &dst_2_bytes, + src_ending_in_2_byte_codepoint, + ); + try std.testing.expectEqualStrings("a", std.mem.sliceTo(&dst_2_bytes, 0)); + + var dst_3_bytes: [3:0]u8 = undefined; + const src_ending_in_3_byte_codepoint = "a€"; + try std.testing.expect(DesktopNotification.isUtf8ContinuationByte( + src_ending_in_3_byte_codepoint[dst_3_bytes.len], + )); + DesktopNotification.copyUtf8Z( + dst_3_bytes.len, + &dst_3_bytes, + src_ending_in_3_byte_codepoint, + ); + try std.testing.expectEqualStrings("a", std.mem.sliceTo(&dst_3_bytes, 0)); + + var dst_4_bytes: [4:0]u8 = undefined; + const src_ending_in_4_byte_codepoint = "a😀"; + try std.testing.expect(DesktopNotification.isUtf8ContinuationByte( + src_ending_in_4_byte_codepoint[dst_4_bytes.len], + )); + DesktopNotification.copyUtf8Z( + dst_4_bytes.len, + &dst_4_bytes, + src_ending_in_4_byte_codepoint, + ); + try std.testing.expectEqualStrings("a", std.mem.sliceTo(&dst_4_bytes, 0)); +} + +test "copyUtf8Z keeps a complete codepoint at the truncation boundary" { + var dst: [5:0]u8 = undefined; + + Message.DesktopNotification.copyUtf8Z(dst.len, &dst, "abcЯz"); + + try std.testing.expectEqualStrings("abcЯ", std.mem.sliceTo(&dst, 0)); +} + +test "copyUtf8Z preserves UTF-8 that fits" { + var dst: [5:0]u8 = undefined; + + Message.DesktopNotification.copyUtf8Z(dst.len, &dst, "abcЯ"); + + try std.testing.expectEqualStrings("abcЯ", std.mem.sliceTo(&dst, 0)); +} diff --git a/vendor/libghostty-vt/src/build/Config.zig b/vendor/libghostty-vt/src/build/Config.zig index 07e047e..71fa4d2 100644 --- a/vendor/libghostty-vt/src/build/Config.zig +++ b/vendor/libghostty-vt/src/build/Config.zig @@ -62,6 +62,10 @@ emit_xcframework: bool = false, emit_webdata: bool = false, emit_unicode_table_gen: bool = false, +/// Feature gates for libghostty-vt artifacts (-Dvt-features). The full +/// Ghostty application ignores this and always enables everything. +vt_features: TerminalBuildOptions.Features = .{}, + /// True when Ghostty is being built as a dependency of another project /// rather than as the root project. is_dep: bool = false, @@ -106,6 +110,25 @@ pub fn init(b: *std.Build, appVersion: []const u8, libVersion: []const u8) !Conf result = b.resolveTargetQuery(query); } + // On wasm, default to enabling the simd128 feature. Every + // browser engine has supported it since 2023 or earlier + // (Chrome 91, Firefox 89, Safari 16.4) and it is a large + // performance win for the terminal hot paths (50%+ on + // print-heavy VT streams). Only apply when no explicit CPU + // was requested; targets for exotic non-browser runtimes + // can opt out with `-Dcpu=generic`. + if (result.result.cpu.arch.isWasm() and + result.query.cpu_model == .determined_by_arch_os and + result.query.cpu_features_add.isEmpty() and + result.query.cpu_features_sub.isEmpty()) + { + var query = result.query; + query.cpu_features_add.addFeature( + @intFromEnum(std.Target.wasm.Feature.simd128), + ); + result = b.resolveTargetQuery(query); + } + // The full Ghostty build no longer supports iOS; Fail early // with a clear message rather than partway through the build. if (result.result.os.tag == .ios and !emit_lib_vt) { @@ -217,9 +240,10 @@ pub fn init(b: *std.Build, appVersion: []const u8, libVersion: []const u8) !Conf "simd", "Build with SIMD-accelerated code paths. Results in significant performance improvements.", ) orelse simd: { - // We can't build our SIMD dependencies for Wasm. Note that we may - // still use SIMD features in the Wasm-builds. - if (target.result.cpu.arch.isWasm()) break :simd false; + // We can't build our SIMD dependencies for Wasm or freestanding + // targets. Note that we may still use SIMD features in the Wasm-builds. + if (target.result.cpu.arch.isWasm() or + target.result.os.tag == .freestanding) break :simd false; break :simd true; }; @@ -402,6 +426,30 @@ pub fn init(b: *std.Build, appVersion: []const u8, libVersion: []const u8) !Conf config.emit_lib_vt = emit_lib_vt; + config.vt_features = features: { + const list = b.option( + []const u8, + "vt-features", + "Comma-separated libghostty-vt feature modifications applied " ++ + "to the default all-enabled set, -Dcpu style: `+feature` " ++ + "or `feature` enables, `-feature` disables, and `all` " ++ + "means every feature (e.g. `-all,+render-state` for a " ++ + "render-only build). Only applies to lib artifacts.", + ) orelse break :features .{}; + break :features TerminalBuildOptions.Features.parse(list) catch { + var valid: std.ArrayList(u8) = .empty; + inline for (@typeInfo(TerminalBuildOptions.Features).@"struct".fields) |field| { + if (valid.items.len > 0) try valid.appendSlice(b.allocator, ", "); + try valid.appendSlice(b.allocator, field.name); + } + std.log.err( + "-Dvt-features={s} contains an unknown feature. Valid features: all, {s}", + .{ list, valid.items }, + ); + return error.UnknownVtFeature; + }; + }; + config.emit_exe = b.option( bool, "emit-exe", @@ -665,6 +713,12 @@ pub fn terminalOptions( .simd = self.simd, .oniguruma = true, .c_abi = false, + // The application requires every feature; only lib artifacts + // may trim them. + .features = switch (artifact) { + .ghostty => .{}, + .lib => self.vt_features, + }, .version = switch (artifact) { .ghostty => self.version, .lib => self.lib_version, diff --git a/vendor/libghostty-vt/src/build/GhosttyLibVt.zig b/vendor/libghostty-vt/src/build/GhosttyLibVt.zig index 7010a78..690f864 100644 --- a/vendor/libghostty-vt/src/build/GhosttyLibVt.zig +++ b/vendor/libghostty-vt/src/build/GhosttyLibVt.zig @@ -58,6 +58,12 @@ pub fn initWasm( // There is no entrypoint for this wasm module. exe.entry = .disabled; + // Default Zig stack size in Zig 0.16 is 1MB. Lower to 128 KB since + // this has to be preallocated up front in Wasm linear memory. Peak + // stack under various artificial loads is 17 KB at the time of this + // comment but lets do 128KB to be safe. We can lower later. + exe.stack_size = 128 * 1024; + // Zig's WASM linker doesn't support --growable-table, so the table // is emitted with max == min and can't be grown from JS. Run a // small Zig build tool that patches the binary's table section to @@ -240,7 +246,9 @@ fn initLib( // Enable PIC so the static library can be linked into PIE // executables, which is the default on most Linux distributions. - lib.root_module.pic = true; + // Native freestanding targets don't have a dynamic loader and some, + // such as Xtensa, don't support PIC relocations. + lib.root_module.pic = target.result.os.tag != .freestanding; } if (target.result.os.tag == .windows) { diff --git a/vendor/libghostty-vt/src/build/GhosttyZig.zig b/vendor/libghostty-vt/src/build/GhosttyZig.zig index 4671219..46ab05d 100644 --- a/vendor/libghostty-vt/src/build/GhosttyZig.zig +++ b/vendor/libghostty-vt/src/build/GhosttyZig.zig @@ -137,6 +137,20 @@ fn initVt( // We need uucode for grapheme break support vt.addImport("uucode", deps.uucode_mod); + // We need wuffs for Kitty graphics pixel operations (format + // conversion and alpha blending). Unlike pure Zig dependencies + // its C code is compiled whenever the module is in the build + // graph regardless of analysis, so only wire it in when Kitty + // graphics is actually enabled. + if (vt_options.kittyGraphics(cfg.target.result)) { + if (b.lazyDependency("wuffs", .{ + .target = cfg.target, + .optimize = cfg.optimize, + })) |dep| { + vt.addImport("wuffs", dep.module("wuffs")); + } + } + // If SIMD is enabled, add all our SIMD dependencies. if (cfg.simd) { try SharedDeps.addSimd(b, vt, simd_libs); diff --git a/vendor/libghostty-vt/src/build/docker/lib-c-docs/Dockerfile b/vendor/libghostty-vt/src/build/docker/lib-c-docs/Dockerfile index a3cfdcc..b5d1761 100644 --- a/vendor/libghostty-vt/src/build/docker/lib-c-docs/Dockerfile +++ b/vendor/libghostty-vt/src/build/docker/lib-c-docs/Dockerfile @@ -14,6 +14,7 @@ WORKDIR /ghostty COPY include/ ./include/ COPY images/ ./images/ COPY dist/doxygen/ ./dist/doxygen/ +COPY src/terminal/c/types.schema.json ./src/terminal/c/types.schema.json COPY example/ ./example/ COPY Doxyfile ./ COPY DoxygenLayout.xml ./ diff --git a/vendor/libghostty-vt/src/cli/help.zig b/vendor/libghostty-vt/src/cli/help.zig index 755aa12..ee67483 100644 --- a/vendor/libghostty-vt/src/cli/help.zig +++ b/vendor/libghostty-vt/src/cli/help.zig @@ -17,7 +17,7 @@ pub const Options = struct { }; /// The `help` command shows general help about Ghostty. Recognized as either -/// `-h, `--help`, or like other actions `+help`. +/// `-h`, `--help`, or like other actions `+help`. /// /// You can also specify `--help` or `-h` along with any action such as /// `+list-themes` to see help for a specific action. diff --git a/vendor/libghostty-vt/src/cli/list_themes.zig b/vendor/libghostty-vt/src/cli/list_themes.zig index f2643f8..8fbf650 100644 --- a/vendor/libghostty-vt/src/cli/list_themes.zig +++ b/vendor/libghostty-vt/src/cli/list_themes.zig @@ -1205,7 +1205,7 @@ const Preview = struct { .x_off = x_off, .y_off = next_start, .width = width, - .height = 24, + .height = 28, }, ); const bold: vaxis.Style = .{ @@ -1382,7 +1382,9 @@ const Preview = struct { .{ .text = "pub ", .style = color5 }, .{ .text = "fn ", .style = color12 }, .{ .text = "main", .style = color2 }, - .{ .text = "() ", .style = standard }, + .{ .text = "(init: ", .style = standard }, + .{ .text = "std.process.Init", .style = color12 }, + .{ .text = ") ", .style = standard }, .{ .text = "!", .style = color5 }, .{ .text = "void", .style = color12 }, .{ .text = " {", .style = standard }, @@ -1395,10 +1397,11 @@ const Preview = struct { _ = child.print( &.{ .{ .text = " 4 │ ", .style = color238 }, - .{ .text = "const ", .style = color5 }, - .{ .text = "stdout ", .style = standard }, - .{ .text = "=", .style = color5 }, - .{ .text = " std.Io.getStdOut().writer();", .style = standard }, + .{ .text = "var ", .style = color5 }, + .{ .text = "buf:", .style = standard }, + .{ .text = " [1024]u8", .style = color12 }, + .{ .text = " =", .style = color5 }, + .{ .text = " undefined;", .style = standard }, }, .{ .row_offset = 7, @@ -1409,6 +1412,41 @@ const Preview = struct { &.{ .{ .text = " 5 │ ", .style = color238 }, .{ .text = "var ", .style = color5 }, + .{ .text = "stdout ", .style = standard }, + .{ .text = "=", .style = color5 }, + .{ .text = " std.Io.File.stdout().writer(init.io, &buf);", .style = standard }, + }, + .{ + .row_offset = 8, + .col_offset = 2, + }, + ); + _ = child.print( + &.{ + .{ .text = " 6 │ ", .style = color238 }, + .{ .text = "const ", .style = color5 }, + .{ .text = "w ", .style = standard }, + .{ .text = "=", .style = color5 }, + .{ .text = " &stdout.interface;", .style = standard }, + }, + .{ + .row_offset = 9, + .col_offset = 2, + }, + ); + _ = child.print( + &.{ + .{ .text = " 7 │", .style = color238 }, + }, + .{ + .row_offset = 10, + .col_offset = 2, + }, + ); + _ = child.print( + &.{ + .{ .text = " 8 │ ", .style = color238 }, + .{ .text = "var ", .style = color5 }, .{ .text = "i:", .style = standard }, .{ .text = " usize", .style = color12 }, .{ .text = " =", .style = color5 }, @@ -1416,13 +1454,13 @@ const Preview = struct { .{ .text = ";", .style = standard }, }, .{ - .row_offset = 8, + .row_offset = 11, .col_offset = 2, }, ); _ = child.print( &.{ - .{ .text = " 6 │ ", .style = color238 }, + .{ .text = " 9 │ ", .style = color238 }, .{ .text = "while ", .style = color5 }, .{ .text = "(i ", .style = standard }, .{ .text = "<= ", .style = color5 }, @@ -1433,13 +1471,13 @@ const Preview = struct { .{ .text = ") {", .style = standard }, }, .{ - .row_offset = 9, + .row_offset = 12, .col_offset = 2, }, ); _ = child.print( &.{ - .{ .text = " 7 │ ", .style = color238 }, + .{ .text = " 10 │ ", .style = color238 }, .{ .text = "if ", .style = color5 }, .{ .text = "(i ", .style = standard }, .{ .text = "% ", .style = color5 }, @@ -1449,28 +1487,28 @@ const Preview = struct { .{ .text = ") {", .style = standard }, }, .{ - .row_offset = 10, + .row_offset = 13, .col_offset = 2, }, ); _ = child.print( &.{ - .{ .text = " 8 │ ", .style = color238 }, + .{ .text = " 11 │ ", .style = color238 }, .{ .text = "try ", .style = color5 }, - .{ .text = "stdout.writeAll(", .style = standard }, + .{ .text = "w.writeAll(", .style = standard }, .{ .text = "\"ZiggZagg", .style = color10 }, .{ .text = "\\n", .style = color12 }, .{ .text = "\"", .style = color10 }, .{ .text = ");", .style = standard }, }, .{ - .row_offset = 11, + .row_offset = 14, .col_offset = 2, }, ); _ = child.print( &.{ - .{ .text = " 9 │ ", .style = color238 }, + .{ .text = " 12 │ ", .style = color238 }, .{ .text = "} ", .style = standard }, .{ .text = "else if ", .style = color5 }, .{ .text = "(i ", .style = standard }, @@ -1481,28 +1519,28 @@ const Preview = struct { .{ .text = ") {", .style = standard }, }, .{ - .row_offset = 12, + .row_offset = 15, .col_offset = 2, }, ); _ = child.print( &.{ - .{ .text = " 10 │ ", .style = color238 }, + .{ .text = " 13 │ ", .style = color238 }, .{ .text = "try ", .style = color5 }, - .{ .text = "stdout.writeAll(", .style = standard }, + .{ .text = "w.writeAll(", .style = standard }, .{ .text = "\"Zigg", .style = color10 }, .{ .text = "\\n", .style = color12 }, .{ .text = "\"", .style = color10 }, .{ .text = ");", .style = standard }, }, .{ - .row_offset = 13, + .row_offset = 16, .col_offset = 2, }, ); _ = child.print( &.{ - .{ .text = " 11 │ ", .style = color238 }, + .{ .text = " 14 │ ", .style = color238 }, .{ .text = "} ", .style = standard }, .{ .text = "else if ", .style = color5 }, .{ .text = "(i ", .style = standard }, @@ -1513,76 +1551,87 @@ const Preview = struct { .{ .text = ") {", .style = standard }, }, .{ - .row_offset = 14, + .row_offset = 17, .col_offset = 2, }, ); _ = child.print( &.{ - .{ .text = " 12 │ ", .style = color238 }, + .{ .text = " 15 │ ", .style = color238 }, .{ .text = "try ", .style = color5 }, - .{ .text = "stdout.writeAll(", .style = standard }, + .{ .text = "w.writeAll(", .style = standard }, .{ .text = "\"Zagg", .style = color10 }, .{ .text = "\\n", .style = color12 }, .{ .text = "\"", .style = color10 }, .{ .text = ");", .style = standard }, }, .{ - .row_offset = 15, + .row_offset = 18, .col_offset = 2, }, ); _ = child.print( &.{ - .{ .text = " 13 │ ", .style = color238 }, + .{ .text = " 16 │ ", .style = color238 }, .{ .text = "} ", .style = standard }, .{ .text = "else ", .style = color5 }, .{ .text = "{", .style = standard }, }, .{ - .row_offset = 16, + .row_offset = 19, .col_offset = 2, }, ); _ = child.print( &.{ - .{ .text = " 14 │ ", .style = color238 }, + .{ .text = " 17 │ ", .style = color238 }, .{ .text = "try ", .style = color5 }, - .{ .text = "stdout.print(\"{d}\\n\", .{i})", .style = standard_selection }, + .{ .text = "w.print(\"{d}\\n\", .{i})", .style = standard_selection }, .{ .text = ";", .style = cursor }, }, .{ - .row_offset = 17, + .row_offset = 20, .col_offset = 2, }, ); _ = child.print( &.{ - .{ .text = " 15 │ ", .style = color238 }, + .{ .text = " 18 │ ", .style = color238 }, .{ .text = "}", .style = standard }, }, .{ - .row_offset = 18, + .row_offset = 21, .col_offset = 2, }, ); _ = child.print( &.{ - .{ .text = " 16 │ ", .style = color238 }, + .{ .text = " 19 │ ", .style = color238 }, .{ .text = "}", .style = standard }, }, .{ - .row_offset = 19, + .row_offset = 22, .col_offset = 2, }, ); _ = child.print( &.{ - .{ .text = " 17 │ ", .style = color238 }, + .{ .text = " 20 │ ", .style = color238 }, + .{ .text = "try ", .style = color5 }, + .{ .text = "w.flush();", .style = standard }, + }, + .{ + .row_offset = 23, + .col_offset = 2, + }, + ); + _ = child.print( + &.{ + .{ .text = " 21 │ ", .style = color238 }, .{ .text = "}", .style = standard }, }, .{ - .row_offset = 20, + .row_offset = 24, .col_offset = 2, }, ); @@ -1595,7 +1644,7 @@ const Preview = struct { }, }, .{ - .row_offset = 21, + .row_offset = 25, .col_offset = 2, }, ); @@ -1610,7 +1659,7 @@ const Preview = struct { }, }, .{ - .row_offset = 21, + .row_offset = 25, .col_offset = col, }, ); @@ -1624,12 +1673,12 @@ const Preview = struct { .{ .text = " main ", .style = color4 }, .{ .text = "[+] ", .style = color1 }, .{ .text = "via ", .style = standard }, - .{ .text = " v0.13.0 ", .style = color3 }, + .{ .text = " v0.16.0 ", .style = color3 }, .{ .text = "via ", .style = standard }, .{ .text = " impure (ghostty-env)", .style = color4 }, }, .{ - .row_offset = 22, + .row_offset = 26, .col_offset = 2, }, ); @@ -1641,7 +1690,7 @@ const Preview = struct { .{ .text = "→", .style = color2 }, }, .{ - .row_offset = 23, + .row_offset = 27, .col_offset = 2, }, ); diff --git a/vendor/libghostty-vt/src/cli/ssh-cache/DiskCache.zig b/vendor/libghostty-vt/src/cli/ssh-cache/DiskCache.zig index bc5619d..ea64dfe 100644 --- a/vendor/libghostty-vt/src/cli/ssh-cache/DiskCache.zig +++ b/vendor/libghostty-vt/src/cli/ssh-cache/DiskCache.zig @@ -58,6 +58,7 @@ pub fn add( self: DiskCache, alloc: Allocator, key: []const u8, + version: []const u8, timestamp: i64, ) !void { if (!isValidCacheKey(key)) return error.InvalidCacheKey; @@ -111,16 +112,19 @@ pub fn add( // `deinitEntries` defer to walk. if (entries.getPtr(key)) |existing| { existing.timestamp = timestamp; + const version_copy = try alloc.dupe(u8, version); + alloc.free(existing.terminfo_version); + existing.terminfo_version = version_copy; } else { const key_copy = try alloc.dupe(u8, key); errdefer alloc.free(key_copy); - const terminfo_copy = try alloc.dupe(u8, "xterm-ghostty"); - errdefer alloc.free(terminfo_copy); + const version_copy = try alloc.dupe(u8, version); + errdefer alloc.free(version_copy); try entries.put(key_copy, .{ .hostname = key_copy, .timestamp = timestamp, - .terminfo_version = terminfo_copy, + .terminfo_version = version_copy, }); } @@ -225,12 +229,13 @@ pub fn prune( return expired.items.len; } -/// Check if a key exists in the cache. -/// Returns false if the cache file doesn't exist. +/// Check if a key with `version` exists in the cache. +/// Returns false if the cache file doesn't exist or the version doesn't match. pub fn contains( self: DiskCache, alloc: Allocator, key: []const u8, + version: []const u8, ) !bool { if (!isValidCacheKey(key)) return error.InvalidCacheKey; @@ -249,7 +254,8 @@ pub fn contains( var entries = try readEntries(alloc, file); defer deinitEntries(alloc, &entries); - return entries.contains(key); + const entry = entries.get(key) orelse return false; + return std.mem.eql(u8, entry.terminfo_version, version); } fn fixupPermissions(file: std.Io.File) !void { @@ -506,20 +512,25 @@ test "disk cache operations" { // Setup our cache. Adding the same key twice exercises both the new // and existing-entry paths. const cache: DiskCache = .{ .path = path }; - try cache.add(alloc, "example.com", std.Io.Timestamp.now(testing.io, .real).toSeconds()); - try cache.add(alloc, "example.com", std.Io.Timestamp.now(testing.io, .real).toSeconds()); - try testing.expect(try cache.contains(alloc, "example.com")); + try cache.add(alloc, "example.com", "v1", std.Io.Timestamp.now(testing.io, .real).toSeconds()); + try testing.expect(!try cache.contains(alloc, "example.com", "v2")); + try cache.add(alloc, "example.com", "v2", std.Io.Timestamp.now(testing.io, .real).toSeconds()); + try testing.expect(try cache.contains(alloc, "example.com", "v2")); // List var entries = try cache.list(alloc); - deinitEntries(alloc, &entries); + defer deinitEntries(alloc, &entries); + try testing.expectEqualStrings( + "v2", + entries.get("example.com").?.terminfo_version, + ); // Remove reports that it removed the entry, and a second remove of the // same key reports nothing to remove. try testing.expect(try cache.remove(alloc, "example.com")); try testing.expect(!try cache.remove(alloc, "example.com")); - try testing.expect(!(try cache.contains(alloc, "example.com"))); - try cache.add(alloc, "example.com", std.Io.Timestamp.now(testing.io, .real).toSeconds()); + try testing.expect(!(try cache.contains(alloc, "example.com", "v2"))); + try cache.add(alloc, "example.com", "v2", std.Io.Timestamp.now(testing.io, .real).toSeconds()); } test "disk cache cleans up temp files" { @@ -535,8 +546,8 @@ test "disk cache cleans up temp files" { defer alloc.free(cache_path); const cache: DiskCache = .{ .path = cache_path }; - try cache.add(alloc, "example.com", std.Io.Timestamp.now(testing.io, .real).toSeconds()); - try cache.add(alloc, "example.org", std.Io.Timestamp.now(testing.io, .real).toSeconds()); + try cache.add(alloc, "example.com", "v1", std.Io.Timestamp.now(testing.io, .real).toSeconds()); + try cache.add(alloc, "example.org", "v1", std.Io.Timestamp.now(testing.io, .real).toSeconds()); // Verify only the cache file exists and no temp files left behind var count: usize = 0; @@ -566,20 +577,20 @@ test "disk cache prune" { const day = std.time.s_per_day; const hour = std.time.s_per_hour; const now = std.Io.Timestamp.now(testing.io, .real).toSeconds(); - try cache.add(alloc, "recent.com", now - hour); - try cache.add(alloc, "old.com", now - 100 * day); + try cache.add(alloc, "recent.com", "v1", now - hour); + try cache.add(alloc, "old.com", "v1", now - 100 * day); // Prune entries older than 90 days: only old.com goes. try testing.expectEqual(@as(usize, 1), try cache.prune(alloc, 90 * day)); - try testing.expect(try cache.contains(alloc, "recent.com")); - try testing.expect(!try cache.contains(alloc, "old.com")); + try testing.expect(try cache.contains(alloc, "recent.com", "v1")); + try testing.expect(!try cache.contains(alloc, "old.com", "v1")); // Pruning again removes nothing. try testing.expectEqual(@as(usize, 0), try cache.prune(alloc, 90 * day)); // Sub-day granularity: a 30-minute max age prunes the hour-old entry. try testing.expectEqual(@as(usize, 1), try cache.prune(alloc, 30 * std.time.s_per_min)); - try testing.expect(!try cache.contains(alloc, "recent.com")); + try testing.expect(!try cache.contains(alloc, "recent.com", "v1")); } test "disk cache prune missing file" { @@ -707,7 +718,7 @@ test "disk cache add survives allocation failure" { ); const alloc = failing.allocator(); - if (cache.add(alloc, "user@example.com", 100)) |_| { + if (cache.add(alloc, "user@example.com", "v1", 100)) |_| { if (!failing.has_induced_failure) break; } else |err| { try testing.expectEqual(error.OutOfMemory, err); diff --git a/vendor/libghostty-vt/src/cli/ssh.zig b/vendor/libghostty-vt/src/cli/ssh.zig index f20188d..d11a946 100644 --- a/vendor/libghostty-vt/src/cli/ssh.zig +++ b/vendor/libghostty-vt/src/cli/ssh.zig @@ -7,7 +7,7 @@ const diagnostics = @import("diagnostics.zig"); const Action = @import("ghostty.zig").Action; const DiskCache = @import("ssh_cache.zig").DiskCache; const internal_os = @import("../os/main.zig"); -const ghostty_terminfo = @import("../terminfo/main.zig").ghostty; +const terminfopkg = @import("../terminfo/main.zig"); const global = @import("../global.zig"); const log = std.log.scoped(.ssh); @@ -128,9 +128,9 @@ pub const Options = struct { /// forwarding to succeed. /// /// 2. **Terminfo install** (`--terminfo`). On the first connection to a -/// given destination, installs Ghostty's terminfo entry on the remote -/// host using `infocmp -x xterm-ghostty | ssh tic -x -` over a -/// shared `ControlMaster` connection. Successful installs are cached +/// given destination, installs Ghostty's embedded terminfo entry on the +/// remote host using `ssh tic -x -` over a shared `ControlMaster` +/// connection. Successful installs are cached /// (see `ghostty +ssh-cache`) so subsequent connections skip this /// step. When terminfo is successfully installed or already cached, /// `TERM` is set to `xterm-ghostty` instead of `xterm-256color`. @@ -162,17 +162,17 @@ pub const Options = struct { /// /// Examples: /// -/// # Basic invocation using defaults: -/// ghostty +ssh user@example.com +/// # Basic invocation using defaults: +/// ghostty +ssh user@example.com /// -/// # Forward Ghostty env vars but skip the terminfo install: -/// ghostty +ssh --terminfo=false user@example.com +/// # Forward Ghostty env vars but skip the terminfo install: +/// ghostty +ssh --terminfo=false user@example.com /// -/// # `ssh` flags (short-form `-p`, etc.) pass through unchanged: -/// ghostty +ssh -p 2222 -i ~/.ssh/id_ed25519 user@example.com +/// # `ssh` flags (short-form `-p`, etc.) pass through unchanged: +/// ghostty +ssh -p 2222 -i ~/.ssh/id_ed25519 user@example.com /// -/// # Use `--` explicitly if your ssh args might collide with our flags: -/// ghostty +ssh -- --some-rare-ssh-arg user@example.com +/// # Use `--` explicitly if your ssh args might collide with our flags: +/// ghostty +ssh -- --some-rare-ssh-arg user@example.com /// /// Pass `--verbose` to see what `+ssh` is doing. For cache inspection /// and management, see `ghostty +ssh-cache`. @@ -252,7 +252,11 @@ fn runInner( } else null; if (cache) |c| { - const cached = c.contains(alloc, dest) catch |err| cached: { + const cached = c.contains( + alloc, + dest, + terminfopkg.version, + ) catch |err| cached: { if (DiskCache.isFailure(err)) warnPrint( stderr, "unable to read the cache '{s}': {t}", @@ -313,10 +317,12 @@ fn runInner( // Attempt to cache (if needed) on a successful ssh execution. if (exit_code == 0) if (session.to_cache) |entry| { - if (entry.cache.add(alloc, entry.dest, std.Io.Timestamp.now( - global.io(), - .real, - ).toSeconds())) |_| { + if (entry.cache.add( + alloc, + entry.dest, + terminfopkg.version, + std.Io.Timestamp.now(global.io(), .real).toSeconds(), + )) |_| { verbosePrint(opts, stderr, "cache: wrote {s}", .{entry.dest}); } else |err| { if (DiskCache.isFailure(err)) { @@ -473,7 +479,7 @@ fn installRemoteTerminfo( ) !void { var buf: std.Io.Writer.Allocating = .init(alloc); defer buf.deinit(); - try ghostty_terminfo.encode(&buf.writer); + try terminfopkg.ghostty.encode(&buf.writer); const terminfo = buf.written(); // ControlPath is in TMPDIR with a short, random basename. ssh uses @@ -491,20 +497,17 @@ fn installRemoteTerminfo( // the most common failure source) and inherit ssh's stderr so it // reaches the user's terminal. Other steps stay quiet either way. const remote_script = if (opts.verbose) - \\infocmp xterm-ghostty >/dev/null 2>&1 && exit 0 \\command -v tic >/dev/null 2>&1 || exit 1 \\mkdir -p ~/.terminfo 2>/dev/null && tic -x - && exit 0 \\exit 1 else - \\infocmp xterm-ghostty >/dev/null 2>&1 && exit 0 \\command -v tic >/dev/null 2>&1 || exit 1 \\mkdir -p ~/.terminfo 2>/dev/null && tic -x - 2>/dev/null && exit 0 \\exit 1 ; // Set up an SSH ControlMaster scoped to this single install: - // - ControlMaster=yes makes our client also act as the master, - // so `infocmp | ssh tic` runs over a single connection. + // - ControlMaster=yes makes our client also act as the master. // - ControlPersist=no tears the master down when our client // exits; no socket lingers on the remote side. const argv = try std.mem.concat(alloc, []const u8, &.{ diff --git a/vendor/libghostty-vt/src/cli/ssh_cache.zig b/vendor/libghostty-vt/src/cli/ssh_cache.zig index f509f69..5de0c2a 100644 --- a/vendor/libghostty-vt/src/cli/ssh_cache.zig +++ b/vendor/libghostty-vt/src/cli/ssh_cache.zig @@ -5,6 +5,7 @@ const args = @import("args.zig"); const global = @import("../global.zig"); const Action = @import("ghostty.zig").Action; const Duration = @import("../config.zig").Config.Duration; +const terminfopkg = @import("../terminfo/main.zig"); pub const Entry = @import("ssh-cache/Entry.zig"); pub const DiskCache = @import("ssh-cache/DiskCache.zig"); @@ -49,13 +50,14 @@ pub const Options = struct { /// `--prune=6h`, `--prune=1y`. /// /// Examples: -/// ghostty +ssh-cache # List all cached destinations -/// ghostty +ssh-cache user@example.com # Show that destination -/// ghostty +ssh-cache example.com # Show all users on that host -/// ghostty +ssh-cache --add=user@example.com # Manually add a destination -/// ghostty +ssh-cache --remove=user@example.com # Remove a destination -/// ghostty +ssh-cache --prune=30d # Remove entries older than 30 days -/// ghostty +ssh-cache --clear # Clear entire cache +/// +/// ghostty +ssh-cache # List all cached destinations +/// ghostty +ssh-cache user@example.com # Show that destination +/// ghostty +ssh-cache example.com # Show all users on that host +/// ghostty +ssh-cache --add=user@example.com # Manually add a destination +/// ghostty +ssh-cache --remove=user@example.com # Remove a destination +/// ghostty +ssh-cache --prune=30d # Remove entries older than 30 days +/// ghostty +ssh-cache --clear # Clear entire cache pub fn run(alloc_gpa: Allocator) !u8 { var arena = std.heap.ArenaAllocator.init(alloc_gpa); defer arena.deinit(); @@ -198,6 +200,7 @@ pub fn runInner( cache.add( alloc, dest, + terminfopkg.version, std.Io.Timestamp.now(global.io(), .real).toSeconds(), ) catch |err| switch (err) { error.InvalidCacheKey => { diff --git a/vendor/libghostty-vt/src/config/Config.zig b/vendor/libghostty-vt/src/config/Config.zig index 814532b..bf4e75f 100644 --- a/vendor/libghostty-vt/src/config/Config.zig +++ b/vendor/libghostty-vt/src/config/Config.zig @@ -100,6 +100,12 @@ pub const compatibility = std.StaticStringMap( // Ghostty 1.4 renamed `scrollback-limit` to `scrollback-limit-bytes` // when `scrollback-limit-lines` was added so the units are explicit. .{ "scrollback-limit", cli.compatibilityRenamed(Config, "scrollback-limit-bytes") }, + + // Ghostty 1.4 updated "copy-on-select", allow copying to the selection + // clipboard (on supported operating systems), the system clipboard, or + // both. The semantics also changed but this is the correct mapping. + // See: https://github.com/ghostty-org/ghostty/pull/12604 + .{ "copy-on-select", compatCopyOnSelect }, }); /// Set Ghostty's graphical user interface language to a language other than the @@ -2454,6 +2460,32 @@ keybind: Keybinds = .{}, @"clipboard-read": ClipboardAccess = .ask, @"clipboard-write": ClipboardAccess = .allow, +/// The maximum size in bytes of a single clipboard write by a program +/// running in the terminal via the Kitty clipboard protocol (OSC 5522). +/// This doesn't apply to OSC 52, which is limited by the maximum length +/// of an escape sequence hardcoded into Ghostty for now. +/// +/// Data beyond the limit fails the entire write with an `EFBIG` status, +/// discards the transaction, and leaves the clipboard untouched. Later +/// write-related packets are ignored until a new write begins. +/// +/// The data is buffered in memory while the write is in progress, so +/// this limit bounds how much memory a program can make Ghostty +/// allocate per write. A future improvement will attempt to spool large +/// writes to disk. +/// +/// The default is 64 MiB, the minimum a conforming implementation must +/// accept. Set this to `unlimited` to remove the limit, allowing writes +/// bounded only by available memory. A value of `0` rejects every non-empty +/// write. To reject clipboard writes entirely, use `clipboard-write = deny` +/// instead. +/// +/// This can be changed at runtime and applies to writes that begin +/// after the change. +/// +/// Available since: 1.4.0 +@"clipboard-write-limit-bytes": Limit(usize, 64 * 1024 * 1024) = .default, + /// Trims trailing whitespace on data that is copied to the clipboard. This does /// not affect data sent to the clipboard via `clipboard-write`. This only /// applies to trailing whitespace on lines that have other characters. @@ -2491,25 +2523,28 @@ keybind: Keybinds = .{}, /// limit per surface is double. @"image-storage-limit": u32 = 320 * 1000 * 1000, -/// Whether to automatically copy selected text to the clipboard. `true` -/// will prefer to copy to the selection clipboard, otherwise it will copy to -/// the system clipboard. +/// Whether to automatically copy selected text to the clipboard. +/// +/// Valid values: +/// +/// * `none` - Do not copy selected text automatically. +/// +/// * `primary` - On Linux, copy to the selection clipboard only. This has no +/// effect on macOS. (Available since: 1.4.0) +/// +/// * `clipboard` - Copy text to the system clipboard only. /// -/// The value `clipboard` will always copy text to the selection clipboard -/// as well as the system clipboard. +/// * `both` - Copy to both clipboards on Linux, and only the system clipboard +/// on macOS. (Available since: 1.4.0) /// -/// Middle-click primary paste (see `middle-click-action`) is enabled by -/// default even if this is `false`. The clipboard it pastes from follows -/// this setting: with `true` (or `false`) it reads from the selection -/// clipboard (falling back to the system clipboard on platforms without a -/// selection clipboard); with `clipboard` it reads from the system -/// clipboard. +/// For backward compatibility and convenience, a value of `true` is the same as +/// `primary` on Linux and `clipboard` on macOS, and `false` is an alias for +/// `none`. /// -/// The default value is true on Linux and macOS. +/// The default value is `primary` on Linux and `none` otherwise. @"copy-on-select": CopyOnSelect = switch (builtin.os.tag) { - .linux => .true, - .macos => .true, - else => .false, + .linux => .primary, + else => .none, }, /// The action to take when the user right-clicks on the terminal surface. @@ -2528,8 +2563,9 @@ keybind: Keybinds = .{}, /// The action to take when the user middle-clicks on the terminal surface. /// /// Valid values: -/// * `primary-paste` - Paste from the selection (or system) clipboard per -/// `copy-on-select`. +/// * `primary-paste` - Paste from the selection clipboard on Linux. +/// Does nothing on macOS. +/// * `clipboard-paste` - Paste from the system clipboard. /// * `ignore` - Do nothing, ignore the middle click. /// /// The default value is `primary-paste`. @@ -2931,10 +2967,9 @@ keybind: Keybinds = .{}, /// (Available since: 1.2.0) /// /// * `ssh-terminfo` - Enable automatic terminfo installation on remote hosts. -/// Attempts to install Ghostty's terminfo entry using `infocmp` and `tic` when -/// connecting to hosts that lack it. Requires `infocmp` to be available locally -/// and `tic` to be available on remote hosts. Once terminfo is installed on a -/// remote host, it will be automatically "cached" to avoid repeat installations. +/// Attempts to install Ghostty's embedded terminfo entry using `tic` on local +/// cache misses. Requires `tic` to be available on remote hosts. Successful +/// installations are cached locally to avoid repeat installations. /// If desired, the `+ssh-cache` CLI action can be used to manage the installation /// cache manually using various arguments. /// (Available since: 1.2.0) @@ -4518,6 +4553,29 @@ fn expandPaths(self: *Config, base: []const u8) !void { } } +/// Expand tilde paths to absolute paths to the user's home directory. +/// If expansion fails, an error is logged and the original path is returned. +fn expandHome(path: []const u8, buf: []u8) []const u8 { + if (!std.mem.startsWith(u8, path, "~/")) + return path; + + var environ_map = global.environMap() catch |err| { + log.warn("failed to get environment map for path \"{s}\": {}", .{ path, err }); + return path; + }; + defer environ_map.deinit(); + + return internal_os.expandHome( + global.io(), + &environ_map, + path, + buf, + ) catch |err| { + log.warn("failed to expand home directory in path \"{s}\": {}", .{ path, err }); + return path; + }; +} + fn loadTheme(self: *Config, theme: Theme) !void { // Load the correct theme depending on the conditional state. // Dark/light themes were programmed prior to conditional configuration @@ -4617,14 +4675,17 @@ fn loadTheme(self: *Config, theme: Theme) !void { /// Call this once after you are done setting configuration. This /// is idempotent but will waste memory if called multiple times. pub fn finalize(self: *Config) !void { + const alloc = self._arena.?.allocator(); + // We always load the theme first because it may set other fields // in our config. - if (self.theme) |theme| { + if (self.theme) |*theme| { + try theme.finalize(alloc); const different = !std.mem.eql(u8, theme.light, theme.dark); // Warning: loadTheme will deinit our existing config and replace // it so all memory from self prior to this point will be freed. - try self.loadTheme(theme); + try self.loadTheme(theme.*); // If we have different light vs dark mode themes, disable // window-theme = auto since that breaks it. @@ -4638,8 +4699,6 @@ pub fn finalize(self: *Config) !void { } } - const alloc = self._arena.?.allocator(); - // Used for a variety of defaults. See the function docs as well the // specific variable use sites for more details. const probable_cli = probableCliEnvironment(); @@ -4998,6 +5057,31 @@ fn compatMacOSDockDropBehavior( return false; } +fn compatCopyOnSelect( + self: *Config, + alloc: Allocator, + key: []const u8, + value: ?[]const u8, +) bool { + _ = alloc; + assert(std.mem.eql(u8, key, "copy-on-select")); + + if (std.mem.eql(u8, value orelse "", "true")) { + self.@"copy-on-select" = switch (builtin.os.tag) { + .linux, .freebsd => .primary, + else => .clipboard, + }; + return true; + } + + if (std.mem.eql(u8, value orelse "", "false")) { + self.@"copy-on-select" = .none; + return true; + } + + return false; +} + /// Add a diagnostic message to the config with the given string. /// This is always added with a location of "none". pub fn addDiagnosticFmt( @@ -5444,20 +5528,8 @@ pub const WorkingDirectory = union(enum) { else => return, }; - if (!std.mem.startsWith(u8, path, "~/")) return; - var buf: [std.fs.max_path_bytes]u8 = undefined; - const expanded = expanded: { - var environ_map = global.environMap() catch |err| break :expanded err; - defer environ_map.deinit(); - break :expanded internal_os.expandHome(global.io(), &environ_map, path, &buf); - } catch |err| { - log.warn( - "error expanding home directory for working-directory path={s}: {}", - .{ path, err }, - ); - return; - }; + const expanded = expandHome(path, &buf); if (std.mem.eql(u8, expanded, path)) return; self.* = .{ .path = try alloc.dupe(u8, expanded) }; @@ -8691,16 +8763,20 @@ pub const RepeatableLink = struct { /// Options for copy on select behavior. pub const CopyOnSelect = enum { - /// Disables copy on select entirely. - false, + /// Disables copy on select entirely. This is the default on macOS. + none, /// Copy on select is enabled, but goes to the selection clipboard. - /// This is not supported on platforms such as macOS. This is the default. - true, + /// This is not supported on platforms such as macOS. This is the default + /// on Linux. + primary, + + /// Copy on select is enabled and goes to the system clipboard. + clipboard, /// Copy on select is enabled and goes to both the system clipboard /// and the selection clipboard (for Linux). - clipboard, + both, }; /// Options for right-click actions. @@ -8724,8 +8800,10 @@ pub const RightClickAction = enum { /// Options for middle-click actions. pub const MiddleClickAction = enum { - /// Paste from the selection/standard clipboard per `copy-on-select`. + /// Paste from the selection clipboard. @"primary-paste", + /// Paste from the standard clipboard. + @"clipboard-paste", /// No action is taken on middle click. ignore, @@ -9980,6 +10058,19 @@ pub const Theme = struct { }; } + /// Expand tilde paths in light/dark theme values. + pub fn finalize(self: *Theme, alloc: Allocator) Allocator.Error!void { + var buf: [std.fs.max_path_bytes]u8 = undefined; + + const light = expandHome(self.light, &buf); + if (!std.mem.eql(u8, light, self.light)) + self.light = try alloc.dupeZ(u8, light); + + const dark = expandHome(self.dark, &buf); + if (!std.mem.eql(u8, dark, self.dark)) + self.dark = try alloc.dupeZ(u8, dark); + } + /// Deep copy of the struct. Required by Config. pub fn clone(self: *const Theme, alloc: Allocator) Allocator.Error!Theme { return .{ @@ -10036,6 +10127,34 @@ pub const Theme = struct { try testing.expectEqualStrings("bar", v.dark); } + // Expand tilde to home + { + var environ_map = try testing.environ.createMap(alloc); + defer environ_map.deinit(); + + var home_buf: [std.fs.max_path_bytes]u8 = undefined; + const home = try internal_os.expandHome( + testing.io, + &environ_map, + "~/", + &home_buf, + ); + + var v: Theme = undefined; + try v.parseCLI(alloc, "light:~/foo, dark:~/bar"); + try v.finalize(alloc); + + var expected_buf: [std.fs.max_path_bytes]u8 = undefined; + try testing.expectEqualStrings( + try std.fmt.bufPrint(&expected_buf, "{s}foo", .{home}), + v.light, + ); + try testing.expectEqualStrings( + try std.fmt.bufPrint(&expected_buf, "{s}bar", .{home}), + v.dark, + ); + } + var v: Theme = undefined; try testing.expectError(error.ValueRequired, v.parseCLI(alloc, null)); try testing.expectError(error.ValueRequired, v.parseCLI(alloc, "")); @@ -10984,6 +11103,38 @@ test "scrollback limits" { ); } +test "clipboard write limit" { + const testing = std.testing; + const alloc = testing.allocator; + + var cfg = try Config.default(alloc); + defer cfg.deinit(); + try testing.expectEqual( + @as(usize, 64 * 1024 * 1024), + cfg.@"clipboard-write-limit-bytes".value, + ); + + var it: TestIterator = .{ .data = &.{ + "--clipboard-write-limit-bytes=1234", + } }; + try cfg.loadIter(alloc, &it); + + try testing.expectEqual( + @as(usize, 1234), + cfg.@"clipboard-write-limit-bytes".value, + ); + + var unlimited_it: TestIterator = .{ .data = &.{ + "--clipboard-write-limit-bytes=unlimited", + } }; + try cfg.loadIter(alloc, &unlimited_it); + + try testing.expectEqual( + std.math.maxInt(usize), + cfg.@"clipboard-write-limit-bytes".value, + ); +} + test "compatibility: scrollback-limit renamed to bytes" { const testing = std.testing; const alloc = testing.allocator; diff --git a/vendor/libghostty-vt/src/config/formatter_file.zig b/vendor/libghostty-vt/src/config/formatter_file.zig index 37beb7b..71e8004 100644 --- a/vendor/libghostty-vt/src/config/formatter_file.zig +++ b/vendor/libghostty-vt/src/config/formatter_file.zig @@ -41,7 +41,7 @@ pub const FileFormatter = struct { if (field.name[0] == '_') continue; const value = @field(self.config, field.name); - const do_format = if (default) |d| format: { + const do_format = if (default) |*d| format: { const key = @field(Key, field.name); break :format d.changed(self.config, key); } else true; diff --git a/vendor/libghostty-vt/src/config/string.zig b/vendor/libghostty-vt/src/config/string.zig index 4507993..8d4fc7b 100644 --- a/vendor/libghostty-vt/src/config/string.zig +++ b/vendor/libghostty-vt/src/config/string.zig @@ -20,16 +20,23 @@ pub fn parse(out: []u8, bytes: []const u8) ![]u8 { continue; } + // Zig's \xNN syntax represents a single byte, while the other + // escape sequences represent Unicode codepoints. + const is_byte_escape = src_i + 1 < bytes.len and + bytes[src_i + 1] == 'x'; + // Parse the escape sequence switch (std.zig.string_literal.parseEscapeSequence( bytes, &src_i, )) { .failure => return error.InvalidString, - .success => |cp| dst_i += try std.unicode.utf8Encode( - cp, - out[dst_i..], - ), + .success => |cp| if (is_byte_escape) { + out[dst_i] = @intCast(cp); + dst_i += 1; + } else { + dst_i += try std.unicode.utf8Encode(cp, out[dst_i..]); + }, } } @@ -100,6 +107,15 @@ test "parse: escapes" { } } +test "parse: hex escapes are bytes" { + var buf: [128]u8 = undefined; + const result = try parse( + &buf, + "\\xe6\\x97\\xa5\\xe6\\x9c\\xac\\xe8\\xaa\\x9e", + ); + try std.testing.expectEqualStrings("日本語", result); +} + test "codepointIterator: empty" { var it = codepointIterator(""); try std.testing.expectEqual(null, try it.next()); diff --git a/vendor/libghostty-vt/src/crc32c.zig b/vendor/libghostty-vt/src/crc32c.zig index 8b8ebd1..3d8aaca 100644 --- a/vendor/libghostty-vt/src/crc32c.zig +++ b/vendor/libghostty-vt/src/crc32c.zig @@ -4,8 +4,10 @@ //! lookup (as of Zig 0.16), which is more than an order of magnitude slower //! than the dedicated CRC32C instructions available on aarch64 (CRC //! extension) and x86_64 (SSE4.2). This module selects the best backend at -//! compile time and falls back to the standard library elsewhere, including -//! WebAssembly. +//! compile time. +//! +//! Targets without a dedicated instruction, such as WebAssembly, use a +//! custom implementation that is faster than Zig's stdlib. //! //! The resulting value is identical across all backends: this is the //! iSCSI CRC32C parameter set (reflected, initial and final XOR @@ -14,10 +16,6 @@ const std = @import("std"); const builtin = @import("builtin"); -/// The standard-library implementation of the same parameter set. This is -/// both the portable fallback and the reference the tests compare against. -const Software = std.hash.crc.Crc32Iscsi; - const Backend = enum { aarch64_crc, x86_64_sse42, @@ -58,11 +56,7 @@ pub const Crc32c = struct { pub fn update(self: *Crc32c, bytes: []const u8) void { self.crc = switch (comptime backend) { .aarch64_crc, .x86_64_sse42 => updateHardware(self.crc, bytes), - .software => software: { - var crc: Software = .{ .crc = self.crc }; - crc.update(bytes); - break :software crc.crc; - }, + .software => Software.update(self.crc, bytes), }; } @@ -148,6 +142,243 @@ inline fn step(comptime T: type, crc: u32, value: T) u32 { }; } +/// The portable software backend, used by targets without a dedicated +/// CRC32C instruction. +const Software = struct { + /// The reflected CRC32C (Castagnoli) polynomial. + const reflected_poly: u32 = 0x82F63B78; + + /// Number of slicing tables, which is also the bytes folded per + /// iteration. + const slices = 16; + + /// Inputs below this length use the single-stream pass: the + /// stream-combine matrix work would not pay for itself. + const multi_stream_threshold = 4096; + + /// Slicing tables: `tables[i][b]` is the CRC of byte `b` followed by + /// `i` zero bytes. Table zero is the classic one-byte-per-step table; + /// the higher tables let one iteration fold a whole block with + /// independent lookups instead of a byte-by-byte dependency chain. + const tables: [slices][256]u32 = tables: { + @setEvalBranchQuota(200_000); + var result: [slices][256]u32 = undefined; + for (0..256) |n| { + var crc: u32 = n; + for (0..8) |_| { + crc = (crc >> 1) ^ (reflected_poly * (crc & 1)); + } + result[0][n] = crc; + } + for (1..slices) |i| { + for (0..256) |n| { + const prev = result[i - 1][n]; + result[i][n] = (prev >> 8) ^ result[0][prev & 0xFF]; + } + } + break :tables result; + }; + + fn update(initial: u32, bytes: []const u8) u32 { + if (bytes.len >= multi_stream_threshold) return updateMulti( + initial, + bytes, + ); + + return updateSingle(initial, bytes); + } + + /// One single-stream update pass using slicing. + fn updateSingle(initial: u32, bytes: []const u8) u32 { + const t = &tables; + var crc = initial; + var i: usize = 0; + while (i + slices <= bytes.len) : (i += slices) { + crc = foldChunk(bytes, i, crc); + } + for (bytes[i..]) |byte| { + crc = (crc >> 8) ^ t[0][(crc ^ byte) & 0xFF]; + } + return crc; + } + + /// One update pass as three independent interleaved streams. Faster + /// for large enough inputs. + fn updateMulti(initial: u32, bytes: []const u8) u32 { + // Both leading parts are block multiples so the interleaved loop + // needs no tail handling; the third part absorbs the remainder. + const part = (bytes.len / 3) & ~@as(usize, slices - 1); + const p0 = bytes[0..part]; + const p1 = bytes[part..][0..part]; + const p2 = bytes[2 * part ..]; + + var s0 = initial; + var s1: u32 = 0; + var s2: u32 = 0; + var i: usize = 0; + while (i + slices <= part) : (i += slices) { + s0 = foldChunk(p0, i, s0); + s1 = foldChunk(p1, i, s1); + s2 = foldChunk(p2, i, s2); + } + s2 = updateSingle(s2, p2[part..]); + + const s01 = s1 ^ zeroShift(s0, p1.len); + return s2 ^ zeroShift(s01, p2.len); + } + + /// Fold one aligned block through the per-position slicing tables. + /// The running CRC must already be XORed into the block's first word. + inline fn foldBlock(comptime len: usize, words: *const [len / 4]u32) u32 { + const t = &tables; + var crc: u32 = 0; + inline for (0..len / 4) |w| { + const word = words[w]; + const base = len - 1 - w * 4; + crc ^= t[base][word & 0xFF] ^ + t[base - 1][(word >> 8) & 0xFF] ^ + t[base - 2][(word >> 16) & 0xFF] ^ + t[base - 3][word >> 24]; + } + return crc; + } + + /// Fold the block starting at `offset`, chaining the running CRC state. + inline fn foldChunk(bytes: []const u8, offset: usize, crc: u32) u32 { + var words: [slices / 4]u32 = undefined; + inline for (&words, 0..) |*word, w| { + word.* = std.mem.readInt( + u32, + bytes[offset + w * 4 ..][0..4], + .little, + ); + } + words[0] ^= crc; + return foldBlock(slices, &words); + } + + /// Advance a CRC state as if `len` zero bytes had been processed. + fn zeroShift(state: u32, len: usize) u32 { + var s = state; + var remaining = len; + var k: usize = 0; + while (remaining != 0) : ({ + remaining >>= 1; + k += 1; + }) { + if (remaining & 1 != 0) { + const mat = &zero_shift_matrices[k / 2]; + s = matTimesVec(mat, s); + if (k % 2 != 0) s = matTimesVec(mat, s); + } + } + return s; + } + + /// Multiply the GF(2) matrix by a CRC state column vector. + inline fn matTimesVec(mat: *const [32]u32, vec: u32) u32 { + var sum: u32 = 0; + var v = vec; + var i: usize = 0; + while (v != 0) : ({ + v >>= 1; + i += 1; + }) { + if (v & 1 != 0) sum ^= mat[i]; + } + return sum; + } + + const zero_shift_matrices: [32][32]u32 = matrices: { + @setEvalBranchQuota(500_000); + var matrices: [32][32]u32 = undefined; + var previous: [32]u32 = undefined; + for (0..32) |i| { + const unit: u32 = 1 << i; + previous[i] = (unit >> 8) ^ tables[0][unit & 0xFF]; + } + matrices[0] = previous; + for (1..64) |k| { + var squared: [32]u32 = undefined; + for (0..32) |i| { + squared[i] = matTimesVec(&previous, previous[i]); + } + previous = squared; + if (k % 2 == 0) matrices[k / 2] = squared; + } + break :matrices matrices; + }; +}; + +/// The standard-library implementation of the same parameter set. This is +/// the reference the tests compare against. +const Reference = std.hash.crc.Crc32Iscsi; + +test "software slicing matches the standard library" { + // The selected backend may be hardware, so cover the sliced software + // path directly: every length around the sixteen-byte boundary, several + // alignments, and continuation across arbitrary split points. + var bytes: [512 + 19]u8 = undefined; + var prng = std.Random.DefaultPrng.init(0x511C); + prng.random().bytes(&bytes); + + for (0..64 + 1) |len| { + for (0..4) |offset| { + const input = bytes[offset..][0..len]; + var reference: Reference = .{ .crc = 0xFFFF_FFFF }; + reference.update(input); + try std.testing.expectEqual( + reference.crc, + Software.update(0xFFFF_FFFF, input), + ); + } + } + + const long = bytes[0..512]; + var reference: Reference = .{ .crc = 0xFFFF_FFFF }; + reference.update(long); + for ([_]usize{ 0, 1, 15, 16, 17, 100, 511, 512 }) |split| { + const first = Software.update(0xFFFF_FFFF, long[0..split]); + try std.testing.expectEqual( + reference.crc, + Software.update(first, long[split..]), + ); + } +} + +test "software multi-stream matches the standard library" { + // Lengths around and far above the interleaving threshold, plus odd + // remainders, so all three streams and both combine steps are covered. + var bytes: [96 * 1024]u8 = undefined; + var prng = std.Random.DefaultPrng.init(0x3517_1A3B); + prng.random().bytes(&bytes); + + for ([_]usize{ + Software.multi_stream_threshold - 1, + Software.multi_stream_threshold, + Software.multi_stream_threshold + 1, + Software.multi_stream_threshold + 97, + 12 * 1024, + 64 * 1024 + 31, + bytes.len, + }) |len| { + const input = bytes[0..len]; + var reference: Reference = .{ .crc = 0xFFFF_FFFF }; + reference.update(input); + try std.testing.expectEqual( + reference.crc, + Software.update(0xFFFF_FFFF, input), + ); + + // Continuation across a split inside the multi-stream range. + const first = Software.update(0xFFFF_FFFF, input[0 .. len / 2]); + try std.testing.expectEqual( + reference.crc, + Software.update(first, input[len / 2 ..]), + ); + } +} + test "matches the check value" { // The catalog check value for CRC-32/ISCSI. try std.testing.expectEqual( @@ -164,7 +395,7 @@ test "matches the standard library at every length and split" { for (0..bytes.len + 1) |len| { const input = bytes[0..len]; try std.testing.expectEqual( - Software.hash(input), + Reference.hash(input), Crc32c.hash(input), ); @@ -174,7 +405,7 @@ test "matches the standard library at every length and split" { split.update(input[0 .. len / 3]); split.update(input[len / 3 .. len - len / 3]); split.update(input[len - len / 3 ..]); - try std.testing.expectEqual(Software.hash(input), split.final()); + try std.testing.expectEqual(Reference.hash(input), split.final()); } } @@ -186,7 +417,7 @@ test "matches the standard library at every alignment" { for (0..16) |offset| { const input = bytes[offset..][0..64]; try std.testing.expectEqual( - Software.hash(input), + Reference.hash(input), Crc32c.hash(input), ); } diff --git a/vendor/libghostty-vt/src/datastruct/array_list_collection.zig b/vendor/libghostty-vt/src/datastruct/array_list_collection.zig deleted file mode 100644 index d3fbddb..0000000 --- a/vendor/libghostty-vt/src/datastruct/array_list_collection.zig +++ /dev/null @@ -1,44 +0,0 @@ -const std = @import("std"); -const Allocator = std.mem.Allocator; - -/// A collection of ArrayLists with methods for bulk operations. -pub fn ArrayListCollection(comptime T: type) type { - return struct { - const Self = ArrayListCollection(T); - const ArrayListT = std.ArrayListUnmanaged(T); - - // An array containing the lists that belong to this collection. - lists: []ArrayListT, - - // The collection will be initialized with empty ArrayLists. - pub fn init( - alloc: Allocator, - list_count: usize, - initial_capacity: usize, - ) Allocator.Error!Self { - const self: Self = .{ - .lists = try alloc.alloc(ArrayListT, list_count), - }; - - for (self.lists) |*list| { - list.* = try .initCapacity(alloc, initial_capacity); - } - - return self; - } - - pub fn deinit(self: *Self, alloc: Allocator) void { - for (self.lists) |*list| { - list.deinit(alloc); - } - alloc.free(self.lists); - } - - /// Clear all lists in the collection, retaining capacity. - pub fn reset(self: *Self) void { - for (self.lists) |*list| { - list.clearRetainingCapacity(); - } - } - }; -} diff --git a/vendor/libghostty-vt/src/datastruct/main.zig b/vendor/libghostty-vt/src/datastruct/main.zig index 73e9afb..32c5ff6 100644 --- a/vendor/libghostty-vt/src/datastruct/main.zig +++ b/vendor/libghostty-vt/src/datastruct/main.zig @@ -14,6 +14,7 @@ pub const IntrusiveDoublyLinkedList = intrusive_linked_list.DoublyLinkedList; pub const LimitedAllocator = @import("limited_allocator.zig").LimitedAllocator; pub const MessageData = @import("message_data.zig").MessageData; pub const SplitTree = split_tree.SplitTree; +pub const WasmPagePool = @import("wasm_page_pool.zig").WasmPagePool; test { @import("std").testing.refAllDecls(@This()); diff --git a/vendor/libghostty-vt/src/datastruct/wasm_page_pool.zig b/vendor/libghostty-vt/src/datastruct/wasm_page_pool.zig new file mode 100644 index 0000000..7f11938 --- /dev/null +++ b/vendor/libghostty-vt/src/datastruct/wasm_page_pool.zig @@ -0,0 +1,157 @@ +const std = @import("std"); +const builtin = @import("builtin"); +const assert = std.debug.assert; +const Allocator = std.mem.Allocator; + +/// A memory pool of wasm-page-multiple-sized items backed directly by +/// wasm linear memory, for wasm targets only. +/// +/// This is necessary for two reasons: the std.heap.MemoryPool grows 1.5x +/// at each growth point. The backing allocator for that is usually a GPA +/// which is the BrkAllocator for wasm. This grows by power-of-two +/// big-allocation slots. If you pair these together you get a massive +/// permanent linear memory growth. Native (non-wasm) targets don't care +/// because unused virtual mappings are effectively free, but this isn't +/// exactly true for Wasm runtimes. +/// +/// This pool instead grows exactly @sizeOf(Item) bytes of fresh linear +/// memory per item with memory.grow (which also guarantees wasm-page +/// alignment and zeroing) and recycles freed items through a free list +/// that is container-level, i.e. shared by every pool of the same Item +/// type in the module instance. Every freed item is immediately +/// reusable by every pool, and each item costs exactly @sizeOf(Item) +/// reserved bytes, ever. +/// +/// The "shared by every pool of the same Item type" is really important: +/// the normal Ghostty memory pool is per-terminal. This one is per-module. +/// The Ghostty wasm modules are not multi-threaded so this doesn't require +/// any synchronization. But this means that all terminals share one pool +/// so memory doesn't balloon like crazy that way either. +/// +/// Requirements on Item: +/// +/// - @sizeOf(Item) must be a nonzero multiple of the wasm page size +/// (64KiB), since memory.grow allocates in whole pages. This is +/// also what makes the pool exact-fit: any other size would strand +/// the remainder of the last page. +/// - Natural alignment must be at most the wasm page size. +/// +/// Free-list items are dirty except that their first pointer-size bytes +/// hold the intrusive free-list node, exactly like the std MemoryPool. +/// Callers that need zeroed items must zero them (fresh items from +/// memory.grow are zero by the wasm spec; recycled items retain +/// whatever the caller left, so zero either on destroy or on create). +/// +/// The API mirrors the subset of std.heap.memory_pool.AlignedManaged +/// that callers use (initCapacity/deinit/reset/create/destroy plus the +/// managed allocator field), so it can be swapped in comptime. +pub fn WasmPagePool(comptime Item: type) type { + return struct { + const Self = @This(); + + comptime { + if (builtin.target.cpu.arch.isWasm()) { + // The shared free list (and wasm's single linear + // memory) requires a single-threaded target. + assert(builtin.single_threaded); + // Items are whole wasm pages + assert(item_size > 0); + assert(item_size % std.heap.page_size_min == 0); + assert(@alignOf(Item) <= std.heap.page_size_min); + // Free items store the intrusive node in their bytes. + assert(item_size >= @sizeOf(std.SinglyLinkedList.Node)); + } + } + + /// The allocator this managed pool was initialized with, + /// carried for interface compatibility with the std managed + /// memory pools. Pool items never come from it (they come + /// from memory.grow), but callers may use it for related + /// allocations that don't fit the pool. + allocator: Allocator, + + pub const item_size = @sizeOf(Item); + pub const ItemPtr = *align(std.heap.page_size_min) Item; + + /// Freed items, shared by every pool of this Item type in the + /// module instance. + var free_list: std.SinglyLinkedList = .{}; + + pub fn initCapacity( + allocator: Allocator, + preheat: usize, + ) Allocator.Error!Self { + // Preheating is intentionally a no-op: item creation is a + // cheap memory.grow or free-list pop. And we want to avoid + // the preallocation mem cost. + _ = preheat; + return .{ .allocator = allocator }; + } + + pub fn deinit(self: *Self) void { + // Items are global to the instance and outlive the pool so + // other pools can reuse them. Callers must destroy() any + // items they still hold before deinit or they leak. + self.* = undefined; + } + + pub fn reset( + self: *Self, + mode: std.heap.ArenaAllocator.ResetMode, + ) bool { + // There is nothing to trim: linear memory cannot shrink, + // so retaining every freed item for reuse is always the + // right policy on wasm. As with deinit, live items must be + // destroyed by the caller first. + _ = self; + _ = mode; + return true; + } + + pub fn create(self: *Self) Allocator.Error!ItemPtr { + _ = self; + + // If we have a free item, use it. + if (free_list.popFirst()) |node| return @ptrCast(@alignCast(node)); + + // Grow linear memory by exactly one item. + const pages = comptime item_size / std.heap.page_size_min; + const page_index = @wasmMemoryGrow(0, pages); + if (page_index == -1) return error.OutOfMemory; + return @ptrFromInt(@as(usize, @intCast(page_index)) * std.heap.page_size_min); + } + + pub fn destroy(self: *Self, ptr: ItemPtr) void { + _ = self; + const node: *std.SinglyLinkedList.Node = @ptrCast(ptr); + node.* = .{}; + free_list.prepend(node); + } + }; +} + +test WasmPagePool { + if (!comptime builtin.target.cpu.arch.isWasm()) return error.SkipZigTest; + + const testing = std.testing; + const Pool = WasmPagePool([64 * 1024]u8); + var pool: Pool = try .initCapacity(testing.allocator, 0); + defer pool.deinit(); + + const a = try pool.create(); + const b = try pool.create(); + try testing.expect(a != b); + + // Freed items are recycled, most recent first. + pool.destroy(b); + try testing.expectEqual(b, try pool.create()); + + // The free list is shared across pools of the same Item type. + var pool2: Pool = try .initCapacity(testing.allocator, 0); + defer pool2.deinit(); + pool.destroy(a); + try testing.expectEqual(a, try pool2.create()); + + pool.destroy(a); + pool.destroy(b); +} diff --git a/vendor/libghostty-vt/src/font/glyf_rasterize.zig b/vendor/libghostty-vt/src/font/glyf_rasterize.zig index f321214..f288ff0 100644 --- a/vendor/libghostty-vt/src/font/glyf_rasterize.zig +++ b/vendor/libghostty-vt/src/font/glyf_rasterize.zig @@ -99,14 +99,16 @@ pub fn rasterize( // bitmap just like an empty outline. if (bounds.width() == 0 or bounds.height() == 0) return Bitmap.initEmpty(alloc, width, height); - // Build the surface we'll draw on. This is a simple alpha8 drawing. - var sfc: z2d.Surface = try .init( + // Build the surface directly over the bitmap we'll return. + const data = try alloc.alloc(u8, @as(usize, width) * @as(usize, height)); + errdefer alloc.free(data); + var sfc: z2d.Surface = .initBuffer( .image_surface_alpha8, - alloc, + null, + std.mem.bytesAsSlice(z2d.pixel.Alpha8, data), @intCast(width), @intCast(height), ); - defer sfc.deinit(alloc); var path: z2d.Path = .empty; defer path.deinit(alloc); @@ -133,7 +135,7 @@ pub fn rasterize( return .{ .width = width, .height = height, - .data = try alloc.dupe(u8, std.mem.sliceAsBytes(sfc.image_surface_alpha8.buf)), + .data = data, }; } diff --git a/vendor/libghostty-vt/src/input/key_encode.zig b/vendor/libghostty-vt/src/input/key_encode.zig index 6ab5a4c..18e4a1e 100644 --- a/vendor/libghostty-vt/src/input/key_encode.zig +++ b/vendor/libghostty-vt/src/input/key_encode.zig @@ -224,9 +224,11 @@ fn kitty( const entry = entry_ orelse { // No entry found. If we have UTF-8 text this is a pure text event // (e.g. composed/IME text), so send it as-is so programs can - // still receive it. - if (event.utf8.len > 0) return try writer.writeAll(event.utf8); - return; + // still receive it. Release events never insert text, same as the + // plain-text path above. + if (event.action == .release) return; + if (event.utf8.len == 0) return; + return try writer.writeAll(event.utf8); }; // If this is just a modifier we require "report all" to send the sequence. @@ -1357,6 +1359,22 @@ test "kitty: shift+backspace emits CSI u" { try testing.expectEqualStrings("\x1b[127;2u", writer.buffered()); } +test "kitty: alt+backspace emits CSI u" { + var buf: [128]u8 = undefined; + var writer: std.Io.Writer = .fixed(&buf); + try kitty(&writer, .{ + .key = .backspace, + .mods = .{ .alt = true }, + // macOS may mark Option as consumed while translating the key. With + // no attached control text, all modifiers must remain effective. + .consumed_mods = .{ .alt = true }, + .utf8 = "", + }, .{ + .kitty_flags = .{ .disambiguate = true }, + }); + try testing.expectEqualStrings("\x1b[127;3u", writer.buffered()); +} + test "kitty: shift+enter emits CSI u" { var buf: [128]u8 = undefined; var writer: std.Io.Writer = .fixed(&buf); @@ -1492,6 +1510,50 @@ test "kitty: composed text with report all" { try testing.expectEqualStrings("\xc3\xbb", writer.buffered()); } +// A key whose base (unshifted) keysym is a dead key has no unshifted +// codepoint, so it has no kitty entry and falls back to writing the text +// directly. That must not happen on release or the text is inserted twice. +test "kitty: text fallback on release" { + for ([_]bool{ false, true }) |report_all| { + var buf: [128]u8 = undefined; + var writer: std.Io.Writer = .fixed(&buf); + try kitty(&writer, .{ + .action = .release, + .key = .unidentified, + .mods = .{ .shift = true }, + .utf8 = "!", + }, .{ + .kitty_flags = .{ + .disambiguate = true, + .report_events = true, + .report_alternates = true, + .report_all = report_all, + .report_associated = true, + }, + }); + try testing.expectEqualStrings("", writer.buffered()); + } +} + +test "kitty: text fallback on repeat" { + var buf: [128]u8 = undefined; + var writer: std.Io.Writer = .fixed(&buf); + try kitty(&writer, .{ + .action = .repeat, + .key = .unidentified, + .mods = .{ .shift = true }, + .utf8 = "!", + }, .{ + .kitty_flags = .{ + .disambiguate = true, + .report_events = true, + .report_alternates = true, + .report_associated = true, + }, + }); + try testing.expectEqualStrings("!", writer.buffered()); +} + test "kitty: shift+a on US keyboard" { var buf: [128]u8 = undefined; var writer: std.Io.Writer = .fixed(&buf); diff --git a/vendor/libghostty-vt/src/input/paste.zig b/vendor/libghostty-vt/src/input/paste.zig index 16b6266..60a4fdd 100644 --- a/vendor/libghostty-vt/src/input/paste.zig +++ b/vendor/libghostty-vt/src/input/paste.zig @@ -1,6 +1,14 @@ const std = @import("std"); const Terminal = @import("../terminal/Terminal.zig"); +/// The bracketed paste (mode 2004) frame written around the data. +pub const bracketed_prefix = "\x1b[200~"; +pub const bracketed_suffix = "\x1b[201~"; + +/// The maximum number of bytes `encode` adds around the data, so callers +/// can size a buffer for the full encoded result. +pub const max_frame_size = bracketed_prefix.len + bracketed_suffix.len; + pub const Options = struct { /// True if bracketed paste mode is on. bracketed: bool, @@ -93,8 +101,8 @@ pub fn encode( // Bracketed paste mode (mode 2004) wraps pasted data in // fenceposts so that the terminal can ignore things like newlines. if (opts.bracketed) { - result[0] = "\x1b[200~"; - result[2] = "\x1b[201~"; + result[0] = bracketed_prefix; + result[2] = bracketed_suffix; return result; } @@ -116,6 +124,42 @@ pub const Error = error{ MutableRequired, }; +/// Encode the given data for pasting directly into a writer. This is +/// the same transformation as `encode` (unsafe bytes replaced, bracketed +/// frame or newline conversion per `opts`) but the data is copied +/// exactly once: into the writer's buffer, where it is modified in place. +/// This is the form to use when the data is const and the result is +/// being assembled into a single buffer anyway. +/// +/// The data is copied in chunks sized to the writer's buffer, so any +/// writer works; a writer with less total capacity than the writer +/// needs to hold at once reports `error.WriteFailed` as usual. +/// +/// WARNING: The input data is not checked for safety. See `isSafe` +/// and `isSafeWith` to check if the data is safe to paste. +pub fn encodeWriter( + writer: *std.Io.Writer, + data: []const u8, + opts: Options, +) std.Io.Writer.Error!void { + if (opts.bracketed) try writer.writeAll(bracketed_prefix); + + // The byte transformations are position-independent, so the data + // can be copied and encoded chunk by chunk. The frame returned by + // encode is ignored since it's written around the whole data here. + var remaining = data; + while (remaining.len > 0) { + const dest = try writer.writableSliceGreedy(1); + const n = @min(dest.len, remaining.len); + @memcpy(dest[0..n], remaining[0..n]); + _ = encode(dest[0..n], opts); + writer.advance(n); + remaining = remaining[n..]; + } + + if (opts.bracketed) try writer.writeAll(bracketed_suffix); +} + /// Returns true if the data looks safe to paste. Data is considered /// unsafe if it contains any of the following: /// @@ -133,6 +177,22 @@ pub fn isSafe(data: []const u8) bool { std.mem.indexOf(u8, data, "\x1b[201~") == null; } +/// Returns true if the data looks safe to paste given how it will be +/// encoded. This is the terminal-state-aware counterpart of `isSafe`: +/// +/// - Bracketed (mode 2004 on): the program receives the data as one +/// framed unit, so newlines are fine. The data is unsafe only if it +/// contains the end of the frame (`\x1b[201~`), which would let the +/// rest of the data escape the frame and inject commands. +/// - Unbracketed: the same rule as `isSafe`. +/// +/// Callers wanting the conservative rule regardless of terminal state +/// should use `isSafe` instead. +pub fn isSafeWith(data: []const u8, opts: Options) bool { + if (opts.bracketed) return std.mem.indexOf(u8, data, bracketed_suffix) == null; + return isSafe(data); +} + test isSafe { const testing = std.testing; try testing.expect(isSafe("hello")); @@ -141,6 +201,110 @@ test isSafe { try testing.expect(!isSafe("he\x1b[201~llo")); } +test isSafeWith { + const testing = std.testing; + + // Bracketed: newlines are fine, the frame terminator is not. + try testing.expect(isSafeWith("hello", .{ .bracketed = true })); + try testing.expect(isSafeWith("hello\nworld", .{ .bracketed = true })); + try testing.expect(!isSafeWith("he\x1b[201~llo", .{ .bracketed = true })); + try testing.expect(!isSafeWith("hello\n\x1b[201~", .{ .bracketed = true })); + + // Unbracketed: the conservative rule. + try testing.expect(isSafeWith("hello", .{ .bracketed = false })); + try testing.expect(!isSafeWith("hello\nworld", .{ .bracketed = false })); + try testing.expect(!isSafeWith("he\x1b[201~llo", .{ .bracketed = false })); +} + +test "encodeWriter bracketed" { + const testing = std.testing; + var buf: [64]u8 = undefined; + var writer: std.Io.Writer = .fixed(&buf); + try encodeWriter(&writer, "hel\x1blo\nworld", .{ .bracketed = true }); + try testing.expectEqualStrings("\x1b[200~hel lo\nworld\x1b[201~", writer.buffered()); +} + +test "encodeWriter unbracketed" { + const testing = std.testing; + var buf: [64]u8 = undefined; + var writer: std.Io.Writer = .fixed(&buf); + try encodeWriter(&writer, "hel\x00lo\r\nworld", .{ .bracketed = false }); + try testing.expectEqualStrings("hel lo\r\rworld", writer.buffered()); +} + +test "encodeWriter empty" { + const testing = std.testing; + var buf: [64]u8 = undefined; + var writer: std.Io.Writer = .fixed(&buf); + try encodeWriter(&writer, "", .{ .bracketed = true }); + try testing.expectEqualStrings("\x1b[200~\x1b[201~", writer.buffered()); + writer = .fixed(&buf); + try encodeWriter(&writer, "", .{ .bracketed = false }); + try testing.expectEqualStrings("", writer.buffered()); +} + +test "encodeWriter chunks through a small writer buffer" { + const testing = std.testing; + const alloc = testing.allocator; + + // A writer with a 4-byte staging buffer that drains into a list, + // so the data is copied and encoded in several chunks. + const Sink = struct { + list: std.ArrayList(u8) = .empty, + writer: std.Io.Writer, + + fn drain( + w: *std.Io.Writer, + data: []const []const u8, + splat: usize, + ) std.Io.Writer.Error!usize { + const self: *@This() = @alignCast(@fieldParentPtr("writer", w)); + self.list.appendSlice(testing.allocator, w.buffered()) catch return error.WriteFailed; + w.end = 0; + var n: usize = 0; + for (data[0 .. data.len - 1]) |slice| { + self.list.appendSlice(testing.allocator, slice) catch return error.WriteFailed; + n += slice.len; + } + for (0..splat) |_| { + self.list.appendSlice(testing.allocator, data[data.len - 1]) catch return error.WriteFailed; + } + return n + splat * data[data.len - 1].len; + } + }; + + var staging: [4]u8 = undefined; + var sink: Sink = .{ .writer = .{ + .buffer = &staging, + .vtable = &.{ .drain = Sink.drain }, + } }; + defer sink.list.deinit(alloc); + + const data = "line one\nline\x1btwo\nline three\n"; + try encodeWriter(&sink.writer, data, .{ .bracketed = true }); + try sink.writer.flush(); + try testing.expectEqualStrings( + "\x1b[200~line one\nline two\nline three\n\x1b[201~", + sink.list.items, + ); +} + +test "encodeWriter too small" { + const testing = std.testing; + var buf: [4]u8 = undefined; + var writer: std.Io.Writer = .fixed(&buf); + try testing.expectError( + error.WriteFailed, + encodeWriter(&writer, "hello", .{ .bracketed = true }), + ); +} + +test max_frame_size { + const testing = std.testing; + const result = try encode(@as([]const u8, ""), .{ .bracketed = true }); + try testing.expectEqual(max_frame_size, result[0].len + result[2].len); +} + test "encode bracketed" { const testing = std.testing; const result = try encode( diff --git a/vendor/libghostty-vt/src/inspector/widgets.zig b/vendor/libghostty-vt/src/inspector/widgets.zig index dd8ebc0..970ca08 100644 --- a/vendor/libghostty-vt/src/inspector/widgets.zig +++ b/vendor/libghostty-vt/src/inspector/widgets.zig @@ -136,92 +136,3 @@ pub const DetachableHeader = struct { return is_open; } }; - -pub const DetachableHeaderState = struct { - show_window: bool = false, - - /// Internal state. Don't touch. - first_show: bool = false, -}; - -/// Render a collapsing header that can be detached into its own window. -/// When detached, renders as a separate window with a close button. -/// When attached, renders as a collapsing header with a pop-out button. -pub fn detachableHeader( - label: [:0]const u8, - state: *DetachableHeaderState, - ctx: anytype, - comptime contentFn: fn (@TypeOf(ctx)) void, -) void { - cimgui.c.ImGui_PushID(label); - defer cimgui.c.ImGui_PopID(); - - if (state.show_window) { - // On first show, dock this window to the right of the parent window's dock. - // We only do this once so the user can freely reposition the window afterward - // without it snapping back to the right on every frame. - if (!state.first_show) { - state.first_show = true; - const current_dock_id = cimgui.c.ImGui_GetWindowDockID(); - if (current_dock_id != 0) { - var dock_id_right: cimgui.c.ImGuiID = 0; - var dock_id_left: cimgui.c.ImGuiID = 0; - _ = cimgui.ImGui_DockBuilderSplitNode( - current_dock_id, - cimgui.c.ImGuiDir_Right, - 0.3, - &dock_id_right, - &dock_id_left, - ); - cimgui.ImGui_DockBuilderDockWindow(label, dock_id_right); - cimgui.ImGui_DockBuilderFinish(current_dock_id); - } - } - - defer cimgui.c.ImGui_End(); - if (cimgui.c.ImGui_Begin( - label, - &state.show_window, - cimgui.c.ImGuiWindowFlags_NoFocusOnAppearing, - )) contentFn(ctx); - return; - } - - // Reset first_show when window is closed so next open docks again - state.first_show = false; - - cimgui.c.ImGui_SetNextItemAllowOverlap(); - const is_open = cimgui.c.ImGui_CollapsingHeader( - label, - cimgui.c.ImGuiTreeNodeFlags_None, - ); - - // Place pop-out button inside the header bar - const header_max = cimgui.c.ImGui_GetItemRectMax(); - const header_min = cimgui.c.ImGui_GetItemRectMin(); - const frame_height = cimgui.c.ImGui_GetFrameHeight(); - const button_size = frame_height - 4; - const padding = 4; - - cimgui.c.ImGui_SameLine(); - cimgui.c.ImGui_SetCursorScreenPos(.{ - .x = header_max.x - button_size - padding, - .y = header_min.y + 2, - }); - cimgui.c.ImGui_PushStyleVarImVec2( - cimgui.c.ImGuiStyleVar_FramePadding, - .{ .x = 0, .y = 0 }, - ); - if (cimgui.c.ImGui_ButtonEx( - ">>##detach", - .{ .x = button_size, .y = button_size }, - )) { - state.show_window = true; - } - cimgui.c.ImGui_PopStyleVar(); - if (cimgui.c.ImGui_IsItemHovered(cimgui.c.ImGuiHoveredFlags_DelayShort)) { - cimgui.c.ImGui_SetTooltip("Pop out into separate window"); - } - - if (is_open) contentFn(ctx); -} diff --git a/vendor/libghostty-vt/src/lib/TinyIo.zig b/vendor/libghostty-vt/src/lib/TinyIo.zig index f892780..ddabe99 100644 --- a/vendor/libghostty-vt/src/lib/TinyIo.zig +++ b/vendor/libghostty-vt/src/lib/TinyIo.zig @@ -161,7 +161,7 @@ const vtable: Io.VTable = if (!supported) std.Io.failing.vtable.* else .{ .progressParentFile = Io.failingProgressParentFile, .random = Io.noRandom, - .randomSecure = Io.failingRandomSecure, + .randomSecure = randomSecure, .now = Io.noNow, .clockResolution = Io.failingClockResolution, @@ -212,6 +212,35 @@ fn swapCancelProtection( fn checkCancel(_: ?*anyopaque) Io.Cancelable!void {} +fn randomSecure(_: ?*anyopaque, buffer: []u8) Io.RandomSecureError!void { + if (buffer.len == 0) return; + + // The same sources as `std.Io.Threaded.randomSecure` minus + // cancelation and the /dev/urandom fallback: arc4random_buf where + // libc provides it (all the BSDs and Darwin, glibc 2.36+), otherwise + // the getrandom syscall on Linux. Anything else has no entropy. + if (builtin.link_libc and @TypeOf(posix.system.arc4random_buf) != void) { + posix.system.arc4random_buf(buffer.ptr, buffer.len); + return; + } + + if (builtin.os.tag == .linux) { + const linux = std.os.linux; + var i: usize = 0; + while (i < buffer.len) { + const rc = linux.getrandom(buffer[i..].ptr, buffer.len - i, 0); + switch (linux.errno(rc)) { + .SUCCESS => i += rc, + .INTR => continue, + else => return error.EntropyUnavailable, + } + } + return; + } + + return error.EntropyUnavailable; +} + fn closeFd(fd: posix.fd_t) void { // Never retry close on EINTR: POSIX leaves the fd state unspecified // and Linux always closes it, so retrying risks closing an unrelated @@ -965,6 +994,28 @@ test "Io.Mutex through TinyIo" { test_io.vtable.futexWaitUncancelable(test_io.userdata, &word, 1); } +test "randomSecure fills with fresh entropy" { + if (comptime !supported) return error.SkipZigTest; + const tio: TinyIo = .init; + const test_io = tio.io(); + const testing = std.testing; + + var a: [32]u8 = @splat(0); + var b: [32]u8 = @splat(0); + try test_io.randomSecure(&a); + try test_io.randomSecure(&b); + + // Non-zero and non-repeating. A zero fill is what `random` does + // without a source, which would make every one-time password the + // same; identical draws would mean the same thing. + try testing.expect(!std.mem.allEqual(u8, &a, 0)); + try testing.expect(!std.mem.allEqual(u8, &b, 0)); + try testing.expect(!std.mem.eql(u8, &a, &b)); + + // Zero-length is a no-op. + try test_io.randomSecure(a[0..0]); +} + test "unused operations fail gracefully" { if (comptime !supported) return error.SkipZigTest; const tio: TinyIo = .init; diff --git a/vendor/libghostty-vt/src/lib/allocator.zig b/vendor/libghostty-vt/src/lib/allocator.zig index fc56033..de343da 100644 --- a/vendor/libghostty-vt/src/lib/allocator.zig +++ b/vendor/libghostty-vt/src/lib/allocator.zig @@ -2,8 +2,8 @@ const std = @import("std"); const builtin = @import("builtin"); const testing = std.testing; -/// Convenience functions -pub const convenience = @import("allocator/convenience.zig"); +/// Wasm-specific allocation helpers. +pub const wasm = @import("allocator/wasm.zig"); /// Useful alias since they're required to create Zig allocators pub const ZigVTable = std.mem.Allocator.VTable; @@ -34,6 +34,10 @@ pub fn default(c_alloc_: ?*const Allocator) std.mem.Allocator { // Wasm if (comptime builtin.target.cpu.arch.isWasm()) return std.heap.wasm_allocator; + // Freestanding targets don't have a default heap. Using the failing + // allocator makes a missing allocator show up as out-of-memory. + if (comptime builtin.os.tag == .freestanding) return std.mem.Allocator.failing; + // No libc, use the preferred allocator for releases which is the // Zig SMP allocator. return std.heap.smp_allocator; diff --git a/vendor/libghostty-vt/src/lib/allocator/convenience.zig b/vendor/libghostty-vt/src/lib/allocator/convenience.zig deleted file mode 100644 index 0f5f88d..0000000 --- a/vendor/libghostty-vt/src/lib/allocator/convenience.zig +++ /dev/null @@ -1,59 +0,0 @@ -//! This contains convenience functions for allocating various types. -//! -//! The primary use case for this is Wasm builds. Ghostty relies a lot on -//! pointers to various types for ABI compatibility and creating those pointers -//! in Wasm is tedious. This file contains a purely additive set of functions -//! that can be exposed to the Wasm module without changing the API from the -//! C library. -//! -//! Given these are convenience methods, they always use the default allocator. -//! If a caller is using a custom allocator, they have the expertise to -//! allocate these types manually using their custom allocator. - -// Get our default allocator at comptime since it is known. -const default = @import("../allocator.zig").default; -const alloc = default(null); - -pub const Opaque = *anyopaque; - -pub fn allocOpaque() callconv(.c) ?*Opaque { - return alloc.create(*anyopaque) catch return null; -} - -pub fn freeOpaque(ptr: ?*Opaque) callconv(.c) void { - if (ptr) |p| alloc.destroy(p); -} - -pub fn allocU8Array(len: usize) callconv(.c) ?[*]u8 { - const slice = alloc.alloc(u8, len) catch return null; - return slice.ptr; -} - -pub fn freeU8Array(ptr: ?[*]u8, len: usize) callconv(.c) void { - if (ptr) |p| alloc.free(p[0..len]); -} - -pub fn allocU16Array(len: usize) callconv(.c) ?[*]u16 { - const slice = alloc.alloc(u16, len) catch return null; - return slice.ptr; -} - -pub fn freeU16Array(ptr: ?[*]u16, len: usize) callconv(.c) void { - if (ptr) |p| alloc.free(p[0..len]); -} - -pub fn allocU8() callconv(.c) ?*u8 { - return alloc.create(u8) catch return null; -} - -pub fn freeU8(ptr: ?*u8) callconv(.c) void { - if (ptr) |p| alloc.destroy(p); -} - -pub fn allocUsize() callconv(.c) ?*usize { - return alloc.create(usize) catch return null; -} - -pub fn freeUsize(ptr: ?*usize) callconv(.c) void { - if (ptr) |p| alloc.destroy(p); -} diff --git a/vendor/libghostty-vt/src/lib/allocator/wasm.zig b/vendor/libghostty-vt/src/lib/allocator/wasm.zig new file mode 100644 index 0000000..2243910 --- /dev/null +++ b/vendor/libghostty-vt/src/lib/allocator/wasm.zig @@ -0,0 +1,98 @@ +//! Wasm allocation conveniences for caller-owned storage and opaque out slots. +//! +//! The primary use case for this is Wasm builds. Ghostty relies a lot on +//! pointers to various types for ABI compatibility and creating those pointers +//! in Wasm is tedious. This file contains the small set of functions exposed by +//! the Wasm module without changing the API from the C library. +//! +//! Given these are convenience methods, they always use the default allocator. +//! If a caller is using a custom allocator, they have the expertise to +//! allocate these types manually using their custom allocator. + +const std = @import("std"); +const c_abi = @import("../c_abi.zig"); + +// Get our default allocator at comptime since it is known. +const default = @import("../allocator.zig").default; +const alloc = default(null); +const wasm_alignment: std.mem.Alignment = .fromByteUnits(c_abi.max_alignment); + +/// A nullable opaque C handle stored in a constructor out-parameter slot. +pub const Opaque = ?*anyopaque; + +/// Allocate `len` bytes of uninitialized, caller-owned Wasm ABI storage. +/// +/// The returned pointer is aligned for any fundamental C ABI type and must be +/// released with `freeBytes` using the same `len`. Returns null when `len` is +/// zero or allocation fails. +pub fn allocBytes(len: usize) callconv(.c) ?[*]u8 { + if (len == 0) return null; + return alloc.rawAlloc(len, wasm_alignment, @returnAddress()); +} + +/// Release storage returned by `allocBytes`. +/// +/// `len` must exactly match the allocation length. A null pointer is ignored. +pub fn freeBytes(ptr: ?[*]u8, len: usize) callconv(.c) void { + const p = ptr orelse return; + if (len == 0) return; + alloc.rawFree(p[0..len], wasm_alignment, @returnAddress()); +} + +/// Allocate a null-initialized slot for an opaque constructor out-parameter. +/// +/// The slot may be reused after each value is removed with `takeOpaque`. It +/// must eventually be released with `freeOpaque`. +pub fn allocOpaque() callconv(.c) ?*Opaque { + const ptr = allocBytes(@sizeOf(Opaque)) orelse return null; + const result: *Opaque = @ptrCast(@alignCast(ptr)); + result.* = null; + return result; +} + +/// Release a slot returned by `allocOpaque` without freeing its stored handle. +/// +/// Call the handle's type-specific destructor before freeing a populated slot. +/// A null slot pointer is ignored. +pub fn freeOpaque(ptr: ?*Opaque) callconv(.c) void { + freeBytes(@ptrCast(ptr), @sizeOf(Opaque)); +} + +/// Remove and return the handle in an opaque out-parameter slot. +/// +/// The slot is reset to null so it can be safely reused for another +/// constructor. This does not free either the handle or the slot. Returns null +/// when the slot pointer is null or the slot is empty. +pub fn takeOpaque(ptr: ?*Opaque) callconv(.c) Opaque { + const p = ptr orelse return null; + const result = p.*; + p.* = null; + return result; +} + +test "Wasm allocation" { + const ptr = allocBytes(1) orelse return error.OutOfMemory; + defer freeBytes(ptr, 1); + + try std.testing.expectEqual( + @as(usize, 0), + @intFromPtr(ptr) % c_abi.max_alignment, + ); + ptr[0] = 42; + try std.testing.expectEqual(@as(u8, 42), ptr[0]); + + try std.testing.expect(allocBytes(0) == null); + freeBytes(null, 0); +} + +test "opaque slots are initialized and reusable" { + const slot = allocOpaque() orelse return error.OutOfMemory; + defer freeOpaque(slot); + + try std.testing.expect(takeOpaque(slot) == null); + + var value: u8 = 42; + slot.* = &value; + try std.testing.expectEqual(@as(?*anyopaque, &value), takeOpaque(slot)); + try std.testing.expect(takeOpaque(slot) == null); +} diff --git a/vendor/libghostty-vt/src/lib/c_abi.zig b/vendor/libghostty-vt/src/lib/c_abi.zig new file mode 100644 index 0000000..3ce3475 --- /dev/null +++ b/vendor/libghostty-vt/src/lib/c_abi.zig @@ -0,0 +1,28 @@ +//! Memory-layout properties shared with C API callers. +//! +//! The C ABI defines how values are represented in memory so code written in +//! different languages can safely exchange them. One part of that contract is +//! alignment: some values must begin at an address divisible by 2, 4, 8, or +//! another power of two. Reading a value from a less-aligned address can be +//! slow on some CPUs and invalid on others. +//! +//! This module derives those properties from Zig's compile-time target data. +//! It does not call or link libc. Keeping the calculation here gives allocators +//! and ABI metadata one source of truth. + +const std = @import("std"); +const builtin = @import("builtin"); + +/// Largest address alignment required by a fundamental C ABI type, in bytes. +/// +/// For example, a value of 16 means storage intended to hold an arbitrary C +/// value must begin at an address evenly divisible by 16. The Wasm allocator +/// uses this value for caller-owned ABI storage, and `ghostty_type_json` +/// publishes it so hosts know the guarantee made by that allocator. +pub const max_alignment: u16 = max: { + var result: u16 = @alignOf(*anyopaque); + for (std.enums.values(std.Target.CType)) |c_type| { + result = @max(result, builtin.target.cTypeAlignment(c_type)); + } + break :max result; +}; diff --git a/vendor/libghostty-vt/src/lib/main.zig b/vendor/libghostty-vt/src/lib/main.zig index 45cdb71..573007b 100644 --- a/vendor/libghostty-vt/src/lib/main.zig +++ b/vendor/libghostty-vt/src/lib/main.zig @@ -1,5 +1,6 @@ const std = @import("std"); const enumpkg = @import("enum.zig"); +const packedpkg = @import("packed.zig"); const structpkg = @import("struct.zig"); const types = @import("types.zig"); const unionpkg = @import("union.zig"); @@ -8,6 +9,8 @@ pub const allocator = @import("allocator.zig"); pub const TinyIo = @import("TinyIo.zig"); pub const Buffer = types.Buffer; pub const Enum = enumpkg.Enum; +pub const Packed = packedpkg.Packed; +pub const PackedTaggedUnion = packedpkg.PackedTaggedUnion; pub const checkGhosttyHEnum = enumpkg.checkGhosttyHEnum; pub const String = types.String; pub const Struct = structpkg.Struct; diff --git a/vendor/libghostty-vt/src/lib/packed.zig b/vendor/libghostty-vt/src/lib/packed.zig new file mode 100644 index 0000000..b210242 --- /dev/null +++ b/vendor/libghostty-vt/src/lib/packed.zig @@ -0,0 +1,290 @@ +const std = @import("std"); +const testing = std.testing; + +/// Metadata for a field in a packed layout. +pub const FieldOptions = struct { + /// Public name for this field. The Zig field name is used by default. + name: ?[]const u8 = null, + + /// Public type name for scalar fields. The Zig type is used by default. + type_name: ?[]const u8 = null, + + /// Omit this field from public metadata. This is intended for padding. + omit: bool = false, + + /// How the field is represented in public metadata. + encoding: Encoding = .scalar, + + pub const Encoding = union(enum) { + scalar, + @"packed": type, + tagged_union: type, + }; +}; + +/// Create metadata for a packed struct. Physical layout information is always +/// reflected from `T`; options only provide public names and relationships. +pub fn Packed( + comptime T: type, + comptime options: PackedOptions(T), +) type { + const info = packedStructInfo(T); + const FieldT = std.meta.FieldEnum(T); + + return struct { + pub const Zig = T; + + // I know this is an insane amount of validation but getting + // this correct is critical to C APIs working so we go overboard. + comptime { + for (info.fields, 0..) |field, i| { + const field_options = @field(options.fields, field.name); + if (field_options.omit) { + if (field_options.name != null or + field_options.type_name != null or + field_options.encoding != .scalar) + { + @compileError("omitted packed field has other options: " ++ field.name); + } + continue; + } + + if (field_options.name) |name| { + if (name.len == 0) + @compileError("packed field name cannot be empty: " ++ field.name); + } + + switch (field_options.encoding) { + .scalar => switch (@typeInfo(field.type)) { + .bool, .int, .@"enum" => {}, + else => @compileError("packed field requires an explicit encoding: " ++ field.name), + }, + .@"packed" => |Layout| { + if (Layout.Zig != field.type) + @compileError("nested packed layout has the wrong Zig type: " ++ field.name); + if (field_options.type_name != null) + @compileError("nested packed field cannot have a scalar type name: " ++ field.name); + }, + .tagged_union => |Layout| { + if (Layout.Owner != T or Layout.Union != field.type or + Layout.union_field != @field(FieldT, field.name)) + { + @compileError("tagged union layout does not match packed field: " ++ field.name); + } + if (field_options.type_name != null) + @compileError("tagged union field cannot have a scalar type name: " ++ field.name); + }, + } + + const public_name = field_options.name orelse field.name; + for (info.fields[0..i]) |previous| { + const previous_options = @field(options.fields, previous.name); + if (previous_options.omit) continue; + const previous_name = previous_options.name orelse previous.name; + if (std.mem.eql(u8, public_name, previous_name)) + @compileError("duplicate public packed field name: " ++ public_name); + } + } + } + + pub const Backing = info.backing_integer.?; + pub const Field = std.meta.FieldEnum(T); + + pub fn fieldOptions(comptime field: Field) FieldOptions { + return @field(options.fields, @tagName(field)); + } + + pub fn fieldName(comptime field: Field) ?[]const u8 { + const field_options = fieldOptions(field); + if (field_options.omit) return null; + return field_options.name orelse @tagName(field); + } + + pub fn bitOffset(comptime field: Field) usize { + return @bitOffsetOf(T, @tagName(field)); + } + + pub fn bitWidth(comptime field: Field) usize { + return @bitSizeOf(@FieldType(T, @tagName(field))); + } + }; +} + +/// Options for a packed struct. A field is generated for every field in `T` +/// so unknown field names are rejected by normal Zig type checking. +pub fn PackedOptions(comptime T: type) type { + const fields = packedStructInfo(T).fields; + const default_options: FieldOptions = .{}; + + var names: [fields.len][]const u8 = undefined; + var types: [fields.len]type = undefined; + var attrs: [fields.len]std.builtin.Type.StructField.Attributes = undefined; + + for (fields, 0..) |field, i| { + names[i] = field.name; + types[i] = FieldOptions; + attrs[i] = .{ .default_value_ptr = &default_options }; + } + + const Fields = @Struct(.auto, null, &names, &types, &attrs); + return struct { + fields: Fields = .{}, + }; +} + +/// Describe a packed union field whose active arm is selected by another +/// field in the containing packed struct. Each tag maps to a reflected packed +/// arm layout. Multiple tags may map to the same source union field. +pub fn PackedTaggedUnion( + comptime OwnerT: type, + comptime union_field_value: std.meta.FieldEnum(OwnerT), + comptime tag_field_value: std.meta.FieldEnum(OwnerT), + comptime options: PackedTaggedUnionOptions( + @FieldType(OwnerT, @tagName(union_field_value)), + @FieldType(OwnerT, @tagName(tag_field_value)), + ), +) type { + _ = packedStructInfo(OwnerT); + const UnionT = @FieldType(OwnerT, @tagName(union_field_value)); + const union_info = @typeInfo(UnionT).@"union"; + if (union_info.layout != .@"packed") + @compileError("packed tagged union value must be a packed union"); + + const TagT = @FieldType(OwnerT, @tagName(tag_field_value)); + const tag_info = @typeInfo(TagT).@"enum"; + const ArmT = PackedTaggedUnionArm(UnionT); + + return struct { + pub const Owner = OwnerT; + pub const Union = UnionT; + pub const Tag = TagT; + pub const Arm = ArmT; + pub const union_field = union_field_value; + pub const tag_field = tag_field_value; + + comptime { + for (tag_info.fields) |tag| { + const arm_value = @field(options.arms, tag.name) orelse continue; + switch (arm_value) { + inline else => |Layout, source| { + const source_name = @tagName(source); + if (Layout.Zig != @FieldType(UnionT, source_name)) + @compileError("packed union arm layout has the wrong Zig type for tag: " ++ tag.name); + if (@bitSizeOf(Layout.Zig) > @bitSizeOf(UnionT)) + @compileError("packed union arm is wider than its union: " ++ tag.name); + }, + } + } + } + + pub fn arm(comptime tag: Tag) ?Arm { + return @field(options.arms, @tagName(tag)); + } + }; +} + +/// Options for mapping tag values to packed union arms. +pub fn PackedTaggedUnionOptions(comptime Union: type, comptime Tag: type) type { + const union_info = @typeInfo(Union).@"union"; + if (union_info.layout != .@"packed") + @compileError("packed tagged union value must be a packed union"); + const tag_fields = @typeInfo(Tag).@"enum".fields; + const Arm = PackedTaggedUnionArm(Union); + const default_arm: ?Arm = null; + + var names: [tag_fields.len][]const u8 = undefined; + var types: [tag_fields.len]type = undefined; + var attrs: [tag_fields.len]std.builtin.Type.StructField.Attributes = undefined; + + for (tag_fields, 0..) |field, i| { + names[i] = field.name; + types[i] = ?Arm; + attrs[i] = .{ .default_value_ptr = &default_arm }; + } + + const Arms = @Struct(.auto, null, &names, &types, &attrs); + return struct { + arms: Arms = .{}, + }; +} + +fn PackedTaggedUnionArm(comptime Union: type) type { + const fields = @typeInfo(Union).@"union".fields; + const Tag = std.meta.FieldEnum(Union); + + var names: [fields.len][]const u8 = undefined; + var types: [fields.len]type = undefined; + var attrs: [fields.len]std.builtin.Type.UnionField.Attributes = undefined; + + for (fields, 0..) |field, i| { + names[i] = field.name; + types[i] = type; + attrs[i] = .{ .@"align" = 1 }; + } + + return @Union(.auto, Tag, &names, &types, &attrs); +} + +fn packedStructInfo(comptime T: type) std.builtin.Type.Struct { + const info = @typeInfo(T).@"struct"; + if (info.layout != .@"packed") + @compileError("Packed requires a packed struct"); + if (info.backing_integer == null) + @compileError("Packed requires an integer-backed packed struct"); + return info; +} + +test "Packed: reflected fields and nested tagged union" { + const Tag = enum(u2) { first, second, third }; + const Value = packed union { + number: packed struct(u4) { + data: u3, + _pad: u1, + }, + flags: packed struct(u4) { + a: bool, + b: bool, + _pad: u2, + }, + }; + const Nested = packed struct(u1) { enabled: bool }; + const Subject = packed struct(u8) { + tag: Tag, + value: Value, + nested: Nested, + _padding: u1, + }; + + const Number = Packed(@FieldType(Value, "number"), .{ .fields = .{ + .data = .{ .name = "number" }, + ._pad = .{ .omit = true }, + } }); + const Flags = Packed(@FieldType(Value, "flags"), .{ .fields = .{ + ._pad = .{ .omit = true }, + } }); + const NestedLayout = Packed(Nested, .{}); + const ValueLayout = PackedTaggedUnion(Subject, .value, .tag, .{ .arms = .{ + .first = .{ .number = Number }, + .second = .{ .number = Number }, + .third = .{ .flags = Flags }, + } }); + const Layout = Packed(Subject, .{ .fields = .{ + .value = .{ .encoding = .{ .tagged_union = ValueLayout } }, + .nested = .{ .encoding = .{ .@"packed" = NestedLayout } }, + ._padding = .{ .omit = true }, + } }); + + try testing.expectEqual(@bitOffsetOf(Subject, "value"), Layout.bitOffset(.value)); + try testing.expectEqual(@bitSizeOf(Value), Layout.bitWidth(.value)); + try testing.expectEqualStrings("number", Number.fieldName(.data).?); + try testing.expect(Number.fieldName(._pad) == null); + switch (ValueLayout.arm(.first).?) { + .number => |ArmLayout| try testing.expect(ArmLayout == Number), + else => return error.TestUnexpectedResult, + } + switch (ValueLayout.arm(.second).?) { + .number => |ArmLayout| try testing.expect(ArmLayout == Number), + else => return error.TestUnexpectedResult, + } + try testing.expectEqual(@as(usize, 1), NestedLayout.bitWidth(.enabled)); +} diff --git a/vendor/libghostty-vt/src/lib/types.zig b/vendor/libghostty-vt/src/lib/types.zig index 655fdc2..0105224 100644 --- a/vendor/libghostty-vt/src/lib/types.zig +++ b/vendor/libghostty-vt/src/lib/types.zig @@ -8,6 +8,7 @@ pub const String = extern struct { .ptr = zig.ptr, .len = zig.len, }, + else => @compileError("unsupported String.init type: " ++ @typeName(@TypeOf(zig))), }; } }; diff --git a/vendor/libghostty-vt/src/lib/union.zig b/vendor/libghostty-vt/src/lib/union.zig index 63a34a8..7ed3fc7 100644 --- a/vendor/libghostty-vt/src/lib/union.zig +++ b/vendor/libghostty-vt/src/lib/union.zig @@ -15,14 +15,18 @@ const Target = @import("target.zig").Target; /// be an enum created with the `Enum` function in this library, so that /// automatic C ABI compatibility is ensured. /// -/// The `Padding` type is a type that is always added to the C union -/// with the key `_padding`. This should be set to a type that has the size -/// and alignment needed to pad the C union to the expected size. This -/// should never change to ensure ABI compatibility. +/// `options.padding` is a type that is always added to the C union with the key +/// `_padding`. It should have the size and alignment needed to pad the C union +/// to the expected size and should never change to ensure ABI compatibility. +/// +/// Each tag has an optional field in `options.field_renames` that may rename its +/// C value field. Multiple tags may map to the same field when their C value +/// types are identical. Tags with no payload are omitted. This is used +/// for metadata and should match the C headers directly. pub fn TaggedUnion( comptime target: Target, comptime Union: type, - comptime Padding: type, + comptime options: TaggedUnionOptions(Union), ) type { return struct { comptime { @@ -30,7 +34,7 @@ pub fn TaggedUnion( .zig => {}, // For ABI compatibility, we expect that this is our union size. - .c => if (@sizeOf(CValue) != @sizeOf(Padding)) { + .c => if (@sizeOf(CValue) != @sizeOf(options.padding)) { @compileLog(@sizeOf(CValue)); @compileError("TaggedUnion CValue size does not match expected fixed size"); }, @@ -49,11 +53,25 @@ pub fn TaggedUnion( .c => extern struct { tag: Tag, value: CValue, + + /// Returns the public C value-union field name for `tag`, or + /// null when the tag has no value field. This is metadata only; + /// it does not rename fields in `CValue`. + pub fn cFieldRename(comptime tag: Tag) ?[]const u8 { + const tag_name = @tagName(tag); + const value = @field(@unionInit(Union, tag_name, undefined), tag_name); + + if (@field(options.field_renames, tag_name)) |name| return name; + if (@sizeOf(@TypeOf(value)) == 0) return null; + + return tag_name; + } }, }; /// The C ABI compatible union value type. pub const CValue = cvalue: { + @setEvalBranchQuota(10_000); switch (target) { .zig => break :cvalue extern struct {}, .c => {}, @@ -83,8 +101,8 @@ pub fn TaggedUnion( } names[tag_fields.len] = "_padding"; - types[tag_fields.len] = Padding; - attrs[tag_fields.len] = .{ .@"align" = @alignOf(Padding) }; + types[tag_fields.len] = options.padding; + attrs[tag_fields.len] = .{ .@"align" = @alignOf(options.padding) }; break :cvalue @Union(.@"extern", null, &names, &types, &attrs); }; @@ -114,17 +132,35 @@ pub fn TaggedUnion( /// Returns the value type for the given tag. pub fn Value(comptime tag: Tag) type { - @setEvalBranchQuota(10000); - inline for (@typeInfo(Union).@"union".fields) |field| { - const field_tag = @field(Tag, field.name); - if (field_tag == tag) return field.type; - } - - unreachable; + return @FieldType(Union, @tagName(tag)); } }; } +/// Options for generating the C representation of a tagged union. +pub fn TaggedUnionOptions(comptime Union: type) type { + const Tag = @typeInfo(Union).@"union".tag_type.?; + const tag_fields = @typeInfo(Tag).@"enum".fields; + const FieldRenames: type = field_renames: { + const default_rename: ?[]const u8 = null; + + var names: [tag_fields.len][]const u8 = undefined; + var types: [tag_fields.len]type = undefined; + var attrs: [tag_fields.len]std.builtin.Type.StructField.Attributes = undefined; + + for (tag_fields, 0..) |field, i| { + names[i] = field.name; + types[i] = ?[]const u8; + attrs[i] = .{ .default_value_ptr = &default_rename }; + } + + break :field_renames @Struct(.auto, null, &names, &types, &attrs); + }; + return struct { + padding: type, + field_renames: FieldRenames = .{}, + }; +} test "TaggedUnion: matching size" { const Tag = enum(c_int) { a, b }; const U = TaggedUnion( @@ -133,7 +169,7 @@ test "TaggedUnion: matching size" { a: u32, b: u64, }, - u64, + .{ .padding = u64 }, ); try testing.expectEqual(8, @sizeOf(U.CValue)); @@ -146,7 +182,7 @@ test "TaggedUnion: padded size" { union(Tag) { a: u32, }, - u64, + .{ .padding = u64 }, ); try testing.expectEqual(8, @sizeOf(U.CValue)); @@ -157,9 +193,39 @@ test "TaggedUnion: c conversion" { const U = TaggedUnion(.c, union(Tag) { a: u32, b: u64, - }, u64); + }, .{ .padding = u64 }); const c = U.cval(.{ .a = 42 }); try testing.expectEqual(Tag.a, c.tag); try testing.expectEqual(42, c.value.a); } + +test "TaggedUnion: custom C value fields" { + const Tag = enum(c_int) { a, b, none }; + const Union = union(Tag) { + a: u32, + b: u32, + none, + }; + const U = TaggedUnion(.c, Union, .{ + .padding = u64, + .field_renames = .{ + .a = "value", + .b = "value", + }, + }); + + try testing.expect(!@hasField(U.CValue, "value")); + try testing.expect(@hasField(U.CValue, "a")); + try testing.expect(@hasField(U.CValue, "b")); + try testing.expect(@hasField(U.CValue, "none")); + try testing.expectEqualStrings("value", U.C.cFieldRename(.a).?); + try testing.expect(U.C.cFieldRename(.none) == null); + + const a = U.cval(.{ .a = 42 }); + try testing.expectEqual(Tag.a, a.tag); + try testing.expectEqual(42, a.value.a); + + const none = U.cval(.none); + try testing.expectEqual(Tag.none, none.tag); +} diff --git a/vendor/libghostty-vt/src/lib_vt.zig b/vendor/libghostty-vt/src/lib_vt.zig index 66da515..1627988 100644 --- a/vendor/libghostty-vt/src/lib_vt.zig +++ b/vendor/libghostty-vt/src/lib_vt.zig @@ -50,6 +50,7 @@ pub const sys = terminal.sys; pub const TinyIo = @import("lib/TinyIo.zig"); pub const apc = terminal.apc; +pub const clipboard = terminal.clipboard; pub const dcs = terminal.dcs; pub const osc = terminal.osc; pub const point = terminal.point; @@ -64,6 +65,7 @@ pub const parse_table = terminal.parse_table; pub const search = terminal.search; pub const sgr = terminal.sgr; pub const size = terminal.size; +pub const snapshot = terminal.snapshot; pub const x11_color = terminal.x11_color; pub const Charset = terminal.Charset; @@ -93,6 +95,10 @@ pub const TerminalStream = terminal.TerminalStream; pub const Stream = terminal.Stream; pub const StreamAction = terminal.StreamAction; pub const UnknownSequence = terminal.UnknownSequence; + +pub const Paste = terminal.Paste; +pub const PasteSource = terminal.PasteSource; +pub const PasteError = terminal.PasteError; pub const Cursor = Screen.Cursor; pub const CursorStyle = Screen.CursorStyle; pub const CursorStyleReq = terminal.CursorStyle; @@ -127,8 +133,11 @@ pub const input = struct { // Paste-related APIs pub const PasteError = paste.Error; pub const PasteOptions = paste.Options; + pub const max_paste_frame_size = paste.max_frame_size; pub const isSafePaste = paste.isSafe; + pub const isSafePasteWith = paste.isSafeWith; pub const encodePaste = paste.encode; + pub const encodePasteWriter = paste.encodeWriter; // Key encoding pub const Key = key.Key; @@ -180,44 +189,51 @@ comptime { _ = @import("quirks_memset.zig"); const c = terminal.c_api; - @export(&c.key_event_new, .{ .name = "ghostty_key_event_new" }); - @export(&c.key_event_free, .{ .name = "ghostty_key_event_free" }); - @export(&c.key_event_set_action, .{ .name = "ghostty_key_event_set_action" }); - @export(&c.key_event_get_action, .{ .name = "ghostty_key_event_get_action" }); - @export(&c.key_event_set_key, .{ .name = "ghostty_key_event_set_key" }); - @export(&c.key_event_get_key, .{ .name = "ghostty_key_event_get_key" }); - @export(&c.key_event_set_mods, .{ .name = "ghostty_key_event_set_mods" }); - @export(&c.key_event_get_mods, .{ .name = "ghostty_key_event_get_mods" }); - @export(&c.key_event_set_consumed_mods, .{ .name = "ghostty_key_event_set_consumed_mods" }); - @export(&c.key_event_get_consumed_mods, .{ .name = "ghostty_key_event_get_consumed_mods" }); - @export(&c.key_event_set_composing, .{ .name = "ghostty_key_event_set_composing" }); - @export(&c.key_event_get_composing, .{ .name = "ghostty_key_event_get_composing" }); - @export(&c.key_event_set_utf8, .{ .name = "ghostty_key_event_set_utf8" }); - @export(&c.key_event_get_utf8, .{ .name = "ghostty_key_event_get_utf8" }); - @export(&c.key_event_set_unshifted_codepoint, .{ .name = "ghostty_key_event_set_unshifted_codepoint" }); - @export(&c.key_event_get_unshifted_codepoint, .{ .name = "ghostty_key_event_get_unshifted_codepoint" }); - @export(&c.key_encoder_new, .{ .name = "ghostty_key_encoder_new" }); - @export(&c.key_encoder_free, .{ .name = "ghostty_key_encoder_free" }); - @export(&c.key_encoder_setopt, .{ .name = "ghostty_key_encoder_setopt" }); - @export(&c.key_encoder_setopt_from_terminal, .{ .name = "ghostty_key_encoder_setopt_from_terminal" }); - @export(&c.key_encoder_encode, .{ .name = "ghostty_key_encoder_encode" }); - @export(&c.mouse_event_new, .{ .name = "ghostty_mouse_event_new" }); - @export(&c.mouse_event_free, .{ .name = "ghostty_mouse_event_free" }); - @export(&c.mouse_event_set_action, .{ .name = "ghostty_mouse_event_set_action" }); - @export(&c.mouse_event_get_action, .{ .name = "ghostty_mouse_event_get_action" }); - @export(&c.mouse_event_set_button, .{ .name = "ghostty_mouse_event_set_button" }); - @export(&c.mouse_event_clear_button, .{ .name = "ghostty_mouse_event_clear_button" }); - @export(&c.mouse_event_get_button, .{ .name = "ghostty_mouse_event_get_button" }); - @export(&c.mouse_event_set_mods, .{ .name = "ghostty_mouse_event_set_mods" }); - @export(&c.mouse_event_get_mods, .{ .name = "ghostty_mouse_event_get_mods" }); - @export(&c.mouse_event_set_position, .{ .name = "ghostty_mouse_event_set_position" }); - @export(&c.mouse_event_get_position, .{ .name = "ghostty_mouse_event_get_position" }); - @export(&c.mouse_encoder_new, .{ .name = "ghostty_mouse_encoder_new" }); - @export(&c.mouse_encoder_free, .{ .name = "ghostty_mouse_encoder_free" }); - @export(&c.mouse_encoder_setopt, .{ .name = "ghostty_mouse_encoder_setopt" }); - @export(&c.mouse_encoder_setopt_from_terminal, .{ .name = "ghostty_mouse_encoder_setopt_from_terminal" }); - @export(&c.mouse_encoder_reset, .{ .name = "ghostty_mouse_encoder_reset" }); - @export(&c.mouse_encoder_encode, .{ .name = "ghostty_mouse_encoder_encode" }); + const features = terminal.options; + if (features.input_encode) { + @export(&c.key_event_new, .{ .name = "ghostty_key_event_new" }); + @export(&c.key_event_free, .{ .name = "ghostty_key_event_free" }); + @export(&c.key_event_set_action, .{ .name = "ghostty_key_event_set_action" }); + @export(&c.key_event_get_action, .{ .name = "ghostty_key_event_get_action" }); + @export(&c.key_event_set_key, .{ .name = "ghostty_key_event_set_key" }); + @export(&c.key_event_get_key, .{ .name = "ghostty_key_event_get_key" }); + @export(&c.key_event_set_mods, .{ .name = "ghostty_key_event_set_mods" }); + @export(&c.key_event_get_mods, .{ .name = "ghostty_key_event_get_mods" }); + @export(&c.key_event_set_consumed_mods, .{ .name = "ghostty_key_event_set_consumed_mods" }); + @export(&c.key_event_get_consumed_mods, .{ .name = "ghostty_key_event_get_consumed_mods" }); + @export(&c.key_event_set_composing, .{ .name = "ghostty_key_event_set_composing" }); + @export(&c.key_event_get_composing, .{ .name = "ghostty_key_event_get_composing" }); + @export(&c.key_event_set_utf8, .{ .name = "ghostty_key_event_set_utf8" }); + @export(&c.key_event_get_utf8, .{ .name = "ghostty_key_event_get_utf8" }); + @export(&c.key_event_set_unshifted_codepoint, .{ .name = "ghostty_key_event_set_unshifted_codepoint" }); + @export(&c.key_event_get_unshifted_codepoint, .{ .name = "ghostty_key_event_get_unshifted_codepoint" }); + @export(&c.key_encoder_new, .{ .name = "ghostty_key_encoder_new" }); + @export(&c.key_encoder_free, .{ .name = "ghostty_key_encoder_free" }); + @export(&c.key_encoder_setopt, .{ .name = "ghostty_key_encoder_setopt" }); + @export(&c.key_encoder_setopt_from_terminal, .{ .name = "ghostty_key_encoder_setopt_from_terminal" }); + @export(&c.key_encoder_encode, .{ .name = "ghostty_key_encoder_encode" }); + @export(&c.focus_encode, .{ .name = "ghostty_focus_encode" }); + @export(&c.paste_is_safe, .{ .name = "ghostty_paste_is_safe" }); + @export(&c.paste_encode, .{ .name = "ghostty_paste_encode" }); + @export(&c.terminal_paste, .{ .name = "ghostty_terminal_paste" }); + @export(&c.mouse_event_new, .{ .name = "ghostty_mouse_event_new" }); + @export(&c.mouse_event_free, .{ .name = "ghostty_mouse_event_free" }); + @export(&c.mouse_event_set_action, .{ .name = "ghostty_mouse_event_set_action" }); + @export(&c.mouse_event_get_action, .{ .name = "ghostty_mouse_event_get_action" }); + @export(&c.mouse_event_set_button, .{ .name = "ghostty_mouse_event_set_button" }); + @export(&c.mouse_event_clear_button, .{ .name = "ghostty_mouse_event_clear_button" }); + @export(&c.mouse_event_get_button, .{ .name = "ghostty_mouse_event_get_button" }); + @export(&c.mouse_event_set_mods, .{ .name = "ghostty_mouse_event_set_mods" }); + @export(&c.mouse_event_get_mods, .{ .name = "ghostty_mouse_event_get_mods" }); + @export(&c.mouse_event_set_position, .{ .name = "ghostty_mouse_event_set_position" }); + @export(&c.mouse_event_get_position, .{ .name = "ghostty_mouse_event_get_position" }); + @export(&c.mouse_encoder_new, .{ .name = "ghostty_mouse_encoder_new" }); + @export(&c.mouse_encoder_free, .{ .name = "ghostty_mouse_encoder_free" }); + @export(&c.mouse_encoder_setopt, .{ .name = "ghostty_mouse_encoder_setopt" }); + @export(&c.mouse_encoder_setopt_from_terminal, .{ .name = "ghostty_mouse_encoder_setopt_from_terminal" }); + @export(&c.mouse_encoder_reset, .{ .name = "ghostty_mouse_encoder_reset" }); + @export(&c.mouse_encoder_encode, .{ .name = "ghostty_mouse_encoder_encode" }); + } @export(&c.osc_new, .{ .name = "ghostty_osc_new" }); @export(&c.osc_free, .{ .name = "ghostty_osc_free" }); @export(&c.osc_next, .{ .name = "ghostty_osc_next" }); @@ -226,10 +242,7 @@ comptime { @export(&c.osc_command_type, .{ .name = "ghostty_osc_command_type" }); @export(&c.osc_command_data, .{ .name = "ghostty_osc_command_data" }); @export(&c.color_scheme_report_encode, .{ .name = "ghostty_color_scheme_report_encode" }); - @export(&c.focus_encode, .{ .name = "ghostty_focus_encode" }); @export(&c.mode_report_encode, .{ .name = "ghostty_mode_report_encode" }); - @export(&c.paste_is_safe, .{ .name = "ghostty_paste_is_safe" }); - @export(&c.paste_encode, .{ .name = "ghostty_paste_encode" }); @export(&c.unicode_codepoint_width, .{ .name = "ghostty_unicode_codepoint_width" }); @export(&c.unicode_grapheme_width, .{ .name = "ghostty_unicode_grapheme_width" }); @export(&c.size_report_encode, .{ .name = "ghostty_size_report_encode" }); @@ -237,21 +250,25 @@ comptime { @export(&c.style_is_default, .{ .name = "ghostty_style_is_default" }); @export(&c.sys_log_stderr, .{ .name = "ghostty_sys_log_stderr" }); @export(&c.sys_set, .{ .name = "ghostty_sys_set" }); - @export(&c.cell_get, .{ .name = "ghostty_cell_get" }); - @export(&c.cell_get_multi, .{ .name = "ghostty_cell_get_multi" }); - @export(&c.row_get, .{ .name = "ghostty_row_get" }); - @export(&c.row_get_multi, .{ .name = "ghostty_row_get_multi" }); - @export(&c.color_rgb_get, .{ .name = "ghostty_color_rgb_get" }); - @export(&c.color_contrast, .{ .name = "ghostty_color_contrast" }); - @export(&c.color_luminance, .{ .name = "ghostty_color_luminance" }); - @export(&c.color_parse, .{ .name = "ghostty_color_parse" }); - @export(&c.color_parse_palette_entry, .{ .name = "ghostty_color_parse_palette_entry" }); - @export(&c.color_parse_x11, .{ .name = "ghostty_color_parse_x11" }); - @export(&c.color_palette_default, .{ .name = "ghostty_color_palette_default" }); - @export(&c.color_palette_generate, .{ .name = "ghostty_color_palette_generate" }); - @export(&c.color_perceived_luminance, .{ .name = "ghostty_color_perceived_luminance" }); - @export(&c.color_x11_name_count, .{ .name = "ghostty_color_x11_name_count" }); - @export(&c.color_x11_names, .{ .name = "ghostty_color_x11_names" }); + if (features.grid_introspection) { + @export(&c.cell_get, .{ .name = "ghostty_cell_get" }); + @export(&c.cell_get_multi, .{ .name = "ghostty_cell_get_multi" }); + @export(&c.row_get, .{ .name = "ghostty_row_get" }); + @export(&c.row_get_multi, .{ .name = "ghostty_row_get_multi" }); + } + if (features.color) { + @export(&c.color_rgb_get, .{ .name = "ghostty_color_rgb_get" }); + @export(&c.color_contrast, .{ .name = "ghostty_color_contrast" }); + @export(&c.color_luminance, .{ .name = "ghostty_color_luminance" }); + @export(&c.color_parse, .{ .name = "ghostty_color_parse" }); + @export(&c.color_parse_palette_entry, .{ .name = "ghostty_color_parse_palette_entry" }); + @export(&c.color_parse_x11, .{ .name = "ghostty_color_parse_x11" }); + @export(&c.color_palette_default, .{ .name = "ghostty_color_palette_default" }); + @export(&c.color_palette_generate, .{ .name = "ghostty_color_palette_generate" }); + @export(&c.color_perceived_luminance, .{ .name = "ghostty_color_perceived_luminance" }); + @export(&c.color_x11_name_count, .{ .name = "ghostty_color_x11_name_count" }); + @export(&c.color_x11_names, .{ .name = "ghostty_color_x11_names" }); + } @export(&c.sgr_new, .{ .name = "ghostty_sgr_new" }); @export(&c.sgr_free, .{ .name = "ghostty_sgr_free" }); @export(&c.sgr_reset, .{ .name = "ghostty_sgr_reset" }); @@ -261,33 +278,41 @@ comptime { @export(&c.sgr_unknown_partial, .{ .name = "ghostty_sgr_unknown_partial" }); @export(&c.sgr_attribute_tag, .{ .name = "ghostty_sgr_attribute_tag" }); @export(&c.sgr_attribute_value, .{ .name = "ghostty_sgr_attribute_value" }); - @export(&c.formatter_terminal_new, .{ .name = "ghostty_formatter_terminal_new" }); - @export(&c.formatter_format_buf, .{ .name = "ghostty_formatter_format_buf" }); - @export(&c.formatter_format_alloc, .{ .name = "ghostty_formatter_format_alloc" }); - @export(&c.formatter_free, .{ .name = "ghostty_formatter_free" }); - @export(&c.terminal_selection_format_buf, .{ .name = "ghostty_terminal_selection_format_buf" }); - @export(&c.terminal_selection_format_alloc, .{ .name = "ghostty_terminal_selection_format_alloc" }); - @export(&c.render_state_new, .{ .name = "ghostty_render_state_new" }); - @export(&c.render_state_update, .{ .name = "ghostty_render_state_update" }); - @export(&c.render_state_begin_update, .{ .name = "ghostty_render_state_begin_update" }); - @export(&c.render_state_end_update, .{ .name = "ghostty_render_state_end_update" }); - @export(&c.render_state_get, .{ .name = "ghostty_render_state_get" }); - @export(&c.render_state_get_multi, .{ .name = "ghostty_render_state_get_multi" }); - @export(&c.render_state_set, .{ .name = "ghostty_render_state_set" }); - @export(&c.render_state_colors_get, .{ .name = "ghostty_render_state_colors_get" }); - @export(&c.render_state_row_iterator_new, .{ .name = "ghostty_render_state_row_iterator_new" }); - @export(&c.render_state_row_iterator_next, .{ .name = "ghostty_render_state_row_iterator_next" }); - @export(&c.render_state_row_get, .{ .name = "ghostty_render_state_row_get" }); - @export(&c.render_state_row_get_multi, .{ .name = "ghostty_render_state_row_get_multi" }); - @export(&c.render_state_row_set, .{ .name = "ghostty_render_state_row_set" }); - @export(&c.render_state_row_iterator_free, .{ .name = "ghostty_render_state_row_iterator_free" }); - @export(&c.render_state_row_cells_new, .{ .name = "ghostty_render_state_row_cells_new" }); - @export(&c.render_state_row_cells_next, .{ .name = "ghostty_render_state_row_cells_next" }); - @export(&c.render_state_row_cells_select, .{ .name = "ghostty_render_state_row_cells_select" }); - @export(&c.render_state_row_cells_get, .{ .name = "ghostty_render_state_row_cells_get" }); - @export(&c.render_state_row_cells_get_multi, .{ .name = "ghostty_render_state_row_cells_get_multi" }); - @export(&c.render_state_row_cells_free, .{ .name = "ghostty_render_state_row_cells_free" }); - @export(&c.render_state_free, .{ .name = "ghostty_render_state_free" }); + if (features.formatter) { + @export(&c.formatter_terminal_new, .{ .name = "ghostty_formatter_terminal_new" }); + @export(&c.formatter_format, .{ .name = "ghostty_formatter_format" }); + @export(&c.formatter_format_buf, .{ .name = "ghostty_formatter_format_buf" }); + @export(&c.formatter_format_alloc, .{ .name = "ghostty_formatter_format_alloc" }); + @export(&c.formatter_free, .{ .name = "ghostty_formatter_free" }); + } + if (features.formatter and features.selection) { + @export(&c.terminal_selection_format_buf, .{ .name = "ghostty_terminal_selection_format_buf" }); + @export(&c.terminal_selection_format_alloc, .{ .name = "ghostty_terminal_selection_format_alloc" }); + } + if (features.render_state) { + @export(&c.render_state_new, .{ .name = "ghostty_render_state_new" }); + @export(&c.render_state_update, .{ .name = "ghostty_render_state_update" }); + @export(&c.render_state_begin_update, .{ .name = "ghostty_render_state_begin_update" }); + @export(&c.render_state_end_update, .{ .name = "ghostty_render_state_end_update" }); + @export(&c.render_state_clean, .{ .name = "ghostty_render_state_clean" }); + @export(&c.render_state_get, .{ .name = "ghostty_render_state_get" }); + @export(&c.render_state_get_multi, .{ .name = "ghostty_render_state_get_multi" }); + @export(&c.render_state_set, .{ .name = "ghostty_render_state_set" }); + @export(&c.render_state_row_iterator_new, .{ .name = "ghostty_render_state_row_iterator_new" }); + @export(&c.render_state_row_iterator_next, .{ .name = "ghostty_render_state_row_iterator_next" }); + @export(&c.render_state_row_iterator_next_dirty, .{ .name = "ghostty_render_state_row_iterator_next_dirty" }); + @export(&c.render_state_row_get, .{ .name = "ghostty_render_state_row_get" }); + @export(&c.render_state_row_get_multi, .{ .name = "ghostty_render_state_row_get_multi" }); + @export(&c.render_state_row_set, .{ .name = "ghostty_render_state_row_set" }); + @export(&c.render_state_row_iterator_free, .{ .name = "ghostty_render_state_row_iterator_free" }); + @export(&c.render_state_row_cells_new, .{ .name = "ghostty_render_state_row_cells_new" }); + @export(&c.render_state_row_cells_next, .{ .name = "ghostty_render_state_row_cells_next" }); + @export(&c.render_state_row_cells_select, .{ .name = "ghostty_render_state_row_cells_select" }); + @export(&c.render_state_row_cells_get, .{ .name = "ghostty_render_state_row_cells_get" }); + @export(&c.render_state_row_cells_get_multi, .{ .name = "ghostty_render_state_row_cells_get_multi" }); + @export(&c.render_state_row_cells_free, .{ .name = "ghostty_render_state_row_cells_free" }); + @export(&c.render_state_free, .{ .name = "ghostty_render_state_free" }); + } @export(&c.terminal_new, .{ .name = "ghostty_terminal_new" }); @export(&c.terminal_free, .{ .name = "ghostty_terminal_free" }); @export(&c.terminal_reset, .{ .name = "ghostty_terminal_reset" }); @@ -303,66 +328,92 @@ comptime { @export(&c.terminal_continuation_write, .{ .name = "ghostty_terminal_continuation_write" }); @export(&c.terminal_continuation_buf, .{ .name = "ghostty_terminal_continuation_buf" }); @export(&c.terminal_continuation_alloc, .{ .name = "ghostty_terminal_continuation_alloc" }); - @export(&c.terminal_select_word, .{ .name = "ghostty_terminal_select_word" }); - @export(&c.terminal_select_word_between, .{ .name = "ghostty_terminal_select_word_between" }); - @export(&c.terminal_select_line, .{ .name = "ghostty_terminal_select_line" }); - @export(&c.terminal_select_all, .{ .name = "ghostty_terminal_select_all" }); - @export(&c.terminal_select_output, .{ .name = "ghostty_terminal_select_output" }); - @export(&c.terminal_selection_adjust, .{ .name = "ghostty_terminal_selection_adjust" }); - @export(&c.terminal_selection_order, .{ .name = "ghostty_terminal_selection_order" }); - @export(&c.terminal_selection_ordered, .{ .name = "ghostty_terminal_selection_ordered" }); - @export(&c.terminal_selection_contains, .{ .name = "ghostty_terminal_selection_contains" }); - @export(&c.terminal_selection_equal, .{ .name = "ghostty_terminal_selection_equal" }); - @export(&c.selection_gesture_new, .{ .name = "ghostty_selection_gesture_new" }); - @export(&c.selection_gesture_free, .{ .name = "ghostty_selection_gesture_free" }); - @export(&c.selection_gesture_reset, .{ .name = "ghostty_selection_gesture_reset" }); - @export(&c.selection_gesture_event, .{ .name = "ghostty_selection_gesture_event" }); - @export(&c.selection_gesture_get, .{ .name = "ghostty_selection_gesture_get" }); - @export(&c.selection_gesture_get_multi, .{ .name = "ghostty_selection_gesture_get_multi" }); - @export(&c.selection_gesture_event_new, .{ .name = "ghostty_selection_gesture_event_new" }); - @export(&c.selection_gesture_event_free, .{ .name = "ghostty_selection_gesture_event_free" }); - @export(&c.selection_gesture_event_set, .{ .name = "ghostty_selection_gesture_event_set" }); - @export(&c.terminal_grid_ref, .{ .name = "ghostty_terminal_grid_ref" }); - @export(&c.terminal_grid_ref_track, .{ .name = "ghostty_terminal_grid_ref_track" }); - @export(&c.terminal_point_from_grid_ref, .{ .name = "ghostty_terminal_point_from_grid_ref" }); - @export(&c.snapshot_encode, .{ .name = "ghostty_snapshot_encode" }); - @export(&c.snapshot_encode_buf, .{ .name = "ghostty_snapshot_encode_buf" }); - @export(&c.snapshot_encode_alloc, .{ .name = "ghostty_snapshot_encode_alloc" }); - @export(&c.snapshot_decoder_new, .{ .name = "ghostty_snapshot_decoder_new" }); - @export(&c.snapshot_decoder_new_buf, .{ .name = "ghostty_snapshot_decoder_new_buf" }); - @export(&c.snapshot_decoder_free, .{ .name = "ghostty_snapshot_decoder_free" }); - @export(&c.snapshot_decoder_set, .{ .name = "ghostty_snapshot_decoder_set" }); - @export(&c.snapshot_decoder_get, .{ .name = "ghostty_snapshot_decoder_get" }); - @export(&c.snapshot_decoder_get_multi, .{ .name = "ghostty_snapshot_decoder_get_multi" }); - @export(&c.snapshot_decoder_ready, .{ .name = "ghostty_snapshot_decoder_ready" }); - @export(&c.snapshot_decoder_next, .{ .name = "ghostty_snapshot_decoder_next" }); - @export(&c.snapshot_decoder_decode, .{ .name = "ghostty_snapshot_decoder_decode" }); - @export(&c.kitty_graphics_get, .{ .name = "ghostty_kitty_graphics_get" }); - @export(&c.kitty_graphics_image, .{ .name = "ghostty_kitty_graphics_image" }); - @export(&c.kitty_graphics_image_get, .{ .name = "ghostty_kitty_graphics_image_get" }); - @export(&c.kitty_graphics_image_get_multi, .{ .name = "ghostty_kitty_graphics_image_get_multi" }); - @export(&c.kitty_graphics_placement_iterator_new, .{ .name = "ghostty_kitty_graphics_placement_iterator_new" }); - @export(&c.kitty_graphics_placement_iterator_free, .{ .name = "ghostty_kitty_graphics_placement_iterator_free" }); - @export(&c.kitty_graphics_placement_iterator_set, .{ .name = "ghostty_kitty_graphics_placement_iterator_set" }); - @export(&c.kitty_graphics_placement_next, .{ .name = "ghostty_kitty_graphics_placement_next" }); - @export(&c.kitty_graphics_placement_get, .{ .name = "ghostty_kitty_graphics_placement_get" }); - @export(&c.kitty_graphics_placement_get_multi, .{ .name = "ghostty_kitty_graphics_placement_get_multi" }); - @export(&c.kitty_graphics_placement_rect, .{ .name = "ghostty_kitty_graphics_placement_rect" }); - @export(&c.kitty_graphics_placement_pixel_size, .{ .name = "ghostty_kitty_graphics_placement_pixel_size" }); - @export(&c.kitty_graphics_placement_grid_size, .{ .name = "ghostty_kitty_graphics_placement_grid_size" }); - @export(&c.kitty_graphics_placement_viewport_pos, .{ .name = "ghostty_kitty_graphics_placement_viewport_pos" }); - @export(&c.kitty_graphics_placement_source_rect, .{ .name = "ghostty_kitty_graphics_placement_source_rect" }); - @export(&c.kitty_graphics_placement_render_info, .{ .name = "ghostty_kitty_graphics_placement_render_info" }); - @export(&c.grid_ref_cell, .{ .name = "ghostty_grid_ref_cell" }); - @export(&c.grid_ref_row, .{ .name = "ghostty_grid_ref_row" }); - @export(&c.grid_ref_graphemes, .{ .name = "ghostty_grid_ref_graphemes" }); - @export(&c.grid_ref_hyperlink_uri, .{ .name = "ghostty_grid_ref_hyperlink_uri" }); - @export(&c.grid_ref_style, .{ .name = "ghostty_grid_ref_style" }); - @export(&c.tracked_grid_ref_free, .{ .name = "ghostty_tracked_grid_ref_free" }); - @export(&c.tracked_grid_ref_has_value, .{ .name = "ghostty_tracked_grid_ref_has_value" }); - @export(&c.tracked_grid_ref_point, .{ .name = "ghostty_tracked_grid_ref_point" }); - @export(&c.tracked_grid_ref_set, .{ .name = "ghostty_tracked_grid_ref_set" }); - @export(&c.tracked_grid_ref_snapshot, .{ .name = "ghostty_tracked_grid_ref_snapshot" }); + if (features.selection) { + @export(&c.terminal_select_word, .{ .name = "ghostty_terminal_select_word" }); + @export(&c.terminal_select_word_between, .{ .name = "ghostty_terminal_select_word_between" }); + @export(&c.terminal_select_line, .{ .name = "ghostty_terminal_select_line" }); + @export(&c.terminal_select_all, .{ .name = "ghostty_terminal_select_all" }); + @export(&c.terminal_select_output, .{ .name = "ghostty_terminal_select_output" }); + @export(&c.terminal_selection_adjust, .{ .name = "ghostty_terminal_selection_adjust" }); + @export(&c.terminal_selection_order, .{ .name = "ghostty_terminal_selection_order" }); + @export(&c.terminal_selection_ordered, .{ .name = "ghostty_terminal_selection_ordered" }); + @export(&c.terminal_selection_contains, .{ .name = "ghostty_terminal_selection_contains" }); + @export(&c.terminal_selection_equal, .{ .name = "ghostty_terminal_selection_equal" }); + @export(&c.selection_gesture_new, .{ .name = "ghostty_selection_gesture_new" }); + @export(&c.selection_gesture_free, .{ .name = "ghostty_selection_gesture_free" }); + @export(&c.selection_gesture_reset, .{ .name = "ghostty_selection_gesture_reset" }); + @export(&c.selection_gesture_event, .{ .name = "ghostty_selection_gesture_event" }); + @export(&c.selection_gesture_get, .{ .name = "ghostty_selection_gesture_get" }); + @export(&c.selection_gesture_get_multi, .{ .name = "ghostty_selection_gesture_get_multi" }); + @export(&c.selection_gesture_event_new, .{ .name = "ghostty_selection_gesture_event_new" }); + @export(&c.selection_gesture_event_free, .{ .name = "ghostty_selection_gesture_event_free" }); + @export(&c.selection_gesture_event_set, .{ .name = "ghostty_selection_gesture_event_set" }); + } + if (features.search) { + @export(&c.search_new, .{ .name = "ghostty_search_new" }); + @export(&c.search_free, .{ .name = "ghostty_search_free" }); + @export(&c.search_tick, .{ .name = "ghostty_search_tick" }); + @export(&c.search_feed, .{ .name = "ghostty_search_feed" }); + @export(&c.search_run, .{ .name = "ghostty_search_run" }); + @export(&c.search_set, .{ .name = "ghostty_search_set" }); + @export(&c.search_get, .{ .name = "ghostty_search_get" }); + @export(&c.search_get_multi, .{ .name = "ghostty_search_get_multi" }); + } + // Selections are expressed in grid references, so the untracked + // reference constructors are required by both features. + if (features.grid_introspection or features.selection) { + @export(&c.terminal_grid_ref, .{ .name = "ghostty_terminal_grid_ref" }); + @export(&c.terminal_point_from_grid_ref, .{ .name = "ghostty_terminal_point_from_grid_ref" }); + } + if (features.grid_introspection) { + @export(&c.terminal_grid_ref_track, .{ .name = "ghostty_terminal_grid_ref_track" }); + } + if (features.snapshot) { + @export(&c.snapshot_encode, .{ .name = "ghostty_snapshot_encode" }); + @export(&c.snapshot_encode_buf, .{ .name = "ghostty_snapshot_encode_buf" }); + @export(&c.snapshot_encode_alloc, .{ .name = "ghostty_snapshot_encode_alloc" }); + @export(&c.snapshot_decoder_new, .{ .name = "ghostty_snapshot_decoder_new" }); + @export(&c.snapshot_decoder_new_buf, .{ .name = "ghostty_snapshot_decoder_new_buf" }); + @export(&c.snapshot_decoder_free, .{ .name = "ghostty_snapshot_decoder_free" }); + @export(&c.snapshot_decoder_set, .{ .name = "ghostty_snapshot_decoder_set" }); + @export(&c.snapshot_decoder_get, .{ .name = "ghostty_snapshot_decoder_get" }); + @export(&c.snapshot_decoder_get_multi, .{ .name = "ghostty_snapshot_decoder_get_multi" }); + @export(&c.snapshot_decoder_ready, .{ .name = "ghostty_snapshot_decoder_ready" }); + @export(&c.snapshot_decoder_next, .{ .name = "ghostty_snapshot_decoder_next" }); + @export(&c.snapshot_decoder_decode, .{ .name = "ghostty_snapshot_decoder_decode" }); + } + if (features.kitty_graphics) { + @export(&c.kitty_graphics_get, .{ .name = "ghostty_kitty_graphics_get" }); + @export(&c.kitty_graphics_image, .{ .name = "ghostty_kitty_graphics_image" }); + @export(&c.kitty_graphics_image_get, .{ .name = "ghostty_kitty_graphics_image_get" }); + @export(&c.kitty_graphics_image_get_multi, .{ .name = "ghostty_kitty_graphics_image_get_multi" }); + @export(&c.kitty_graphics_placement_iterator_new, .{ .name = "ghostty_kitty_graphics_placement_iterator_new" }); + @export(&c.kitty_graphics_placement_iterator_free, .{ .name = "ghostty_kitty_graphics_placement_iterator_free" }); + @export(&c.kitty_graphics_placement_iterator_set, .{ .name = "ghostty_kitty_graphics_placement_iterator_set" }); + @export(&c.kitty_graphics_placement_next, .{ .name = "ghostty_kitty_graphics_placement_next" }); + @export(&c.kitty_graphics_placement_get, .{ .name = "ghostty_kitty_graphics_placement_get" }); + @export(&c.kitty_graphics_placement_get_multi, .{ .name = "ghostty_kitty_graphics_placement_get_multi" }); + @export(&c.kitty_graphics_placement_rect, .{ .name = "ghostty_kitty_graphics_placement_rect" }); + @export(&c.kitty_graphics_placement_pixel_size, .{ .name = "ghostty_kitty_graphics_placement_pixel_size" }); + @export(&c.kitty_graphics_placement_grid_size, .{ .name = "ghostty_kitty_graphics_placement_grid_size" }); + @export(&c.kitty_graphics_placement_viewport_pos, .{ .name = "ghostty_kitty_graphics_placement_viewport_pos" }); + @export(&c.kitty_graphics_placement_source_rect, .{ .name = "ghostty_kitty_graphics_placement_source_rect" }); + @export(&c.kitty_graphics_placement_render_info, .{ .name = "ghostty_kitty_graphics_placement_render_info" }); + } + if (features.grid_introspection) { + @export(&c.grid_ref_cell, .{ .name = "ghostty_grid_ref_cell" }); + @export(&c.grid_ref_row, .{ .name = "ghostty_grid_ref_row" }); + @export(&c.grid_ref_graphemes, .{ .name = "ghostty_grid_ref_graphemes" }); + @export(&c.grid_ref_hyperlink_uri, .{ .name = "ghostty_grid_ref_hyperlink_uri" }); + @export(&c.grid_ref_style, .{ .name = "ghostty_grid_ref_style" }); + @export(&c.tracked_grid_ref_free, .{ .name = "ghostty_tracked_grid_ref_free" }); + @export(&c.tracked_grid_ref_has_value, .{ .name = "ghostty_tracked_grid_ref_has_value" }); + @export(&c.tracked_grid_ref_point, .{ .name = "ghostty_tracked_grid_ref_point" }); + @export(&c.tracked_grid_ref_set, .{ .name = "ghostty_tracked_grid_ref_set" }); + } + if (features.grid_introspection and features.snapshot) { + @export(&c.tracked_grid_ref_snapshot, .{ .name = "ghostty_tracked_grid_ref_snapshot" }); + } @export(&c.build_info, .{ .name = "ghostty_build_info" }); @export(&c.type_json, .{ .name = "ghostty_type_json" }); @export(&c.alloc_alloc, .{ .name = "ghostty_alloc" }); @@ -370,19 +421,12 @@ comptime { // On Wasm we need to export our allocator convenience functions. if (builtin.target.cpu.arch.isWasm()) { - const alloc = @import("lib/allocator/convenience.zig"); + const alloc = @import("lib/allocator/wasm.zig"); + @export(&alloc.allocBytes, .{ .name = "ghostty_wasm_alloc" }); + @export(&alloc.freeBytes, .{ .name = "ghostty_wasm_free" }); @export(&alloc.allocOpaque, .{ .name = "ghostty_wasm_alloc_opaque" }); @export(&alloc.freeOpaque, .{ .name = "ghostty_wasm_free_opaque" }); - @export(&alloc.allocU8Array, .{ .name = "ghostty_wasm_alloc_u8_array" }); - @export(&alloc.freeU8Array, .{ .name = "ghostty_wasm_free_u8_array" }); - @export(&alloc.allocU16Array, .{ .name = "ghostty_wasm_alloc_u16_array" }); - @export(&alloc.freeU16Array, .{ .name = "ghostty_wasm_free_u16_array" }); - @export(&alloc.allocU8, .{ .name = "ghostty_wasm_alloc_u8" }); - @export(&alloc.freeU8, .{ .name = "ghostty_wasm_free_u8" }); - @export(&alloc.allocUsize, .{ .name = "ghostty_wasm_alloc_usize" }); - @export(&alloc.freeUsize, .{ .name = "ghostty_wasm_free_usize" }); - @export(&c.wasm_alloc_sgr_attribute, .{ .name = "ghostty_wasm_alloc_sgr_attribute" }); - @export(&c.wasm_free_sgr_attribute, .{ .name = "ghostty_wasm_free_sgr_attribute" }); + @export(&alloc.takeOpaque, .{ .name = "ghostty_wasm_take_opaque" }); } } } @@ -390,17 +434,25 @@ comptime { pub const std_options: std.Options = opts: { var options: std.Options = .{}; - if (builtin.target.cpu.arch.isWasm()) { - // Wasm builds we specifically want to optimize for space with small - // releases so we bump up to warn. Everything else acts pretty normal. - options.log_level = switch (builtin.mode) { - .Debug => .debug, - .ReleaseSmall => .warn, - else => .info, - }; + if (native_freestanding) { + // Freestanding targets don't have an OS page size. We still need an + // alignment for terminal page allocations, and 16 covers everything + // stored in a page without requiring 4 KiB-aligned embedded heaps. + options.page_size_min = 16; + options.page_size_max = 16; + options.allow_stack_tracing = false; + } - // Wasm doesn't have access to stdio so we have a custom log function. - options.logFn = @import("os/wasm/log.zig").log; + if (builtin.target.cpu.arch.isWasm()) { + // In non-debug modes, we want to ship effectively no logging + // warn and lower add ~200KB at the time of this comment. + if (builtin.mode == .Debug) { + options.log_level = .debug; + options.logFn = @import("os/wasm/log.zig").log; + } else { + options.log_level = .err; + options.logFn = @import("os/wasm/log.zig").noop; + } } else if (terminal.options.c_abi) { // For C ABI builds, use a custom log function that dispatches to an // embedder-provided callback (or silently discards when none is set). @@ -431,10 +483,14 @@ pub const std_options: std.Options = opts: { /// traces on panic, std.debug.print, etc.). These builds are for /// development, where the roughly 160KB of binary size it costs is /// worth it. -const debug_machinery: bool = builtin.is_test or switch (builtin.mode) { - .Debug, .ReleaseSafe => true, - .ReleaseFast, .ReleaseSmall => false, -}; +const native_freestanding = builtin.target.os.tag == .freestanding and + !builtin.target.cpu.arch.isWasm(); + +const debug_machinery: bool = !native_freestanding and + (builtin.is_test or switch (builtin.mode) { + .Debug, .ReleaseSafe => true, + .ReleaseFast, .ReleaseSmall => false, + }); /// The panic handler for when this file is the root module. /// diff --git a/vendor/libghostty-vt/src/main_ghostty.zig b/vendor/libghostty-vt/src/main_ghostty.zig index 86e7798..f4fa741 100644 --- a/vendor/libghostty-vt/src/main_ghostty.zig +++ b/vendor/libghostty-vt/src/main_ghostty.zig @@ -228,7 +228,7 @@ pub const std_options: std.Options = .{ .allow_stack_tracing = if (builtin.target.os.tag.isDarwin() and builtin.target.os.tag != .macos) false else - builtin.strip_debug_info, + !builtin.strip_debug_info, }; test { diff --git a/vendor/libghostty-vt/src/os/TempDir.zig b/vendor/libghostty-vt/src/os/TempDir.zig index ca23426..c25589a 100644 --- a/vendor/libghostty-vt/src/os/TempDir.zig +++ b/vendor/libghostty-vt/src/os/TempDir.zig @@ -2,7 +2,6 @@ //! store temporary data and is destroyed on deinit. const TempDir = @This(); -const builtin = @import("builtin"); const std = @import("std"); const Dir = std.Io.Dir; const file = @import("file.zig"); @@ -84,8 +83,6 @@ test { var path_buf: [std.fs.max_path_bytes]u8 = undefined; var path_len: usize = undefined; - var dir_handle: Dir.Handle = undefined; - var parent_handle: Dir.Handle = undefined; { var td = try init(); errdefer td.deinit(); @@ -98,23 +95,12 @@ test { dir.close(testing.io); path_len = try td.dir.realPath(testing.io, &path_buf); - dir_handle = td.dir.handle; - parent_handle = td.parent.handle; - // Should be deleted after we deinit. + // Don't check the raw handles after close: descriptor numbers can be + // reused immediately, so a valid number does not imply that we leaked it. td.deinit(); } - switch (builtin.os.tag) { - .freebsd, .ios, .linux, .macos => { - for ([_]Dir.Handle{ dir_handle, parent_handle }) |handle| { - const result = std.posix.system.fcntl(handle, std.posix.F.GETFD); - try testing.expectEqual(std.posix.E.BADF, std.posix.errno(result)); - } - }, - - else => {}, - } try testing.expectError( error.FileNotFound, Dir.openDirAbsolute(testing.io, path_buf[0..path_len], .{}), @@ -126,30 +112,18 @@ test "close retains temporary directory" { var path_buf: [std.fs.max_path_bytes]u8 = undefined; var path_len: usize = undefined; - var dir_handle: Dir.Handle = undefined; - var parent_handle: Dir.Handle = undefined; { var td = try init(); errdefer td.deinit(); path_len = try td.dir.realPath(testing.io, &path_buf); - dir_handle = td.dir.handle; - parent_handle = td.parent.handle; + // Don't check the raw handles after close: descriptor numbers can be + // reused immediately, so a valid number does not imply that we leaked it. td.close(.retain); } defer Dir.deleteDirAbsolute(testing.io, path_buf[0..path_len]) catch {}; - switch (builtin.os.tag) { - .freebsd, .ios, .linux, .macos => { - for ([_]Dir.Handle{ dir_handle, parent_handle }) |handle| { - const result = std.posix.system.fcntl(handle, std.posix.F.GETFD); - try testing.expectEqual(std.posix.E.BADF, std.posix.errno(result)); - } - }, - - else => {}, - } var dir = try Dir.openDirAbsolute(testing.io, path_buf[0..path_len], .{}); dir.close(testing.io); } diff --git a/vendor/libghostty-vt/src/os/i18n_locales.zig b/vendor/libghostty-vt/src/os/i18n_locales.zig index 54fa2c9..9df96ad 100644 --- a/vendor/libghostty-vt/src/os/i18n_locales.zig +++ b/vendor/libghostty-vt/src/os/i18n_locales.zig @@ -60,4 +60,6 @@ pub const locales = [_][:0]const u8{ "be", "eu", "da", + "sr", + "sr@latin", }; diff --git a/vendor/libghostty-vt/src/os/macos.zig b/vendor/libghostty-vt/src/os/macos.zig index fcd1c3e..b59882d 100644 --- a/vendor/libghostty-vt/src/os/macos.zig +++ b/vendor/libghostty-vt/src/os/macos.zig @@ -28,7 +28,7 @@ pub fn appSupportDir( return try commonDir( alloc, .NSApplicationSupportDirectory, - &.{ build_config.bundle_id, sub_path }, + sub_path, ); } @@ -43,7 +43,7 @@ pub fn cacheDir( return try commonDir( alloc, .NSCachesDirectory, - &.{ build_config.bundle_id, sub_path }, + sub_path, ); } @@ -110,7 +110,7 @@ pub const NSSearchPathDomainMask = enum(c_ulong) { fn commonDir( alloc: Allocator, directory: NSSearchPathDirectory, - sub_paths: []const []const u8, + sub_path: []const u8, ) (error{AppleAPIFailed} || Allocator.Error)![]const u8 { comptime assert(builtin.target.os.tag.isDarwin()); @@ -140,13 +140,10 @@ fn commonDir( return error.AppleAPIFailed; const base_dir = std.mem.sliceTo(c_str, 0); - // Create a new array with base_dir as the first element - var paths = try alloc.alloc([]const u8, sub_paths.len + 1); - paths[0] = base_dir; - @memcpy(paths[1..], sub_paths); - defer alloc.free(paths); - - return try std.fs.path.join(alloc, paths); + return try std.fs.path.join( + alloc, + &.{ base_dir, build_config.bundle_id, sub_path }, + ); } test "cacheDir paths" { diff --git a/vendor/libghostty-vt/src/os/wasm/log.zig b/vendor/libghostty-vt/src/os/wasm/log.zig index faa885c..5c5a8f6 100644 --- a/vendor/libghostty-vt/src/os/wasm/log.zig +++ b/vendor/libghostty-vt/src/os/wasm/log.zig @@ -4,6 +4,22 @@ const wasm = @import("../wasm.zig"); // Use the correct implementation pub const log = Freestanding.log; +/// A log function that discards everything. Since the format string and +/// scope are comptime parameters, using this compiles out all logging +/// machinery: call sites, format strings, and the std.fmt code they +/// reference (~220KB in a ReleaseFast wasm build). +pub fn noop( + comptime level: std.log.Level, + comptime scope: @TypeOf(.EnumLiteral), + comptime format: []const u8, + args: anytype, +) void { + _ = level; + _ = scope; + _ = format; + _ = args; +} + /// Freestanding implementation calls an extern "log" function. pub const Freestanding = struct { // The function std.log will call. diff --git a/vendor/libghostty-vt/src/renderer/OpenGL.zig b/vendor/libghostty-vt/src/renderer/OpenGL.zig index 4b01da0..40c11f6 100644 --- a/vendor/libghostty-vt/src/renderer/OpenGL.zig +++ b/vendor/libghostty-vt/src/renderer/OpenGL.zig @@ -335,6 +335,12 @@ pub fn presentLastTarget(self: *OpenGL) !void { if (self.last_target) |target| try self.present(target); } +/// Called when the renderer released its GPU resources; the last +/// presented target is deinited with them so we must drop our copy. +pub fn gpuResourcesReleased(self: *OpenGL) void { + self.last_target = null; +} + /// Returns the options to use when constructing buffers. pub inline fn bufferOptions(self: OpenGL) bufferpkg.Options { _ = self; diff --git a/vendor/libghostty-vt/src/renderer/Thread.zig b/vendor/libghostty-vt/src/renderer/Thread.zig index e3e2cec..f66fd38 100644 --- a/vendor/libghostty-vt/src/renderer/Thread.zig +++ b/vendor/libghostty-vt/src/renderer/Thread.zig @@ -18,19 +18,10 @@ const App = @import("../App.zig"); const Allocator = std.mem.Allocator; const log = std.log.scoped(.renderer_thread); -const DRAW_INTERVAL = 8; // 120 FPS const CURSOR_BLINK_INTERVAL = 600; /// Whether calls to `drawFrame` must be done from the app thread. /// -/// If this is `true` then we send a `redraw_surface` message to the apprt -/// whenever we need to draw instead of calling `drawFrame` directly. -const must_draw_from_app_thread = - if (@hasDecl(apprt.App, "must_draw_from_app_thread")) - apprt.App.must_draw_from_app_thread - else - false; - /// The type used for sending messages to the IO thread. For now this is /// hardcoded with a capacity. We can make this a comptime parameter in /// the future if we want it configurable. @@ -53,16 +44,15 @@ wakeup_c: xev.Completion = .{}, stop: xev.Async, stop_c: xev.Completion = .{}, -/// The timer used for rendering +/// The timer used for animations (custom shaders, Kitty graphics). +/// Normal rendering is driven by wakeup messages instead. render_h: xev.Timer, render_c: xev.Completion = .{}, +render_c_cancel: xev.Completion = .{}, -/// The timer used for draw calls. Draw calls don't update from the -/// terminal state so they're much cheaper. They're used for animation -/// and are paused when the terminal is not focused. -draw_h: xev.Timer, -draw_c: xev.Completion = .{}, -draw_active: bool = false, +/// The kind of work the currently scheduled animation wake needs, +/// stored when the timer is armed. +animation_wake: rendererpkg.Renderer.AnimationWake.Kind = .draw, /// This async is used to force a draw immediately. This does not /// coalesce like the wakeup does. @@ -115,12 +105,10 @@ flags: packed struct { } = .{}, pub const DerivedConfig = struct { - custom_shader_animation: configpkg.CustomShaderAnimation, scrollback_compression: bool, pub fn init(config: *const configpkg.Config) DerivedConfig { return .{ - .custom_shader_animation = config.@"custom-shader-animation", .scrollback_compression = config.@"scrollback-compression", }; } @@ -153,10 +141,6 @@ pub fn init( var render_h = try xev.Timer.init(); errdefer render_h.deinit(); - // Draw timer, see comments. - var draw_h = try xev.Timer.init(); - errdefer draw_h.deinit(); - // Draw now async, see comments. var draw_now = try xev.Async.init(); errdefer draw_now.deinit(); @@ -176,7 +160,6 @@ pub fn init( .wakeup = wakeup_h, .stop = stop_h, .render_h = render_h, - .draw_h = draw_h, .draw_now = draw_now, .cursor_h = cursor_timer, .surface = surface, @@ -201,7 +184,6 @@ pub fn deinit(self: *Thread) void { self.stop.deinit(); self.wakeup.deinit(); self.render_h.deinit(); - self.draw_h.deinit(); self.draw_now.deinit(); self.cursor_h.deinit(); if (comptime terminalpkg.compression_enabled) @@ -270,8 +252,9 @@ fn threadMain_(self: *Thread) !void { cursorTimerCallback, ); - // Start the draw timer - self.syncDrawTimer(); + // Arm the animation timer in case the renderer already needs + // animation wakes (e.g. custom shaders loaded at startup). + self.armAnimationTimer(); // Run log.debug("starting renderer thread", .{}); @@ -309,47 +292,6 @@ fn setQosClass(self: *const Thread) void { } } -fn syncDrawTimer(self: *Thread) void { - skip: { - // If our renderer supports animations and has them, then we - // can apply draw timer based on custom shader animation configuration. - if (@hasDecl(rendererpkg.Renderer, "hasAnimations") and - self.renderer.hasAnimations()) - { - // If our config says to always animate, we do so. - switch (self.config.custom_shader_animation) { - // Always animate - .always => break :skip, - // Only when focused - .true => if (self.flags.focused) break :skip, - // Never animate - .false => {}, - } - } - - // We're skipping the draw timer. Stop it on the next iteration. - self.draw_active = false; - return; - } - - // Set our active state so it knows we're running. We set this before - // even checking the active state in case we have a pending shutdown. - self.draw_active = true; - - // If our draw timer is already active, then we don't have to do anything. - if (self.draw_c.state() == .active) return; - - // Start the timer which loops - self.draw_h.run( - &self.loop, - &self.draw_c, - DRAW_INTERVAL, - Thread, - self, - drawCallback, - ); -} - /// Drain the mailbox. fn drainMailbox(self: *Thread) !void { // There's probably a more elegant way to do this... @@ -378,15 +320,11 @@ fn drainMailbox(self: *Thread) !void { self.setQosClass(); // If we became visible then we immediately rebuild cells - // (renderCallback skips updateFrame while invisible) and draw. - if (v) { - self.renderer.updateFrame( - self.state, - self.flags.cursor_blink_visible, - ) catch |err| - log.warn("error rendering on visibility regain err={}", .{err}); - self.drawFrame(false); - } + // (renderCallback skips updateFrame while invisible) and + // draw. Going through renderCallback also reschedules + // any Kitty graphics animation wakeup that lapsed + // while we were invisible. + if (v) _ = renderCallback(self, undefined, undefined, {}); // Notify the renderer so it can update any state. self.renderer.setVisible(v); @@ -412,8 +350,9 @@ fn drainMailbox(self: *Thread) !void { // Set it on the renderer try self.renderer.setFocus(v); - // We always resync our draw timer (may disable it) - self.syncDrawTimer(); + // Focus gates custom shader animation, so re-arm + // the animation timer for the new state. + self.armAnimationTimer(); if (!v) { // If we're not focused, then we stop the cursor blink @@ -474,9 +413,9 @@ fn drainMailbox(self: *Thread) !void { try self.changeConfig(config.thread); try self.renderer.changeConfig(config.impl); - // Stop and start the draw timer to capture the new - // hasAnimations value. - self.syncDrawTimer(); + // The config affects what animation wakes the + // renderer needs (custom shaders, animation mode). + self.armAnimationTimer(); }, .search_viewport_matches => |v| { @@ -532,7 +471,7 @@ fn drawFrame(self: *Thread, now: bool) void { // when we're forced to via `now`. if (!now and self.renderer.hasVsync()) return; - if (must_draw_from_app_thread) { + if (apprt.must_draw_from_app_thread) { _ = self.app_mailbox.push( .{ .redraw_surface = self.surface }, .{ .instant = {} }, @@ -606,58 +545,110 @@ fn drawNowCallback( return .rearm; } -fn drawCallback( +fn renderCallback( self_: ?*Thread, _: *xev.Loop, _: *xev.Completion, r: xev.Timer.RunError!void, ) xev.CallbackAction { - _ = r catch unreachable; + _ = r catch |err| switch (err) { + // Sent when a scheduled animation wakeup is superseded by a + // newer one (Timer.reset cancels the pending run). Nothing to + // do; the replacement timer carries on. + error.Canceled => return .disarm, + else => unreachable, + }; const t: *Thread = self_ orelse { // This shouldn't happen so we log it. log.warn("render callback fired without data set", .{}); return .disarm; }; + // If we're not visible there's no point spending CPU rebuilding cells — + // we'll catch up when the .visible mailbox message flips us back on. + // Kitty graphics animations pause with us and resume on visibility. + if (!t.flags.visible) return .disarm; + + // Update our frame data + t.renderer.updateFrame( + t.state, + t.flags.cursor_blink_visible, + ) catch |err| + log.warn("error rendering err={}", .{err}); + // Draw t.drawFrame(false); - // Only continue if we're still active - if (t.draw_active) { - t.draw_h.run(&t.loop, &t.draw_c, DRAW_INTERVAL, Thread, t, drawCallback); - } + // Schedule the next animation wake, if the renderer needs one. + t.armAnimationTimer(); return .disarm; } -fn renderCallback( +/// Schedule the animation timer for the renderer's next animation +/// wake, if it needs one. +/// +/// This is called after every frame update or animation draw and +/// whenever the wake inputs change (focus, config, visibility +/// regain). Resetting a pending timer is always safe: every call +/// recomputes the wake, so the deadline only ever moves toward the +/// actual next wake. +fn armAnimationTimer(self: *Thread) void { + const wake = self.renderer.animationWake() orelse return; + self.animation_wake = wake.kind; + self.render_h.reset( + &self.loop, + &self.render_c, + &self.render_c_cancel, + wake.delay_ms, + Thread, + self, + animationTimerCallback, + ); +} + +fn animationTimerCallback( self_: ?*Thread, _: *xev.Loop, _: *xev.Completion, r: xev.Timer.RunError!void, ) xev.CallbackAction { - _ = r catch unreachable; + _ = r catch |err| switch (err) { + // Sent when a scheduled animation wake is superseded by a + // newer one (Timer.reset cancels the pending run). Nothing to + // do; the replacement timer carries on. + error.Canceled => return .disarm, + else => unreachable, + }; const t: *Thread = self_ orelse { // This shouldn't happen so we log it. - log.warn("render callback fired without data set", .{}); + log.warn("animation callback fired without data set", .{}); return .disarm; }; - // If we're not visible there's no point spending CPU rebuilding cells — - // we'll catch up when the .visible mailbox message flips us back on. + // Animations pause entirely while we're invisible; the .visible + // mailbox message re-arms us when we can be seen again. if (!t.flags.visible) return .disarm; - // Update our frame data - t.renderer.updateFrame( - t.state, - t.flags.cursor_blink_visible, - ) catch |err| - log.warn("error rendering err={}", .{err}); - - // Draw - t.drawFrame(false); + switch (t.animation_wake) { + // Frame data must be updated (a Kitty animation frame is + // due). renderCallback updates, draws, and re-arms us. + .update => return renderCallback( + t, + undefined, + undefined, + {}, + ), - return .disarm; + // A redraw alone suffices (custom shader time uniform). + // Draw calls don't update from the terminal state so they + // are much cheaper than a frame update. + .draw => { + t.drawFrame(false); + t.armAnimationTimer(); + return .disarm; + }, + } } fn cursorTimerCallback( diff --git a/vendor/libghostty-vt/src/renderer/cell.zig b/vendor/libghostty-vt/src/renderer/cell.zig index b9880fb..8753d1a 100644 --- a/vendor/libghostty-vt/src/renderer/cell.zig +++ b/vendor/libghostty-vt/src/renderer/cell.zig @@ -5,9 +5,10 @@ const font = @import("../font/main.zig"); const terminal = @import("../terminal/main.zig"); const renderer = @import("../renderer.zig"); const shaderpkg = renderer.Renderer.API.shaders; -const ArrayListCollection = @import("../datastruct/array_list_collection.zig").ArrayListCollection; const symbols = @import("../unicode/symbols_table.zig").table; +const CellTextRow = std.ArrayListUnmanaged(shaderpkg.CellText); + /// The possible cell content keys that exist. pub const Key = enum { bg, @@ -47,11 +48,11 @@ pub const Contents = struct { /// /// Prefer accessing with `Contents.bgCell(row, col).*` instead /// of directly indexing in order to avoid integer size bugs. - bg_cells: []shaderpkg.CellBg = undefined, + bg_cells: []shaderpkg.CellBg = &.{}, - /// The ArrayListCollection which holds all of the foreground cells. When - /// sized with Contents.resize the individual ArrayLists are given enough - /// room that they can hold a single row with #cols glyphs, underlines, and + /// The lists which hold all of the foreground cells. When sized with + /// Contents.resize the individual ArrayLists are given enough room that + /// they can hold a single row with #cols glyphs, underlines, and /// strikethroughs; however, appendAssumeCapacity MUST NOT be used since /// it is possible to exceed this with combining glyphs that add a glyph /// but take up no column since they combine with the previous one, as @@ -70,11 +71,12 @@ pub const Contents = struct { /// /// Must be initialized by calling resize on the Contents struct before /// calling any operations. - fg_rows: ArrayListCollection(shaderpkg.CellText) = .{ .lists = &.{} }, + fg_rows: []CellTextRow = &.{}, pub fn deinit(self: *Contents, alloc: Allocator) void { alloc.free(self.bg_cells); - self.fg_rows.deinit(alloc); + for (self.fg_rows) |*row| row.deinit(alloc); + alloc.free(self.fg_rows); } /// Resize the cell contents for the given grid size. This will @@ -84,55 +86,50 @@ pub const Contents = struct { alloc: Allocator, size: renderer.GridSize, ) Allocator.Error!void { - self.size = size; + const row_count: usize = size.rows; + + // The two extra lists hold cursor cells: index 0 is drawn before the + // row contents, and index row_count + 1 is drawn after them. + const fg_rows = try alloc.alloc(CellTextRow, row_count + 2); + @memset(fg_rows, .empty); + errdefer { + for (fg_rows) |*row| row.deinit(alloc); + alloc.free(fg_rows); + } - const cell_count = @as(usize, size.columns) * @as(usize, size.rows); + // Foreground rows hold glyphs plus underlines, overlines, and + // strikethroughs. Three entries per column cover the common cases + // without reserving space for every decoration combination. We can't + // assume this capacity because combining glyphs and font substitutions + // can produce arbitrarily many glyphs in one column. + const fg_row_capacity = @as(usize, size.columns) * 3; - const bg_cells = try alloc.alloc(shaderpkg.CellBg, cell_count); - errdefer alloc.free(bg_cells); - @memset(bg_cells, .{ 0, 0, 0, 0 }); + // The cursor lists need just one cell. The rest get the full capacity. + fg_rows[0] = try .initCapacity(alloc, 1); + fg_rows[row_count + 1] = try .initCapacity(alloc, 1); + for (fg_rows[1 .. row_count + 1]) |*row| { + row.* = try .initCapacity(alloc, fg_row_capacity); + } - // The foreground lists can hold 3 types of items: - // - Glyphs - // - Underlines - // - Strikethroughs - // So we give them an initial capacity of size.columns * 3, which will - // avoid any further allocations in the vast majority of cases. Sadly - // we can not assume capacity though, since with combining glyphs that - // form a single grapheme, and multi-substitutions in fonts, the number - // of glyphs in a row is theoretically unlimited. - // - // We have size.rows + 2 lists because indexes 0 and size.rows - 1 are - // used for special lists containing the cursor cell which need to - // be first and last in the buffer, respectively. - var fg_rows: ArrayListCollection(shaderpkg.CellText) = try .init( - alloc, - size.rows + 2, - size.columns * 3, + const bg_cells = try alloc.realloc( + self.bg_cells, + row_count * @as(usize, size.columns), ); - errdefer fg_rows.deinit(alloc); - - // We don't need 3*cols worth of cells for the cursor lists, so we can - // replace them with smaller lists. This is technically a tiny bit of - // extra work but resize is not a hot function so it's worth it to not - // waste the memory. - fg_rows.lists[0].deinit(alloc); - fg_rows.lists[0] = try .initCapacity(alloc, 1); - fg_rows.lists[size.rows + 1].deinit(alloc); - fg_rows.lists[size.rows + 1] = try .initCapacity(alloc, 1); // Perform the swap, no going back from here. errdefer comptime unreachable; - alloc.free(self.bg_cells); - self.fg_rows.deinit(alloc); + for (self.fg_rows) |*row| row.deinit(alloc); + alloc.free(self.fg_rows); + self.size = size; self.bg_cells = bg_cells; self.fg_rows = fg_rows; + self.reset(); } /// Reset the cell contents to an empty state without resizing. pub fn reset(self: *Contents) void { @memset(self.bg_cells, .{ 0, 0, 0, 0 }); - self.fg_rows.reset(); + for (self.fg_rows) |*row| row.clearRetainingCapacity(); } /// Set the cursor value. If the value is null then the cursor is hidden. @@ -142,28 +139,28 @@ pub const Contents = struct { cursor_style: ?renderer.CursorStyle, ) void { if (self.size.rows == 0) return; - self.fg_rows.lists[0].clearRetainingCapacity(); - self.fg_rows.lists[self.size.rows + 1].clearRetainingCapacity(); + self.fg_rows[0].clearRetainingCapacity(); + self.fg_rows[self.size.rows + 1].clearRetainingCapacity(); const cell = v orelse return; const style = cursor_style orelse return; switch (style) { // Block cursors should be drawn first - .block => self.fg_rows.lists[0].appendAssumeCapacity(cell), + .block => self.fg_rows[0].appendAssumeCapacity(cell), // Other cursor styles should be drawn last - .block_hollow, .bar, .underline, .lock => self.fg_rows.lists[self.size.rows + 1].appendAssumeCapacity(cell), + .block_hollow, .bar, .underline, .lock => self.fg_rows[self.size.rows + 1].appendAssumeCapacity(cell), } } /// Returns the current cursor glyph if present, checking both cursor lists. pub fn getCursorGlyph(self: *Contents) ?shaderpkg.CellText { if (self.size.rows == 0) return null; - if (self.fg_rows.lists[0].items.len > 0) { - return self.fg_rows.lists[0].items[0]; + if (self.fg_rows[0].items.len > 0) { + return self.fg_rows[0].items[0]; } - if (self.fg_rows.lists[self.size.rows + 1].items.len > 0) { - return self.fg_rows.lists[self.size.rows + 1].items[0]; + if (self.fg_rows[self.size.rows + 1].items.len > 0) { + return self.fg_rows[self.size.rows + 1].items[0]; } return null; } @@ -201,7 +198,7 @@ pub const Contents = struct { // We have a special list containing the cursor cell at the start // of our fg row collection, so we need to add 1 to the y to get // the correct index. - => try self.fg_rows.lists[y + 1].append(alloc, cell), + => try self.fg_rows[y + 1].append(alloc, cell), } } @@ -214,7 +211,7 @@ pub const Contents = struct { // We have a special list containing the cursor cell at the start // of our fg row collection, so we need to add 1 to the y to get // the correct index. - self.fg_rows.lists[y + 1].clearRetainingCapacity(); + self.fg_rows[y + 1].clearRetainingCapacity(); } }; @@ -362,14 +359,14 @@ test Contents { // We should start off empty after resizing. for (0..rows) |y| { - try testing.expect(c.fg_rows.lists[y + 1].items.len == 0); + try testing.expect(c.fg_rows[y + 1].items.len == 0); for (0..cols) |x| { try testing.expectEqual(.{ 0, 0, 0, 0 }, c.bgCell(y, x).*); } } // And the cursor row should have a capacity of 1 and also be empty. - try testing.expect(c.fg_rows.lists[0].capacity == 1); - try testing.expect(c.fg_rows.lists[0].items.len == 0); + try testing.expect(c.fg_rows[0].capacity == 1); + try testing.expect(c.fg_rows[0].items.len == 0); // Add some contents. const bg_cell: shaderpkg.CellBg = .{ 0, 0, 0, 1 }; @@ -382,12 +379,12 @@ test Contents { try c.add(alloc, .text, fg_cell); try testing.expectEqual(bg_cell, c.bgCell(1, 4).*); // The fg row index is offset by 1 because of the cursor list. - try testing.expectEqual(fg_cell, c.fg_rows.lists[2].items[0]); + try testing.expectEqual(fg_cell, c.fg_rows[2].items[0]); // And we should be able to clear it. c.clear(1); for (0..rows) |y| { - try testing.expect(c.fg_rows.lists[y + 1].items.len == 0); + try testing.expect(c.fg_rows[y + 1].items.len == 0); for (0..cols) |x| { try testing.expectEqual(.{ 0, 0, 0, 0 }, c.bgCell(y, x).*); } @@ -401,20 +398,49 @@ test Contents { .color = .{ 0, 0, 0, 1 }, }; c.setCursor(cursor_cell, .block); - try testing.expectEqual(cursor_cell, c.fg_rows.lists[0].items[0]); + try testing.expectEqual(cursor_cell, c.fg_rows[0].items[0]); try testing.expectEqual(cursor_cell, c.getCursorGlyph().?); // And remove it. c.setCursor(null, null); - try testing.expectEqual(0, c.fg_rows.lists[0].items.len); + try testing.expectEqual(0, c.fg_rows[0].items.len); try testing.expect(c.getCursorGlyph() == null); // Add a hollow cursor. c.setCursor(cursor_cell, .block_hollow); - try testing.expectEqual(cursor_cell, c.fg_rows.lists[rows + 1].items[0]); + try testing.expectEqual(cursor_cell, c.fg_rows[rows + 1].items[0]); try testing.expectEqual(cursor_cell, c.getCursorGlyph().?); } +test "Contents resize grows and shrinks" { + const testing = std.testing; + const alloc = testing.allocator; + + var c: Contents = .{}; + defer c.deinit(alloc); + + try c.resize(alloc, .{ .rows = 2, .columns = 2 }); + var cell: shaderpkg.CellText = .{ + .atlas = .grayscale, + .grid_pos = .{ 1, 1 }, + .color = .{ 0, 0, 0, 1 }, + }; + try c.add(alloc, .text, cell); + c.bgCell(1, 1).* = .{ 0, 0, 0, 1 }; + c.setCursor(cell, .bar); + + try c.resize(alloc, .{ .rows = 3, .columns = 4 }); + try testing.expectEqual(.{ 0, 0, 0, 0 }, c.bgCell(1, 1).*); + try testing.expectEqual(@as(usize, 0), c.fg_rows[2].items.len); + try testing.expectEqual(@as(?shaderpkg.CellText, null), c.getCursorGlyph()); + cell.grid_pos = .{ 3, 2 }; + try c.add(alloc, .text, cell); + + try c.resize(alloc, .{ .rows = 1, .columns = 1 }); + cell.grid_pos = .{ 0, 0 }; + try c.add(alloc, .text, cell); +} + test "Contents clear retains other content" { const testing = std.testing; const alloc = testing.allocator; @@ -452,7 +478,7 @@ test "Contents clear retains other content" { // Row 2 should still contain its cells. try testing.expectEqual(bg_cell_2, c.bgCell(2, 4).*); // Fg row index is +1 because of cursor list at start - try testing.expectEqual(fg_cell_2, c.fg_rows.lists[3].items[0]); + try testing.expectEqual(fg_cell_2, c.fg_rows[3].items[0]); } test "Contents clear last added content" { @@ -492,7 +518,7 @@ test "Contents clear last added content" { // Row 1 should still contain its cells. try testing.expectEqual(bg_cell_1, c.bgCell(1, 4).*); // Fg row index is +1 because of cursor list at start - try testing.expectEqual(fg_cell_1, c.fg_rows.lists[2].items[0]); + try testing.expectEqual(fg_cell_1, c.fg_rows[2].items[0]); } test "Contents with zero-sized screen" { diff --git a/vendor/libghostty-vt/src/renderer/generic.zig b/vendor/libghostty-vt/src/renderer/generic.zig index 1337c51..2a81f42 100644 --- a/vendor/libghostty-vt/src/renderer/generic.zig +++ b/vendor/libghostty-vt/src/renderer/generic.zig @@ -207,8 +207,15 @@ pub fn Renderer(comptime GraphicsAPI: type) type { /// Health of the most recently completed frame. health: std.atomic.Value(Health) = .{ .raw = .healthy }, - /// Our swap chain (multiple buffering) - swap_chain: SwapChain, + /// True when we have a graphics context that can create GPU + /// resources. Creating any GPU resource while this is false is invalid. + display_realized: bool = true, + + /// Our swap chain (multiple buffering). Null when it has + /// been released, either because the surface is hidden + /// (`releaseGpuResources`) or because the display is + /// unrealized. Rebuilt on the next `drawFrame`. + swap_chain: ?SwapChain, /// This value is used to force-update swap chain targets in the /// event of a config change that requires it (such as blending mode). @@ -236,6 +243,18 @@ pub fn Renderer(comptime GraphicsAPI: type) type { /// Our overlay state, if any. overlay: ?Overlay = null, + /// The base timestamp for the Kitty graphics animation clock. + /// Animation frame timing is expressed as milliseconds since + /// this instant. Set on the first frame update that observes + /// Kitty images. + kitty_animation_clock: ?std.Io.Timestamp = null, + + /// When the next Kitty animation frame is due, in + /// milliseconds on the animation clock, from the most recent + /// frame update. Null when no running animation needs a + /// wakeup. + kitty_animation_next_ms: ?u64 = null, + const HighlightTag = enum(u8) { search_match, search_match_selected, @@ -259,14 +278,6 @@ pub fn Renderer(comptime GraphicsAPI: type) type { /// frame state struct so we can start working on a new frame. frame_sema: std.Io.Semaphore = .{ .permits = buf_count }, - /// Set to true when deinited, if you try to deinit a defunct - /// swap chain it will just be ignored, to prevent double-free. - /// - /// This is required because of `displayUnrealized`, since it - /// `deinits` the swapchain, which leads to a double-free if - /// the renderer is deinited after that. - defunct: bool = false, - pub fn init(api: GraphicsAPI, custom_shaders: bool) !SwapChain { var result: SwapChain = .{ .frames = undefined }; @@ -279,9 +290,6 @@ pub fn Renderer(comptime GraphicsAPI: type) type { } pub fn deinit(self: *SwapChain) void { - if (self.defunct) return; - self.defunct = true; - // Wait for all of our inflight draws to complete // so that we can cleanly deinit our GPU state. for (0..buf_count) |_| self.frame_sema.waitUncancelable( @@ -293,11 +301,8 @@ pub fn Renderer(comptime GraphicsAPI: type) type { /// Get the next frame state to draw to. This will wait on the /// semaphore to ensure that the frame is available. This must /// always be paired with a call to releaseFrame. - pub fn nextFrame(self: *SwapChain) error{Defunct}!*FrameState { - if (self.defunct) return error.Defunct; - + pub fn nextFrame(self: *SwapChain) *FrameState { self.frame_sema.waitUncancelable(global.io()); - errdefer self.frame_sema.post(); self.frame_index = (self.frame_index + 1) % buf_count; return &self.frames[self.frame_index]; } @@ -577,6 +582,7 @@ pub fn Renderer(comptime GraphicsAPI: type) type { blending: configpkg.Config.AlphaBlending, background_blur: configpkg.Config.BackgroundBlur, scroll_to_bottom_on_output: bool, + custom_shader_animation: configpkg.CustomShaderAnimation, pub fn init( alloc_gpa: Allocator, @@ -651,6 +657,7 @@ pub fn Renderer(comptime GraphicsAPI: type) type { .blending = config.@"alpha-blending", .background_blur = config.@"background-blur", .scroll_to_bottom_on_output = config.@"scroll-to-bottom".output, + .custom_shader_animation = config.@"custom-shader-animation", .arena = arena, }; } @@ -800,7 +807,7 @@ pub fn Renderer(comptime GraphicsAPI: type) type { self.terminal_state.deinit(self.alloc); if (self.search_selected_match) |*m| m.arena.deinit(); if (self.search_matches) |*m| m.arena.deinit(); - self.swap_chain.deinit(); + if (self.swap_chain) |*sc| sc.deinit(); if (DisplayLink != void) { if (self.display_link) |display_link| { @@ -936,9 +943,9 @@ pub fn Renderer(comptime GraphicsAPI: type) type { defer self.draw_mutex.unlock(global.io()); // We assume that the swap chain was deinited in - // `displayUnrealized`, in which case it should be - // marked defunct. If not, we have a problem. - assert(self.swap_chain.defunct); + // `displayUnrealized`. If not, we have a problem. + assert(self.swap_chain == null); + assert(!self.display_realized); // We reinitialize our shaders and our swap chain. try self.initShaders(); @@ -946,6 +953,7 @@ pub fn Renderer(comptime GraphicsAPI: type) type { self.api, self.has_custom_shaders, ); + self.display_realized = true; self.reinitialize_shaders = false; self.target_config_modified = 1; } @@ -964,11 +972,13 @@ pub fn Renderer(comptime GraphicsAPI: type) type { self.draw_mutex.lockUncancelable(global.io()); defer self.draw_mutex.unlock(global.io()); - // We deinit our swap chain and shaders. - // - // This will mark them as defunct so that they - // can't be double-freed or used in draw calls. - self.swap_chain.deinit(); + // We deinit our swap chain and shaders. Clearing + // `display_realized` ensures drawFrame doesn't attempt + // to rebuild the swap chain (we have no GPU context); + // displayRealized will. + if (self.swap_chain) |*sc| sc.deinit(); + self.swap_chain = null; + self.display_realized = false; self.shaders.deinit(self.alloc); } @@ -997,10 +1007,74 @@ pub fn Renderer(comptime GraphicsAPI: type) type { self.syncDisplayLink(id, draw_now); } - /// True if our renderer has animations so that a higher frequency - /// timer is used. - pub fn hasAnimations(self: *const Self) bool { - return self.has_custom_shaders; + /// The cadence of continuous (draw-only) animation wakes, + /// i.e. 120fps, and the floor for any animation wake delay. + pub const draw_interval_ms: u64 = 8; + + /// A point in the future when the renderer needs to be driven + /// again to keep animating, and what kind of drive it needs. + pub const AnimationWake = struct { + /// Delay in milliseconds until the wake is due. + delay_ms: u64, + kind: Kind, + + pub const Kind = enum { + /// A redraw alone suffices, no updateFrame. Much cheaper + /// than `update`. + draw, + + /// Frame data must be updated first: updateFrame, then draw. + update, + }; + }; + + /// The soonest animation wake this renderer needs, if any: + /// custom shader animation wants continuous draw-only wakes + /// at draw_interval_ms while active, and a running Kitty + /// graphics animation wants an update wake when its next + /// frame is due. The renderer thread drives its animation + /// timer off this, re-querying after every wake. + /// + /// Must be called on the render thread. + pub fn animationWake(self: *const Self) ?AnimationWake { + // Custom shaders animate by redrawing on a fixed cadence, + // gated by configuration and focus. + const shader_delay: ?u64 = shader: { + if (!self.has_custom_shaders) break :shader null; + break :shader switch (self.config.custom_shader_animation) { + .false => null, + .always => draw_interval_ms, + .true => if (self.focused) draw_interval_ms else null, + }; + }; + + // Kitty animations tick during updateFrame; between + // updates the deadline is absolute on the animation + // clock, so a stream of draw wakes recomputing this + // cannot starve it into the future. + const kitty_delay: ?u64 = kitty: { + const next = self.kitty_animation_next_ms orelse break :kitty null; + const base = self.kitty_animation_clock orelse break :kitty null; + const now: std.Io.Timestamp = .now(global.io(), .awake); + const now_ms: u64 = @intCast(@divTrunc( + base.durationTo(now).nanoseconds, + std.time.ns_per_ms, + )); + // Never wake faster than the draw interval; an + // overdue frame is picked up on the next wake. + break :kitty @max(next -| now_ms, draw_interval_ms); + }; + + // An update wake includes a draw, so it wins ties. + if (kitty_delay) |k| { + if (shader_delay == null or k <= shader_delay.?) { + return .{ .delay_ms = k, .kind = .update }; + } + } + + if (shader_delay) |s| return .{ .delay_ms = s, .kind = .draw }; + + return null; } /// True if our renderer is using vsync. If true, the renderer or apprt @@ -1032,6 +1106,48 @@ pub fn Renderer(comptime GraphicsAPI: type) type { pub fn setVisible(self: *Self, visible: bool) void { self.visible = visible; self.syncDisplayLink(null, null); + + // When we're hidden, release our GPU resources if GPU + // operations are allowed from this thread. Apprts where + // they aren't (GTK owns the OpenGL context on the app + // thread) call `releaseGpuResources` at the appropriate + // time instead. + if (comptime !apprt.must_draw_from_app_thread) { + if (!visible) self.releaseGpuResources(); + } + } + + /// Release the GPU resources we hold while the surface is not + /// visible. Today this is the swap chain (render targets, font + /// atlas texture copies, cell buffers, custom shader textures), + /// which makes up nearly all of a surface's GPU memory usage; + /// a hidden surface doesn't draw, so it doesn't need it. The + /// swap chain is rebuilt on the next `drawFrame`. + /// + /// This is safe to call in any state; resources that are + /// already released are skipped. + /// + /// For OpenGL this must be called on the app thread with the + /// GL context current (the same requirement as + /// `displayUnrealized`). Other APIs may call this from the + /// render thread; see `apprt.must_draw_from_app_thread`. + pub fn releaseGpuResources(self: *Self) void { + self.draw_mutex.lockUncancelable(global.io()); + defer self.draw_mutex.unlock(global.io()); + + if (self.swap_chain) |*sc| { + // Waits for any in-flight frames to complete, then + // frees all GPU resources. + sc.deinit(); + self.swap_chain = null; + + // Let the API drop any references it holds to swap + // chain resources (e.g. OpenGL's last presented + // target). + if (comptime @hasDecl(GraphicsAPI, "gpuResourcesReleased")) { + self.api.gpuResourcesReleased(); + } + } } /// Create or update the display link and match it to the current @@ -1075,11 +1191,16 @@ pub fn Renderer(comptime GraphicsAPI: type) type { }; } - // If we're not visible, then we want to stop the display link - // because it is a waste of resources and we can move to pure - // change-driven updates. - if (self.visible and self.focused) { - display_link.start() catch {}; + const should_run = + // Non-visible windows never vsync + self.visible and + // Only vsync if we have cell changes or animation + (self.cells_rebuilt or self.animationWake() != null); + + if (should_run) { + if (!display_link.isRunning()) { + display_link.start() catch {}; + } } else { display_link.stop() catch {}; } @@ -1097,11 +1218,12 @@ pub fn Renderer(comptime GraphicsAPI: type) type { // Update all our textures so that they sync on the next frame. // We can modify this without a lock because the GPU does not - // touch this data. - for (&self.swap_chain.frames) |*frame| { + // touch this data. A released swap chain is rebuilt with + // fresh frames that sync all textures on first use. + if (self.swap_chain) |*sc| for (&sc.frames) |*frame| { frame.grayscale_modified = 0; frame.color_modified = 0; - } + }; // Get our metrics from the grid. This doesn't require a lock because // the metrics are never recalculated. @@ -1248,6 +1370,33 @@ pub fn Renderer(comptime GraphicsAPI: type) type { break :preedit try p.clone(arena_alloc); }; + // Advance any running Kitty graphics animations to the + // frame due now, and remember when the next frame is + // due (as an absolute deadline, see animationWake) so + // the renderer thread can schedule a wakeup for it. + // This must happen before the dirty check below: + // advancing a frame marks the image state dirty. + self.kitty_animation_next_ms = next: { + // Likely case: we have no kitty images, so do nothing. + const storage = &state.terminal.screens.active.kitty_images; + if (storage.images.count() == 0) break :next null; + + const now: std.Io.Timestamp = .now(global.io(), .awake); + const base = self.kitty_animation_clock orelse base: { + self.kitty_animation_clock = now; + break :base now; + }; + const now_ms: u64 = @intCast(@divTrunc( + base.durationTo(now).nanoseconds, + std.time.ns_per_ms, + )); + const delay = storage.animationTick( + global.io(), + now_ms, + ) orelse break :next null; + break :next now_ms + delay; + }; + // If we have Kitty graphics data, we enter a SLOW SLOW SLOW path. // We only do this if the Kitty image state is dirty meaning only if // it changes. @@ -1449,6 +1598,9 @@ pub fn Renderer(comptime GraphicsAPI: type) type { // Update custom shader uniforms that depend on terminal state. self.updateCustomShaderUniformsFromState(); } + + // Start the display link now that the rebuilt frame is ready. + self.syncDisplayLink(null, null); } /// Draw the frame to the screen. @@ -1486,16 +1638,39 @@ pub fn Renderer(comptime GraphicsAPI: type) type { // then drawing is absurd, so we just return. if (surface_size.width == 0 or surface_size.height == 0) return; + // If we have no graphics context we can't draw. This is + // only the case while unrealized (GTK); displayRealized + // rebuilds the swap chain. + if (!self.display_realized) return; + + // Get our swap chain, rebuilding it if it was released + // while we were hidden. Rebuilding is deferred to draw + // time because resource creation must happen somewhere + // our graphics API allows it (OpenGL requires a current + // context, which drawFrame guarantees). + const swap_chain: *SwapChain, const swap_chain_rebuilt: bool = + if (self.swap_chain) |*sc| .{ sc, false } else rebuild: { + self.swap_chain = try SwapChain.init( + self.api, + self.has_custom_shaders, + ); + break :rebuild .{ &self.swap_chain.?, true }; + }; + const size_changed = self.size.screen.width != surface_size.width or self.size.screen.height != surface_size.height; // Conditions under which we need to draw the frame, otherwise we // don't need to since the previous frame should be identical. + // + // While any animation is in progress (a pending animation wake) + // every draw must actually render. const needs_redraw = size_changed or + swap_chain_rebuilt or self.cells_rebuilt or - self.hasAnimations() or + self.animationWake() != null or sync; if (!needs_redraw) { @@ -1503,14 +1678,18 @@ pub fn Renderer(comptime GraphicsAPI: type) type { // apprt may be swapping buffers and display an outdated frame // if we don't draw something new. try self.api.presentLastTarget(); + + // Resync the display link because we can probably pause + // the display link at this point. + self.syncDisplayLink(null, null); return; } self.cells_rebuilt = false; // Wait for a frame to be available. - const frame = try self.swap_chain.nextFrame(); - errdefer self.swap_chain.releaseFrame(); - // log.debug("drawing frame index={}", .{self.swap_chain.frame_index}); + const frame = swap_chain.nextFrame(); + errdefer swap_chain.releaseFrame(); + // log.debug("drawing frame index={}", .{swap_chain.frame_index}); // If we need to reinitialize our shaders, do so. if (self.reinitialize_shaders) { @@ -1576,7 +1755,7 @@ pub fn Renderer(comptime GraphicsAPI: type) type { // Setup our frame data try frame.uniforms.sync(&.{self.uniforms}); try frame.cells_bg.sync(self.cells.bg_cells); - const fg_count = try frame.cells.syncFromArrayLists(self.cells.fg_rows.lists); + const fg_count = try frame.cells.syncFromArrayLists(self.cells.fg_rows); // If our background image buffer has changed, sync it. if (frame.bg_image_buffer_modified != self.bg_image_buffer_modified) { @@ -1755,8 +1934,11 @@ pub fn Renderer(comptime GraphicsAPI: type) type { }, .{ .forever = {} }); } - // Always release our semaphore - self.swap_chain.releaseFrame(); + // Always release our semaphore. The swap chain is + // guaranteed to exist here: it is only torn down after + // waiting for all in-flight frames to complete, and this + // callback is what signals that completion. + self.swap_chain.?.releaseFrame(); } /// Call this any time the background image path changes. diff --git a/vendor/libghostty-vt/src/renderer/image.zig b/vendor/libghostty-vt/src/renderer/image.zig index 0126d1c..ef0367b 100644 --- a/vendor/libghostty-vt/src/renderer/image.zig +++ b/vendor/libghostty-vt/src/renderer/image.zig @@ -299,6 +299,18 @@ pub const State = struct { const top_y = t.screens.active.pages.pointFromPin(.screen, top).?.screen.y; const bot_y = t.screens.active.pages.pointFromPin(.screen, bot).?.screen.y; + // Relative placements whose parent chain roots at a virtual + // placement can only be positioned once the placeholder cells + // have been scanned below, so they are collected here first. + var pending_relative: std.ArrayListUnmanaged(struct { + image_id: u32, + p: terminal.kitty.graphics.ImageStorage.Placement, + root_key: terminal.kitty.graphics.ImageStorage.PlacementKey, + horizontal_offset: i32, + vertical_offset: i32, + }) = .empty; + defer pending_relative.deinit(alloc); + // Go through the placements and ensure the image is // on the GPU or else is ready to be sent to the GPU. var it = storage.placements.iterator(); @@ -306,8 +318,9 @@ pub const State = struct { const p = kv.value_ptr; // Special logic based on location - switch (p.location) { - .pin => {}, + const origin: Origin = switch (p.location) { + .pin => |pin| .{ .pin = pin }, + .virtual => { // We need to mark virtual placements on our renderer so that // we know to rebuild in more scenarios since cell changes can @@ -319,7 +332,46 @@ pub const State = struct { // placement itself. continue; }, - } + + .relative => |rel| origin: { + // An unresolvable chain is never drawn. This only + // happens transiently (storage reaps orphans) or for + // chains re-parented too deep, which kitty doesn't + // draw either. + const chain = storage.resolveChain(rel) orelse continue; + switch (chain.root.location) { + // Rooted at a pin: anchored at the root's pin, + // offset by the accumulated chain offsets. + .pin => |root_pin| break :origin .{ + .pin = root_pin, + .horizontal_offset = chain.horizontal_offset, + .vertical_offset = chain.vertical_offset, + }, + + // Rooted at a virtual placement: positioned from + // the root's placeholder cells, which we only + // know after the placeholder scan below. The + // placeholders also move with cell changes so we + // must rebuild every frame, like virtuals. + .virtual => { + self.kitty_virtual = true; + pending_relative.append(alloc, .{ + .image_id = kv.key_ptr.image_id, + .p = p.*, + .root_key = chain.root_key, + .horizontal_offset = chain.horizontal_offset, + .vertical_offset = chain.vertical_offset, + }) catch |err| { + log.warn("error deferring relative placement err={}", .{err}); + }; + continue; + }, + + // resolveChain roots are never relative. + .relative => unreachable, + } + }, + }; // Get the image for the placement const image = storage.imageById(kv.key_ptr.image_id) orelse { @@ -337,6 +389,7 @@ pub const State = struct { bot_y, &image, p, + origin, ) catch |err| { // For errors we log and continue. We try to place // other placements even if one fails. @@ -346,6 +399,17 @@ pub const State = struct { // If we have virtual placements then we need to scan for placeholders. if (self.kitty_virtual) { + // The minimum placeholder cell seen per virtual placement, + // in viewport coordinates. This is the origin for relative + // placements rooted at a virtual placement: kitty positions + // those at the min-x/min-y of the parent's placeholder + // cells. Only tracked when such placements exist. + var virtual_origins: std.AutoHashMapUnmanaged( + terminal.kitty.graphics.ImageStorage.PlacementKey, + struct { x: u32, y: u32 }, + ) = .empty; + defer virtual_origins.deinit(alloc); + var v_it = terminal.kitty.graphics.unicode.placementIterator(top, bot); while (v_it.next()) |virtual_p| { self.prepKittyVirtualPlacement( @@ -358,6 +422,69 @@ pub const State = struct { // other placements even if one fails. log.warn("error preparing kitty placement err={}", .{err}); }; + + // We need to track the origins of all the placeholders + // when we have relative cells so that we can calculate + // the proper offsets later. + if (pending_relative.items.len > 0) fold: { + // Find the target for this virtual placeholder. + const target = storage.placeholderTarget( + virtual_p.image_id, + virtual_p.placement_id, + ) orelse break :fold; + + // Get the actual viewport position for it. + const vp = t.screens.active.pages.pointFromPin( + .viewport, + virtual_p.pin, + ) orelse break :fold; + + // Add the origin for this target + const gop = virtual_origins.getOrPut( + alloc, + target.key, + ) catch |err| { + log.warn("error tracking virtual origin err={}", .{err}); + break :fold; + }; + if (!gop.found_existing) { + gop.value_ptr.* = .{ .x = vp.viewport.x, .y = vp.viewport.y }; + } else { + gop.value_ptr.x = @min(gop.value_ptr.x, vp.viewport.x); + gop.value_ptr.y = @min(gop.value_ptr.y, vp.viewport.y); + } + } + } + + // Position the relative placements rooted at virtual + // placements now that the placeholder cells are known. A + // root with no placeholders on screen leaves its relative + // placements undrawn, matching kitty. + for (pending_relative.items) |pr| { + const origin = virtual_origins.get(pr.root_key) orelse continue; + const image = storage.imageById(pr.image_id) orelse continue; + if (image.data.isPending()) continue; + + const grid = pr.p.gridSize(image, t); + if (grid.cols == 0 or grid.rows == 0) continue; + + // Viewport-relative signed position; cull placements + // entirely outside the viewport. + const x: i64 = @as(i64, origin.x) + pr.horizontal_offset; + const y: i64 = @as(i64, origin.y) + pr.vertical_offset; + if (y >= t.rows or y + grid.rows - 1 < 0) continue; + if (x >= t.cols or x + grid.cols - 1 < 0) continue; + + self.appendKittyPlacement( + alloc, + t, + &image, + &pr.p, + std.math.cast(i32, x) orelse continue, + std.math.cast(i32, y) orelse continue, + ) catch |err| { + log.warn("error preparing kitty placement err={}", .{err}); + }; } } @@ -403,8 +530,19 @@ pub const State = struct { ImageConversionError, }; - /// Get the viewport-relative position for this - /// placement and add it to the placements list. + /// Where a placement is anchored on screen: a pin, plus cell + /// offsets from it for relative placements. The pin is the + /// placement's own pin for pin placements; for relative placements + /// it is the root of the parent chain and the offsets are the + /// accumulated chain offsets in cells. + const Origin = struct { + pin: *const terminal.Pin, + horizontal_offset: i32 = 0, + vertical_offset: i32 = 0, + }; + + /// Get the viewport-relative position for this placement and add it + /// to the placements list. fn prepKittyPlacement( self: *State, alloc: Allocator, @@ -413,23 +551,58 @@ pub const State = struct { bot_y: u32, image: *const terminal.kitty.graphics.Image, p: *const terminal.kitty.graphics.ImageStorage.Placement, + origin: Origin, ) PrepImageError!void { // Keep the native placement but do not create a renderer placement or // texture until the decoded bytes arrive. if (image.data.isPending()) return; - // Get the rect for the placement. If this placement doesn't have - // a rect then its virtual or something so skip it. - const rect = p.rect(image.*, t) orelse return; + // An origin whose tracked content was pruned has no position. + if (origin.pin.garbage) return; + + // The size of the placement in grid cells. A zero size can + // occur when pixel geometry is unavailable; nothing to place. + const grid = p.gridSize(image.*, t); + if (grid.cols == 0 or grid.rows == 0) return; // This is expensive but necessary. - const img_top_y = t.screens.active.pages.pointFromPin(.screen, rect.top_left).?.screen.y; - const img_bot_y = t.screens.active.pages.pointFromPin(.screen, rect.bottom_right).?.screen.y; + const origin_y = t.screens.active.pages.pointFromPin( + .screen, + origin.pin.*, + ).?.screen.y; - // If the selection isn't within our viewport then skip it. - if (img_top_y > bot_y) return; - if (img_bot_y < top_y) return; + // The placement's edges in screen coordinates. Chain offsets + // are signed: a relative placement can hang above or to the + // left of its origin, so this math must be signed and widened. + const img_top_y: i64 = @as(i64, origin_y) + origin.vertical_offset; + const img_bot_y: i64 = img_top_y + grid.rows - 1; + const img_left_x: i64 = @as(i64, origin.pin.x) + origin.horizontal_offset; + const img_right_x: i64 = img_left_x + grid.cols - 1; + // If the placement isn't within our viewport then skip it. + if (img_top_y > bot_y or img_bot_y < top_y) return; + if (img_left_x >= t.cols or img_right_x < 0) return; + + // Viewport-relative position. Offsets so extreme that the + // position is unrepresentable have no renderable pixels. + const y_pos = std.math.cast(i32, img_top_y - top_y) orelse return; + const x_pos = std.math.cast(i32, img_left_x) orelse return; + + try self.appendKittyPlacement(alloc, t, image, p, x_pos, y_pos); + } + + /// Compute the sizes for a native kitty placement positioned at the + /// given viewport cell position, prepare its image for the GPU, and + /// append it to the placements list. + fn appendKittyPlacement( + self: *State, + alloc: Allocator, + t: *const terminal.Terminal, + image: *const terminal.kitty.graphics.Image, + p: *const terminal.kitty.graphics.ImageStorage.Placement, + x: i32, + y: i32, + ) PrepImageError!void { // We need to prep this image for upload if it isn't in the // cache OR it is in the cache but the transmit time doesn't // match meaning this image is different. @@ -438,37 +611,25 @@ pub const State = struct { // Calculate the dimensions of our image, taking in to // account the rows / columns specified by the placement. const dest_size = p.pixelSize(image.*, t); + const cell_offset = p.cellOffset(t); - // Calculate the source rectangle - const source_x = @min(image.width, p.source_x); - const source_y = @min(image.height, p.source_y); - const source_width = if (p.source_width > 0) - @min(image.width - source_x, p.source_width) - else - image.width; - const source_height = if (p.source_height > 0) - @min(image.height - source_y, p.source_height) - else - image.height; - - // Get the viewport-relative Y position of the placement. - const y_pos: i32 = @as(i32, @intCast(img_top_y)) - @as(i32, @intCast(top_y)); + const source = p.sourceRect(image.*); // Accumulate the placement if (dest_size.width > 0 and dest_size.height > 0) { try self.kitty_placements.append(alloc, .{ .image_id = .{ .kitty = image.id }, - .x = @intCast(rect.top_left.x), - .y = y_pos, + .x = x, + .y = y, .z = p.z, .width = dest_size.width, .height = dest_size.height, - .cell_offset_x = p.x_offset, - .cell_offset_y = p.y_offset, - .source_x = source_x, - .source_y = source_y, - .source_width = source_width, - .source_height = source_height, + .cell_offset_x = cell_offset.x, + .cell_offset_y = cell_offset.y, + .source_x = source.x, + .source_y = source.y, + .source_width = source.width, + .source_height = source.height, }); } } @@ -549,11 +710,8 @@ pub const State = struct { return; } - // Copy the data so we own it. - const data = if (alloc.dupe( - u8, - pending.dataSlice(), - )) |v| v else |_| { + // Store it in the map + const new_image: Image = if (pending.convertCopy(alloc)) |v| v else |_| { if (!gop.found_existing) { // If this is a new entry we can just remove it since it // was never sent to the GPU. @@ -570,15 +728,6 @@ pub const State = struct { // put into the map immediately below and our errdefer to // handle our map state will fix this up. - // Store it in the map - const new_image: Image = .{ - .pending = .{ - .width = pending.width, - .height = pending.height, - .pixel_format = pending.pixel_format, - .data = data.ptr, - }, - }; if (!gop.found_existing) { gop.value_ptr.* = .{ .image = new_image, @@ -594,7 +743,7 @@ pub const State = struct { // If any error happens, we unload the image and it is invalid. errdefer gop.value_ptr.image.markForUnload(); - gop.value_ptr.image.prepForUpload(alloc) catch |err| { + gop.value_ptr.image.getPendingPointer().?.prepForUpload(alloc) catch |err| { log.warn("error preparing image for upload err={}", .{err}); return error.ImageConversionError; }; @@ -608,7 +757,10 @@ pub const State = struct { alloc: Allocator, image: *const terminal.kitty.graphics.Image, ) PrepImageError!void { - const data = image.data.bytes() orelse unreachable; + // For animated images this is the current animation frame; + // the image generation changes whenever the current frame + // does, so the upload cache stays coherent. + const data = image.renderData().bytes() orelse unreachable; try self.prepImage( alloc, .{ .kitty = image.id }, @@ -780,6 +932,61 @@ pub const Image = union(enum) { }; } }; + + /// Converts the image data and replaces it with a format that can be uploaded to the GPU. + /// If the data is already in a format that can be uploaded, this is a + /// no-op. + /// Use `convertCopy()` to convert and copy in a single-pass, such as for owning the data to upload. + fn convertReplace(self: *Image.Pending, alloc: Allocator) wuffs.Error!void { + // As things stand, we currently convert all images to RGBA before + // uploading to the GPU. This just makes things easier. In the future + // we may want to support other formats. + if (self.pixel_format == .rgba) return; + // If the pending data isn't RGBA we'll need to swizzle it. + const data = self.dataSlice(); + const rgba = try switch (self.pixel_format) { + .gray => wuffs.swizzle.gToRgba(alloc, data), + .gray_alpha => wuffs.swizzle.gaToRgba(alloc, data), + .rgb => wuffs.swizzle.rgbToRgba(alloc, data), + .bgr => wuffs.swizzle.bgrToRgba(alloc, data), + .rgba => unreachable, + .bgra => wuffs.swizzle.bgraToRgba(alloc, data), + }; + alloc.free(data); + self.data = rgba.ptr; + self.pixel_format = .rgba; + } + + /// Converts the image data to a copy with a format that can be uploaded to the GPU. + /// If the data is already owned, use `convertReplace()` to be a no-op for data already + /// in a format that can be uploaded. + fn convertCopy(self: *const Image.Pending, alloc: Allocator) wuffs.Error!Image { + // As things stand, we currently convert all images to RGBA before + // uploading to the GPU. This just makes things easier. In the future + // we may want to support other formats. + const data = self.dataSlice(); + const rgba = try switch (self.pixel_format) { + .gray => wuffs.swizzle.gToRgba(alloc, data), + .gray_alpha => wuffs.swizzle.gaToRgba(alloc, data), + .rgb => wuffs.swizzle.rgbToRgba(alloc, data), + .bgr => wuffs.swizzle.bgrToRgba(alloc, data), + .rgba => alloc.dupe(u8, data), + .bgra => wuffs.swizzle.bgraToRgba(alloc, data), + }; + const result: Image = .{ .pending = .{ + .height = self.height, + .width = self.width, + .pixel_format = .rgba, + .data = rgba.ptr, + } }; + return result; + } + + /// Prepare the pending image data for upload to the GPU. + /// This doesn't need GPU access so is safe to call any time. + fn prepForUpload(self: *Image.Pending, alloc: Allocator) wuffs.Error!void { + try self.convertReplace(alloc); + } }; pub fn deinit(self: Image, alloc: Allocator) void { @@ -860,37 +1067,6 @@ pub const Image = union(enum) { }; } - /// Converts the image data to a format that can be uploaded to the GPU. - /// If the data is already in a format that can be uploaded, this is a - /// no-op. - fn convert(self: *Image, alloc: Allocator) wuffs.Error!void { - const p = self.getPendingPointer().?; - // As things stand, we currently convert all images to RGBA before - // uploading to the GPU. This just makes things easier. In the future - // we may want to support other formats. - if (p.pixel_format == .rgba) return; - // If the pending data isn't RGBA we'll need to swizzle it. - const data = p.dataSlice(); - const rgba = try switch (p.pixel_format) { - .gray => wuffs.swizzle.gToRgba(alloc, data), - .gray_alpha => wuffs.swizzle.gaToRgba(alloc, data), - .rgb => wuffs.swizzle.rgbToRgba(alloc, data), - .bgr => wuffs.swizzle.bgrToRgba(alloc, data), - .rgba => unreachable, - .bgra => wuffs.swizzle.bgraToRgba(alloc, data), - }; - alloc.free(data); - p.data = rgba.ptr; - p.pixel_format = .rgba; - } - - /// Prepare the pending image data for upload to the GPU. - /// This doesn't need GPU access so is safe to call any time. - fn prepForUpload(self: *Image, alloc: Allocator) wuffs.Error!void { - assert(self.isPending()); - try self.convert(alloc); - } - /// Upload the pending image to the GPU and change the state of this /// image to ready. pub fn upload( @@ -903,12 +1079,12 @@ pub const Image = union(enum) { })!void { assert(self.isPending()); + // Get our pending info + const p = self.getPendingPointer().?; + // No error recover is required after this call because it just // converts in place and is idempotent. - try self.prepForUpload(alloc); - - // Get our pending info - const p = self.getPending().?; + try p.prepForUpload(alloc); // Create our texture const texture = Texture.init( @@ -1036,3 +1212,308 @@ test "kitty renderer ignores pending payloads and removes replaced placements" { try testing.expectEqual(@as(usize, 0), state.kitty_placements.items.len); try testing.expect(state.images.get(.{ .kitty = 1 }).?.image.isUnloading()); } + +test "kitty renderer uses the intersected source rectangle" { + const testing = std.testing; + const alloc = testing.allocator; + const io = testing.io; + + var t = try terminal.Terminal.init(io, alloc, .{ .rows = 3, .cols = 3 }); + defer t.deinit(alloc); + t.width_px = 30; + t.height_px = 30; + + var state: State = .empty; + defer state.deinit(alloc); + + const storage = &t.screens.active.kitty_images; + const pixels = try alloc.alloc(u8, 4 * 3 * 3); + @memset(pixels, 0); + try storage.addImage(io, alloc, t.screens.active, .{ + .id = 1, + .width = 4, + .height = 3, + .format = .rgb, + .data = .{ .complete = pixels }, + }); + const pin = try t.screens.active.pages.trackPin( + t.screens.active.cursor.page_pin.*, + ); + try storage.addPlacement(io, alloc, t.screens.active, 1, 1, .{ + .location = .{ .pin = pin }, + .source_x = 3, + .source_y = 1, + }); + + state.kittyUpdate(alloc, &t, .{ .width = 10, .height = 10 }); + try testing.expectEqual(@as(usize, 1), state.kitty_placements.items.len); + + const placement = state.kitty_placements.items[0]; + try testing.expectEqual(@as(u32, 1), placement.width); + try testing.expectEqual(@as(u32, 2), placement.height); + try testing.expectEqual(@as(u32, 3), placement.source_x); + try testing.expectEqual(@as(u32, 1), placement.source_y); + try testing.expectEqual(@as(u32, 1), placement.source_width); + try testing.expectEqual(@as(u32, 2), placement.source_height); +} + +test "kitty renderer positions relative placements from the parent pin" { + const testing = std.testing; + const alloc = testing.allocator; + const io = testing.io; + + var t = try terminal.Terminal.init(io, alloc, .{ .rows = 10, .cols = 10 }); + defer t.deinit(alloc); + t.width_px = 100; + t.height_px = 100; + + var state: State = .empty; + defer state.deinit(alloc); + + const storage = &t.screens.active.kitty_images; + const pixels = try alloc.alloc(u8, 3); + @memset(pixels, 0); + try storage.addImage(io, alloc, t.screens.active, .{ + .id = 1, + .width = 1, + .height = 1, + .format = .rgb, + .data = .{ .complete = pixels }, + }); + + // Parent at (2, 1). + const pin = try t.screens.active.pages.trackPin( + t.screens.active.pages.pin(.{ .active = .{ .x = 2, .y = 1 } }).?, + ); + try storage.addPlacement(io, alloc, t.screens.active, 1, 1, .{ + .location = .{ .pin = pin }, + .columns = 1, + .rows = 1, + }); + + // Child offset (3, 2) cells from the parent. + try storage.addPlacement(io, alloc, t.screens.active, 1, 2, .{ + .location = .{ .relative = .{ + .parent = .{ + .image_id = 1, + .placement_id = .{ .tag = .external, .id = 1 }, + }, + .horizontal_offset = 3, + .vertical_offset = 2, + } }, + .columns = 1, + .rows = 1, + .z = 1, + }); + + state.kittyUpdate(alloc, &t, .{ .width = 10, .height = 10 }); + try testing.expectEqual(@as(usize, 2), state.kitty_placements.items.len); + + // Sorted by z: parent (z=0) first, child (z=1) second. + const parent = state.kitty_placements.items[0]; + try testing.expectEqual(@as(i32, 2), parent.x); + try testing.expectEqual(@as(i32, 1), parent.y); + const child = state.kitty_placements.items[1]; + try testing.expectEqual(@as(i32, 5), child.x); + try testing.expectEqual(@as(i32, 3), child.y); + + // Pin-rooted relative placements don't force per-frame rebuilds. + try testing.expect(!state.kitty_virtual); +} + +test "kitty renderer relative placement with negative offsets" { + const testing = std.testing; + const alloc = testing.allocator; + const io = testing.io; + + var t = try terminal.Terminal.init(io, alloc, .{ .rows = 10, .cols = 10 }); + defer t.deinit(alloc); + t.width_px = 100; + t.height_px = 100; + + var state: State = .empty; + defer state.deinit(alloc); + + const storage = &t.screens.active.kitty_images; + const pixels = try alloc.alloc(u8, 3); + @memset(pixels, 0); + try storage.addImage(io, alloc, t.screens.active, .{ + .id = 1, + .width = 1, + .height = 1, + .format = .rgb, + .data = .{ .complete = pixels }, + }); + + const pin = try t.screens.active.pages.trackPin( + t.screens.active.pages.pin(.{ .active = .{ .x = 2, .y = 2 } }).?, + ); + try storage.addPlacement(io, alloc, t.screens.active, 1, 1, .{ + .location = .{ .pin = pin }, + .columns = 1, + .rows = 1, + }); + + // Hangs up and to the left of the viewport but still has visible + // cells at (0, 0), so it must be kept with a negative position. + try storage.addPlacement(io, alloc, t.screens.active, 1, 2, .{ + .location = .{ .relative = .{ + .parent = .{ + .image_id = 1, + .placement_id = .{ .tag = .external, .id = 1 }, + }, + .horizontal_offset = -3, + .vertical_offset = -3, + } }, + .columns = 2, + .rows = 2, + .z = 1, + }); + + // Entirely off the left edge of the screen: culled. + try storage.addPlacement(io, alloc, t.screens.active, 1, 3, .{ + .location = .{ .relative = .{ + .parent = .{ + .image_id = 1, + .placement_id = .{ .tag = .external, .id = 1 }, + }, + .horizontal_offset = -9, + } }, + .columns = 2, + .rows = 2, + .z = 2, + }); + + state.kittyUpdate(alloc, &t, .{ .width = 10, .height = 10 }); + try testing.expectEqual(@as(usize, 2), state.kitty_placements.items.len); + const child = state.kitty_placements.items[1]; + try testing.expectEqual(@as(i32, -1), child.x); + try testing.expectEqual(@as(i32, -1), child.y); +} + +test "kitty renderer positions relative placements from virtual parent placeholders" { + const testing = std.testing; + const alloc = testing.allocator; + const io = testing.io; + + var t = try terminal.Terminal.init(io, alloc, .{ .rows = 5, .cols = 5 }); + defer t.deinit(alloc); + t.width_px = 50; + t.height_px = 50; + t.modes.set(.grapheme_cluster, true); + + var state: State = .empty; + defer state.deinit(alloc); + + const storage = &t.screens.active.kitty_images; + const pixels = try alloc.alloc(u8, 10 * 10 * 3); + @memset(pixels, 0); + try storage.addImage(io, alloc, t.screens.active, .{ + .id = 1, + .width = 10, + .height = 10, + .format = .rgb, + .data = .{ .complete = pixels }, + }); + try storage.addPlacement(io, alloc, t.screens.active, 1, 1, .{ + .location = .{ .virtual = {} }, + }); + + // Placeholder cells for the virtual placement at (1, 1) and + // (3, 2): the minimum cell (1, 1) is the anchor for relative + // placements rooted at the virtual placement. + try t.setAttribute(.{ .@"256_fg" = 1 }); + try t.setAttribute(.{ .@"256_underline_color" = 1 }); + t.screens.active.cursorAbsolute(1, 1); + try t.printString("\u{10EEEE}\u{0305}\u{0305}"); + t.screens.active.cursorAbsolute(3, 2); + try t.printString("\u{10EEEE}\u{0305}\u{0305}"); + + // Child of the virtual placement, offset (1, 2) cells. + try storage.addPlacement(io, alloc, t.screens.active, 1, 2, .{ + .location = .{ .relative = .{ + .parent = .{ + .image_id = 1, + .placement_id = .{ .tag = .external, .id = 1 }, + }, + .horizontal_offset = 1, + .vertical_offset = 2, + } }, + .columns = 1, + .rows = 1, + .z = 5, + }); + + state.kittyUpdate(alloc, &t, .{ .width = 10, .height = 10 }); + try testing.expect(state.kitty_virtual); + + // Two placeholder runs (z=-1) plus the child (z=5), sorted by z. + try testing.expectEqual(@as(usize, 3), state.kitty_placements.items.len); + const child = state.kitty_placements.items[2]; + try testing.expectEqual(@as(i32, 5), child.z); + try testing.expectEqual(@as(i32, 2), child.x); + try testing.expectEqual(@as(i32, 3), child.y); +} + +test "kitty renderer uploads the current animation frame" { + const testing = std.testing; + const alloc = testing.allocator; + const io = testing.io; + + var t = try terminal.Terminal.init(io, alloc, .{ .rows = 3, .cols = 3 }); + defer t.deinit(alloc); + t.width_px = 30; + t.height_px = 30; + + var state: State = .empty; + defer state.deinit(alloc); + + const storage = &t.screens.active.kitty_images; + try storage.addImage(io, alloc, t.screens.active, .{ + .id = 1, + .width = 1, + .height = 1, + .format = .rgba, + .data = .{ .complete = try alloc.dupe(u8, &.{ 255, 0, 0, 255 }) }, + }); + const pin = try t.screens.active.pages.trackPin( + t.screens.active.cursor.page_pin.*, + ); + try storage.addPlacement(io, alloc, t.screens.active, 1, 1, .{ + .location = .{ .pin = pin }, + .columns = 1, + .rows = 1, + }); + + state.kittyUpdate(alloc, &t, .{ .width = 10, .height = 10 }); + const gen1 = state.images.get(.{ .kitty = 1 }).?.generation; + try testing.expectEqualSlices( + u8, + &.{ 255, 0, 0, 255 }, + state.images.get(.{ .kitty = 1 }).?.image.pending.dataSlice(), + ); + + // Attach an animation and make its extra frame current, the way + // an animation tick would. + const img = storage.images.getPtr(1).?; + const anim = try alloc.create(terminal.kitty.graphics.Animation); + anim.* = .{}; + img.animation = anim; + try anim.frames.append(alloc, .{ + .data = try alloc.dupe(u8, &.{ 0, 0, 255, 255 }), + .gap_ms = 40, + }); + anim.current_index = 1; + storage.markImageContentChanged(io, img); + + // The renderer must pick up the frame's pixels under a fresh + // generation. + state.kittyUpdate(alloc, &t, .{ .width = 10, .height = 10 }); + const entry = state.images.get(.{ .kitty = 1 }).?; + try testing.expect(entry.generation > gen1); + try testing.expectEqualSlices( + u8, + &.{ 0, 0, 255, 255 }, + entry.image.pending.dataSlice(), + ); +} diff --git a/vendor/libghostty-vt/src/simd/base64.zig b/vendor/libghostty-vt/src/simd/base64.zig index 81feeb7..b86b71a 100644 --- a/vendor/libghostty-vt/src/simd/base64.zig +++ b/vendor/libghostty-vt/src/simd/base64.zig @@ -1,7 +1,8 @@ const std = @import("std"); const options = @import("build_options"); const assert = @import("../quirks.zig").inlineAssert; -const scalar_decoder = @import("base64_scalar.zig").scalar_decoder; +const base64_scalar = @import("base64_scalar.zig"); +const scalar_decoder = base64_scalar.scalar_decoder; const log = std.log.scoped(.simd_base64); @@ -53,11 +54,170 @@ fn decodeScalar( /// For non-SIMD enabled builds, we trim the padding from the end of the /// base64 input in order to get identical output with the SIMD version. fn scalarInput(input: []const u8) []const u8 { - var i: usize = 0; - while (input[input.len - i - 1] == '=') i += 1; - return input[0 .. input.len - i]; + var end = input.len; + while (end > 0 and input[end - 1] == '=') end -= 1; + return input[0..end]; } +/// Whether strict decoding requires the input to be padded to a +/// multiple of four bytes (RFC 4648 section 3.2). The Kitty clipboard +/// protocol requires padding; the legacy OSC 52 protocol tolerates a +/// missing-padding tail because it has no way to report errors to the +/// client. +pub const Padding = enum { required, optional }; + +/// Decode strict RFC 4648 standard-alphabet base64: characters outside +/// the alphabet (including whitespace) and misplaced padding are +/// errors rather than being skipped, and padding is validated per the +/// given requirement. This is the decoding the Kitty clipboard +/// protocol specifies: +/// https://sw.kovidgoyal.net/kitty/clipboard/#encoding-of-payloads +/// +/// The output must be at least maxLen(input) bytes. +pub fn decodeStrict( + input: []const u8, + output: []u8, + padding: Padding, +) error{Base64Invalid}![]const u8 { + // Padding can only be a suffix of at most two bytes; any '=' + // elsewhere is rejected by the underlying decode. + var pad: usize = 0; + if (input.len > 0 and input[input.len - 1] == '=') pad += 1; + if (input.len > 1 and input[input.len - 2] == '=') pad += 1; + + switch (padding) { + .required => if (input.len % 4 != 0) return error.Base64Invalid, + // Present padding must still complete a four byte group; only + // fully absent padding is tolerated. A single leftover byte + // can never carry a decodable value. + .optional => if (pad > 0) { + if (input.len % 4 != 0) return error.Base64Invalid; + } else if (input.len % 4 == 1) return error.Base64Invalid, + } + + // The permissive decode already rejects every invalid character + // except whitespace, which simdutf silently skips. Skipped + // characters make the decoded length fall short of the exact + // length the input length implies, so comparing the two rejects + // whitespace without a separate validation pass over the input. + const decoded = decode(input, output) catch return error.Base64Invalid; + const expected = switch (input.len % 4) { + 0 => input.len / 4 * 3 - pad, + 2 => input.len / 4 * 3 + 1, + 3 => input.len / 4 * 3 + 2, + else => unreachable, // rejected above + }; + if (decoded.len != expected) return error.Base64Invalid; + return decoded; +} + +/// A streaming strict base64 decoder for one logical payload split +/// across multiple chunks at arbitrary byte boundaries (e.g. the Kitty +/// clipboard protocol's wdata packets): the concatenation of the fed +/// chunks must be valid RFC 4648 standard-alphabet base64. +/// +/// Padding is terminal within a feed: a padded group followed by more +/// data in the same feed is an error. A feed that ends exactly at +/// terminal padding resets the decoder so the next feed starts a +/// fresh stream, exactly like the kitty reference implementation +/// (which resets its aklomp streaming decoder on EOF); this keeps +/// clients that pad each chunk independently working. +pub const Streaming = struct { + /// Partial group carried between feeds; a group only decodes once + /// all four of its characters have arrived. + carry: [4]u8 = undefined, + carry_len: u3 = 0, + + /// Maximum decoded bytes one feed of input can produce. + pub fn maxLen(self: *const Streaming, input: []const u8) usize { + return (self.carry_len + input.len) / 4 * 3; + } + + /// Decode the complete groups of input (with any carried bytes + /// prepended) into output, which must be at least maxLen(input) + /// bytes, and carry the remainder for the next feed. + pub fn feed( + self: *Streaming, + input: []const u8, + output: []u8, + ) error{Base64Invalid}![]const u8 { + assert(output.len >= self.maxLen(input)); + if (input.len == 0) return output[0..0]; + + var rem = input; + var written: usize = 0; + + // Complete a carried partial group first. + if (self.carry_len > 0) { + const take = @min(4 - @as(usize, self.carry_len), rem.len); + for (rem[0..take], self.carry_len..) |c, pos| { + if (!validPartialChar(pos, c)) return error.Base64Invalid; + } + @memcpy(self.carry[self.carry_len..][0..take], rem[0..take]); + self.carry_len += @intCast(take); + rem = rem[take..]; + if (self.carry_len < 4) return output[0..0]; + self.carry_len = 0; + const decoded, const padded = try group(self.carry, output); + written += decoded; + if (padded) { + if (rem.len > 0) return error.Base64Invalid; + return output[0..written]; + } + } + + // Bulk-decode all complete groups with the strict single-shot + // decode, which also enforces that padding only appears as a + // terminal suffix. Terminal padding must then end the feed. + const bulk = rem[0 .. rem.len - (rem.len % 4)]; + written += (try decodeStrict(bulk, output[written..], .required)).len; + if (bulk.len > 0 and bulk[bulk.len - 1] == '=') { + if (bulk.len != rem.len) return error.Base64Invalid; + return output[0..written]; + } + + // Carry the trailing partial group, validated eagerly so + // garbage is reported on the feed that contains it. + const tail = rem[bulk.len..]; + for (tail, 0..) |c, pos| { + if (!validPartialChar(pos, c)) return error.Base64Invalid; + } + @memcpy(self.carry[0..tail.len], tail); + self.carry_len = @intCast(tail.len); + return output[0..written]; + } + + /// Decode one complete four character group, the only place + /// padding is legal: the last two characters may be '=' ('=' in + /// the second-to-last position requires it in the last). Returns + /// the decoded length and whether the group was padded. + fn group( + g: [4]u8, + output: []u8, + ) error{Base64Invalid}!struct { usize, bool } { + const padded = g[3] == '='; + const decoded = try decodeStrict(&g, output, .required); + return .{ decoded.len, padded }; + } + + /// Whether c is valid at the given position of a partial group + /// still waiting for its remaining characters: the first two + /// positions must be alphabet characters and only the last two + /// may open the padding suffix. + fn validPartialChar(pos: usize, c: u8) bool { + return base64_scalar.isAlphabetChar(c) or (pos >= 2 and c == '='); + } + + /// Finish the stream: the concatenation of the fed chunks must + /// have formed complete groups, so a carried partial group is an + /// error (the stream was not correctly padded). The decoder is + /// ready for a fresh stream afterwards either way. + pub fn finish(self: *Streaming) error{Base64Invalid}!void { + defer self.* = .{}; + if (self.carry_len != 0) return error.Base64Invalid; + } +}; + // base64.cpp extern "c" fn ghostty_simd_base64_max_length( input: [*]const u8, @@ -75,6 +235,14 @@ test "base64 maxLen" { try testing.expectEqual(11, len); } +test "base64 empty input" { + const testing = std.testing; + var output: [0]u8 = .{}; + + try testing.expectEqual(0, maxLen("")); + try testing.expectEqualStrings("", try decode("", &output)); +} + test "base64 decode" { const testing = std.testing; const alloc = testing.allocator; @@ -85,3 +253,190 @@ test "base64 decode" { const str = try decode(input, output); try testing.expectEqualStrings("hello world", str); } + +test "base64 strict decode valid" { + const testing = std.testing; + var output: [128]u8 = undefined; + + const cases = [_]struct { input: []const u8, expect: []const u8 }{ + .{ .input = "", .expect = "" }, + .{ .input = "aGVsbG8gd29ybGQ=", .expect = "hello world" }, + .{ .input = "bGlnaHQgdw==", .expect = "light w" }, + .{ .input = "Zm9vYmFy", .expect = "foobar" }, + // Every alphabet character in one input. + .{ + .input = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/", + .expect = "\x00\x10\x83\x10\x51\x87\x20\x92\x8b\x30\xd3\x8f\x41\x14\x93\x51\x55\x97\x61\x96\x9b\x71\xd7\x9f\x82\x18\xa3\x92\x59\xa7\xa2\x9a\xab\xb2\xdb\xaf\xc3\x1c\xb3\xd3\x5d\xb7\xe3\x9e\xbb\xf3\xdf\xbf", + }, + }; + for (cases) |case| { + try testing.expectEqualStrings( + case.expect, + try decodeStrict(case.input, &output, .required), + ); + try testing.expectEqualStrings( + case.expect, + try decodeStrict(case.input, &output, .optional), + ); + } +} + +test "base64 strict decode invalid" { + const testing = std.testing; + var output: [128]u8 = undefined; + + // The invalid inputs from kitty's own strict decoding tests plus + // some extra padding-placement cases. All of these are invalid for + // both padding requirements. + const cases = [_][]const u8{ + "bGlnaHQgdw=", // missing one padding byte + "bGln!!Qgdw==", // invalid characters + "bGlnaHQgdw==\n", // trailing whitespace + "\nbGlnaHQgdw==", // leading whitespace + "bGlnaHQg dw==", // interior whitespace + "!!!!", + "=", + "==", + "A===", + "AB=C", // padding must be the suffix + "Zm9v YmFy", + }; + for (cases) |case| { + try testing.expectError( + error.Base64Invalid, + decodeStrict(case, &output, .required), + ); + try testing.expectError( + error.Base64Invalid, + decodeStrict(case, &output, .optional), + ); + } + + // Unpadded input is only tolerated when padding is optional. A + // single leftover character can never be decoded. + try testing.expectError( + error.Base64Invalid, + decodeStrict("bGlnaHQgdw", &output, .required), + ); + try testing.expectEqualStrings( + "light w", + try decodeStrict("bGlnaHQgdw", &output, .optional), + ); + try testing.expectError( + error.Base64Invalid, + decodeStrict("bGl", &output, .required), + ); + try testing.expectEqualStrings( + "li", + try decodeStrict("bGl", &output, .optional), + ); + try testing.expectError( + error.Base64Invalid, + decodeStrict("bGlna", &output, .optional), + ); +} + +test "base64 streaming decode chunk boundaries" { + const testing = std.testing; + const alloc = testing.allocator; + + // Decoding a stream split at every possible boundary, including + // one byte at a time, matches the single-shot decode. + const input = "c29tZSBsb25nZXIgZGF0YSB3aXRoIHBhZGRpbmc+Pz8="; + const expect = "some longer data with padding>??"; + for (0..input.len + 1) |split| { + var s: Streaming = .{}; + var result: std.ArrayListUnmanaged(u8) = .empty; + defer result.deinit(alloc); + var output: [64]u8 = undefined; + try result.appendSlice(alloc, try s.feed(input[0..split], &output)); + try result.appendSlice(alloc, try s.feed(input[split..], &output)); + try s.finish(); + try testing.expectEqualStrings(expect, result.items); + } + { + var s: Streaming = .{}; + var result: std.ArrayListUnmanaged(u8) = .empty; + defer result.deinit(alloc); + var output: [4]u8 = undefined; + for (0..input.len) |i| { + try result.appendSlice(alloc, try s.feed(input[i..][0..1], &output)); + } + try s.finish(); + try testing.expectEqualStrings(expect, result.items); + } +} + +test "base64 streaming decode invalid" { + const testing = std.testing; + var output: [64]u8 = undefined; + + // Invalid characters are rejected wherever they appear. + { + var s: Streaming = .{}; + try testing.expectError(error.Base64Invalid, s.feed("!!!!", &output)); + } + { + var s: Streaming = .{}; + try testing.expectError(error.Base64Invalid, s.feed("SGVs!!!bG8=", &output)); + } + { + var s: Streaming = .{}; + try testing.expectError( + error.Base64Invalid, + s.feed("\nc29tZSBkYXRh", &output), + ); + } + + // Data after terminal padding within one feed is rejected, even + // when the padded group only completes in that feed. + { + var s: Streaming = .{}; + try testing.expectError(error.Base64Invalid, s.feed("Z29vZA==SGVsbG8=", &output)); + } + { + var s: Streaming = .{}; + _ = try s.feed("Z29vZA=", &output); + try testing.expectError(error.Base64Invalid, s.feed("=SGVs", &output)); + } + + // A feed that ends exactly at terminal padding resets the stream: + // the next feed starts fresh, so clients that pad every chunk + // independently keep working (matching the kitty implementation, + // which resets its streaming decoder on EOF). + { + var s: Streaming = .{}; + try testing.expectEqualStrings("good", try s.feed("Z29vZA==", &output)); + try testing.expectEqualStrings("Hello", try s.feed("SGVsbG8=", &output)); + try s.finish(); + } + { + // Padding split across feeds resets too. + var s: Streaming = .{}; + try testing.expectEqualStrings("goo", try s.feed("Z29vZA=", &output)); + try testing.expectEqualStrings("d", try s.feed("=", &output)); + try testing.expectEqualStrings("more", try s.feed("bW9yZQ==", &output)); + try s.finish(); + } + + // Misplaced padding within a group. + { + var s: Streaming = .{}; + try testing.expectError(error.Base64Invalid, s.feed("YQ=X", &output)); + } + { + var s: Streaming = .{}; + try testing.expectError(error.Base64Invalid, s.feed("=AAA", &output)); + } + + // A stream that ends in a partial group is missing its padding. + // The failed finish resets the decoder for the next stream. + { + var s: Streaming = .{}; + try testing.expectEqualStrings("Hel", try s.feed("SGVsbG8", &output)); + try testing.expectError(error.Base64Invalid, s.finish()); + try testing.expectEqualStrings("Hel", try s.feed("SGVsbG8", &output)); + try testing.expectEqualStrings("lo", try s.feed("=", &output)); + try s.finish(); + } +} diff --git a/vendor/libghostty-vt/src/simd/base64_scalar.zig b/vendor/libghostty-vt/src/simd/base64_scalar.zig index 08886f1..69b3f29 100644 --- a/vendor/libghostty-vt/src/simd/base64_scalar.zig +++ b/vendor/libghostty-vt/src/simd/base64_scalar.zig @@ -6,6 +6,12 @@ pub const scalar_decoder: Base64Decoder = .init( null, ); +/// Whether c is one of the 64 standard base64 alphabet characters +/// (padding is not part of the alphabet). +pub fn isAlphabetChar(c: u8) bool { + return scalar_decoder.char_to_index[c] != Base64Decoder.invalid_char; +} + /// Copied from Zig 0.14.1 stdlib and commented out the invalid padding /// scenarios, because Kitty Graphics requires a decoder that doesn't care /// about invalid padding scenarios. diff --git a/vendor/libghostty-vt/src/simd/vt.zig b/vendor/libghostty-vt/src/simd/vt.zig index 807285f..ded4ab3 100644 --- a/vendor/libghostty-vt/src/simd/vt.zig +++ b/vendor/libghostty-vt/src/simd/vt.zig @@ -53,11 +53,29 @@ fn utf8DecodeUntilControlSeqScalar( while (decode_offset < decode.len) { const b0 = decode[decode_offset]; - // ASCII fast path + // ASCII fast path. Use vectorization if it is available. This + // path is only run when simd=false, but that only controls our C++ + // simd builds. We can still rely on Zig intrinsics for platforms + // like wasm32+simd128. if (b0 < 0x80) { - output[decode_count] = b0; - decode_count += 1; - decode_offset += 1; + if (comptime std.simd.suggestVectorLength(u8)) |vl| { + const V = @Vector(vl, u8); + while (decode_offset + vl <= decode.len) { + const v: V = decode[decode_offset..][0..vl].*; + if (@reduce(.Or, v >= @as(V, @splat(0x80)))) break; + const w: @Vector(vl, u32) = @intCast(v); + output[decode_count..][0..vl].* = w; + decode_count += vl; + decode_offset += vl; + } + } + while (decode_offset < decode.len) { + const b = decode[decode_offset]; + if (b >= 0x80) break; + output[decode_count] = b; + decode_count += 1; + decode_offset += 1; + } continue; } @@ -70,15 +88,27 @@ fn utf8DecodeUntilControlSeqScalar( continue; } - // Multi-byte sequence. Determine expected length and the valid - // range for each continuation byte per Unicode Table 3-7. - const seq = utf8SeqInfo(b0); + // Multi-byte sequence. Only the first continuation byte has a + // lead-dependent valid range per Unicode Table 3-7; later + // continuation bytes are always 80-BF. Range validity per + // Table 3-7 excludes overlong, surrogate, and out-of-range + // encodings, so a fully valid sequence can be decoded by + // direct bit assembly with no further checks. + const seq_len: usize = if (b0 < 0xE0) 2 else if (b0 < 0xF0) 3 else 4; + const cb1_lo: u8, const cb1_hi: u8 = switch (b0) { + 0xE0 => .{ 0xA0, 0xBF }, + 0xED => .{ 0x80, 0x9F }, + 0xF0 => .{ 0x90, 0xBF }, + 0xF4 => .{ 0x80, 0x8F }, + else => .{ 0x80, 0xBF }, + }; // Check how many continuation bytes form a valid prefix (the - // maximal subpart). We check each byte against its specific - // valid range. + // maximal subpart), accumulating codepoint bits as we go. The + // lead byte contributes its low 7-len bits. + var cp: u32 = b0 & (@as(u32, 0x7F) >> @intCast(seq_len)); var valid: usize = 1; // lead byte is valid - for (0..seq.len - 1) |ci| { + while (valid < seq_len) { if (decode_offset + valid >= decode.len) { // The sequence is cut off by the end of the decode // region. If the region ends at the true end of the @@ -94,29 +124,17 @@ fn utf8DecodeUntilControlSeqScalar( break; } const cb = decode[decode_offset + valid]; - if (cb < seq.ranges[ci][0] or cb > seq.ranges[ci][1]) { - // Byte doesn't match expected range. The maximal - // subpart ends here. - break; - } + const lo: u8 = if (valid == 1) cb1_lo else 0x80; + const hi: u8 = if (valid == 1) cb1_hi else 0xBF; + if (cb < lo or cb > hi) break; + cp = (cp << 6) | (cb & 0x3F); valid += 1; } - if (valid == seq.len) { - // Full sequence present and structurally valid. Decode it. - // (Structural validity per Table 3-7 guarantees decode success.) - const cp_bytes = decode[decode_offset..][0..seq.len]; - if (std.unicode.utf8Decode(cp_bytes)) |cp| { - output[decode_count] = @intCast(cp); - decode_count += 1; - decode_offset += seq.len; - } else |_| { - // Should not happen given Table 3-7 validation, but - // be safe: emit FFFD for the lead byte. - output[decode_count] = 0xFFFD; - decode_count += 1; - decode_offset += 1; - } + if (valid == seq_len) { + output[decode_count] = cp; + decode_count += 1; + decode_offset += seq_len; } else { // Incomplete/ill-formed: the maximal subpart (valid bytes) // maps to a single FFFD. @@ -132,27 +150,6 @@ fn utf8DecodeUntilControlSeqScalar( }; } -const Utf8SeqInfo = struct { - len: u3, - ranges: [3][2]u8, -}; - -/// Returns the expected byte count and valid continuation byte ranges -/// for a UTF-8 sequence based on its lead byte, per Unicode Table 3-7. -fn utf8SeqInfo(lead: u8) Utf8SeqInfo { - return switch (lead) { - 0xC2...0xDF => .{ .len = 2, .ranges = .{ .{ 0x80, 0xBF }, .{ 0, 0 }, .{ 0, 0 } } }, - 0xE0 => .{ .len = 3, .ranges = .{ .{ 0xA0, 0xBF }, .{ 0x80, 0xBF }, .{ 0, 0 } } }, - 0xE1...0xEC => .{ .len = 3, .ranges = .{ .{ 0x80, 0xBF }, .{ 0x80, 0xBF }, .{ 0, 0 } } }, - 0xED => .{ .len = 3, .ranges = .{ .{ 0x80, 0x9F }, .{ 0x80, 0xBF }, .{ 0, 0 } } }, - 0xEE...0xEF => .{ .len = 3, .ranges = .{ .{ 0x80, 0xBF }, .{ 0x80, 0xBF }, .{ 0, 0 } } }, - 0xF0 => .{ .len = 4, .ranges = .{ .{ 0x90, 0xBF }, .{ 0x80, 0xBF }, .{ 0x80, 0xBF } } }, - 0xF1...0xF3 => .{ .len = 4, .ranges = .{ .{ 0x80, 0xBF }, .{ 0x80, 0xBF }, .{ 0x80, 0xBF } } }, - 0xF4 => .{ .len = 4, .ranges = .{ .{ 0x80, 0x8F }, .{ 0x80, 0xBF }, .{ 0x80, 0xBF } } }, - else => unreachable, - }; -} - // Differential test: the SIMD implementation must agree with the // scalar implementation on any input. Exercises random mixtures of // ASCII, escapes, controls, valid and invalid UTF-8, at various diff --git a/vendor/libghostty-vt/src/terminal/PageList.zig b/vendor/libghostty-vt/src/terminal/PageList.zig index 429a363..1e818ef 100644 --- a/vendor/libghostty-vt/src/terminal/PageList.zig +++ b/vendor/libghostty-vt/src/terminal/PageList.zig @@ -12,6 +12,7 @@ const fastmem = @import("../fastmem.zig"); const simd = @import("../simd/main.zig"); const tripwire = @import("../tripwire.zig"); const DoublyLinkedList = @import("../datastruct/main.zig").IntrusiveDoublyLinkedList; +const WasmPagePool = @import("../datastruct/main.zig").WasmPagePool; const color = @import("color.zig"); const compression = @import("compress.zig"); const highlight = @import("highlight.zig"); @@ -28,6 +29,8 @@ const Page = pagepkg.Page; const Row = pagepkg.Row; const log = std.log.scoped(.page_list); +const native_freestanding = builtin.os.tag == .freestanding and + !builtin.target.cpu.arch.isWasm(); /// The number of PageList.Nodes we preheat the pool with. A node is /// a very small struct so we can afford to preheat many, but the exact @@ -306,13 +309,24 @@ const std_capacity = pagepkg.std_capacity; /// The byte size required for a standard page. const std_size = Page.layout(std_capacity).total_size; +/// True when the page pool is the wasm page pool, which recycles items +/// through a free list shared by the whole module instance instead of +/// dying with the pool. +/// +/// Test builds use the std pool even on wasm so that pool memory goes +/// through the testing allocator and participates in leak detection. +const wasm_page_pool = builtin.target.cpu.arch.isWasm() and !builtin.is_test; + /// The memory pool we use for page memory buffers. We use a separate pool /// so we can allocate these with a page allocator. We have to use a page /// allocator because we need memory that is zero-initialized and page-aligned. -const PagePool = std.heap.memory_pool.AlignedManaged( - [std_size]u8, - .fromByteUnits(std.heap.page_size_min), -); +const PagePool = if (wasm_page_pool) + WasmPagePool([std_size]u8) +else + std.heap.memory_pool.AlignedManaged( + [std_size]u8, + .fromByteUnits(std.heap.page_size_min), + ); /// List of pins, known as "tracked" pins. These are pins that are kept /// up to date automatically through page-modifying operations. @@ -526,12 +540,19 @@ fn initialCapacity(cols: size.CellCountInt) Capacity { return cap; } -/// This is the page allocator we'll use for all our underlying -/// VM page allocations. -inline fn pageAllocator() Allocator { +/// Returns the allocator used for underlying page allocations. +/// +/// `alloc` is the caller-provided allocator. It is used on native freestanding +/// targets, where no OS page allocator is available. Other targets select a +/// platform-specific allocator below. +inline fn pageAllocator(alloc: Allocator) Allocator { // In tests we use our testing allocator so we can detect leaks. if (builtin.is_test) return std.testing.allocator; + // Native freestanding targets don't have an OS page allocator, so use + // the allocator provided by the embedder. + if (native_freestanding) return alloc; + // On non-macOS we use our standard Zig page allocator. if (!builtin.target.os.tag.isDarwin()) return std.heap.page_allocator; @@ -602,7 +623,7 @@ pub fn init( try tw.check(.init_memory_pool); var pool = try MemoryPool.init( alloc, - pageAllocator(), + pageAllocator(alloc), page_preheat, ); errdefer pool.deinit(); @@ -677,13 +698,13 @@ fn initPages( // redundant here for safety. assert(layout.total_size <= size.max_page_size); - // If we have an error, we need to clean up our heap-owned pages - // since they're not in the pool. + // If we have an error, we need to clean up our pages: heap-owned + // pages are freed directly and pool-owned pages are reclaimed. errdefer { var it = page_list.first; while (it) |node| : (it = node.next) { switch (node.owned) { - .pool => {}, + .pool => reclaimPoolPage(pool, node.page()), .heap => page_alloc.free(node.page().memory), } } @@ -876,6 +897,20 @@ fn verifyIntegrity(self: *const PageList) IntegrityError!void { } } +/// Return a pool-owned page buffer to the page pool during a teardown +/// walk, zeroed for reuse (mirroring destroyNodeExt). Teardown walks +/// call this unconditionally for every pool-owned page. +fn reclaimPoolPage(pool: *MemoryPool, page: *const Page) void { + // Only wasm requires this + if (comptime !wasm_page_pool) return; + + const item: *align(std.heap.page_size_min) [std_size]u8 = + @ptrCast(@alignCast(page.memory.ptr)); + // We have to zero the item + _ = terminal_mem.decommit(.zero, item, page.memory.len); + pool.pages.destroy(item); +} + /// Deinit the pagelist, freeing all page memory and the memory pool. pub fn deinit(self: *PageList) void { // Verify integrity before cleanup @@ -884,14 +919,14 @@ pub fn deinit(self: *PageList) void { // Always deallocate our hashmap. self.tracked_pins.deinit(self.pool.alloc); - // Go through our linked list and deallocate all pages that are - // heap-owned (not in the pool). + // Go through our linked list and release every page: heap-owned + // pages are freed directly and pool-owned pages are reclaimed. const page_alloc = self.pool.pages.allocator; var it = self.pages.first; while (it) |node| : (it = node.next) { const page = node.restore(.discard); switch (node.owned) { - .pool => {}, + .pool => reclaimPoolPage(&self.pool, page), .heap => page_alloc.free(page.memory), } } @@ -933,15 +968,16 @@ pub fn reset(self: *PageList) void { cap.rows, ) catch unreachable; - // Before resetting our pools we need to free any pages that - // are heap-owned since those were allocated outside the pool. + // Before resetting our pools we need to release our pages: + // heap-owned pages are freed since they were allocated outside + // the pool, and pool-owned pages are reclaimed. { const page_alloc = self.pool.pages.allocator; var it = self.pages.first; while (it) |node| : (it = node.next) { const page = node.restore(.discard); switch (node.owned) { - .pool => {}, + .pool => reclaimPoolPage(&self.pool, page), .heap => page_alloc.free(page.memory), } } @@ -962,46 +998,51 @@ pub fn reset(self: *PageList) void { // retaining a certain amount of memory, it won't use mmap and won't // be zeroed. This block zeroes out all the memory in the pool arena. // + // The wasm page pool has no arena to scrub: its free-list items were + // zeroed by reclaimPoolPage above. + // // Note: we only have to do this for the page pool because the nodes are // always fully overwritten on each allocation. - inline for (.{ - self.pool.pages.unmanaged.arena_state.used_list, - self.pool.pages.unmanaged.arena_state.free_list, - }) |first| { - var node_ = first; - while (node_) |node| : (node_ = node.next) { - // NOTE: Zig 0.16.0's arenas don't use the linked list types - // anymore, so we can just reference fields directly. The node - // type is still private though, so we have to parse out some - // of the internal methods to work with the buffer - namely - // Node.loadBuf and Node.Size.toInt. They are combined below. - // - // PS: My (vancluever's) reading of the code gives me the - // impression that we no longer need to offset the data by the - // header, because there's no linked list overhead anymore. But - // I'm sure we'll see pretty quick when I run the tests. :) - // - const BufNode = struct { - size: Size, - end_index: usize, - next: ?*@This(), + if (comptime !wasm_page_pool) { + inline for (.{ + self.pool.pages.unmanaged.arena_state.used_list, + self.pool.pages.unmanaged.arena_state.free_list, + }) |first| { + var node_ = first; + while (node_) |node| : (node_ = node.next) { + // NOTE: Zig 0.16.0's arenas don't use the linked list types + // anymore, so we can just reference fields directly. The node + // type is still private though, so we have to parse out some + // of the internal methods to work with the buffer - namely + // Node.loadBuf and Node.Size.toInt. They are combined below. + // + // PS: My (vancluever's) reading of the code gives me the + // impression that we no longer need to offset the data by the + // header, because there's no linked list overhead anymore. But + // I'm sure we'll see pretty quick when I run the tests. :) + // + const BufNode = struct { + size: Size, + end_index: usize, + next: ?*@This(), - const Size = packed struct(usize) { - resizing: bool, - _: @Int(.unsigned, @bitSizeOf(usize) - 1) = 0, + const Size = packed struct(usize) { + resizing: bool, + _: @Int(.unsigned, @bitSizeOf(usize) - 1) = 0, - fn toInt(s: Size) usize { - var int = s; - int.resizing = false; - return @bitCast(int); - } + fn toInt(s: Size) usize { + var int = s; + int.resizing = false; + return @bitCast(int); + } + }; }; - }; - const buf_node_ptr: *BufNode = @ptrCast(node); - const buf_node_size = @atomicLoad(BufNode.Size, &buf_node_ptr.size, .monotonic); - const buf = @as([*]u8, @ptrCast(node))[0..buf_node_size.toInt()][@sizeOf(BufNode)..]; - @memset(buf, 0); + const buf_node_ptr: *BufNode = @ptrCast(node); + const buf_node_size = @atomicLoad(BufNode.Size, &buf_node_ptr.size, .monotonic); + const buf = @as([*]u8, @ptrCast(node))[0..buf_node_size.toInt()][@sizeOf(BufNode)..]; + @memset(buf, 0); + } } } @@ -1085,7 +1126,7 @@ pub fn clone( // Setup our pool var pool: MemoryPool = try .init( alloc, - pageAllocator(), + pageAllocator(alloc), page_count, ); errdefer pool.deinit(); @@ -1104,7 +1145,7 @@ pub fn clone( var page_it = page_list.first; while (page_it) |node| : (page_it = node.next) { switch (node.owned) { - .pool => {}, + .pool => reclaimPoolPage(&pool, node.page()), .heap => page_alloc.free(node.page().memory), } } @@ -1711,18 +1752,20 @@ const ReflowCursor = struct { // Fast path: bulk-copy a run of simple cells directly // into the destination row. The vast majority of cells - // are narrow, have no managed memory (graphemes, - // hyperlinks), and share a single style in long runs, so - // this avoids the per-cell state machine below for most - // of the work. Rows with tracked pins take the slow path - // so pin remapping behaves identically. + // are narrow cells or complete wide pairs, have no + // managed memory (graphemes, hyperlinks), and share a + // single style in long runs, so this avoids the + // per-cell state machine below for most of the work. + // Rows with tracked pins take the slow path so pin + // remapping behaves identically. if (!row_has_pins) { const max_run = @min( cols_len - x, @as(usize, self.page.size.cols) - self.x, ); - const run = bulkRunLength(cells[x..][0..max_run]); - if (run > 0 and self.copyRun(cells[x..][0..run], src_page)) { + const window = cells[x..][0..max_run]; + const run = bulkRunLength(window); + if (run > 0 and self.copyRun(window[0..run], src_page)) { x += run; continue; } @@ -1898,9 +1941,21 @@ const ReflowCursor = struct { fn bulkRunLength(cells: []const pagepkg.Cell) usize { if (cells.len == 0) return 0; const first = cells[0]; - if (!bulkCopyable(first)) return 0; - const run_pattern = BulkRunMask.pattern(first); + // A run may start on a bulk-copyable narrow cell or on a wide pair. + const pair_start = first.content_tag == .codepoint and + first.wide == .wide and + !first.hyperlink; + if (!pair_start and !bulkCopyable(first)) return 0; + + // Patterns for each cell shape admitted to the run. + var proto = first; + proto.wide = .narrow; + const narrow_pattern = BulkRunMask.pattern(proto); + proto.wide = .wide; + const wide_pattern = BulkRunMask.pattern(proto); + proto.wide = .spacer_tail; + const tail_pattern = BulkRunMask.pattern(proto); // Only text cells can contain a placeholder; for bg color // tags the content bits are a color, so we skip the check for @@ -1919,28 +1974,50 @@ const ReflowCursor = struct { )); }; - var len: usize = 1; + var len: usize = 0; + outer: while (true) { + // Vectorized scan: check whole groups of narrow cells at + // once. If a group fully matches, the run extends by the + // whole group; otherwise fall through to the scalar loop + // below, which finds the exact end of the run within it + // or continues through a wide pair. + while (cells.len - len >= bulk_group_len) { + if (!BulkRunMask.eql(cells, len, narrow_pattern)) break; + if (check_placeholder and PlaceholderMask.eqlAny( + cells, + len, + placeholder_pattern, + )) break; + len += bulk_group_len; + } - // Vectorized scan: check whole groups of cells at once. If a - // group fully matches, the run extends by the whole group; - // otherwise fall through to the scalar loop below, which - // finds the exact end of the run within it. - while (cells.len - len >= bulk_group_len) { - if (!BulkRunMask.eql(cells, len, run_pattern)) break; - if (check_placeholder and PlaceholderMask.eqlAny( - cells, - len, - placeholder_pattern, - )) break; - len += bulk_group_len; - } + while (len < cells.len) { + const cell = cells[len]; + if (BulkRunMask.eqlScalar(cell, narrow_pattern)) { + if (check_placeholder and PlaceholderMask.eqlScalar( + cell, + placeholder_pattern, + )) break :outer; + len += 1; + continue; + } + + // Wide pairs are consumed atomically so the run + // (or window) end can never split a pair. + if (len + 1 < cells.len and + BulkRunMask.eqlScalar(cell, wide_pattern) and + BulkRunMask.eqlScalar(cells[len + 1], tail_pattern)) + { + len += 2; + // Re-enter the vectorized loop: narrow groups + // commonly follow a stretch of pairs. + continue :outer; + } - while (len < cells.len) : (len += 1) { - if (!BulkRunMask.eqlScalar(cells[len], run_pattern)) break; - if (check_placeholder and PlaceholderMask.eqlScalar( - cells[len], - placeholder_pattern, - )) break; + break :outer; + } + + break; } return len; @@ -2499,8 +2576,9 @@ const ReflowCursor = struct { } } - // Clear the row from the old page and truncate it. - old_page.clearCells(old_row, 0, old_page.size.cols); + // Reset the row on the old page and truncate it. The retired + // storage must be left in the default state (see resetRow). + old_page.resetRow(old_row); old_page.size.rows -= 1; // If that was the last row in that page @@ -2990,11 +3068,7 @@ fn resizeWithoutReflowGrowCols( const prev_page = prev.?.page(); const prev_size = prev_page.size.rows - prev_copied; const prev_rows = prev_page.rows.ptr(prev_page.memory)[prev_size..prev_page.size.rows]; - for (prev_rows) |*row| prev_page.clearCells( - row, - 0, - prev_page.size.cols, - ); + for (prev_rows) |*row| prev_page.resetRow(row); prev_page.size.rows = prev_size; }; @@ -3144,6 +3218,14 @@ fn trimTrailingBlankRows( self.invalidateNodeLayout(row_pin.node); invalidated_node = row_pin.node; } + + // The row has no text but can still carry metadata (e.g. a + // blank prompt continuation line) and background-colored + // cells. The retired storage is re-exposed by the grow() + // fast path without any clearing, so it must be left in the + // default state. + row_pin.node.page().resetRow(row_pin.rowAndCell().row); + row_pin.node.page().size.rows -= 1; if (row_pin.node.page().size.rows == 0) { self.erasePage(row_pin.node); @@ -3696,13 +3778,11 @@ pub fn split( // p.x remains the same since we're copying the row as-is } - // Clear our rows + // Reset our rows. They are retired into unused page capacity, + // which the grow() fast path re-exposes without any clearing, so + // they must be left in the default state. for (page.rows.ptr(page.memory)[y_start..y_end]) |*row| { - page.clearCells( - row, - 0, - page.size.cols, - ); + page.resetRow(row); } page.size.rows -= y_end - y_start; @@ -3909,7 +3989,12 @@ pub fn grow(self: *PageList) Allocator.Error!?*List.Node { const last = self.pages.last.?; if (last.capacity().rows > last.rows()) { - // Fast path: we have capacity in the last page. + // Fast path: we have capacity in the last page. The exposed + // row requires no clearing work here: rows in unused page + // capacity are always in the default zero state, either + // because the page memory was never used (pool buffers are + // zeroed) or because whatever retired the row reset it (see + // Page.resetRow). const page = last.page(); page.size.rows += 1; page.assertIntegrity(); @@ -4168,6 +4253,64 @@ pub fn increaseCapacity( if (layout.total_size > size.max_page_size) { return error.OutOfSpace; } + + // Doubling alone has a really bad behavior in that if you're + // in a pathological scenario with only one dimension of cap + // increase, you have to pay repeat double costs. Each time you + // double we have to reclone the entire page. As the page gets + // bigger this gets more expensive. + // + // Instead, for some dimensions, we do something else: we look + // at the current utilization, and project that to every row + // in the page capacity (if it fits). We just assume that your + // future workload will look the one you're currently filling. + // If we're wrong, its some wasted capacity but future growth + // still works in other dimensions. + // + // This only applies to the current page being grown. I previously + // tried a high-water-mark style solution to preallocate pages, + // which does work really well, but its not clear when you reset + // the mark. + project: { + // The dimensions we project must measure their current + // usage in the same units as the capacity field. + const used: u64 = switch (comptime tag) { + .grapheme_bytes => page.grapheme_alloc.usedBytes(page.memory), + + // Living item count. Note the capacity field is a + // requested item count that Layout.init rounds in + // both directions (table to the next power of two, + // items to the load factor of that), so this is an + // approximation in capacity units; the headroom + // below absorbs the error and an undershoot only + // costs one more (non-ladder) growth event. + .styles => page.styles.count(), + + else => break :project, + }; + if (used == 0 or page.size.rows == 0) break :project; + + // Full-page need at current density, plus 25% headroom + // for chunk rounding and fragmentation. + const density = used * @as(u64, cap.rows) / page.size.rows; + const projected_raw = density + density / 4; + + // Bound the jump to 32× the pre-growth capacity. Arbitrary + // choice until we can show otherwise. + const projected = @min( + projected_raw, + @as(u64, old) * 32, + std.math.maxInt(Int), + ); + if (projected <= @field(cap, field_name)) break :project; + + // Only take the projection if the resulting page still fits. + var proj_cap = cap; + @field(proj_cap, field_name) = @intCast(projected); + if (Page.layout(proj_cap).total_size > size.max_page_size) + break :project; + cap = proj_cap; + } }, }; @@ -5081,8 +5224,10 @@ pub fn eraseRow( } } - // Clear the final row which was rotated from the top of the page. - page.clearCells(&rows[node.rows() - 1], 0, node.cols()); + // Reset the final row which was rotated from the top of the page. + // A full reset (not just clearing cells) so no metadata from the + // erased row is retained by the new blank row. + page.resetRow(&rows[node.rows() - 1]); } /// A variant of eraseRow that shifts only a bounded number of following @@ -5120,7 +5265,7 @@ pub fn eraseRowBounded( if (node.rows() - pn.y > limit) { // Rotating this bounded region changes its cached row coordinates. self.invalidateNodeLayout(node); - page.clearCells(&rows[pn.y], 0, node.cols()); + page.resetRow(&rows[pn.y]); fastmem.rotateOnce(Row, rows[pn.y..][0 .. limit + 1]); // Mark the whole page as dirty. @@ -5232,7 +5377,7 @@ pub fn eraseRowBounded( if (node.rows() > shifted_limit) { // Rotating this bounded prefix changes its cached row coordinates. self.invalidateNodeLayout(node); - page.clearCells(&rows[0], 0, node.cols()); + page.resetRow(&rows[0]); fastmem.rotateOnce(Row, rows[0 .. shifted_limit + 1]); // Mark the whole page as dirty. @@ -5300,9 +5445,9 @@ pub fn eraseRowBounded( } } - // We reached the end of the page list before the limit, so we clear + // We reached the end of the page list before the limit, so we reset // the final row since it was rotated down from the top of this page. - page.clearCells(&rows[node.rows() - 1], 0, node.cols()); + page.resetRow(&rows[node.rows() - 1]); } /// Erase all history rows, optionally up to a bottom-left bound. @@ -5395,15 +5540,12 @@ fn eraseRows( dst.dirty = true; } - // Clear our remaining cells that we didn't shift or swapped - // in case we grow back into them. + // Reset our remaining rows that we didn't shift or swapped. + // These are retired into unused page capacity, which the + // grow() fast path re-exposes without any clearing, so they + // must be left in the default state. for (scroll_amount..chunk.node.rows()) |i| { - const row: *Row = &rows[i]; - page.clearCells( - row, - 0, - chunk.node.cols(), - ); + page.resetRow(&rows[i]); } // Update any tracked pins to shift their y. If it was in the erased @@ -7366,7 +7508,7 @@ pub const Builder = struct { return .{ .pool = try MemoryPool.init( alloc, - pageAllocator(), + pageAllocator(alloc), page_preheat, ), .options = options, @@ -11734,6 +11876,68 @@ test "PageList increaseCapacity to increase styles" { } } +test "PageList increaseCapacity styles projects capacity from page density" { + const testing = std.testing; + const alloc = testing.allocator; + + var s = try init(alloc, .{ .cols = 2, .rows = 2, .max_size = 0 }); + defer s.deinit(); + + const original_cap = s.pages.first.?.capacity().styles; + + // Write styled cells so the page has a measurable per-row style + // density (unlike the plain doubling test above, which grows a + // page with no styles in use). + const bold: stylepkg.Style = .{ .flags = .{ .bold = true } }; + { + try testing.expect(s.pages.first == s.pages.last); + const page = s.pages.first.?.page(); + + for (0..s.rows) |y| { + for (0..s.cols) |x| { + const rac = page.getRowAndCell(x, y); + const style_id = try page.styles.add(page.memory, bold); + rac.row.styled = true; + rac.cell.* = .{ + .content_tag = .codepoint, + .content = .{ .codepoint = .{ .data = @intCast(x + 1) } }, + .style_id = style_id, + }; + } + } + } + + _ = try s.increaseCapacity(s.pages.first.?, .styles); + + { + try testing.expect(s.pages.first == s.pages.last); + const page = s.pages.first.?.page(); + + // The page uses only two active rows out of thousands of rows + // of capacity, so the projected full-page need saturates the + // 32x-per-event growth bound instead of merely doubling. + try testing.expectEqual( + original_cap * 32, + page.capacity.styles, + ); + + // All cell content and styles are preserved by the growth. + for (0..s.rows) |y| { + for (0..s.cols) |x| { + const rac = page.getRowAndCell(x, y); + try testing.expectEqual( + @as(u21, @intCast(x + 1)), + rac.cell.content.codepoint.data, + ); + try testing.expect(rac.cell.style_id != stylepkg.default_id); + try testing.expect(bold.eql( + page.styles.get(page.memory, rac.cell.style_id).*, + )); + } + } + } +} + test "PageList increaseCapacity to increase graphemes" { const testing = std.testing; const alloc = testing.allocator; @@ -11778,6 +11982,70 @@ test "PageList increaseCapacity to increase graphemes" { } } +test "PageList increaseCapacity graphemes projects capacity from page density" { + const testing = std.testing; + const alloc = testing.allocator; + + var s = try init(alloc, .{ .cols = 2, .rows = 2, .max_size = 0 }); + defer s.deinit(); + + const original_cap = s.pages.first.?.capacity().grapheme_bytes; + + // Write cells with grapheme data so the page has a measurable + // per-row grapheme density (unlike the plain doubling test above, + // which grows a page with no grapheme usage). + { + try testing.expect(s.pages.first == s.pages.last); + const page = s.pages.first.?.page(); + + for (0..s.rows) |y| { + for (0..s.cols) |x| { + const rac = page.getRowAndCell(x, y); + rac.cell.* = .{ + .content_tag = .codepoint, + .content = .{ .codepoint = .{ .data = @intCast(x + 1) } }, + }; + try page.appendGrapheme(rac.row, rac.cell, 0x0301); + try page.appendGrapheme(rac.row, rac.cell, 0x0302); + } + } + } + + _ = try s.increaseCapacity(s.pages.first.?, .grapheme_bytes); + + { + try testing.expect(s.pages.first == s.pages.last); + const page = s.pages.first.?.page(); + + // The page uses only two active rows out of thousands of rows + // of capacity, so the projected full-page need saturates the + // 32x-per-event growth bound instead of merely doubling. + try testing.expectEqual( + original_cap * 32, + page.capacity.grapheme_bytes, + ); + + // All cell and grapheme content is preserved by the growth. + try testing.expectEqual( + @as(usize, s.rows * s.cols), + page.graphemeCount(), + ); + for (0..s.rows) |y| { + for (0..s.cols) |x| { + const rac = page.getRowAndCell(x, y); + try testing.expectEqual( + @as(u21, @intCast(x + 1)), + rac.cell.content.codepoint.data, + ); + const cps = page.lookupGrapheme(rac.cell).?; + try testing.expectEqual(@as(usize, 2), cps.len); + try testing.expectEqual(@as(u21, 0x0301), cps[0]); + try testing.expectEqual(@as(u21, 0x0302), cps[1]); + } + } + } +} + test "PageList increaseCapacity to increase hyperlinks" { const testing = std.testing; const alloc = testing.allocator; @@ -18023,6 +18291,370 @@ test "PageList resize reflow less cols to wrap a wide char" { } } +test "PageList resize reflow less cols wide char bulk run" { + const testing = std.testing; + const alloc = testing.allocator; + + var s = try init(alloc, .{ .cols = 8, .rows = 1, .max_size = 0 }); + defer s.deinit(); + { + try testing.expect(s.pages.first == s.pages.last); + const page = s.pages.first.?.page(); + + // A full row of wide character pairs so the reflow takes + // the bulk run path. + for (0..4) |i| { + { + const rac = page.getRowAndCell(i * 2, 0); + rac.cell.* = .{ + .content_tag = .codepoint, + .content = .{ .codepoint = .{ .data = @intCast(0x4E00 + i) } }, + .wide = .wide, + }; + } + { + const rac = page.getRowAndCell(i * 2 + 1, 0); + rac.cell.* = .{ + .content_tag = .codepoint, + .content = .{ .codepoint = .{ .data = 0 } }, + .wide = .spacer_tail, + }; + } + } + } + + // Resize to exactly two pairs per row: runs end on the row + // boundary with no spacer heads needed. + try s.resize(.{ .cols = 4, .reflow = true }); + try testing.expectEqual(@as(usize, 4), s.cols); + try testing.expectEqual(@as(usize, 2), s.totalRows()); + + { + try testing.expect(s.pages.first == s.pages.last); + const page = s.pages.first.?.page(); + + for (0..4) |i| { + const y = i / 2; + const x = (i % 2) * 2; + { + const rac = page.getRowAndCell(x, y); + try testing.expectEqual( + @as(u21, @intCast(0x4E00 + i)), + rac.cell.content.codepoint.data, + ); + try testing.expectEqual(pagepkg.Cell.Wide.wide, rac.cell.wide); + } + { + const rac = page.getRowAndCell(x + 1, y); + try testing.expectEqual(@as(u21, 0), rac.cell.content.codepoint.data); + try testing.expectEqual(pagepkg.Cell.Wide.spacer_tail, rac.cell.wide); + } + } + + { + const rac = page.getRowAndCell(0, 0); + try testing.expect(rac.row.wrap); + try testing.expect(!rac.row.wrap_continuation); + } + { + const rac = page.getRowAndCell(0, 1); + try testing.expect(!rac.row.wrap); + try testing.expect(rac.row.wrap_continuation); + } + } +} + +test "PageList resize reflow less cols wide char bulk run odd cols spacer head" { + const testing = std.testing; + const alloc = testing.allocator; + + var s = try init(alloc, .{ .cols = 8, .rows = 1, .max_size = 0 }); + defer s.deinit(); + { + try testing.expect(s.pages.first == s.pages.last); + const page = s.pages.first.?.page(); + + for (0..4) |i| { + { + const rac = page.getRowAndCell(i * 2, 0); + rac.cell.* = .{ + .content_tag = .codepoint, + .content = .{ .codepoint = .{ .data = @intCast(0x4E00 + i) } }, + .wide = .wide, + }; + } + { + const rac = page.getRowAndCell(i * 2 + 1, 0); + rac.cell.* = .{ + .content_tag = .codepoint, + .content = .{ .codepoint = .{ .data = 0 } }, + .wide = .spacer_tail, + }; + } + } + } + + // Resize to an odd number of columns: the bulk run must stop a + // pair short of the row boundary and the slow path inserts a + // spacer head in the final column. + try s.resize(.{ .cols = 5, .reflow = true }); + try testing.expectEqual(@as(usize, 5), s.cols); + try testing.expectEqual(@as(usize, 2), s.totalRows()); + + { + try testing.expect(s.pages.first == s.pages.last); + const page = s.pages.first.?.page(); + + for (0..4) |i| { + const y = i / 2; + const x = (i % 2) * 2; + { + const rac = page.getRowAndCell(x, y); + try testing.expectEqual( + @as(u21, @intCast(0x4E00 + i)), + rac.cell.content.codepoint.data, + ); + try testing.expectEqual(pagepkg.Cell.Wide.wide, rac.cell.wide); + } + { + const rac = page.getRowAndCell(x + 1, y); + try testing.expectEqual(@as(u21, 0), rac.cell.content.codepoint.data); + try testing.expectEqual(pagepkg.Cell.Wide.spacer_tail, rac.cell.wide); + } + } + + { + const rac = page.getRowAndCell(4, 0); + try testing.expectEqual(@as(u21, 0), rac.cell.content.codepoint.data); + try testing.expectEqual(pagepkg.Cell.Wide.spacer_head, rac.cell.wide); + try testing.expect(rac.row.wrap); + } + { + const rac = page.getRowAndCell(4, 1); + try testing.expectEqual(pagepkg.Cell.Wide.narrow, rac.cell.wide); + try testing.expect(rac.row.wrap_continuation); + } + } +} + +test "PageList resize reflow less cols wide char bulk run mixed narrow round trip" { + const testing = std.testing; + const alloc = testing.allocator; + + // The emoji-in-prose shape: single wide pairs separated by + // narrow cells, which must all share a single bulk run. + const Shape = struct { cp: u21, wide: pagepkg.Cell.Wide }; + const shape: []const Shape = &.{ + .{ .cp = 0x4E00, .wide = .wide }, + .{ .cp = 0, .wide = .spacer_tail }, + .{ .cp = 'x', .wide = .narrow }, + .{ .cp = 0x4E01, .wide = .wide }, + .{ .cp = 0, .wide = .spacer_tail }, + .{ .cp = 'y', .wide = .narrow }, + .{ .cp = 0x4E02, .wide = .wide }, + .{ .cp = 0, .wide = .spacer_tail }, + .{ .cp = 'z', .wide = .narrow }, + }; + + var s = try init(alloc, .{ .cols = 9, .rows = 1, .max_size = 0 }); + defer s.deinit(); + { + try testing.expect(s.pages.first == s.pages.last); + const page = s.pages.first.?.page(); + for (shape, 0..) |c, x| { + const rac = page.getRowAndCell(x, 0); + rac.cell.* = .{ + .content_tag = .codepoint, + .content = .{ .codepoint = .{ .data = c.cp } }, + .wide = c.wide, + }; + } + } + + // Shrink: the run ends exactly on the row boundary after the + // second narrow cell. + try s.resize(.{ .cols = 6, .reflow = true }); + try testing.expectEqual(@as(usize, 6), s.cols); + try testing.expectEqual(@as(usize, 2), s.totalRows()); + + { + try testing.expect(s.pages.first == s.pages.last); + const page = s.pages.first.?.page(); + for (shape[0..6], 0..) |c, x| { + const rac = page.getRowAndCell(x, 0); + try testing.expectEqual(c.cp, rac.cell.content.codepoint.data); + try testing.expectEqual(c.wide, rac.cell.wide); + } + for (shape[6..], 0..) |c, x| { + const rac = page.getRowAndCell(x, 1); + try testing.expectEqual(c.cp, rac.cell.content.codepoint.data); + try testing.expectEqual(c.wide, rac.cell.wide); + } + try testing.expect(page.getRowAndCell(0, 0).row.wrap); + try testing.expect(page.getRowAndCell(0, 1).row.wrap_continuation); + } + + // Grow back: the wrapped rows must rejoin into the original + // single-row layout. + try s.resize(.{ .cols = 9, .reflow = true }); + try testing.expectEqual(@as(usize, 9), s.cols); + try testing.expectEqual(@as(usize, 1), s.totalRows()); + + { + try testing.expect(s.pages.first == s.pages.last); + const page = s.pages.first.?.page(); + for (shape, 0..) |c, x| { + const rac = page.getRowAndCell(x, 0); + try testing.expectEqual(c.cp, rac.cell.content.codepoint.data); + try testing.expectEqual(c.wide, rac.cell.wide); + } + try testing.expect(!page.getRowAndCell(0, 0).row.wrap); + } +} + +test "PageList resize reflow less cols wide char bulk run styled" { + const testing = std.testing; + const alloc = testing.allocator; + + var s = try init(alloc, .{ .cols = 8, .rows = 1, .max_size = 0 }); + defer s.deinit(); + { + try testing.expect(s.pages.first == s.pages.last); + const page = s.pages.first.?.page(); + + // Create a style + const style: stylepkg.Style = .{ .flags = .{ .bold = true } }; + const style_id = try page.styles.add(page.memory, style); + + // Styled pairs: the tail shares the wide cell's style, as + // the print path writes them. + for (0..4) |i| { + { + const rac = page.getRowAndCell(i * 2, 0); + rac.cell.* = .{ + .content_tag = .codepoint, + .content = .{ .codepoint = .{ .data = @intCast(0x4E00 + i) } }, + .wide = .wide, + .style_id = style_id, + }; + page.styles.use(page.memory, style_id); + } + { + const rac = page.getRowAndCell(i * 2 + 1, 0); + rac.cell.* = .{ + .content_tag = .codepoint, + .content = .{ .codepoint = .{ .data = 0 } }, + .wide = .spacer_tail, + .style_id = style_id, + }; + page.styles.use(page.memory, style_id); + } + } + + // We're over-counted by 1 because `add` implies `use`. + page.styles.release(page.memory, style_id); + } + + // Resize + try s.resize(.{ .cols = 4, .reflow = true }); + try testing.expectEqual(@as(usize, 4), s.cols); + try testing.expectEqual(@as(usize, 2), s.totalRows()); + + { + try testing.expect(s.pages.first == s.pages.last); + const page = s.pages.first.?.page(); + + for (0..2) |y| { + for (0..4) |x| { + const rac = page.getRowAndCell(x, y); + const style_id = rac.cell.style_id; + try testing.expect(style_id != 0); + + const style = page.styles.get(page.memory, style_id); + try testing.expect(style.flags.bold); + try testing.expect(rac.row.styled); + try testing.expectEqual( + if (x % 2 == 0) pagepkg.Cell.Wide.wide else pagepkg.Cell.Wide.spacer_tail, + rac.cell.wide, + ); + } + } + } +} + +test "PageList resize reflow less cols wide char bulk run degenerate spacer tail style" { + const testing = std.testing; + const alloc = testing.allocator; + + var s = try init(alloc, .{ .cols = 4, .rows = 1, .max_size = 0 }); + defer s.deinit(); + { + try testing.expect(s.pages.first == s.pages.last); + const page = s.pages.first.?.page(); + + // A styled wide cell whose tail does NOT share its style. + // This can't come from the print path, but the run scan must + // reject the pair (slow path) rather than rewrite the tail + // to the wide cell's style. + const style: stylepkg.Style = .{ .flags = .{ .bold = true } }; + const style_id = try page.styles.add(page.memory, style); + + { + const rac = page.getRowAndCell(0, 0); + rac.cell.* = .{ + .content_tag = .codepoint, + .content = .{ .codepoint = .{ .data = 0x4E00 } }, + .wide = .wide, + .style_id = style_id, + }; + } + { + const rac = page.getRowAndCell(1, 0); + rac.cell.* = .{ + .content_tag = .codepoint, + .content = .{ .codepoint = .{ .data = 0 } }, + .wide = .spacer_tail, + }; + } + { + const rac = page.getRowAndCell(2, 0); + rac.cell.* = .{ + .content_tag = .codepoint, + .content = .{ .codepoint = .{ .data = 'x' } }, + }; + } + } + + // Resize + try s.resize(.{ .cols = 3, .reflow = true }); + try testing.expectEqual(@as(usize, 3), s.cols); + try testing.expectEqual(@as(usize, 1), s.totalRows()); + + { + try testing.expect(s.pages.first == s.pages.last); + const page = s.pages.first.?.page(); + + { + const rac = page.getRowAndCell(0, 0); + try testing.expectEqual(@as(u21, 0x4E00), rac.cell.content.codepoint.data); + try testing.expectEqual(pagepkg.Cell.Wide.wide, rac.cell.wide); + try testing.expect(rac.cell.style_id != 0); + const style = page.styles.get(page.memory, rac.cell.style_id); + try testing.expect(style.flags.bold); + } + { + const rac = page.getRowAndCell(1, 0); + try testing.expectEqual(pagepkg.Cell.Wide.spacer_tail, rac.cell.wide); + try testing.expectEqual(stylepkg.default_id, rac.cell.style_id); + } + { + const rac = page.getRowAndCell(2, 0); + try testing.expectEqual(@as(u21, 'x'), rac.cell.content.codepoint.data); + try testing.expectEqual(pagepkg.Cell.Wide.narrow, rac.cell.wide); + } + } +} + test "PageList resize reflow less cols to wrap a multi-codepoint grapheme with a spacer head" { const testing = std.testing; const alloc = testing.allocator; @@ -19615,3 +20247,158 @@ test "PageList split preserves hyperlinks" { try testing.expectEqualStrings("https://example.com", link.uri.slice(second_page.memory)); } } + +test "PageList eraseRow recycled row has default metadata" { + const testing = std.testing; + const alloc = testing.allocator; + + var s = try init(alloc, .{ .cols = 5, .rows = 3 }); + defer s.deinit(); + + // Simulate the top row being part of a soft-wrapped, prompt-marked + // line. Erasing it recycles its Row storage as the new blank + // bottom row, which must not retain any of this metadata. + { + const rac = s.getCell(.{ .active = .{} }).?; + rac.row.wrap = true; + rac.row.wrap_continuation = true; + rac.row.semantic_prompt = .prompt; + } + + try s.eraseRow(.{ .active = .{} }); + + { + const rac = s.getCell(.{ .active = .{ .y = 2 } }).?; + try testing.expect(!rac.row.wrap); + try testing.expect(!rac.row.wrap_continuation); + try testing.expectEqual(.none, rac.row.semantic_prompt); + } +} + +test "PageList eraseRowBounded recycled row has default metadata" { + const testing = std.testing; + const alloc = testing.allocator; + + // A limit smaller than the remaining rows in the page exercises + // the bounded-rotate branch; a larger limit exercises the fallback + // branch that clears the final row after a full rotation. + for ([_]usize{ 1, 10 }) |limit| { + var s = try init(alloc, .{ .cols = 5, .rows = 3 }); + defer s.deinit(); + + { + const rac = s.getCell(.{ .active = .{} }).?; + rac.row.wrap = true; + rac.row.wrap_continuation = true; + rac.row.semantic_prompt = .prompt; + } + + try s.eraseRowBounded(.{ .active = .{} }, limit); + + const recycled_y = @min(limit, 2); + const rac = s.getCell(.{ .active = .{ .y = @intCast(recycled_y) } }).?; + try testing.expect(!rac.row.wrap); + try testing.expect(!rac.row.wrap_continuation); + try testing.expectEqual(.none, rac.row.semantic_prompt); + } +} + +test "PageList eraseActive regrown rows have default metadata" { + const testing = std.testing; + const alloc = testing.allocator; + + var s = try init(alloc, .{ .cols = 5, .rows = 3 }); + defer s.deinit(); + + // Mark the rows that will be erased. eraseActive retires their + // storage into unused page capacity and then regrows the active + // area, re-exposing the same Row storage via the grow() fast path. + for (0..2) |y| { + const rac = s.getCell(.{ .active = .{ .y = @intCast(y) } }).?; + rac.row.wrap = true; + rac.row.wrap_continuation = true; + rac.row.semantic_prompt = .prompt; + } + + s.eraseActive(1); + + for (0..3) |y| { + const rac = s.getCell(.{ .active = .{ .y = @intCast(y) } }).?; + try testing.expect(!rac.row.wrap); + try testing.expect(!rac.row.wrap_continuation); + try testing.expectEqual(.none, rac.row.semantic_prompt); + } +} + +test "PageList split retired rows have default state" { + const testing = std.testing; + const alloc = testing.allocator; + + var s = try init(alloc, .{ .cols = 5, .rows = 10 }); + defer s.deinit(); + + // Put metadata and a background-colored (non-zero, but text-free) + // cell on a row that the split will move to the new page. The + // retired Row storage on the source page goes back into unused + // capacity that grow() re-exposes without clearing. + { + const rac = s.getCell(.{ .active = .{ .y = 7 } }).?; + rac.row.wrap = true; + rac.row.semantic_prompt = .prompt; + rac.cell.* = .{ + .content_tag = .bg_color_palette, + .content = .{ .color_palette = .{ .data = 42 } }, + }; + } + + const node = s.pages.first.?; + try s.split(s.pin(.{ .active = .{ .y = 5 } }).?); + + // The source page was truncated to 5 rows; peek at the retired + // storage beyond size.rows. + const page = node.page(); + try testing.expectEqual(@as(usize, 5), page.size.rows); + const rows = page.rows.ptr(page.memory.ptr); + for (5..10) |y| { + const row = rows[y]; + try testing.expect(!row.wrap); + try testing.expect(!row.wrap_continuation); + try testing.expectEqual(.none, row.semantic_prompt); + const cells = row.cells.ptr(page.memory.ptr)[0..page.size.cols]; + for (cells) |cell| try testing.expect(cell.isZero()); + } +} + +test "PageList resize trimmed rows have default state" { + const testing = std.testing; + const alloc = testing.allocator; + + var s = try init(alloc, .{ .cols = 5, .rows = 5 }); + defer s.deinit(); + + // A trailing blank row has no text, so shrinking rows trims it, + // but it can still carry metadata (e.g. a blank prompt + // continuation line) and background-colored cells. Trimming + // retires the storage into unused capacity that grow() + // re-exposes without clearing. + { + const rac = s.getCell(.{ .active = .{ .y = 4 } }).?; + rac.row.wrap_continuation = true; + rac.row.semantic_prompt = .prompt_continuation; + rac.cell.* = .{ + .content_tag = .bg_color_palette, + .content = .{ .color_palette = .{ .data = 42 } }, + }; + } + + try s.resize(.{ .rows = 4, .reflow = false }); + try s.resize(.{ .rows = 5, .reflow = false }); + + { + const rac = s.getCell(.{ .active = .{ .y = 4 } }).?; + try testing.expect(!rac.row.wrap); + try testing.expect(!rac.row.wrap_continuation); + try testing.expectEqual(.none, rac.row.semantic_prompt); + try testing.expect(rac.cell.isZero()); + } +} diff --git a/vendor/libghostty-vt/src/terminal/Screen.zig b/vendor/libghostty-vt/src/terminal/Screen.zig index 41149f6..208e276 100644 --- a/vendor/libghostty-vt/src/terminal/Screen.zig +++ b/vendor/libghostty-vt/src/terminal/Screen.zig @@ -378,6 +378,16 @@ pub fn assertIntegrity(self: *const Screen) void { ) orelse unreachable; assert(self.cursor.x == pt.active.x); assert(self.cursor.y == pt.active.y); + + // The cursor style and hyperlink if non-zero must reference + // real data in the page the pin is in. + const page: *const Page = self.cursor.page_pin.node.page(); + if (self.cursor.style_id != style.default_id) { + assert(page.styles.refCount(page.memory, self.cursor.style_id) > 0); + } + if (self.cursor.hyperlink_id != 0) { + assert(page.hyperlink_set.refCount(page.memory, self.cursor.hyperlink_id) > 0); + } } } @@ -397,11 +407,16 @@ pub fn reset(self: *Screen) void { self.pages.reset(); // The above reset preserves tracked pins so we can still use - // our cursor pin, which should be at the top-left already. + // our cursor pin, which should be at the top-left already. The + // reset marks every tracked pin as garbage, but we keep using + // this one at its new valid position, so clear the flag: copies + // of the cursor pin (e.g. for Kitty image placements) must not + // be born garbage. const cursor_pin: *PageList.Pin = self.cursor.page_pin; assert(cursor_pin.node == self.pages.pages.first.?); assert(cursor_pin.x == 0); assert(cursor_pin.y == 0); + cursor_pin.garbage = false; const cursor_rac = cursor_pin.rowAndCell(); self.cursor.deinit(self.alloc); self.cursor = .{ @@ -412,8 +427,14 @@ pub fn reset(self: *Screen) void { if (comptime build_options.kitty_graphics) { // Reset kitty graphics storage + const image_limits = self.kitty_images.image_limits; + const total_limit = self.kitty_images.total_limit; self.kitty_images.deinit(self.alloc, self); - self.kitty_images = .{ .dirty = true }; + self.kitty_images = .{ + .dirty = true, + .image_limits = image_limits, + .total_limit = total_limit, + }; } // Reset our basic state @@ -863,9 +884,31 @@ pub fn cursorReload(self: *Screen) void { .active, self.cursor.page_pin.*, ) orelse reset: { + // Our cached row/cell pointers may be invalid (that is often + // the reason cursorReload is being called), so refresh them + // from the pin first since cursorChangePin below marks the + // old cursor row as dirty. + const old_rac = self.cursor.page_pin.rowAndCell(); + self.cursor.page_row = old_rac.row; + self.cursor.page_cell = old_rac.cell; + + // The cursor style and hyperlink IDs are only valid within the + // page that the pin points at, so the pin change must go through + // cursorChangePin, which migrates them when the active top-left + // is on a different page. Writing the pin directly here would + // leave the cursor holding IDs that are dead or alias unrelated + // entries on the new page. const pin = self.pages.pin(.{ .active = .{} }).?; - self.cursor.page_pin.* = pin; - break :reset self.pages.pointFromPin(.active, pin).?; + self.cursor.x = 0; // Must be set before cursorChangePin + self.cursor.y = 0; + self.cursorChangePin(pin); + + // cursorChangePin can trigger a page capacity adjustment which + // moves the pin again, so we re-read it to derive our point. + break :reset self.pages.pointFromPin( + .active, + self.cursor.page_pin.*, + ).?; }; self.cursor.x = @intCast(pt.active.x); @@ -873,20 +916,6 @@ pub fn cursorReload(self: *Screen) void { const page_rac = self.cursor.page_pin.rowAndCell(); self.cursor.page_row = page_rac.row; self.cursor.page_cell = page_rac.cell; - - // If we have a style, we need to ensure it is in the page because this - // method may also be called after a page change. - if (self.cursor.style_id != style.default_id) { - self.manualStyleUpdate() catch |err| { - // This failure should not happen because manualStyleUpdate - // handles page splitting, overflow, and more. This should only - // happen if we're out of RAM. In this case, we'll just degrade - // gracefully back to the default style. - log.err("failed to update style on cursor reload err={}", .{err}); - self.cursor.style = .{}; - self.cursor.style_id = 0; - }; - } } /// Scroll the active area and keep the cursor at the bottom of the screen. @@ -912,6 +941,12 @@ pub fn cursorDownScroll(self: *Screen) !void { self.cursor.page_row, page.getCells(self.cursor.page_row), ); + + // The row is a fresh blank row now and must not retain + // metadata (wrap state, semantic prompt) from the + // discarded content. + self.cursor.page_row.reset(); + self.cursorMarkDirty(); } else { // The call to `eraseRow` will move the tracked cursor pin up by one @@ -1191,6 +1226,11 @@ fn cursorScrollAboveRotate( cur_page.getCells(&cur_rows[self.cursor.page_pin.y]), ); + // The recycled storage becomes the new blank cursor row and must + // not retain metadata (wrap state, semantic prompt) from the row + // whose content was moved to the next page. + cur_rows[self.cursor.page_pin.y].reset(); + // Mark the whole page as dirty. // // Technically we only need to mark from the cursor row to the @@ -1264,6 +1304,11 @@ pub fn cursorScrollRegionUp(self: *Screen, limit: usize) !void { // row with our blank cell, preserving the background color. self.clearCells(page, row, page.getCells(row)); } + + // The row becomes the new blank cursor row after the rotation + // below and must not retain metadata (wrap state, semantic + // prompt) from the discarded content. + row.reset(); } // Rotate the region rows so the now-blank top row moves to the @@ -1476,6 +1521,10 @@ inline fn cursorChangePin(self: *Screen, new: Pin) void { if (self.cursor.hyperlink != null) { const old_page: *Page = self.cursor.page_pin.node.page(); old_page.hyperlink_set.release(old_page.memory, self.cursor.hyperlink_id); + // Zero the ID, it is invalid now and style changes below may + // run integrity checks. We still have self.cursor.hyperlink to + // rebuild this later. + self.cursor.hyperlink_id = 0; } // Update our pin to the new page @@ -1497,8 +1546,9 @@ inline fn cursorChangePin(self: *Screen, new: Pin) void { // On the new page, we need to migrate our hyperlink if (self.cursor.hyperlink) |link| { - // So we don't attempt to free any memory in the replaced page. - self.cursor.hyperlink_id = 0; + // startHyperlink will try to free old hyperlinks, so set this + // to null. We free it ourselves later since we're doing some + // ref-counting shenanigans in this function. self.cursor.hyperlink = null; // Re-add @@ -1899,6 +1949,11 @@ pub fn splitCellBoundary( p_rac.row, p_cells[p_row.node.cols() - 1 ..][0..1], ); + + // `clearCells` does not mark rows dirty, and our + // callers only mark the cursor row, so mark the + // previous row here. + p_row.markDirty(); } } } @@ -2872,17 +2927,8 @@ pub const SelectionString = struct { /// If true, trim whitespace around the selection. trim: bool = true, - - /// If non-null, a stringmap will be written here. This will use - /// the same allocator as the call to selectionString. The string will - /// be duplicated here and in the return value so both must be freed. - map: ?*StringMap = null, }; -const selectionString_tw = tripwire.module(enum { - copy_map, -}, selectionString); - /// Returns the raw text associated with a selection. This will unwrap /// soft-wrapped edges. The returned slice is owned by the caller and allocated /// using alloc, not the allocator associated with the screen (unless they match). @@ -2893,6 +2939,32 @@ pub fn selectionString( alloc: Allocator, opts: SelectionString, ) Allocator.Error![:0]const u8 { + return self.selectionStringImpl(alloc, opts, null); +} + +/// Returns a StringMap associated with a selection. The map contains the +/// selection's raw text and a mapping from each byte to its screen location. +/// +/// The returned map is owned by the caller. +pub fn selectionStringMap( + self: *Screen, + alloc: Allocator, + opts: SelectionString, +) Allocator.Error!StringMap { + var pins: PinMap.Map = .empty; + errdefer pins.deinit(alloc); + return .{ + .string = try self.selectionStringImpl(alloc, opts, &pins), + .map = pins, + }; +} + +fn selectionStringImpl( + self: *Screen, + alloc: Allocator, + opts: SelectionString, + pins: ?*PinMap.Map, +) Allocator.Error![:0]const u8 { // We'll use this as our buffer to build our string. var aw: std.Io.Writer.Allocating = .init(alloc); defer aw.deinit(); @@ -2908,35 +2980,15 @@ pub fn selectionString( ); formatter.content = .{ .selection = opts.sel }; - // If we have a string map, we need to set that up. - var pins: PinMap.Map = .empty; - defer pins.deinit(alloc); - if (opts.map != null) formatter.pin_map = .{ + if (pins) |map| formatter.pin_map = .{ .alloc = alloc, - .map = &pins, + .map = map, }; // Emit. Since this is an allocating writer, a failed write // just becomes an OOM. formatter.format(&aw.writer) catch return error.OutOfMemory; - - // Build our final text and if we have a string map set that up. - const text = try aw.toOwnedSliceSentinel(0); - errdefer alloc.free(text); - if (opts.map) |map| { - const map_string = try alloc.dupeZ(u8, text); - errdefer alloc.free(map_string); - try selectionString_tw.check(.copy_map); - map.* = .{ - .string = map_string, - .map = pins, - }; - - // Ownership of the pin map moved to the string map. - pins = .empty; - } - - return text; + return try aw.toOwnedSliceSentinel(0); } pub const SelectLine = struct { @@ -3809,6 +3861,23 @@ test "Screen forwards optional scrollback limits" { try testing.expect(!s.no_scrollback); } +test "Screen reset cursor pin is not garbage" { + const testing = std.testing; + const alloc = testing.allocator; + const io = testing.io; + + var s = try Screen.init(io, alloc, .{ .cols = 80, .rows = 24, .max_scrollback_bytes = 1000 }); + defer s.deinit(); + try s.testWriteString("hello, world"); + + // The page reset marks every tracked pin garbage but the screen + // keeps using the cursor pin, so it must come back clean: anything + // that copies it (e.g. Kitty image placements) would otherwise be + // born garbage and reaped. + s.reset(); + try testing.expect(!s.cursor.page_pin.garbage); +} + test "Screen read and write" { const testing = std.testing; const alloc = testing.allocator; @@ -4114,6 +4183,82 @@ test "Screen write regrows compacted page capacity" { try testing.expect(page.graphemeCount() >= 1); } +// The cursor style and hyperlink IDs are only meaningful within the page +// the cursor pin points at. scrollClear can move the active area onto a +// later page while the cursor pin stays with its content on an earlier +// page (now scrollback), so the reset in cursorReload must migrate both +// references to the destination page. It previously replaced the pin +// directly and then released the old style ID on the new page. +test "Screen scrollClear across pages migrates cursor style and hyperlink" { + const testing = std.testing; + const alloc = testing.allocator; + const io = testing.io; + + var s = try init(io, alloc, .{ + .cols = 10, + .rows = 10, + .max_scrollback_bytes = std.math.maxInt(usize), + }); + defer s.deinit(); + + // Fill the first page so the active area spans two pages. + const first_page_size = s.pages.pages.first.?.capacity().rows; + s.pages.pages.first.?.page().pauseIntegrityChecks(true); + for (0..first_page_size - 5) |_| { + try s.testWriteString("\n"); + } + s.pages.pages.first.?.page().pauseIntegrityChecks(false); + try s.testWriteString("1\n2\n3\n4\n5\n6\n7\n8\n9\n10"); + try testing.expect(s.pages.pages.first != s.pages.pages.last); + + // Move the cursor to the top of the active area, which is on the + // first page, and give it a style and a hyperlink there. + s.cursorAbsolute(0, 0); + try testing.expect(s.cursor.page_pin.node == s.pages.pages.first.?); + try s.setAttribute(.{ .bold = {} }); + try s.startHyperlink("https://example.com/", null); + + const old_page: *Page = s.cursor.page_pin.node.page(); + const old_style_id = s.cursor.style_id; + const old_hyperlink_id = s.cursor.hyperlink_id; + try testing.expect(old_style_id != style.default_id); + try testing.expect(old_hyperlink_id != 0); + + // All ten active rows are non-empty, so this moves the active area + // fully onto the second page while the cursor pin stays with its + // old row, which is now scrollback. + try s.scrollClear(); + + // The cursor was moved to the new active top-left on the second + // page with its style and hyperlink references rebuilt there. + const new_page: *Page = s.cursor.page_pin.node.page(); + try testing.expect(new_page != old_page); + try testing.expect(s.cursor.style_id != style.default_id); + try testing.expect(s.cursor.hyperlink_id != 0); + try testing.expect(new_page.styles.refCount( + new_page.memory, + s.cursor.style_id, + ) > 0); + try testing.expect(new_page.hyperlink_set.refCount( + new_page.memory, + s.cursor.hyperlink_id, + ) > 0); + + // The cursor's references on the old page were released. Nothing + // else referenced either entry, so both are dead there now. + try testing.expectEqual(0, old_page.styles.refCount( + old_page.memory, + old_style_id, + )); + try testing.expectEqual(0, old_page.hyperlink_set.refCount( + old_page.memory, + old_hyperlink_id, + )); + + // Printing attaches the migrated style and hyperlink to a cell. + try s.testWriteString("B"); +} + test "Screen cursorCopy hyperlink deref new page" { const testing = std.testing; const alloc = testing.allocator; @@ -7797,6 +7942,14 @@ test "Screen: resize errors preserve state" { try testing.expectEqual(before.pages.viewport, s.pages.viewport); try testing.expectEqual(before_viewport_pin, s.pages.viewport_pin.*); try testing.expectEqual(before_tracked_pins, s.pages.countTrackedPins()); + if (std.valgrind.runningOnValgrind() > 0) { + // This assertion deliberately compares the complete raw page, + // including semantically irrelevant struct padding. + std.valgrind.memcheck.makeMemDefined(before_page); + std.valgrind.memcheck.makeMemDefined( + s.pages.pages.first.?.page().memory, + ); + } try testing.expectEqualSlices( u8, before_page, @@ -11440,38 +11593,6 @@ test "Screen setAttribute splits page on OutOfSpace at max styles" { try testing.expect(page_was_split); } -test "selectionString map allocation failure cleanup" { - // This test verifies that if toOwnedSlice fails when building - // the StringMap, we don't leak the already-allocated map.string. - const testing = std.testing; - const alloc = testing.allocator; - const io = testing.io; - var s = try Screen.init(io, alloc, .{ .cols = 10, .rows = 5, .max_scrollback_bytes = 0 }); - defer s.deinit(); - - try s.testWriteString("hello"); - - // Get a selection - const sel = Selection.init( - s.pages.pin(.{ .active = .{ .x = 0, .y = 0 } }).?, - s.pages.pin(.{ .active = .{ .x = 4, .y = 0 } }).?, - false, - ); - - // Trigger allocation failure on toOwnedSlice - var map: StringMap = undefined; - selectionString_tw.errorAlways(.copy_map, error.OutOfMemory); - const result = s.selectionString(alloc, .{ - .sel = sel, - .map = &map, - }); - try testing.expectError(error.OutOfMemory, result); - try selectionString_tw.end(.reset); - - // If this test passes without memory leaks (when run with testing.allocator), - // it means the errdefer properly cleaned up map.string when toOwnedSlice failed. -} - test "Screen: promptClickMove line right basic" { const testing = std.testing; const alloc = testing.allocator; @@ -12009,3 +12130,222 @@ test "Screen: promptClickMove click right of input cursor on last char" { try testing.expectEqual(@as(usize, 1), result.right); try testing.expectEqual(@as(usize, 0), result.left); } + +test "Screen: cursorScrollRegionUp recycled row has default metadata" { + const testing = std.testing; + const alloc = testing.allocator; + const io = testing.io; + + var s = try init(io, alloc, .{ .cols = 5, .rows = 5, .max_scrollback_bytes = 0 }); + defer s.deinit(); + try s.testWriteString("1ABCD\n2EFGH\n3IJKL\n4MNOP\n5QRST"); + + // Simulate the top region row being part of a soft-wrapped, + // prompt-marked line. Its Row storage is recycled as the new + // blank cursor row and must not retain the metadata. + { + const rac = s.pages.getCell(.{ .active = .{} }).?; + rac.row.wrap = true; + rac.row.wrap_continuation = true; + rac.row.semantic_prompt = .prompt; + } + + s.cursorAbsolute(1, 2); + try s.cursorScrollRegionUp(2); + + { + const rac = s.pages.getCell(.{ .active = .{ .y = 2 } }).?; + try testing.expect(!rac.row.wrap); + try testing.expect(!rac.row.wrap_continuation); + try testing.expectEqual(.none, rac.row.semantic_prompt); + } + { + const contents = try s.dumpStringAlloc(alloc, .{ .screen = .{} }); + defer alloc.free(contents); + try testing.expectEqualStrings("2EFGH\n3IJKL\n\n4MNOP\n5QRST", contents); + } +} + +test "Screen: cursorScrollRegionUp cross-page recycled row has default metadata" { + const testing = std.testing; + const alloc = testing.allocator; + const io = testing.io; + + var s = try init(io, alloc, .{ .cols = 10, .rows = 5, .max_scrollback_bytes = 10 }); + defer s.deinit(); + + // We need to get the active area to span two pages so that the + // scroll region does too, exercising the slow path (eraseRowBounded). + const first_page_size = s.pages.pages.first.?.capacity().rows; + s.pages.pages.first.?.page().pauseIntegrityChecks(true); + for (0..first_page_size - 3) |_| try s.testWriteString("\n"); + s.pages.pages.first.?.page().pauseIntegrityChecks(false); + try s.testWriteString("1A\n2B\n3C\n4D\n5E"); + + // The first row of the last page is the Row storage that ends up + // recycled as the blank region-bottom row: the erased row's + // storage stays on the first page (receiving this row's content + // via clone) while this storage is cleared for the blank row. + { + const rac = s.pages.getCell(.{ .active = .{ .y = 3 } }).?; + rac.row.wrap = true; + rac.row.wrap_continuation = true; + rac.row.semantic_prompt = .prompt; + } + + // Region rows 1-3 with the cursor on the region bottom, which is + // on the second page while the region top is on the first page. + s.cursorAbsolute(0, 3); + try s.cursorScrollRegionUp(2); + + { + const rac = s.pages.getCell(.{ .active = .{ .y = 3 } }).?; + try testing.expect(!rac.row.wrap); + try testing.expect(!rac.row.wrap_continuation); + try testing.expectEqual(.none, rac.row.semantic_prompt); + } + { + const contents = try s.dumpStringAlloc(alloc, .{ .viewport = .{} }); + defer alloc.free(contents); + try testing.expectEqualStrings("1A\n3C\n4D\n\n5E", contents); + } +} + +test "Screen: cursorScrollAbove cross-page recycled row has default metadata" { + const testing = std.testing; + const alloc = testing.allocator; + const io = testing.io; + + var s = try init(io, alloc, .{ .cols = 10, .rows = 5, .max_scrollback_bytes = 10 }); + defer s.deinit(); + + // Get the cursor page and the last page to differ so that + // cursorScrollAbove takes the cross-page rotate path. + const first_page_size = s.pages.pages.first.?.capacity().rows; + s.pages.pages.first.?.page().pauseIntegrityChecks(true); + for (0..first_page_size - 3) |_| try s.testWriteString("\n"); + s.pages.pages.first.?.page().pauseIntegrityChecks(false); + try s.testWriteString("1A\n2B\n3C\n4D\n5E"); + s.cursorAbsolute(0, 1); + try testing.expect(s.cursor.page_pin.node == s.pages.pages.first.?); + try testing.expect(s.pages.pages.first.?.next != null); + + // The last row of the cursor page is the Row storage that gets + // recycled as the new blank row below the cursor after its content + // is moved down to the next page. + { + const rac = s.pages.getCell(.{ .active = .{ .y = 2 } }).?; + rac.row.wrap = true; + rac.row.wrap_continuation = true; + rac.row.semantic_prompt = .prompt; + } + + try s.cursorScrollAbove(); + + // One row scrolled into history, so the blank row is at active + // y=1 (just below the cursor's original row). + { + const rac = s.pages.getCell(.{ .active = .{ .y = 1 } }).?; + try testing.expect(!rac.row.wrap); + try testing.expect(!rac.row.wrap_continuation); + try testing.expectEqual(.none, rac.row.semantic_prompt); + } + { + const contents = try s.dumpStringAlloc(alloc, .{ .viewport = .{} }); + defer alloc.free(contents); + try testing.expectEqualStrings("2B\n\n3C\n4D\n5E", contents); + } +} + +test "Screen: cursorDownScroll no scrollback recycled row has default metadata" { + const testing = std.testing; + const alloc = testing.allocator; + const io = testing.io; + + var s = try init(io, alloc, .{ .cols = 5, .rows = 3, .max_scrollback_bytes = 0 }); + defer s.deinit(); + try s.testWriteString("1ABCD\n2EFGH\n3IJKL"); + + { + const rac = s.pages.getCell(.{ .active = .{} }).?; + rac.row.wrap = true; + rac.row.wrap_continuation = true; + rac.row.semantic_prompt = .prompt; + } + + s.cursorAbsolute(0, 2); + try s.cursorDownScroll(); + + { + const rac = s.pages.getCell(.{ .active = .{ .y = 2 } }).?; + try testing.expect(!rac.row.wrap); + try testing.expect(!rac.row.wrap_continuation); + try testing.expectEqual(.none, rac.row.semantic_prompt); + } +} + +test "Screen: cursorDownScroll single row no scrollback resets metadata" { + const testing = std.testing; + const alloc = testing.allocator; + const io = testing.io; + + var s = try init(io, alloc, .{ .cols = 5, .rows = 1, .max_scrollback_bytes = 0 }); + defer s.deinit(); + try s.testWriteString("1ABCD"); + + { + const rac = s.pages.getCell(.{ .active = .{} }).?; + rac.row.wrap = true; + rac.row.wrap_continuation = true; + rac.row.semantic_prompt = .prompt; + } + + try s.cursorDownScroll(); + + { + const rac = s.pages.getCell(.{ .active = .{} }).?; + try testing.expect(!rac.row.wrap); + try testing.expect(!rac.row.wrap_continuation); + try testing.expectEqual(.none, rac.row.semantic_prompt); + } +} + +test "Screen: selectLine does not join lines across a recycled row" { + const testing = std.testing; + const alloc = testing.allocator; + const io = testing.io; + + var s = try init(io, alloc, .{ .cols = 6, .rows = 5, .max_scrollback_bytes = 0 }); + defer s.deinit(); + + // A soft-wrapped line across rows 0 and 1: row 0 gets wrap=true. + try s.testWriteString("AAAAAAA"); + + // Scroll a region of rows 0-2 up by one: row 0 is discarded and + // its Row storage recycled as the blank row 2. + s.cursorAbsolute(0, 2); + try s.cursorScrollRegionUp(2); + + // Write unrelated single-line words on the recycled row and below. + s.cursorAbsolute(0, 2); + try s.testWriteString("world"); + s.cursorAbsolute(0, 3); + try s.testWriteString("hello"); + + // Selecting the line "world" must not extend into "hello": these + // are separate hard lines. A stale wrap flag on the recycled row + // would join them. + { + var sel = s.selectLine(.{ .pin = s.pages.pin(.{ .active = .{ + .x = 0, + .y = 2, + } }).? }).?; + defer sel.deinit(&s); + const contents = try s.selectionString(alloc, .{ + .sel = sel, + .trim = false, + }); + defer alloc.free(contents); + try testing.expectEqualStrings("world", contents); + } +} diff --git a/vendor/libghostty-vt/src/terminal/StringMap.zig b/vendor/libghostty-vt/src/terminal/StringMap.zig index b06a6d1..7b8aac0 100644 --- a/vendor/libghostty-vt/src/terminal/StringMap.zig +++ b/vendor/libghostty-vt/src/terminal/StringMap.zig @@ -145,13 +145,10 @@ test "StringMap searchIterator" { .y = 1, } }).?, }).?; - var map: StringMap = undefined; - const sel_str = try s.selectionString(alloc, .{ + const map = try s.selectionStringMap(alloc, .{ .sel = line, .trim = false, - .map = &map, }); - alloc.free(sel_str); defer map.deinit(alloc); // Get our iterator @@ -205,13 +202,10 @@ test "StringMap searchIterator URL detection" { .y = 0, } }).?, }).?; - var map: StringMap = undefined; - const sel_str = try s.selectionString(alloc, .{ + const map = try s.selectionStringMap(alloc, .{ .sel = line, .trim = false, - .map = &map, }); - alloc.free(sel_str); defer map.deinit(alloc); // Search for URL match @@ -270,13 +264,10 @@ test "StringMap searchIterator URL with click position" { const line = s.selectLine(.{ .pin = click_pin, }).?; - var map: StringMap = undefined; - const sel_str = try s.selectionString(alloc, .{ + const map = try s.selectionStringMap(alloc, .{ .sel = line, .trim = false, - .map = &map, }); - alloc.free(sel_str); defer map.deinit(alloc); // Search for URL match and verify click position is within URL diff --git a/vendor/libghostty-vt/src/terminal/Tabstops.zig b/vendor/libghostty-vt/src/terminal/Tabstops.zig index 7e74bc9..fb1d7ae 100644 --- a/vendor/libghostty-vt/src/terminal/Tabstops.zig +++ b/vendor/libghostty-vt/src/terminal/Tabstops.zig @@ -93,13 +93,13 @@ pub fn unset(self: *Tabstops, col: usize) void { const i = entry(col); const idx = index(col); if (i < prealloc_count) { - self.prealloc_stops[i] ^= masks[idx]; + self.prealloc_stops[i] &= ~masks[idx]; return; } const dynamic_i = i - prealloc_count; assert(dynamic_i < self.dynamic_stops.len); - self.dynamic_stops[dynamic_i] ^= masks[idx]; + self.dynamic_stops[dynamic_i] &= ~masks[idx]; } /// Get the value of a tabstop at a specific column. The columns are 0-indexed. @@ -198,6 +198,10 @@ test "Tabstops: basic" { try testing.expect(t.get(4)); t.unset(4); try testing.expect(!t.get(4)); + + // Unsetting a column with no tabstop should be a no-op, not a toggle. + t.unset(4); + try testing.expect(!t.get(4)); } test "Tabstops: dynamic allocations" { @@ -214,6 +218,10 @@ test "Tabstops: dynamic allocations" { try testing.expect(t.get(cap + 4)); try testing.expect(!t.get(cap + 3)); + // Unsetting a column with no tabstop should be a no-op, not a toggle. + t.unset(cap + 3); + try testing.expect(!t.get(cap + 3)); + // Growing again preserves existing stops and clears the new unit. try t.resize(testing.allocator, cap + unit_bits + 1); try testing.expect(t.get(cap + 4)); diff --git a/vendor/libghostty-vt/src/terminal/Terminal.zig b/vendor/libghostty-vt/src/terminal/Terminal.zig index 6f5cc19..93cad0c 100644 --- a/vendor/libghostty-vt/src/terminal/Terminal.zig +++ b/vendor/libghostty-vt/src/terminal/Terminal.zig @@ -91,6 +91,12 @@ mouse_shape: mouse.Shape = .text, /// Per-session Glyph Protocol registrations. glyph_glossary: glyph.Glossary = .empty, +/// Kitty drag and drop protocol (OSC 72) state. Allocated when a client +/// registers to accept drops (t=a) and freed when it unregisters (t=A), +/// so a terminal that never runs a drag and drop aware program pays +/// nothing for it. Non-null means a client currently accepts drops. +kitty_dnd: ?*kitty.dnd.State = null, + /// These are just a packed set of flags we may set on the terminal. flags: packed struct { // This supports a Kitty extension where programs using semantic @@ -353,6 +359,7 @@ pub fn deinit(self: *Terminal, alloc: Allocator) void { self.pwd.deinit(alloc); self.title.deinit(alloc); self.glyph_glossary.deinit(alloc); + if (self.kitty_dnd) |dnd| dnd.destroy(alloc); self.* = undefined; } @@ -498,7 +505,14 @@ pub fn printRepeat(self: *Terminal, count_req: usize) !void { // identical to calling print per codepoint: ineligible characters // or terminal states (insert mode, grapheme clustering, hyperlinks, // etc.) fall back to the per-codepoint print() path internally. - var buf: [4096]u32 = @splat(c); + // + // The buffer is filled with a runtime-bounded loop rather than + // `= @splat(c)`: a comptime-known 4096-element splat gets fully + // unrolled into ~33KB of consecutive stores (LLVM won't re-roll + // or vectorize it, see quirks_memset.zig), and it would fill the + // whole buffer even for the typical small repeat counts. + var buf: [4096]u32 = undefined; + for (buf[0..@min(remaining, buf.len)]) |*cp| cp.* = c; while (remaining > 0) { const n = @min(remaining, buf.len); try self.printSlice(buf[0..n]); @@ -522,11 +536,55 @@ pub fn printRepeat(self: *Terminal, count_req: usize) !void { /// slower per-codepoint path. They're less common and this is optimized /// for the aforementioned cases. pub fn printSlice(self: *Terminal, cps: []const u32) !void { + // Check if we can do the fast path up front. If we can't + // we need to go back to scalar `print`. + const fast = fast: { + // Only the main display is supported. + if (self.status_display != .main) break :fast false; + + // Modes that require per-codepoint handling in print(). + // Wraparound is required (its the default) so that our + // row-fill logic below can assume soft-wrap semantics. Insert + // mode shifts cells per print. + if (self.modes.get(.insert)) break :fast false; + if (!self.modes.get(.wraparound)) break :fast false; + + // Charset must map ASCII as-is (true unless a DEC special + // charset is actively invoked, which is rare). + const screen: *Screen = self.screens.active; + if (screen.charset.single_shift != null) break :fast false; + switch (screen.charset.charsets.get(screen.charset.gl)) { + .utf8, .ascii => {}, + else => break :fast false, + } + + // Hyperlinks require per-cell map bookkeeping. + if (screen.cursor.hyperlink_id != 0) break :fast false; + + break :fast true; + }; + if (!fast) { + for (cps) |cp| try self.print(@intCast(cp)); + return; + } + + const grapheme_cluster = self.modes.get(.grapheme_cluster); + + // When grapheme clustering is enabled and a left margin is set, + // print() consults the cell left of the margin after wrapping, + // which we can't reason about here. Restrict the fast path to + // the [0x10, 0xFF] range in that case (those never cluster). + const allow_unicode = !grapheme_cluster or self.scrolling_region.left == 0; + var i: usize = 0; while (i < cps.len) { // Try the fast-path print first. This will return the number of // codepoints it consumed. - const consumed = try self.printSliceFast(cps[i..]); + const consumed = try self.printSliceFast( + cps[i..], + grapheme_cluster, + allow_unicode, + ); if (consumed > 0) { i += consumed; continue; @@ -548,32 +606,16 @@ pub fn printSlice(self: *Terminal, cps: []const u32) !void { /// /// The fast path handles runs of narrow (width 1) and wide (width 2) /// codepoints being written to simple cells. Everything else (zero -/// width codepoints, grapheme cluster continuations, insert mode, -/// charset mapping, hyperlinks, complex cells, etc.) is rejected so -/// `print` can handle it with full generality. -fn printSliceFast(self: *Terminal, cps: []const u32) !usize { - // Only the main display is supported. - if (self.status_display != .main) return 0; - - // Modes that require per-codepoint handling in print(). Wraparound - // is required (its the default) so that our row-fill logic below can - // assume soft-wrap semantics. Insert mode shifts cells per print. - if (self.modes.get(.insert)) return 0; - if (!self.modes.get(.wraparound)) return 0; - +/// width codepoints, grapheme cluster continuations, complex cells, +/// etc.) is rejected so `print` can handle it with full generality. +fn printSliceFast( + self: *Terminal, + cps: []const u32, + grapheme_cluster: bool, + allow_unicode: bool, +) !usize { const screen: *Screen = self.screens.active; - // Charset must map ASCII as-is (true unless a DEC special charset - // is actively invoked, which is rare). - if (screen.charset.single_shift != null) return 0; - switch (screen.charset.charsets.get(screen.charset.gl)) { - .utf8, .ascii => {}, - else => return 0, - } - - // Hyperlinks require per-cell map bookkeeping. - if (screen.cursor.hyperlink_id != 0) return 0; - // Codepoints in [0x10, 0xFF] are always narrow (width 1, matching // the c <= 0xFF fast path in print) and can never interact with // grapheme clustering (which requires a codepoint > 0xFF). @@ -584,13 +626,6 @@ fn printSliceFast(self: *Terminal, cps: []const u32) !usize { // 2027) is enabled, if they are a grapheme break from the // previously printed codepoint (so print would never attach them // to the previous cell). - const grapheme_cluster = self.modes.get(.grapheme_cluster); - - // When grapheme clustering is enabled and a left margin is set, - // print() consults the cell left of the margin after wrapping, - // which we can't reason about here. Restrict the fast path to - // the [0x10, 0xFF] range in that case (those never cluster). - const allow_unicode = !grapheme_cluster or self.scrolling_region.left == 0; // Codepoints in [0x10, 0xFF] are always narrow: print() // hardcodes width 1 for c <= 0xFF (no width table lookup). @@ -619,14 +654,46 @@ fn printSliceFast(self: *Terminal, cps: []const u32) !usize { } // The first codepoint requires care when grapheme clustering is - // enabled: print() examines the previous *cell* which can hold - // state (grapheme data) that we can't cheaply reason about here. - // Note this includes the pending-wrap state: print() may attach - // to the pending cell *instead of wrapping*. We only take the - // first codepoint if the cursor is at column zero with no pending - // wrap, where print() skips clustering entirely. - if (grapheme_cluster) { - if (screen.cursor.pending_wrap or screen.cursor.x != 0) return 0; + // enabled: print() may attach it to the previous *cell* instead + // of writing a new one. Take the first codepoint only when we can + // determine — computing exactly what print() would — that it + // starts a new cluster. At column zero with no pending wrap, + // print() skips clustering entirely. Otherwise resolve the + // previous cell the way print() does and check for a break. + // + // Note the pending-wrap rejection: print() may attach to the + // pending cell *instead of wrapping*, which we can't model here. + if (grapheme_cluster and screen.cursor.x != 0) gate: { + if (screen.cursor.pending_wrap) return 0; + + // Resolve the content cell to our left exactly like print(): + // if the immediate left cell is a wide spacer tail, the + // content lives one further left. (A spacer tail can never + // be at column zero — its wide half would have to be in the + // previous row — so the second cursorCellLeft is in bounds.) + const immediate = screen.cursorCellLeft(1); + const prev: *Cell = switch (immediate.wide) { + .spacer_tail => screen.cursorCellLeft(2), + else => immediate, + }; + + // An empty previous cell is necessarily a grapheme break. + if (prev.codepoint() == 0) break :gate; + + // Grapheme data on the previous cell requires the full + // cluster state machine replay; only print() can do that. + if (prev.hasGrapheme()) return 0; + + // A simple single-codepoint previous cell: print() would run + // exactly this break check from the default state. + var state: uucode.grapheme.BreakState = .default; + if (!unicode.graphemeBreak( + prev.content.codepoint.data, + @intCast(cp0), + &state, + )) return 0; + } else if (grapheme_cluster) { + if (screen.cursor.pending_wrap) return 0; } // The width lookup is a runtime value while printSliceFill is @@ -1263,14 +1330,21 @@ pub fn print(self: *Terminal, c: u21) !void { const old_rac = old_pin.rowAndCell(); if (new_pin.node == old_pin.node) { - new_pin.node.page().moveGrapheme(prev.cell, new_rac.cell); - prev.cell.content_tag = .codepoint; + new_pin.node.page().moveGrapheme(old_rac.cell, new_rac.cell); + old_rac.cell.content_tag = .codepoint; new_rac.cell.content_tag = .codepoint_grapheme; new_rac.row.grapheme = true; } else { const cps = old_pin.node.page().lookupGrapheme(old_rac.cell).?; for (cps) |cp| { - try self.screens.active.appendGrapheme(new_rac.cell, cp); + // appendGrapheme can grow the cursor + // page, so read the destination from + // the cursor each time rather than + // holding a pointer across the call. + try self.screens.active.appendGrapheme( + self.screens.active.cursor.page_cell, + cp, + ); } old_pin.node.page().clearGrapheme(old_rac.cell); } @@ -1280,7 +1354,7 @@ pub fn print(self: *Terminal, c: u21) !void { // Point prev.cell to our new previous cell that // we'll be appending graphemes to - prev.cell = new_rac.cell; + prev.cell = self.screens.active.cursor.page_cell; } else { self.printCell( 0, @@ -1299,8 +1373,30 @@ pub fn print(self: *Terminal, c: u21) !void { // Write our spacer, since prev.cell is now wide self.screens.active.cursorRight(1); + + // Writing the spacer can grow the page to make room for + // the cursor hyperlink. Growing replaces the page, which + // invalidates `prev.cell`. Record the page identity first + // so the common case where nothing grows stays free. + // + // A pointer comparison alone isn't enough: pages are + // pooled, so a replacement can reuse the same address. + // The serial makes the pair a unique identity. + const spacer_node = self.screens.active.cursor.page_pin.node; + const spacer_serial = spacer_node.serial; + self.printCell(0, .spacer_tail); + if (self.screens.active.cursor.page_pin.node != spacer_node or + self.screens.active.cursor.page_pin.node.serial != spacer_serial) + { + @branchHint(.unlikely); + + // The cursor is on the spacer tail we just wrote, so + // the wide cell we append to is the one to its left. + prev.cell = self.screens.active.cursorCellLeft(1); + } + // Move the cursor again so we're beyond our spacer if (self.screens.active.cursor.x == right_limit - 1) { self.screens.active.cursor.pending_wrap = true; @@ -1644,7 +1740,11 @@ fn printCell( self.screens.active.cursorSetHyperlink() catch |err| { @branchHint(.unlikely); log.warn("error reallocating for more hyperlink space, ignoring hyperlink err={}", .{err}); - assert(!cell.hyperlink); + + // A partially successful grow can replace the page even when the + // call fails, so `cell` may be stale here. The cursor pointers are + // always reloaded, so read the cell through the cursor. + assert(!self.screens.active.cursor.page_cell.hyperlink); }; } else if (had_hyperlink) { // If the previous cell had a hyperlink then we need to clear it. @@ -2287,11 +2387,25 @@ pub fn index(self: *Terminal) !void { (!screen.no_scrollback or self.scrolling_region.bottom == 0)) { + // If a bottom margin is set, kitty image placements may + // need adjusting around the scroll. The rare placements- + // present case is handled out of line so this hot path + // only pays a count check (a load from a cache line we + // already write, above). + if (comptime build_options.kitty_graphics) { + if (screen.kitty_images.placements.count() != 0) { + @branchHint(.unlikely); + try self.indexScrollWithImages(.window_shift); + return; + } + } + try screen.cursorScrollAbove(); return; } // Slow path for left and right scrolling region margins. + // scrollUp handles the kitty image adjustment itself. if (self.scrolling_region.left != 0 or self.scrolling_region.right != self.cols - 1) { @@ -2299,6 +2413,16 @@ pub fn index(self: *Terminal) !void { return; } + // Kitty image placements may need adjusting around the scroll; + // handled out of line like the scrollback path above. + if (comptime build_options.kitty_graphics) { + if (screen.kitty_images.placements.count() != 0) { + @branchHint(.unlikely); + try self.indexScrollWithImages(.in_place); + return; + } + } + // Otherwise use a fast path function to efficiently scroll // the contents of the scrolling region. try screen.cursorScrollRegionUp( @@ -2314,6 +2438,69 @@ pub fn index(self: *Terminal) !void { } } +/// The operation when we have Kitty image placements during index. +/// Split out of index() so its hot paths don't carry the adjustment +/// state in their stack frame, which measurably slows the scroll +/// hot path. +fn indexScrollWithImages( + self: *Terminal, + comptime op: kitty.graphics.ImageStorage.ScrollOp, +) !void { + var kitty_scroll = self.kittyScrollMarginsBegin(-1, op); + defer if (kitty_scroll) |*state| state.end(); + switch (op) { + .window_shift => try self.screens.active.cursorScrollAbove(), + .in_place => try self.screens.active.cursorScrollRegionUp( + self.scrolling_region.bottom - self.scrolling_region.top, + ), + } +} + +// Handle when Kitty graphics is disabled. +const KittyScrollMargins = if (build_options.kitty_graphics) + kitty.graphics.ImageStorage.ScrollMargins +else + struct { + pub inline fn end(self: *@This()) void { + _ = self; + } + }; + +/// Begin adjusting kitty image placements for a scroll of the +/// scrolling region by delta rows (negative moves content up). If +/// adjustment is needed this returns state whose end() must be called +/// after the scroll's row operations complete (see +/// ImageStorage.scrollMarginsBegin for why this is two phases). This +/// returns null when the scrolling region is the full screen, because +/// placements then follow their anchored rows via pin tracking which +/// matches kitty's marginless behavior. +/// +/// Callers must comptime-gate on build_options.kitty_graphics and +/// check that placements exist before calling, which keeps the cost +/// on the hot scroll paths cheap. +fn kittyScrollMarginsBegin( + self: *Terminal, + delta: isize, + op: kitty.graphics.ImageStorage.ScrollOp, +) ?kitty.graphics.ImageStorage.ScrollMargins { + @branchHint(.cold); + + // Full-screen scrolls need no adjustment: placements follow their + // anchored rows (possibly into the scrollback) via pin tracking. + if (self.scrolling_region.top == 0 and + self.scrolling_region.bottom == self.rows - 1 and + self.scrolling_region.left == 0 and + self.scrolling_region.right == self.cols - 1) return null; + + const screen: *Screen = self.screens.active; + return screen.kitty_images.scrollMarginsBegin( + self.io(), + self, + delta, + op, + ); +} + /// Move the cursor to the previous line in the scrolling region, possibly /// scrolling. /// @@ -2439,6 +2626,24 @@ pub fn scrollDown(self: *Terminal, count: usize) void { self.screens.active.cursor.pending_wrap = old_wrap; } + // If margins are set and kitty image placements exist, they need + // adjusting around the scroll. Note this wraps scrollDown and NOT + // insertLines: kitty scrolls images for SD/RI but leaves them + // alone for IL/DL. + var kitty_scroll: ?KittyScrollMargins = null; + defer if (kitty_scroll) |*state| state.end(); + if (comptime build_options.kitty_graphics) { + if (self.screens.active.kitty_images.placements.count() != 0) { + @branchHint(.unlikely); + const region_height: usize = + @as(usize, self.scrolling_region.bottom - self.scrolling_region.top) + 1; + kitty_scroll = self.kittyScrollMarginsBegin( + @intCast(@min(count, region_height)), + .in_place, + ); + } + } + // Move to the top of the scroll region self.screens.active.cursorAbsolute(self.scrolling_region.left, self.scrolling_region.top); self.insertLines(count); @@ -2461,6 +2666,35 @@ pub fn scrollUp(self: *Terminal, count: usize) !void { self.screens.active.cursor.pending_wrap = old_wrap; } + // If margins are set and kitty image placements exist, they need + // adjusting around the scroll. Note this wraps scrollUp and NOT + // deleteLines: kitty scrolls images for SU/IND but leaves them + // alone for IL/DL. + var kitty_scroll: ?KittyScrollMargins = null; + defer if (kitty_scroll) |*state| state.end(); + if (comptime build_options.kitty_graphics) { + if (self.screens.active.kitty_images.placements.count() != 0) { + @branchHint(.unlikely); + + // The op must mirror the branch below: the scrollback path + // shifts the active window while the deleteLines path + // moves rows in place. + const region_height: usize = + @as(usize, self.scrolling_region.bottom - self.scrolling_region.top) + 1; + kitty_scroll = self.kittyScrollMarginsBegin( + -@as(isize, @intCast(@min(count, region_height))), + if (self.scrolling_region.top == 0 and + self.scrolling_region.left == 0 and + self.scrolling_region.right == self.cols - 1 and + (!self.screens.active.no_scrollback or + self.scrolling_region.bottom == self.rows - 1)) + .window_shift + else + .in_place, + ); + } + } + // If our scroll region is at the top and we have no left/right // margins then we move the scrolled out text into the scrollback. // @@ -2530,7 +2764,7 @@ pub const ScrollViewport = union(Tag) { @This(), // Padding: largest variant is isize (8 bytes on 64-bit). // Use [2]u64 (16 bytes) for future expansion. - [2]u64, + .{ .padding = [2]u64 }, ); pub const C = c_union.C; pub const CValue = c_union.CValue; @@ -2852,6 +3086,14 @@ pub fn insertLines(self: *Terminal, count: usize) void { cur_row, cells[self.scrolling_region.left .. self.scrolling_region.right + 1], ); + + // With a full-width scroll region the entire row is a + // fresh blank row: reset the metadata so nothing (wrap + // state, semantic prompt) is retained from the row whose + // storage it recycles. With left/right margins the row + // keeps content outside the margins so the metadata is + // preserved, matching the shift case above. + if (!left_right) cur_row.reset(); } // Mark the row as dirty @@ -3012,6 +3254,14 @@ pub fn deleteLines(self: *Terminal, count: usize) void { cur_row, cells[self.scrolling_region.left .. self.scrolling_region.right + 1], ); + + // With a full-width scroll region the entire row is a + // fresh blank row: reset the metadata so nothing (wrap + // state, semantic prompt) is retained from the row whose + // storage it recycles. With left/right margins the row + // keeps content outside the margins so the metadata is + // preserved, matching the shift case above. + if (!left_right) cur_row.reset(); } // Mark the row as dirty @@ -3341,17 +3591,12 @@ pub fn eraseDisplay( self.screens.active.cursor.pending_wrap = false; if (comptime build_options.kitty_graphics) { - // Clear the placements on this screen, but keep the images - // themselves: a client transmits with `a=t` once and places - // with `a=p` per frame, and freeing the data on a screen clear - // makes every later placement of that id draw nothing. The - // storage limit (320MB, LRU) is what bounds this, which is why - // it exists. slosh patch; see vendor/patches. - self.screens.active.kitty_images.delete( + // Clear only placements still visible after moving the active + // area into scrollback. + self.screens.active.kitty_images.clearScreen( self.io(), self.screens.active.alloc, self, - .{ .all = false }, ); } }, @@ -3404,17 +3649,12 @@ pub fn eraseDisplay( self.screens.active.cursor.pending_wrap = false; if (comptime build_options.kitty_graphics) { - // Clear the placements on this screen, but keep the images - // themselves: a client transmits with `a=t` once and places - // with `a=p` per frame, and freeing the data on a screen clear - // makes every later placement of that id draw nothing. The - // storage limit (320MB, LRU) is what bounds this, which is why - // it exists. slosh patch; see vendor/patches. - self.screens.active.kitty_images.delete( + // ED2 clears visible placements but preserves graphics that + // are wholly in scrollback. + self.screens.active.kitty_images.clearScreen( self.io(), self.screens.active.alloc, self, - .{ .all = false }, ); } @@ -4690,6 +4930,9 @@ pub fn fullReset(self: *Terminal) void { self.pwd.clearRetainingCapacity(); self.title.clearRetainingCapacity(); self.glyph_glossary.clearAndFree(self.gpa()); + // A reset only interrupts an in-progress chunked OSC 72 command; + // drag and drop registration survives, matching kitty. + if (self.kitty_dnd) |dnd| dnd.chunking = .{}; self.status_display = .main; self.scrolling_region = .{ .top = 0, @@ -5948,6 +6191,106 @@ test "Terminal: VS16 to make wide character on next line with hyperlink" { } } +test "Terminal: VS16 widening when the spacer tail grows the page" { + // Regression test for a stale cell pointer in print's grapheme `.wide` + // path: writing the spacer tail can grow the page to fit the hyperlink, + // which replaces the page and invalidates the pointer to the wide cell. + var t = try init(testing.io, testing.allocator, .{ .rows = 10, .cols = 20 }); + defer t.deinit(testing.allocator); + + t.modes.set(.grapheme_cluster, true); + try t.screens.active.startHyperlink("http://example.com", null); + + // Fill the page hyperlink map until a single slot is left. The '#' below + // takes that slot so the spacer tail is what forces the page to grow. + while (true) { + const page = t.screens.active.cursor.page_pin.node.page(); + const map = page.hyperlink_map.map(page.memory); + if (map.maxLoad() - map.count() == 1) break; + try t.print('x'); + } + + const x = t.screens.active.cursor.x; + const y = t.screens.active.cursor.y; + try t.print('#'); + + // Without the fix this crashed appending to a freed page. + try t.print(0xFE0F); + + { + // '#' is wide and carries the VS16 grapheme. + const list_cell = t.screens.active.pages.getCell(.{ .active = .{ + .x = x, + .y = y, + } }).?; + const cell = list_cell.cell; + try testing.expectEqual(@as(u21, '#'), cell.content.codepoint.data); + try testing.expectEqual(Cell.Wide.wide, cell.wide); + try testing.expect(cell.hasGrapheme()); + try testing.expectEqualSlices( + u21, + &.{0xFE0F}, + list_cell.node.page().lookupGrapheme(cell).?, + ); + } + { + const list_cell = t.screens.active.pages.getCell(.{ .active = .{ + .x = x + 1, + .y = y, + } }).?; + try testing.expectEqual(Cell.Wide.spacer_tail, list_cell.cell.wide); + } +} + +test "Terminal: grapheme transfer when widening wraps to the next line" { + // Covers print's grapheme `.wide` path where the previous cell already + // holds grapheme data and has to be moved to the wrapped row. + var t = try init(testing.io, testing.allocator, .{ .rows = 5, .cols = 3 }); + defer t.deinit(testing.allocator); + + t.modes.set(.grapheme_cluster, true); + t.cursorRight(2); + + // A narrow emoji, then ZWJ, then a second emoji. The ZWJ attaches + // without changing the width, so the cell has grapheme data by the time + // the second emoji widens it. + try t.print(0x263A); + try t.print(0x200D); + try t.print(0x2764); + + { + // The old cell becomes a spacer head on the wrapped row. + const list_cell = t.screens.active.pages.getCell(.{ .screen = .{ + .x = 2, + .y = 0, + } }).?; + try testing.expectEqual(Cell.Wide.spacer_head, list_cell.cell.wide); + try testing.expect(list_cell.row.wrap); + } + { + // The grapheme moved with the base codepoint. + const list_cell = t.screens.active.pages.getCell(.{ .screen = .{ + .x = 0, + .y = 1, + } }).?; + const cell = list_cell.cell; + try testing.expectEqual(@as(u21, 0x263A), cell.content.codepoint.data); + try testing.expectEqual(Cell.Wide.wide, cell.wide); + try testing.expectEqualSlices( + u21, + &.{ 0x200D, 0x2764 }, + list_cell.node.page().lookupGrapheme(cell).?, + ); + } + { + const list_cell = t.screens.active.pages.getCell(.{ .screen = .{ + .x = 1, + .y = 1, + } }).?; + try testing.expectEqual(Cell.Wide.spacer_tail, list_cell.cell.wide); + } +} + test "Terminal: VS16 to make wide character with pending wrap" { var t = try init(testing.io, testing.allocator, .{ .rows = 5, .cols = 3 }); defer t.deinit(testing.allocator); @@ -7094,6 +7437,85 @@ test "Terminal: print wide char at right edge with hyperlink" { } } +// A cursor style or hyperlink ID is an index into a set stored in the +// page memory of the page the cursor pin points at. When scrollClear +// (here via ED 22, kitty's scroll_complete) pushes the active area onto +// a later page while the cursor pin is still on an earlier one, +// cursorReload must migrate the cursor's style and hyperlink references +// to the destination page. It previously replaced the pin directly, +// leaving the cursor holding an ID that was dead or aliased an unrelated +// entry on the new page, and the next print attached a live cell to it. +// Found via fuzzing. +test "Terminal: scrollClear across pages keeps cursor hyperlink refs page-local" { + const alloc = testing.allocator; + + // Minimized from a 774-byte AFL fuzz input. Reading it: + // + // A print, so REP has something to repeat + // ESC [ 48111 b REP, filling the page and spilling onto a second + // ESC ] 8 ; ; 0x93 OSC 8; the C1 byte terminates the OSC and makes + // the URI non-empty, so a hyperlink starts + // ESC [ 11 A CUU, moving the cursor back onto the first page + // ESC [ 22 J ED 22, i.e. scroll_complete -> Screen.scrollClear + // B print, which attaches the cursor hyperlink + // ESC ] 8 ; ; ESC OSC 8 with an empty URI, ending the hyperlink + // + // The grid must be wide enough to fill a page from a single REP, so + // this does not reproduce at 80x24. + const input = "A\x1b[48111b\x1b]8;;\x93\x1b[11A\x1b[22JB\x1b]8;;\x1b"; + + var t = try init(testing.io, alloc, .{ .cols = 200, .rows = 50 }); + defer t.deinit(alloc); + + { + var s = t.vtStream(); + defer s.deinit(); + s.nextSlice(input); + } + + // With slow runtime safety on, the page integrity checks during the + // stream above already catch the bug. Verify the ref counts explicitly + // as well so this test is meaningful with runtime safety off: every + // cell holding a hyperlink ID owns a reference, so a count below the + // number of holding cells means a live cell points at an entry that + // was already freed. + var node_ = t.screens.active.pages.pages.first; + while (node_) |node| : (node_ = node.next) { + const page = node.page(); + const cap = page.hyperlink_set.layout.cap; + if (cap == 0) continue; + + const holders = try alloc.alloc(u32, cap); + defer alloc.free(holders); + @memset(holders, 0); + + for (page.rows.ptr(page.memory)[0..page.size.rows]) |*row| { + if (!row.hyperlink) continue; + for (row.cells.ptr(page.memory)[0..page.size.cols]) |*cell| { + if (!cell.hyperlink) continue; + const id = page.lookupHyperlink(cell) orelse continue; + if (id < cap) holders[id] += 1; + } + } + + for (holders, 0..) |held, id| { + if (held == 0) continue; + const refs = page.hyperlink_set.refCount(page.memory, @intCast(id)); + try testing.expect(refs >= held); + } + } + + // If the cursor still has an active hyperlink, its own extra + // reference must live on the cursor's page. + const cursor = &t.screens.active.cursor; + if (cursor.hyperlink_id != 0) { + const page = cursor.page_pin.node.page(); + try testing.expect( + page.hyperlink_set.refCount(page.memory, cursor.hyperlink_id) > 0, + ); + } +} + test "Terminal: linefeed and carriage return" { var t = try init(testing.io, testing.allocator, .{ .cols = 80, .rows = 80 }); defer t.deinit(testing.allocator); @@ -9500,6 +9922,38 @@ test "Terminal: eraseChars wide char wrap boundary conditions" { } } +test "Terminal: eraseChars clearing wrapped wide char marks spacer head row dirty" { + const alloc = testing.allocator; + const io_impl = testing.io; + var t = try init(io_impl, alloc, .{ .rows = 3, .cols = 5 }); + defer t.deinit(alloc); + + // The wide char doesn't fit so it wraps, leaving a spacer head at + // the end of the first row. + try t.printString("ABCD字"); + { + const list_cell = t.screens.active.pages.getCell(.{ .screen = .{ .x = 4, .y = 0 } }).?; + try testing.expectEqual(Cell.Wide.spacer_head, list_cell.cell.wide); + try testing.expect(list_cell.row.wrap); + } + + t.setCursorPos(2, 1); + t.clearDirty(); + t.eraseChars(1); + t.screens.active.cursor.page_pin.node.page().assertIntegrity(); + + // Erasing the wide char also clears the spacer head on the previous + // row, so that row must be dirty too. + try testing.expect(t.isDirty(.{ .screen = .{ .x = 0, .y = 0 } })); + try testing.expect(t.isDirty(.{ .screen = .{ .x = 0, .y = 1 } })); + try testing.expect(!t.isDirty(.{ .screen = .{ .x = 0, .y = 2 } })); + + { + const list_cell = t.screens.active.pages.getCell(.{ .screen = .{ .x = 4, .y = 0 } }).?; + try testing.expectEqual(Cell.Wide.narrow, list_cell.cell.wide); + } +} + test "Terminal: reverseIndex" { const alloc = testing.allocator; const io_impl = testing.io; @@ -12934,9 +13388,16 @@ test "Terminal: deleteChars wide char wrap boundary conditions" { } t.setCursorPos(2, 2); + t.clearDirty(); t.deleteChars(3); t.screens.active.cursor.page_pin.node.page().assertIntegrity(); + // Deleting the wide char also clears the spacer head on the previous + // row, so that row must be dirty too. + try testing.expect(t.isDirty(.{ .screen = .{ .x = 0, .y = 0 } })); + try testing.expect(t.isDirty(.{ .screen = .{ .x = 0, .y = 1 } })); + try testing.expect(!t.isDirty(.{ .screen = .{ .x = 0, .y = 2 } })); + { const str = try t.plainString(alloc); defer testing.allocator.free(str); @@ -15222,6 +15683,34 @@ test "Terminal: fullReset status display" { try testing.expect(t.status_display == .main); } +test "Terminal: fullReset preserves kitty graphics limits" { + if (comptime !build_options.kitty_graphics) return error.SkipZigTest; + + const alloc = testing.allocator; + const temp_dir = "/tmp/ghostty-kitty-images"; + + var t = try init(testing.io, alloc, .{ .cols = 10, .rows = 10 }); + defer t.deinit(alloc); + + t.setKittyGraphicsLoadingLimits(.allWithTempDir(temp_dir)); + for ([_]usize{ 1234, 0 }) |total_limit| { + t.setKittyGraphicsSizeLimit(alloc, total_limit); + t.fullReset(); + + const storage = &t.screens.active.kitty_images; + try testing.expectEqual(total_limit, storage.total_limit); + try testing.expect(storage.image_limits.file); + try testing.expect(storage.image_limits.shared_memory); + switch (storage.image_limits.temporary_file) { + .enabled => |value| try testing.expectEqualStrings( + temp_dir, + value.directory, + ), + .disabled => return error.TestUnexpectedResult, + } + } +} + // https://github.com/mitchellh/ghostty/issues/1607 test "Terminal: fullReset clears alt screen kitty keyboard state" { var t = try init(testing.io, testing.allocator, .{ .cols = 10, .rows = 10 }); @@ -15823,6 +16312,8 @@ test "Terminal: deleteLines wide char at right margin with full clear" { } test "Terminal: glyph APC stores session glossary entries" { + if (comptime !build_options.glyph_protocol) return error.SkipZigTest; + const alloc = testing.allocator; const io_impl = testing.io; var t = try init(io_impl, alloc, .{ .cols = 80, .rows = 24 }); @@ -15854,3 +16345,161 @@ test "Terminal: glyph APC stores session glossary entries" { t.fullReset(); try testing.expect(!t.glyph_glossary.contains(0xE0A0)); } + +test "Terminal: scroll region linefeed recycled row has default metadata" { + const alloc = testing.allocator; + const io_impl = testing.io; + var t = try init(io_impl, alloc, .{ .cols = 5, .rows = 5 }); + defer t.deinit(alloc); + + // A soft-wrapped line across rows 0-2 so that row 1 has both wrap + // flags set. Mark row 1 as a prompt as well (OSC 133 A would). + for (0..12) |_| try t.print('A'); + t.screens.active.pages.getCell( + .{ .active = .{ .y = 1 } }, + ).?.row.semantic_prompt = .prompt; + + // DECSTBM rows 2-4, cursor to the region bottom, and linefeed: + // row 1 is discarded and its Row storage recycled as the new + // blank region-bottom row. + t.setTopAndBottomMargin(2, 4); + t.setCursorPos(4, 1); + try t.linefeed(); + + { + const rac = t.screens.active.pages.getCell(.{ .active = .{ .y = 3 } }).?; + try testing.expect(!rac.row.wrap); + try testing.expect(!rac.row.wrap_continuation); + try testing.expectEqual(.none, rac.row.semantic_prompt); + } +} + +test "Terminal: alt screen scroll up recycled row has default metadata" { + const alloc = testing.allocator; + const io_impl = testing.io; + var t = try init(io_impl, alloc, .{ .cols = 5, .rows = 3 }); + defer t.deinit(alloc); + + try t.switchScreenMode(.@"1049", true); + + // A soft-wrapped line across rows 0-1 and a prompt mark on row 0. + for (0..7) |_| try t.print('A'); + t.screens.active.pages.getCell( + .{ .active = .{} }, + ).?.row.semantic_prompt = .prompt; + + // Scroll up: with no scrollback, row 0 is discarded and its Row + // storage recycled as the new blank bottom row. + try t.scrollUp(1); + + { + const rac = t.screens.active.pages.getCell(.{ .active = .{ .y = 2 } }).?; + try testing.expect(!rac.row.wrap); + try testing.expect(!rac.row.wrap_continuation); + try testing.expectEqual(.none, rac.row.semantic_prompt); + } +} + +test "Terminal: insertLines count over region blanks row metadata" { + const alloc = testing.allocator; + const io_impl = testing.io; + var t = try init(io_impl, alloc, .{ .cols = 5, .rows = 5 }); + defer t.deinit(alloc); + + // A soft-wrapped line across rows 0-2 so that row 1 has both wrap + // flags set, plus a prompt mark on row 1. + for (0..12) |_| try t.print('A'); + t.screens.active.pages.getCell( + .{ .active = .{ .y = 1 } }, + ).?.row.semantic_prompt = .prompt; + + // Insert more lines than remain in the region: every row from the + // cursor to the region bottom is blanked in place, with no shifts. + t.setCursorPos(2, 1); + t.insertLines(10); + + for (1..5) |y| { + const rac = t.screens.active.pages.getCell( + .{ .active = .{ .y = @intCast(y) } }, + ).?; + try testing.expect(!rac.row.wrap); + try testing.expect(!rac.row.wrap_continuation); + try testing.expectEqual(.none, rac.row.semantic_prompt); + } +} + +test "Terminal: deleteLines count over region blanks row metadata" { + const alloc = testing.allocator; + const io_impl = testing.io; + var t = try init(io_impl, alloc, .{ .cols = 5, .rows = 5 }); + defer t.deinit(alloc); + + for (0..12) |_| try t.print('A'); + t.screens.active.pages.getCell( + .{ .active = .{ .y = 1 } }, + ).?.row.semantic_prompt = .prompt; + + t.setCursorPos(2, 1); + t.deleteLines(10); + + for (1..5) |y| { + const rac = t.screens.active.pages.getCell( + .{ .active = .{ .y = @intCast(y) } }, + ).?; + try testing.expect(!rac.row.wrap); + try testing.expect(!rac.row.wrap_continuation); + try testing.expectEqual(.none, rac.row.semantic_prompt); + } +} + +test "Terminal: deleteLines blank row does not retain semantic prompt" { + const alloc = testing.allocator; + const io_impl = testing.io; + var t = try init(io_impl, alloc, .{ .cols = 5, .rows = 3 }); + defer t.deinit(alloc); + + // Mark row 0 as a prompt row, then delete it. The blank row that + // appears at the region bottom reuses the deleted row's storage + // and must not read as a prompt (e.g. for prompt navigation). + try t.print('$'); + t.screens.active.pages.getCell( + .{ .active = .{} }, + ).?.row.semantic_prompt = .prompt; + + t.setCursorPos(1, 1); + t.deleteLines(1); + + { + const rac = t.screens.active.pages.getCell(.{ .active = .{ .y = 2 } }).?; + try testing.expect(!rac.row.wrap); + try testing.expect(!rac.row.wrap_continuation); + try testing.expectEqual(.none, rac.row.semantic_prompt); + } +} + +test "Terminal: eraseDisplay complete ignores stale prompt on recycled row" { + const alloc = testing.allocator; + const io_impl = testing.io; + var t = try init(io_impl, alloc, .{ .cols = 10, .rows = 3 }); + defer t.deinit(alloc); + + // Screen content that must NOT enter the scrollback on a clear. + try t.printString("hello"); + + // Mark row 1 as a prompt row and then discard it with a region + // scroll, recycling its storage as the blank bottom row. + t.screens.active.pages.getCell( + .{ .active = .{ .y = 1 } }, + ).?.row.semantic_prompt = .prompt; + t.setTopAndBottomMargin(2, 3); + t.setCursorPos(3, 1); + try t.linefeed(); + t.setTopAndBottomMargin(0, 0); + + // ED2: since no prompt is on screen, this must NOT take the + // scroll-and-clear path that pushes content into scrollback. A + // stale prompt flag on the recycled blank bottom row would. + t.eraseDisplay(.complete, false); + + try testing.expectEqual(t.screens.active.pages.rows, t.screens.active.pages.total_rows); +} diff --git a/vendor/libghostty-vt/src/terminal/apc.zig b/vendor/libghostty-vt/src/terminal/apc.zig index 0da13b3..1ae70f5 100644 --- a/vendor/libghostty-vt/src/terminal/apc.zig +++ b/vendor/libghostty-vt/src/terminal/apc.zig @@ -82,12 +82,16 @@ pub const Handler = struct { if (byte == ';') { const str = id.buf[0..id.len]; if (std.mem.eql(u8, str, glyph.identifier)) { - if (self.enabled.contains(.glyph)) { - self.state = .{ .glyph = .init( - alloc, - self.max_bytes.get(.glyph) orelse - Protocol.defaultMaxBytes(.glyph), - ) }; + if (comptime build_options.glyph_protocol) { + if (self.enabled.contains(.glyph)) { + self.state = .{ .glyph = .init( + alloc, + self.max_bytes.get(.glyph) orelse + Protocol.defaultMaxBytes(.glyph), + ) }; + } else { + self.state = .ignore; + } } else { self.state = .ignore; } @@ -127,11 +131,13 @@ pub const Handler = struct { }; } else unreachable, - .glyph => |*p| p.feed(byte) catch |err| { - log.warn("glyph protocol error: {}", .{err}); - p.deinit(); - self.state = .ignore; - }, + .glyph => |*p| if (comptime build_options.glyph_protocol) { + p.feed(byte) catch |err| { + log.warn("glyph protocol error: {}", .{err}); + p.deinit(); + self.state = .ignore; + }; + } else unreachable, } } @@ -191,14 +197,14 @@ pub const Handler = struct { return; } else unreachable, - .glyph => |*p| { + .glyph => |*p| if (comptime build_options.glyph_protocol) { p.feedSlice(rem) catch |err| { log.warn("glyph protocol error: {}", .{err}); p.deinit(); self.state = .ignore; }; return; - }, + } else unreachable, } } } @@ -229,6 +235,8 @@ pub const Handler = struct { }, .glyph => |*p| glyph_cmd: { + if (comptime !build_options.glyph_protocol) unreachable; + const command = p.complete(p.alloc) catch |err| { log.warn("glyph protocol error: {}", .{err}); break :glyph_cmd null; @@ -269,7 +277,10 @@ pub const State = union(enum) { void, /// Glyph protocol - glyph: glyph.CommandParser, + glyph: if (build_options.glyph_protocol) + glyph.CommandParser + else + void, /// An unsupported APC retained for the optional unknown callback. /// Keep this after recognized protocol states so their tag values and @@ -280,7 +291,10 @@ pub const State = union(enum) { switch (self.*) { .inactive, .ignore, .identify => {}, .unknown => |*v| v.deinit(), - .glyph => |*v| v.deinit(), + .glyph => |*v| if (comptime build_options.glyph_protocol) + v.deinit() + else + unreachable, .kitty => |*v| if (comptime build_options.kitty_graphics) v.deinit() else @@ -415,7 +429,11 @@ pub const Command = union(enum) { else void, - glyph: glyph.Request, + glyph: if (build_options.glyph_protocol) + glyph.Request + else + void, + unknown: Unknown, pub fn deinit(self: *Command, alloc: Allocator) void { @@ -425,7 +443,11 @@ pub const Command = union(enum) { else unreachable, - .glyph => |*v| v.deinit(alloc), + .glyph => |*v| if (comptime build_options.glyph_protocol) + v.deinit(alloc) + else + unreachable, + .unknown => |*v| v.deinit(alloc), } } @@ -636,6 +658,8 @@ test "garbage glyph command" { } test "valid glyph command" { + if (comptime !build_options.glyph_protocol) return error.SkipZigTest; + const testing = std.testing; const alloc = testing.allocator; @@ -700,6 +724,8 @@ test "feedSlice unknown APC command is ignored" { } test "feedSlice valid glyph command" { + if (comptime !build_options.glyph_protocol) return error.SkipZigTest; + const testing = std.testing; const alloc = testing.allocator; diff --git a/vendor/libghostty-vt/src/terminal/apc/glyph.zig b/vendor/libghostty-vt/src/terminal/apc/glyph.zig index 13454a3..e86ee9f 100644 --- a/vendor/libghostty-vt/src/terminal/apc/glyph.zig +++ b/vendor/libghostty-vt/src/terminal/apc/glyph.zig @@ -133,9 +133,7 @@ //! ## Glossary Capacity //! //! Each session holds at most 1024 registrations keyed by codepoint. -//! Registrations live for the session duration. A 1025th registration -//! evicts the oldest entry (FIFO). Sessions are isolated: two tabs may -//! independently register the same codepoint. +//! Each allocation made by the `glyf` decoder is limited to 64 KiB. //! //! ## Security: PUA-Only Restriction //! diff --git a/vendor/libghostty-vt/src/terminal/apc/glyph/Glossary.zig b/vendor/libghostty-vt/src/terminal/apc/glyph/Glossary.zig index 28458db..7200d16 100644 --- a/vendor/libghostty-vt/src/terminal/apc/glyph/Glossary.zig +++ b/vendor/libghostty-vt/src/terminal/apc/glyph/Glossary.zig @@ -20,6 +20,11 @@ const Constraint = FontGlyph.RenderOptions.Constraint; /// Defined by the specification. pub const max_entries = 1024; +/// Maximum size of any allocation made while decoding a glyf registration. +/// This is limited by the dynamic allocations not the struct size so the +/// actual size allowed is slightly larger, but worst case only by 10KB. +pub const max_glyf_allocation = 64 * 1024; // 64 KiB + /// An empty glossary with no registered glyphs. pub const empty: Glossary = .{ .entries = .empty }; @@ -170,7 +175,7 @@ pub const Entry = struct { // Decode the payload into some usable glyph format for // future rasterization. const glyph: Glyph = switch (fmt) { - .glyf => .{ .glyf = try req.decodeGlyfPayload(alloc) }, + .glyf => .{ .glyf = try req.decodeGlyfPayload(alloc, max_glyf_allocation) }, .colrv0, .colrv1 => return error.UnsupportedFormat, }; @@ -273,6 +278,47 @@ fn testParseRegister(alloc: Allocator, data: []const u8) !RegisterReq { // and three on-curve points. const test_triangle_glyf_payload = "AAEAZABkA4QDhAACAAABAQEB9P5wAyADhPzgAAA="; +fn testRepeatedPointRegisterReq( + alloc: Allocator, + end_points: []const u16, +) !RegisterReq { + assert(end_points.len > 0); + + var decoded: std.ArrayList(u8) = .empty; + defer decoded.deinit(alloc); + + // Simple glyph header with zero bounds. + const contour_count: u16 = @intCast(end_points.len); + try decoded.appendSlice(alloc, &.{ @truncate(contour_count >> 8), @truncate(contour_count) }); + try decoded.appendNTimes(alloc, 0, 8); + for (end_points) |end_point| try decoded.appendSlice( + alloc, + &.{ @truncate(end_point >> 8), @truncate(end_point) }, + ); + try decoded.appendSlice(alloc, &.{ 0x00, 0x00 }); // No instructions. + + // Maximum-length runs describe on-curve, zero-delta points compactly. + var remaining: usize = @as(usize, end_points[end_points.len - 1]) + 1; + while (remaining > 0) { + const run = @min(remaining, 256); + if (run == 1) { + try decoded.append(alloc, 0x31); + } else { + try decoded.appendSlice(alloc, &.{ 0x39, @intCast(run - 1) }); + } + remaining -= run; + } + + const Encoder = std.base64.standard.Encoder; + const payload = try alloc.alloc(u8, Encoder.calcSize(decoded.items.len)); + defer alloc.free(payload); + const encoded = Encoder.encode(payload, decoded.items); + + const command = try std.fmt.allocPrint(alloc, "r;cp=e000;{s}", .{encoded}); + defer alloc.free(command); + return try testParseRegister(alloc, command); +} + fn testRegisterReq(alloc: Allocator, cp: u21) !RegisterReq { const data = try std.fmt.allocPrint( alloc, @@ -330,6 +376,31 @@ test "Entry init rejects invalid register payload" { try testing.expectError(error.MalformedPayload, Entry.init(alloc, req)); } +test "Entry init rejects glyf allocation over limit" { + const testing = std.testing; + const alloc = testing.allocator; + + // 5,462 points require a 65,544-byte points allocation. + const req = try testRepeatedPointRegisterReq(alloc, &.{5461}); + defer alloc.free(req.raw); + + try testing.expectError(error.PayloadTooLarge, Entry.init(alloc, req)); +} + +test "Entry init accepts largest glyf points allocation under limit" { + const testing = std.testing; + const alloc = testing.allocator; + + // 5,461 points require a 65,532-byte points allocation. + const req = try testRepeatedPointRegisterReq(alloc, &.{ 0, 5460 }); + defer alloc.free(req.raw); + + var entry = try Entry.init(alloc, req); + defer entry.deinit(alloc); + try testing.expectEqual(@as(usize, 5461), entry.glyph.glyf.points.len); + try testing.expectEqual(@as(usize, 2), entry.glyph.glyf.contours.len); +} + test "Glossary register overwrites and moves entry to newest position" { const testing = std.testing; const alloc = testing.allocator; diff --git a/vendor/libghostty-vt/src/terminal/apc/glyph/request.zig b/vendor/libghostty-vt/src/terminal/apc/glyph/request.zig index a45feca..6b6d7cc 100644 --- a/vendor/libghostty-vt/src/terminal/apc/glyph/request.zig +++ b/vendor/libghostty-vt/src/terminal/apc/glyph/request.zig @@ -1,7 +1,9 @@ const std = @import("std"); const Allocator = std.mem.Allocator; const assert = std.debug.assert; +const LimitedAllocator = @import("../../../datastruct/main.zig").LimitedAllocator; const Glyf = @import("../../../font/opentype/glyf.zig").Glyf; +const fraction = @import("../../fraction.zig"); /// Maximum decoded glyph payload size accepted by the protocol. /// This is documented in the spec. @@ -271,7 +273,11 @@ pub const Request = union(enum) { }; /// Decode this request's base64 glyf payload into an owned outline. - pub fn decodeGlyfPayload(self: Register, alloc: Allocator) DecodeError!Glyf.Outline { + pub fn decodeGlyfPayload( + self: Register, + alloc: Allocator, + max_allocation_bytes: usize, + ) DecodeError!Glyf.Outline { // Prep base64 decoding, initial validation. const Decoder = std.base64.standard.Decoder; const payload_bytes = self.payload(); @@ -299,8 +305,16 @@ pub const Request = union(enum) { // decode call below. Glyf.Entry.decode returns an owned Outline, so // it is safe to free `data` before returning that outline. const glyf_entry = Glyf.Entry.init(data) catch return error.MalformedPayload; - return glyf_entry.decode(alloc) catch |err| switch (err) { - error.OutOfMemory => error.OutOfMemory, + + // The points slice is the dominant decoded allocation. Limiting + // every allocation made by the decoder prevents a compact record + // from expanding into an unbounded persistent outline. + var limited: LimitedAllocator = .init(alloc, max_allocation_bytes); + return glyf_entry.decode(limited.allocator()) catch |err| switch (err) { + error.OutOfMemory => if (limited.limit_exceeded) + error.PayloadTooLarge + else + error.OutOfMemory, // Unsupported fields error.CompositeNotSupported => error.CompositeUnsupported, error.InstructionsNotSupported => error.HintingUnsupported, @@ -563,9 +577,7 @@ pub const Pad = struct { /// Top/bottom fractions are relative to cell height; left/right fractions /// are relative to render span width. fn parseFraction(value: []const u8) ?f64 { - const result = std.fmt.parseFloat(f64, value) catch return null; - if (!(result >= 0 and result <= 1)) return null; - return result; + return fraction.parse(value); } }; @@ -790,7 +802,10 @@ test "register decodes glyf payload" { var cmd = try testParse(testing.allocator, "r;cp=e0a0;AAAAAAAAAAAAAA=="); defer cmd.deinit(testing.allocator); - var outline = try cmd.register.decodeGlyfPayload(testing.allocator); + var outline = try cmd.register.decodeGlyfPayload( + testing.allocator, + std.math.maxInt(usize), + ); defer outline.deinit(testing.allocator); try testing.expectEqual(@as(usize, 0), outline.points.len); @@ -803,7 +818,10 @@ test "register rejects malformed glyf payload" { var cmd = try testParse(testing.allocator, "r;cp=e0a0;%%%not-base64%%%"); defer cmd.deinit(testing.allocator); - try testing.expectError(error.MalformedPayload, cmd.register.decodeGlyfPayload(testing.allocator)); + try testing.expectError( + error.MalformedPayload, + cmd.register.decodeGlyfPayload(testing.allocator, std.math.maxInt(usize)), + ); } test "register response without payload" { diff --git a/vendor/libghostty-vt/src/terminal/bitmap_allocator.zig b/vendor/libghostty-vt/src/terminal/bitmap_allocator.zig index 787f0ac..a300cf2 100644 --- a/vendor/libghostty-vt/src/terminal/bitmap_allocator.zig +++ b/vendor/libghostty-vt/src/terminal/bitmap_allocator.zig @@ -44,6 +44,11 @@ pub fn BitmapAllocator(comptime chunk_size: comptime_int) type { bitmap: Offset(u64), bitmap_count: usize, + /// Lowest bitmap word index that may contain a free bit; words + /// below it are fully allocated, so alloc scans can start here + /// instead of at zero. + search_start: usize = 0, + /// The contiguous buffer of chunks. chunks: Offset(u8), @@ -95,9 +100,21 @@ pub fn BitmapAllocator(comptime chunk_size: comptime_int) type { return error.OutOfMemory; // Find the index of the free chunk. This also marks it as used. + // Words below search_start have no free bits, so no free span + // can start in or cross them and it is safe to skip them. const bitmaps = self.bitmap.ptr(base); - const idx = findFreeChunks(bitmaps[0..self.bitmap_count], chunk_count) orelse + const start = @min(self.search_start, self.bitmap_count); + const rel = findFreeChunks(bitmaps[start..self.bitmap_count], chunk_count) orelse return error.OutOfMemory; + const idx = start * bitmap_bit_size + rel; + + // Advance past any words the allocation just filled so the + // next scan starts at the first word with a free bit. + self.search_start = start: { + var new_start = start; + while (new_start < self.bitmap_count and bitmaps[new_start] == 0) new_start += 1; + break :start new_start; + }; const chunks = self.chunks.ptr(base); const ptr: [*]T = @ptrCast(@alignCast(&chunks[idx * chunk_size])); @@ -116,6 +133,12 @@ pub fn BitmapAllocator(comptime chunk_size: comptime_int) type { const chunks = self.chunks.ptr(base); const chunk_idx = @divExact(@intFromPtr(slice.ptr) - @intFromPtr(chunks), chunk_size); + // The freed word gains free bits, so scans must not skip it. + self.search_start = @min( + self.search_start, + @divFloor(chunk_idx, bitmap_bit_size), + ); + const bitmaps = self.bitmap.ptr(base); // Current bitmap index. @@ -659,6 +682,90 @@ test "BitmapAllocator alloc and free one bitmap" { ); } +test "BitmapAllocator search hint skips full words" { + const Alloc = BitmapAllocator(1); + // Capacity such that we'll have 3 bitmaps. + const cap = Alloc.bitmap_bit_size * 3; + + const testing = std.testing; + const alloc = testing.allocator; + const layout = Alloc.layout(cap); + const buf = try alloc.alignedAlloc(u8, Alloc.base_align, layout.total_size); + defer alloc.free(buf); + + var bm = Alloc.init(.init(buf), layout); + try testing.expectEqual(@as(usize, 0), bm.search_start); + + // Fill the first bitmap word exactly with single-chunk allocations. + var first: []u8 = undefined; + for (0..Alloc.bitmap_bit_size) |i| { + const slice = try bm.alloc(u8, buf, 1); + if (i == 0) first = slice; + } + + // The first word is exhausted so scans start at the second word. + try testing.expectEqual(@as(usize, 1), bm.search_start); + + // The next allocation is the first chunk of the second word, so + // the skipped-word index arithmetic must still yield the right + // chunk address. + const next = try bm.alloc(u8, buf, 1); + try testing.expectEqual( + @intFromPtr(first.ptr) + Alloc.bitmap_bit_size, + @intFromPtr(next.ptr), + ); + + // Freeing a chunk in the first word lowers the hint so the freed + // space is found again. + bm.free(buf, first); + try testing.expectEqual(@as(usize, 0), bm.search_start); + const again = try bm.alloc(u8, buf, 1); + try testing.expectEqual(@intFromPtr(first.ptr), @intFromPtr(again.ptr)); +} + +test "BitmapAllocator search hint does not skip partial words" { + const Alloc = BitmapAllocator(1); + // Capacity such that we'll have 2 bitmaps. + const cap = Alloc.bitmap_bit_size * 2; + + const testing = std.testing; + const alloc = testing.allocator; + const layout = Alloc.layout(cap); + const buf = try alloc.alignedAlloc(u8, Alloc.base_align, layout.total_size); + defer alloc.free(buf); + + var bm = Alloc.init(.init(buf), layout); + + // Allocate all but 4 chunks of the first word. + var first: []u8 = undefined; + for (0..Alloc.bitmap_bit_size - 4) |i| { + const slice = try bm.alloc(u8, buf, 1); + if (i == 0) first = slice; + } + + // An 8-chunk run can't fit the 4 remaining bits (small runs never + // span words), so it comes from the second word — but the hint + // must stay at the first word, which still has free bits. + const big = try bm.alloc(u8, buf, 8); + try testing.expectEqual( + @intFromPtr(first.ptr) + Alloc.bitmap_bit_size, + @intFromPtr(big.ptr), + ); + try testing.expectEqual(@as(usize, 0), bm.search_start); + + // A 4-chunk run fits the first word's remaining bits exactly; a + // hint that skipped the partial word would wrongly place this in + // the second word (or report OutOfMemory once that filled). + const small = try bm.alloc(u8, buf, 4); + try testing.expectEqual( + @intFromPtr(first.ptr) + Alloc.bitmap_bit_size - 4, + @intFromPtr(small.ptr), + ); + + // Now the first word is full and the hint advances past it. + try testing.expectEqual(@as(usize, 1), bm.search_start); +} + test "BitmapAllocator alloc and free half bitmap" { const Alloc = BitmapAllocator(1); // Capacity such that we'll have 3 bitmaps. diff --git a/vendor/libghostty-vt/src/terminal/build_options.zig b/vendor/libghostty-vt/src/terminal/build_options.zig index 136e0f1..f16cbf4 100644 --- a/vendor/libghostty-vt/src/terminal/build_options.zig +++ b/vendor/libghostty-vt/src/terminal/build_options.zig @@ -36,9 +36,246 @@ pub const Options = struct { /// Options. c_abi: bool, + /// Optional feature gates, all enabled by default. See Features. + features: Features = .{}, + /// The version of the application. version: std.SemanticVersion, + /// Optional features for lib artifacts. Disabling a feature removes + /// its C API exports and any stream-integrated handling from the + /// build, primarily so size-conscious embedders (e.g. wasm) can trim + /// the binary. The Ghostty application itself always builds with + /// every feature enabled. + /// + /// Field names are the accepted tokens for `-Dvt-features`, which + /// acts like `-Dcpu`: comma-separated modifications applied on top + /// of the default (all enabled) set. `+feature` and `feature` + /// enable, `-feature` disables. The special name `all` refers to + /// every feature, so `-Dvt-features=-all,+render-state` builds only + /// the render state API. Hyphens in names are interchangeable with + /// underscores. Fields are emitted verbatim as bool options on the + /// `terminal_options` module. + pub const Features = packed struct { + /// Terminal state serialization: encode a full terminal (screen + /// contents, scrollback, cursor, styles, modes, etc.) to a + /// compact binary format and decode it back into a live + /// terminal. Used for session persistence, window restoration, + /// and moving terminal state between processes. + /// + /// C API: `ghostty_snapshot_*` and, together with + /// `grid_introspection`, `ghostty_tracked_grid_ref_snapshot`. + snapshot: bool = true, + + /// Textual export of terminal contents as plain text, VT + /// (replayable escape sequences), or HTML, with optional extras + /// such as cursor position, palette, modes, and tabstops. Used + /// for copy-to-clipboard, dump-to-file, and debugging. + /// + /// C API: `ghostty_formatter_*` and, together with `selection`, + /// `ghostty_terminal_selection_format_*`. + formatter: bool = true, + + /// Text selection: select word/line/output/all, selection + /// ordering, adjustment, and containment math, and the + /// click/drag gesture state machine (single/double/triple + /// click behaviors). Selections are expressed in grid + /// references, so enabling this also keeps the untracked grid + /// reference constructors even if `grid_introspection` is + /// disabled. + /// + /// C API: `ghostty_terminal_select_*`, + /// `ghostty_terminal_selection_*`, `ghostty_selection_gesture_*`. + selection: bool = true, + + /// Terminal search: find matches for a string in the active + /// area and scrollback (ASCII case-insensitive), with results + /// that survive primary/alternate screen switches, resize, and + /// scrollback pruning, plus match selection with wrap-around + /// and viewport scrolling. Used to implement find bars. + /// + /// C API: `ghostty_search_*`. + search: bool = true, + + /// The render state API: a coherent, update-in-place view of + /// the visible screen (rows, cells, styles, cursor, colors, + /// palette) designed to drive a renderer at frame rates. This + /// is the primary read path for embedders that draw the + /// terminal. + /// + /// C API: `ghostty_render_state_*`. + render_state: bool = true, + + /// Encoding of host input events into the byte sequences a + /// terminal application expects: keyboard input (including the + /// Kitty keyboard protocol), mouse reporting, focus reporting, + /// and (bracketed) paste. Any interactive embedder needs this; + /// a read-only viewer does not. + /// + /// C API: `ghostty_key_event_*`, `ghostty_key_encoder_*`, + /// `ghostty_mouse_event_*`, `ghostty_mouse_encoder_*`, + /// `ghostty_focus_encode`, `ghostty_paste_*`. + input_encode: bool = true, + + /// Color utilities: luminance, perceived luminance, and + /// contrast math (e.g. for minimum-contrast rendering), color + /// string parsing (`#rrggbb`, X11 color names, XParseColor + /// `rgb:`/`rgbi:` device specs), the default 256-color palette + /// and palette generation from configurable bases, and the X11 + /// color name table itself. + /// + /// C API: `ghostty_color_*`. + color: bool = true, + + /// Direct inspection of grid contents at a position via grid + /// references: cell and row data getters (codepoints, + /// graphemes, style, hyperlink URI, wrap state, ...) plus + /// tracked grid references that stay valid across terminal + /// updates. Used for hyperlink hover/open, accessibility, and + /// tests; deliberately not built for render loops (use + /// `render_state` for that). + /// + /// C API: `ghostty_grid_ref_*`, `ghostty_tracked_grid_ref_*`, + /// `ghostty_cell_get*`, `ghostty_row_get*`, and (shared with + /// `selection`) `ghostty_terminal_grid_ref` / + /// `ghostty_terminal_point_from_grid_ref`. + grid_introspection: bool = true, + + /// The APC glyph protocol: a Ghostty extension that lets + /// terminal applications register custom font glyphs (glyf + /// outlines) for private-use-area codepoints. Embedders that + /// don't render registered glyphs can disable this; the + /// sequences are still consumed and safely ignored. + /// + /// No C API surface of its own; this gates the stream handling + /// and glossary storage. + glyph_protocol: bool = true, + + /// The Kitty graphics protocol: APC command parsing, image + /// transmission and storage (PNG decoding via the sys + /// interface), and placement tracking, plus the read APIs a + /// renderer uses to draw placed images. Disabled sequences are + /// still consumed and safely ignored. + /// + /// This requires the ability to get timestamps from the OS, so + /// it is always disabled on freestanding targets (e.g. + /// wasm32-freestanding) regardless of this setting. + /// + /// C API: `ghostty_kitty_graphics_*`. + kitty_graphics: bool = true, + + pub fn parse(list: []const u8) error{UnknownFeature}!Features { + // Modifications apply on top of the default set. + var result: Features = .{}; + + var it = std.mem.splitAny(u8, list, ", "); + while (it.next()) |raw| { + if (raw.len == 0) continue; + + var name = raw; + var enable = true; + switch (name[0]) { + '+' => name = name[1..], + '-' => { + enable = false; + name = name[1..]; + }, + else => {}, + } + + // A dangling sign with no name is malformed. + if (name.len == 0) return error.UnknownFeature; + + // The special name "all" refers to every feature. + if (std.mem.eql(u8, name, "all")) { + inline for (@typeInfo(Features).@"struct".fields) |field| { + @field(result, field.name) = enable; + } + continue; + } + + var found = false; + inline for (@typeInfo(Features).@"struct".fields) |field| { + if (eqlName(name, field.name)) { + @field(result, field.name) = enable; + found = true; + } + } + if (!found) return error.UnknownFeature; + } + + return result; + } + + /// Feature name equality where '-' in the input matches '_' in + /// the field name. + fn eqlName(input: []const u8, name: []const u8) bool { + if (input.len != name.len) return false; + for (input, name) |a, b| { + const norm: u8 = if (a == '-') '_' else a; + if (norm != b) return false; + } + return true; + } + + test "parse" { + const testing = std.testing; + + const none: Features = @bitCast( + @as(@typeInfo(Features).@"struct".backing_integer.?, 0), + ); + + // Empty input keeps the defaults. + try testing.expectEqual(Features{}, try Features.parse("")); + + // All three token forms, comma separated. + try testing.expectEqual( + Features{ .snapshot = false }, + try Features.parse("-snapshot"), + ); + try testing.expectEqual( + Features{ .snapshot = false, .formatter = false }, + try Features.parse("-snapshot,+render_state,-formatter,color"), + ); + + // The special "all" name refers to every feature. + try testing.expectEqual(none, try Features.parse("-all")); + try testing.expectEqual(Features{}, try Features.parse("-all,+all")); + var render_only = none; + render_only.render_state = true; + try testing.expectEqual( + render_only, + try Features.parse("-all,+render-state"), + ); + + // Hyphens are interchangeable with underscores. + try testing.expectEqual( + Features{ .input_encode = false, .grid_introspection = false }, + try Features.parse("-input-encode,-grid_introspection"), + ); + + // Later tokens win. + try testing.expectEqual( + Features{}, + try Features.parse("-snapshot,+snapshot"), + ); + + // Unknown names and dangling signs are errors. + try testing.expectError(error.UnknownFeature, Features.parse("bogus")); + try testing.expectError(error.UnknownFeature, Features.parse("-snapshot,-")); + } + }; + + /// Whether the Kitty graphics feature is effectively enabled for + /// the given target. Kitty graphics requires the ability to get + /// timestamps and there is no way to do that on freestanding + /// targets, so it is always disabled there regardless of the + /// feature setting. + pub fn kittyGraphics(self: Options, target: std.Target) bool { + if (target.os.tag == .freestanding) return false; + return self.features.kitty_graphics; + } + /// Add the required build options for the terminal module. /// /// The memory referenced by self is expected to stick around (it isn't @@ -55,25 +292,23 @@ pub const Options = struct { opts.addOption(bool, "simd", self.simd); opts.addOption(bool, "slow_runtime_safety", self.slow_runtime_safety); - // Kitty graphics is almost always true. This used to be conditional on - // some other factors but we've since generalized the implementation - // to support optional PNG decoding, OS capabilities like filesystems, - // etc. So its safe to always enable it and just have the - // implementation deal with unsupported features as needed. - // - // We disable it on wasm32-freestanding because we at the least - // require the ability to get timestamps and there is no way to - // do that with freestanding targets. - const target = m.resolved_target.?.result; - opts.addOption( - bool, - "kitty_graphics", - !(target.cpu.arch == .wasm32 and target.os.tag == .freestanding), - ); - // These are synthesized based on other options. opts.addOption(bool, "tmux_control_mode", self.oniguruma); + // Feature gates, emitted as flat bools (e.g. `options.snapshot`). + const target = m.resolved_target.?.result; + inline for (@typeInfo(Features).@"struct".fields) |field| { + var value = @field(self.features, field.name); + + // Kitty graphics is force-disabled on some targets; see + // kittyGraphics for details. + if (comptime std.mem.eql(u8, field.name, "kitty_graphics")) { + value = self.kittyGraphics(target); + } + + opts.addOption(bool, field.name, value); + } + // Version information. opts.addOption( []const u8, @@ -92,3 +327,7 @@ pub const Options = struct { m.addOptions("terminal_options", opts); } }; + +test { + _ = Options.Features; +} diff --git a/vendor/libghostty-vt/src/terminal/c/cell.zig b/vendor/libghostty-vt/src/terminal/c/cell.zig index cb76cb2..8444cfd 100644 --- a/vendor/libghostty-vt/src/terminal/c/cell.zig +++ b/vendor/libghostty-vt/src/terminal/c/cell.zig @@ -10,6 +10,12 @@ const Result = @import("result.zig").Result; /// C: GhosttyCell pub const CCell = u64; +/// C: GhosttyCellsView +pub const CellsView = extern struct { + ptr: ?[*]const CCell, + len: usize, +}; + /// C: GhosttyCellContentTag pub const ContentTag = enum(c_int) { codepoint = 0, diff --git a/vendor/libghostty-vt/src/terminal/c/formatter.zig b/vendor/libghostty-vt/src/terminal/c/formatter.zig index 9b3c143..aad33a1 100644 --- a/vendor/libghostty-vt/src/terminal/c/formatter.zig +++ b/vendor/libghostty-vt/src/terminal/c/formatter.zig @@ -2,6 +2,7 @@ const std = @import("std"); const testing = std.testing; const lib = @import("../lib.zig"); const CAllocator = lib.alloc.Allocator; +const io_c = @import("io.zig"); const terminal_c = @import("terminal.zig"); const grid_ref = @import("grid_ref.zig"); const selection_c = @import("selection.zig"); @@ -160,6 +161,32 @@ fn terminal_new_( return ptr; } +/// Format directly to a synchronous C writer callback. +pub fn format( + formatter_: Formatter, + writer: io_c.Writer, +) callconv(lib.calling_conv) Result { + const wrapper = formatter_ orelse return .invalid_value; + if (!writer.valid()) return .invalid_value; + + // Batch the formatter's many small writes into few callback calls. The + // trailing flush upholds the API contract that success means the + // callback has accepted every byte. + var buffer: [io_c.WriterAdapter.recommended_buffer_len]u8 = undefined; + var adapter: io_c.WriterAdapter = .initBuffered(writer, &buffer); + format_: { + switch (wrapper.kind) { + .terminal => |*t| t.format(adapter.writer()) catch break :format_, + } + adapter.writer().flush() catch break :format_; + return .success; + } + + if (adapter.invalid_write) return .limit_exceeded; + if (adapter.callback_failed) return .io_error; + return .io_error; +} + pub fn format_buf( formatter_: Formatter, out_: ?[*]u8, @@ -281,6 +308,96 @@ test "format plain" { try testing.expectEqualStrings("Hello", buf[0..written]); } +test "format streams to writer" { + var t: terminal_c.Terminal = null; + try testing.expectEqual(Result.success, terminal_c.new( + &lib.alloc.test_allocator, + &t, + 80, + 24, + )); + defer terminal_c.free(t); + + terminal_c.vt_write(t, "Hello\r\nWorld", 12); + + var f: Formatter = null; + try testing.expectEqual(Result.success, terminal_new( + &lib.alloc.test_allocator, + &f, + t, + .{ .emit = .plain, .unwrap = false, .trim = true, .extra = .{ .palette = false, .modes = false, .scrolling_region = false, .tabstops = false, .pwd = false, .keyboard = false, .screen = .{ .cursor = false, .style = false, .hyperlink = false, .protection = false, .kitty_keyboard = false, .charsets = false } } }, + )); + defer free(f); + + const Context = struct { + destination: []u8, + offset: usize = 0, + calls: usize = 0, + + fn write( + userdata: ?*anyopaque, + data: [*]const u8, + len: usize, + ) callconv(lib.calling_conv) bool { + const self: *@This() = @ptrCast(@alignCast(userdata.?)); + if (len > self.destination.len - self.offset) return false; + @memcpy(self.destination[self.offset..][0..len], data[0..len]); + self.offset += len; + self.calls += 1; + return true; + } + }; + + var buf: [1024]u8 = undefined; + var context: Context = .{ .destination = &buf }; + try testing.expectEqual(Result.success, format(f, .{ + .write = &Context.write, + .userdata = &context, + })); + try testing.expect(context.calls > 0); + try testing.expectEqualStrings("Hello\nWorld", buf[0..context.offset]); +} + +test "format writer errors" { + const FailWriter = struct { + fn write( + _: ?*anyopaque, + _: [*]const u8, + _: usize, + ) callconv(lib.calling_conv) bool { + return false; + } + }; + + try testing.expectEqual(Result.invalid_value, format(null, .{ + .write = &FailWriter.write, + })); + + var t: terminal_c.Terminal = null; + try testing.expectEqual(Result.success, terminal_c.new( + &lib.alloc.test_allocator, + &t, + 80, + 24, + )); + defer terminal_c.free(t); + terminal_c.vt_write(t, "Hello", 5); + + var f: Formatter = null; + try testing.expectEqual(Result.success, terminal_new( + &lib.alloc.test_allocator, + &f, + t, + .{ .emit = .plain, .unwrap = false, .trim = true, .extra = .{ .palette = false, .modes = false, .scrolling_region = false, .tabstops = false, .pwd = false, .keyboard = false, .screen = .{ .cursor = false, .style = false, .hyperlink = false, .protection = false, .kitty_keyboard = false, .charsets = false } } }, + )); + defer free(f); + + try testing.expectEqual(Result.invalid_value, format(f, .{})); + try testing.expectEqual(Result.io_error, format(f, .{ + .write = &FailWriter.write, + })); +} + test "format reflects terminal changes" { var t: terminal_c.Terminal = null; try testing.expectEqual(Result.success, terminal_c.new( diff --git a/vendor/libghostty-vt/src/terminal/c/io.zig b/vendor/libghostty-vt/src/terminal/c/io.zig index 014935d..76e7451 100644 --- a/vendor/libghostty-vt/src/terminal/c/io.zig +++ b/vendor/libghostty-vt/src/terminal/c/io.zig @@ -44,6 +44,23 @@ pub const Writer = extern struct { } }; +/// C: GhosttyMimeReaderFn +pub const MimeReaderFn = *const fn ( + userdata: ?*anyopaque, + mime: lib.String, + writer: Writer, +) callconv(lib.calling_conv) bool; + +/// C: GhosttyMimeReader +pub const MimeReader = extern struct { + read: ?MimeReaderFn = null, + userdata: ?*anyopaque = null, + + pub fn valid(self: MimeReader) bool { + return self.read != null; + } +}; + /// Adapts a `GhosttyReader` to `std.Io.Reader`. /// /// The adapter must have a stable address while `interface` is in use. Its @@ -228,10 +245,14 @@ pub const ReaderAdapter = struct { } }; -/// Adapts a `GhosttyWriter` to an unbuffered `std.Io.Writer`. +/// Adapts a `GhosttyWriter` to a `std.Io.Writer`. /// -/// Keeping the Zig writer unbuffered ensures success means the C callback has -/// already accepted every byte; there is no hidden flush step at API return. +/// The adapter may be given a buffer via `initBuffered` so that the many +/// small writes typical of streaming producers (e.g. the formatter) are +/// batched into far fewer C callback invocations. A buffered adapter's owner +/// must call `flush` on the interface before reporting success so that the +/// public contract holds: success means the C callback has already accepted +/// every byte. pub const WriterAdapter = struct { /// Copy of the C callback pair. The pointed-to userdata remains borrowed. destination: Writer, @@ -239,7 +260,8 @@ pub const WriterAdapter = struct { /// Zig-facing interface whose drain vtable points back to this adapter. interface: std.Io.Writer, - /// Bytes accepted by successful callback invocations. + /// Bytes accepted by successful callback invocations. Buffered bytes are + /// counted only once a drain or flush hands them to the callback. offset: usize = 0, /// The callback returned false. @@ -248,13 +270,27 @@ pub const WriterAdapter = struct { /// The callback was NULL or offset accounting overflowed. invalid_write: bool = false, + /// The buffer length used by the C API entry points that stream through + /// a callback. 4 KiB amortizes callback overhead while keeping every + /// adapter's stack footprint modest, mirroring ReaderAdapter's + /// transfer_buffer sizing. + pub const recommended_buffer_len = 4096; + + /// An unbuffered adapter: every Zig write reaches the callback + /// immediately and no flush is required before returning. pub fn init(destination: Writer) WriterAdapter { + return initBuffered(destination, &.{}); + } + + /// A buffered adapter. The buffer must outlive the adapter and remain + /// at a stable address (it is referenced, not copied). The owner must + /// flush the interface before treating the operation as successful. + pub fn initBuffered(destination: Writer, buffer: []u8) WriterAdapter { return .{ .destination = destination, .interface = .{ - // With no buffer, every Zig write reaches `drain` immediately. .vtable = &.{ .drain = drain }, - .buffer = &.{}, + .buffer = buffer, .end = 0, }, }; @@ -293,19 +329,49 @@ pub const WriterAdapter = struct { }; } - /// Implement the sole primitive required by an unbuffered std.Io.Writer. + /// Write one slice, batching through the interface buffer when one is + /// attached. Slices that fit are parked in the buffer (a later drain or + /// flush hands them to the callback); anything larger goes to the + /// callback directly after the buffer is emptied to preserve ordering. + fn writeSlice( + self: *WriterAdapter, + writer_: *std.Io.Writer, + slice: []const u8, + ) std.Io.Writer.Error!void { + const buffer = writer_.buffer; + if (slice.len <= buffer.len - writer_.end) { + @memcpy(buffer[writer_.end..][0..slice.len], slice); + writer_.end += slice.len; + return; + } + + if (writer_.end > 0) { + try self.writeAll(buffer[0..writer_.end]); + writer_.end = 0; + } + + if (slice.len <= buffer.len) { + @memcpy(buffer[0..slice.len], slice); + writer_.end = slice.len; + } else { + try self.writeAll(slice); + } + } + + /// Implement the sole primitive required by a std.Io.Writer. /// /// Zig represents a vector write as ordinary slices followed by the last - /// slice repeated `splat` times. The C callback has no vector form, so this - /// method expands that representation into ordered all-or-nothing calls - /// and returns the total logical byte count consumed. + /// slice repeated `splat` times. The C callback has no vector form, so + /// this method expands that representation, batching through the + /// interface buffer when one is attached, and returns the total logical + /// byte count consumed from `data`. Bytes parked in the buffer count as + /// consumed; `std.Io.Writer.flush` drains them via this same method. fn drain( writer_: *std.Io.Writer, data: []const []const u8, splat: usize, ) std.Io.Writer.Error!usize { assert(data.len > 0); // The final element is always the splat pattern. - assert(writer_.end == 0); // This adapter intentionally has no buffer. // The vtable receives the embedded Writer rather than our adapter. const self: *WriterAdapter = @alignCast(@fieldParentPtr( @@ -313,10 +379,16 @@ pub const WriterAdapter = struct { writer_, )); + // Buffered bytes must reach the destination before any of `data`. + if (writer_.end > 0) { + try self.writeAll(writer_.buffer[0..writer_.end]); + writer_.end = 0; + } + var consumed: usize = 0; // Every element except the last is written exactly once. for (data[0 .. data.len - 1]) |slice| { - try self.writeAll(slice); + try self.writeSlice(writer_, slice); consumed = std.math.add(usize, consumed, slice.len) catch { self.invalid_write = true; return error.WriteFailed; @@ -326,8 +398,28 @@ pub const WriterAdapter = struct { // std.Io uses the final element as a compact repeated pattern. A // splat of zero means the final element is not part of this write. const pattern = data[data.len - 1]; - for (0..splat) |_| { - try self.writeAll(pattern); + if (pattern.len == 1 and writer_.buffer.len > 0) { + // Single-byte splats (e.g. runs of blank cells) are memset + // into the buffer in bulk rather than repeated one write at + // a time. + const buffer = writer_.buffer; + var remaining = splat; + while (remaining > 0) { + if (writer_.end == buffer.len) { + try self.writeAll(buffer); + writer_.end = 0; + } + const n = @min(remaining, buffer.len - writer_.end); + @memset(buffer[writer_.end..][0..n], pattern[0]); + writer_.end += n; + remaining -= n; + } + consumed = std.math.add(usize, consumed, splat) catch { + self.invalid_write = true; + return error.WriteFailed; + }; + } else for (0..splat) |_| { + try self.writeSlice(writer_, pattern); consumed = std.math.add(usize, consumed, pattern.len) catch { self.invalid_write = true; return error.WriteFailed; @@ -343,6 +435,8 @@ test "C reader and writer layouts keep callback first" { try std.testing.expectEqual(@sizeOf(?ReaderFn) + @sizeOf(?*anyopaque), @sizeOf(Reader)); try std.testing.expectEqual(@as(usize, 0), @offsetOf(Writer, "write")); + try std.testing.expectEqual(@as(usize, 0), @offsetOf(MimeReader, "read")); + try std.testing.expectEqual(@sizeOf(?MimeReaderFn), @offsetOf(MimeReader, "userdata")); try std.testing.expectEqual(@sizeOf(?WriterFn), @offsetOf(Writer, "userdata")); try std.testing.expectEqual(@sizeOf(?WriterFn) + @sizeOf(?*anyopaque), @sizeOf(Writer)); } @@ -502,6 +596,92 @@ test "WriterAdapter writes vectors and splats" { try std.testing.expect(!adapter.callback_failed); } +test "WriterAdapter buffered batches small writes" { + const Context = struct { + data: [256]u8 = undefined, + len: usize = 0, + calls: usize = 0, + + fn write( + userdata: ?*anyopaque, + data: [*]const u8, + len: usize, + ) callconv(lib.calling_conv) bool { + const self: *@This() = @ptrCast(@alignCast(userdata.?)); + @memcpy(self.data[self.len..][0..len], data[0..len]); + self.len += len; + self.calls += 1; + return true; + } + }; + + var context: Context = .{}; + var buffer: [64]u8 = undefined; + var adapter: WriterAdapter = .initBuffered(.{ + .write = &Context.write, + .userdata = &context, + }, &buffer); + + // Many small writes fit in the buffer: no callback yet. + for (0..16) |_| try adapter.interface.writeAll("ab"); + try std.testing.expectEqual(@as(usize, 0), context.calls); + + // Flush delivers everything in a single call. + try adapter.interface.flush(); + try std.testing.expectEqual(@as(usize, 1), context.calls); + try std.testing.expectEqualStrings( + "ab" ** 16, + context.data[0..context.len], + ); + try std.testing.expectEqual(@as(usize, 32), adapter.offset); +} + +test "WriterAdapter buffered splats and large writes" { + const Context = struct { + data: [256]u8 = undefined, + len: usize = 0, + calls: usize = 0, + + fn write( + userdata: ?*anyopaque, + data: [*]const u8, + len: usize, + ) callconv(lib.calling_conv) bool { + const self: *@This() = @ptrCast(@alignCast(userdata.?)); + @memcpy(self.data[self.len..][0..len], data[0..len]); + self.len += len; + self.calls += 1; + return true; + } + }; + + var context: Context = .{}; + var buffer: [16]u8 = undefined; + var adapter: WriterAdapter = .initBuffered(.{ + .write = &Context.write, + .userdata = &context, + }, &buffer); + + // A splat larger than the buffer is chunked, not one call per byte. + try adapter.interface.splatByteAll(' ', 40); + // A write larger than the buffer flushes and goes to the callback + // directly. + try adapter.interface.writeAll("0123456789abcdef0"); // 17 > 16 + try adapter.interface.writeAll("xy"); + try adapter.interface.flush(); + + try std.testing.expectEqualStrings( + " " ** 40 ++ "0123456789abcdef0" ++ "xy", + context.data[0..context.len], + ); + try std.testing.expectEqual( + @as(usize, context.len), + adapter.offset, + ); + // Chunked delivery: far fewer calls than bytes. + try std.testing.expect(context.calls <= 6); +} + test "WriterAdapter makes callback failure sticky" { const Context = struct { calls: usize = 0, diff --git a/vendor/libghostty-vt/src/terminal/c/kitty_graphics.zig b/vendor/libghostty-vt/src/terminal/c/kitty_graphics.zig index 396b463..2bd5e86 100644 --- a/vendor/libghostty-vt/src/terminal/c/kitty_graphics.zig +++ b/vendor/libghostty-vt/src/terminal/c/kitty_graphics.zig @@ -10,6 +10,7 @@ const grid_ref = @import("grid_ref.zig"); const selection_c = @import("selection.zig"); const terminal_c = @import("terminal.zig"); const Terminal = @import("../Terminal.zig"); +const PageList = @import("../PageList.zig"); const Result = @import("result.zig").Result; /// C: GhosttyKittyGraphics @@ -261,8 +262,11 @@ fn imageGetTyped( .height => out.* = image.height, .format => out.* = image.format, .compression => out.* = image.compression, - .data_ptr => out.* = (image.data.bytes() orelse return .no_value).ptr, - .data_len => out.* = image.data.len(), + // For animated images this is the current animation frame's + // data; the image generation changes whenever the current + // frame does, so generation-keyed caches stay coherent. + .data_ptr => out.* = (image.renderData().bytes() orelse return .no_value).ptr, + .data_len => out.* = image.renderData().len(), .generation => out.* = image.generation, } @@ -516,16 +520,12 @@ pub fn placement_source_rect( const entry = iter.entry orelse return .invalid_value; const p = entry.value_ptr; - // Apply "0 = full image dimension" convention, then clamp to image bounds. - const x = @min(p.source_x, image.width); - const y = @min(p.source_y, image.height); - const w = @min(if (p.source_width > 0) p.source_width else image.width, image.width - x); - const h = @min(if (p.source_height > 0) p.source_height else image.height, image.height - y); + const source = p.sourceRect(image.*); - out_x.* = x; - out_y.* = y; - out_width.* = w; - out_height.* = h; + out_x.* = source.x; + out_y.* = source.y; + out_width.* = source.width; + out_height.* = source.height; return .success; } @@ -576,12 +576,11 @@ pub fn placement_render_info( out.viewport_row = vp.row; out.viewport_visible = vp.visible; - const x = @min(p.source_x, image.width); - const y = @min(p.source_y, image.height); - out.source_x = x; - out.source_y = y; - out.source_width = @min(if (p.source_width > 0) p.source_width else image.width, image.width - x); - out.source_height = @min(if (p.source_height > 0) p.source_height else image.height, image.height - y); + const source = p.sourceRect(image.*); + out.source_x = source.x; + out.source_y = source.y; + out.source_width = source.width; + out.source_height = source.height; return .success; } @@ -605,10 +604,35 @@ fn computeViewportPos( ) struct { col: i32, row: i32, visible: bool } { // Virtual placements use unicode placeholders and don't have a // screen position — they are rendered inline by the text layout. - const pin = switch (p.location) { - .pin => |pin| pin, + // Relative placements are anchored at the root of their parent + // chain, offset by the accumulated chain offsets. A chain rooted + // at a virtual placement has no resolvable position here: its + // origin is the parent's placeholder cells, which only a renderer + // scanning the screen can locate. + const origin: struct { + pin: *const PageList.Pin, + col_offset: i32 = 0, + row_offset: i32 = 0, + } = switch (p.location) { + .pin => |pin| .{ .pin = pin }, .virtual => return .{ .col = 0, .row = 0, .visible = false }, + .relative => |rel| origin: { + const storage = &t.screens.active.kitty_images; + const chain = storage.resolveChain(rel) orelse + return .{ .col = 0, .row = 0, .visible = false }; + switch (chain.root.location) { + .pin => |root_pin| break :origin .{ + .pin = root_pin, + .col_offset = chain.horizontal_offset, + .row_offset = chain.vertical_offset, + }, + .virtual => return .{ .col = 0, .row = 0, .visible = false }, + .relative => unreachable, // resolveChain roots are never relative + } + }, }; + + const pin = origin.pin; if (pin.garbage) return .{ .col = 0, .row = 0, .visible = false }; // Convert both the placement's pin and the viewport's top-left @@ -623,22 +647,30 @@ fn computeViewportPos( // Subtracting viewport origin from the pin gives us viewport- // relative coordinates. The row can be negative when the - // placement has partially scrolled above the viewport. - const vp_row: i32 = @as(i32, @intCast(pin_screen.screen.y)) - - @as(i32, @intCast(vp_screen.screen.y)); - const vp_col: i32 = @intCast(pin_screen.screen.x); + // placement has partially scrolled above the viewport, and both + // can be negative for relative placements with negative offsets. + const vp_row: i32 = (@as(i32, @intCast(pin_screen.screen.y)) - + @as(i32, @intCast(vp_screen.screen.y))) +| origin.row_offset; + const vp_col: i32 = @as(i32, @intCast(pin_screen.screen.x)) +| + origin.col_offset; - // A placement is invisible if its bottom edge (row + height) - // is above the viewport, or its top edge is at or below the - // viewport's last row. + // A placement is invisible if its bottom edge (row + height) is + // above the viewport, or its top edge is at or below the viewport's + // last row. The same applies horizontally: a pin's column is always + // in bounds, but a relative placement's offsets can push it fully + // off either side. const grid_size = p.gridSize(image.*, t); const bottom_row = @as(i64, vp_row) + @as(i64, grid_size.rows); - const visible = bottom_row > 0 and vp_row < @as(i32, t.rows); + const right_col = @as(i64, vp_col) + @as(i64, grid_size.cols); + const visible = bottom_row > 0 and vp_row < @as(i32, t.rows) and + right_col > 0 and vp_col < @as(i32, t.cols); return .{ .col = vp_col, .row = vp_row, .visible = visible }; } test "placement_iterator new/free" { + if (comptime !build_options.kitty_graphics) return error.SkipZigTest; + var iter: PlacementIterator = null; try testing.expectEqual(Result.success, placement_iterator_new( &lib.alloc.test_allocator, @@ -1565,6 +1597,15 @@ test "placement_source_rect clamps to image bounds" { try testing.expectEqual(3, y); try testing.expectEqual(1, w); try testing.expectEqual(1, h); + + var pixel_width: u32 = undefined; + var pixel_height: u32 = undefined; + try testing.expectEqual( + Result.success, + placement_pixel_size(iter, img, t, &pixel_width, &pixel_height), + ); + try testing.expectEqual(1, pixel_width); + try testing.expectEqual(1, pixel_height); } test "placement_source_rect null args return invalid_value" { @@ -1626,7 +1667,7 @@ test "placement_render_info returns all fields" { const entry = iter.?.entry.?; const pin = switch (entry.value_ptr.location) { .pin => |pin| pin, - .virtual => unreachable, + .virtual, .relative => unreachable, }; pin.garbage = true; diff --git a/vendor/libghostty-vt/src/terminal/c/main.zig b/vendor/libghostty-vt/src/terminal/c/main.zig index 3615984..45b4dc4 100644 --- a/vendor/libghostty-vt/src/terminal/c/main.zig +++ b/vendor/libghostty-vt/src/terminal/c/main.zig @@ -40,6 +40,7 @@ pub const mouse_event = @import("mouse_event.zig"); pub const mouse_encode = @import("mouse_encode.zig"); pub const paste = @import("paste.zig"); pub const row = @import("row.zig"); +pub const search = @import("search.zig"); pub const sgr = @import("sgr.zig"); pub const size_report = @import("size_report.zig"); pub const snapshot = @import("snapshot.zig"); @@ -78,6 +79,7 @@ pub const focus_encode = focus.encode; pub const mode_report_encode = modes.report_encode; pub const formatter_terminal_new = formatter.terminal_new; +pub const formatter_format = formatter.format; pub const formatter_format_buf = formatter.format_buf; pub const formatter_format_alloc = formatter.format_alloc; pub const formatter_free = formatter.free; @@ -87,12 +89,13 @@ pub const render_state_free = render.free; pub const render_state_update = render.update; pub const render_state_begin_update = render.begin_update; pub const render_state_end_update = render.end_update; +pub const render_state_clean = render.clean; pub const render_state_get = render.get; pub const render_state_get_multi = render.get_multi; pub const render_state_set = render.set; -pub const render_state_colors_get = render.colors_get; pub const render_state_row_iterator_new = render.row_iterator_new; pub const render_state_row_iterator_next = render.row_iterator_next; +pub const render_state_row_iterator_next_dirty = render.row_iterator_next_dirty; pub const render_state_row_get = render.row_get; pub const render_state_row_get_multi = render.row_get_multi; pub const render_state_row_set = render.row_set; @@ -113,8 +116,6 @@ pub const sgr_unknown_full = sgr.unknown_full; pub const sgr_unknown_partial = sgr.unknown_partial; pub const sgr_attribute_tag = sgr.attribute_tag; pub const sgr_attribute_value = sgr.attribute_value; -pub const wasm_alloc_sgr_attribute = sgr.wasm_alloc_attribute; -pub const wasm_free_sgr_attribute = sgr.wasm_free_attribute; pub const key_event_new = key_event.new; pub const key_event_free = key_event.free; @@ -160,6 +161,7 @@ pub const mouse_encoder_encode = mouse_encode.encode; pub const paste_is_safe = paste.is_safe; pub const paste_encode = paste.encode; +pub const terminal_paste = paste.terminal_paste; pub const alloc_alloc = allocator.alloc; pub const alloc_free = allocator.free; @@ -214,6 +216,14 @@ pub const selection_gesture_get_multi = selection_gesture.get_multi; pub const selection_gesture_event_new = selection_gesture.event_new; pub const selection_gesture_event_free = selection_gesture.event_free; pub const selection_gesture_event_set = selection_gesture.event_set; +pub const search_new = search.new; +pub const search_free = search.free; +pub const search_tick = search.tick; +pub const search_feed = search.feed; +pub const search_run = search.run; +pub const search_set = search.set; +pub const search_get = search.get; +pub const search_get_multi = search.get_multi; pub const terminal_grid_ref = terminal.grid_ref; pub const terminal_grid_ref_track = terminal.grid_ref_track; pub const terminal_point_from_grid_ref = terminal.point_from_grid_ref; @@ -270,6 +280,7 @@ test { _ = mouse_event; _ = mouse_encode; _ = paste; + _ = search; _ = sgr; _ = size_report; _ = snapshot; diff --git a/vendor/libghostty-vt/src/terminal/c/osc.zig b/vendor/libghostty-vt/src/terminal/c/osc.zig index 8eca343..2ecc9c9 100644 --- a/vendor/libghostty-vt/src/terminal/c/osc.zig +++ b/vendor/libghostty-vt/src/terminal/c/osc.zig @@ -12,6 +12,9 @@ pub const Parser = ?*osc.Parser; /// C: GhosttyOscCommand pub const Command = ?*osc.Command; +/// C: GhosttyOscCommandType +pub const CommandType = osc.Command.Key; + pub fn new( alloc_: ?*const CAllocator, result: *Parser, @@ -44,7 +47,7 @@ pub fn end(parser_: Parser, terminator: u8) callconv(lib.calling_conv) Command { return parser_.?.end(terminator); } -pub fn commandType(command_: Command) callconv(lib.calling_conv) osc.Command.Key { +pub fn commandType(command_: Command) callconv(lib.calling_conv) CommandType { const command = command_ orelse return .invalid; return command.*; } diff --git a/vendor/libghostty-vt/src/terminal/c/paste.zig b/vendor/libghostty-vt/src/terminal/c/paste.zig index bce6a56..8dfa649 100644 --- a/vendor/libghostty-vt/src/terminal/c/paste.zig +++ b/vendor/libghostty-vt/src/terminal/c/paste.zig @@ -1,8 +1,130 @@ const std = @import("std"); const lib = @import("../lib.zig"); const paste = @import("../../input/paste.zig"); +const terminal_paste_pkg = @import("../paste.zig"); +const clipboard = @import("../clipboard.zig"); +const io = @import("io.zig"); +const terminal_c = @import("terminal.zig"); +const Terminal = terminal_c.Terminal; +const ClipboardContent = terminal_c.ClipboardContent; +const ClipboardRead = terminal_c.ClipboardRead; +const ClipboardReadReply = terminal_c.ClipboardReadReply; const Result = @import("result.zig").Result; +/// Why a paste happened. The flat C form of the Zig tagged union +/// (terminal.paste.Source); the location rides alongside in +/// GhosttyPaste and only applies to a clipboard paste. +/// +/// C: GhosttyPasteSource +pub const Source = lib.Enum(lib.target, &.{ + "clipboard", + "text", +}); + +/// A paste of clipboard contents into the terminal. Sized struct. +/// +/// C: GhosttyPaste +pub const Request = extern struct { + size: usize = @sizeOf(Request), + location: clipboard.Location, + source: Source, + mimes: ?[*]const lib.String, + mimes_len: usize, + reader: io.MimeReader, + allow_unsafe: bool, +}; + +pub fn terminal_paste( + terminal_: Terminal, + req_: ?*const Request, + out_written: ?*bool, +) callconv(lib.calling_conv) Result { + const wrapper = terminal_ orelse return .invalid_value; + const req = req_ orelse return .invalid_value; + + // Every field is required; a smaller size is a caller from a + // different ABI version than any this struct has had. + if (req.size < @sizeOf(Request)) return .invalid_value; + + // The handler always has a write_pty trampoline that no-ops without + // a C callback, so the "nothing can be written" check is ours. + if (wrapper.effects.write_pty == null) return .invalid_value; + + const c_mimes: []const lib.String = if (req.mimes) |ptr| + ptr[0..req.mimes_len] + else + &.{}; + + // Only a paste with something to read needs a reader. + if (c_mimes.len > 0 and !req.reader.valid()) return .invalid_value; + + // A paste carries a handful of representations, so keep the common + // case allocation-free. + var sfa = std.heap.stackFallback(256, wrapper.terminal.gpa()); + const alloc = sfa.get(); + const mimes = alloc.alloc([]const u8, c_mimes.len) catch return .out_of_memory; + defer alloc.free(mimes); + for (mimes, c_mimes) |*mime, c_mime| mime.* = c_mime.ptr[0..c_mime.len]; + + const written = wrapper.stream.handler.paste(.{ + .source = switch (req.source) { + .clipboard => .{ .clipboard = req.location }, + .text => .text, + }, + .contents = .{ .reader = .{ + .mimes = mimes, + .read = .{ .ctx = @constCast(req), .read_fn = &readTrampoline }, + } }, + .allow_unsafe = req.allow_unsafe, + }) catch |err| return switch (err) { + error.UnsafePaste => .rejected, + error.NoWritePty => .invalid_value, + error.OutOfMemory => .out_of_memory, + error.ReadFailed, + error.EntropyUnavailable, + error.Canceled, + => .io_error, + }; + if (out_written) |ptr| ptr.* = written; + return .success; +} + +/// The sink handed to the C read callback: a GhosttyWriter over the +/// Zig sink, remembering whether the sink itself failed so that can be +/// told apart from the callback failing to read. +const Sink = struct { + writer: *std.Io.Writer, + write_failed: bool = false, + + fn write( + userdata: ?*anyopaque, + data: [*]const u8, + len: usize, + ) callconv(lib.calling_conv) bool { + const self: *Sink = @ptrCast(@alignCast(userdata.?)); + self.writer.writeAll(data[0..len]) catch { + self.write_failed = true; + return false; + }; + return true; + } +}; + +fn readTrampoline( + ctx: ?*anyopaque, + mime: []const u8, + writer: *std.Io.Writer, +) clipboard.MimeReader.Error!void { + const req: *const Request = @ptrCast(@alignCast(ctx.?)); + var sink: Sink = .{ .writer = writer }; + if (!req.reader.read.?(req.reader.userdata, .init(mime), .{ + .write = &Sink.write, + .userdata = &sink, + })) { + return if (sink.write_failed) error.WriteFailed else error.ReadFailed; + } +} + pub fn is_safe(data: ?[*]const u8, len: usize) callconv(lib.calling_conv) bool { const slice: []const u8 = if (data) |v| v[0..len] else &.{}; return paste.isSafe(slice); @@ -127,3 +249,273 @@ test "is_safe with null empty data" { const testing = std.testing; try testing.expect(is_safe(null, 0)); } + +/// Capture state for the terminal_paste tests: every pty write and the +/// clipboard reads that follow a paste event. +const TerminalPasteCapture = struct { + var written: [1024]u8 = undefined; + var written_len: usize = 0; + var write_count: usize = 0; + var read_count: usize = 0; + var last_read_granted: bool = false; + + fn reset() void { + written_len = 0; + write_count = 0; + read_count = 0; + last_read_granted = false; + } + + fn writePty(_: Terminal, _: ?*anyopaque, ptr: [*]const u8, len: usize) callconv(lib.calling_conv) void { + @memcpy(written[written_len..][0..len], ptr[0..len]); + written_len += len; + write_count += 1; + } + + fn clipboardRead(_: Terminal, _: ?*anyopaque, request: *const ClipboardRead) callconv(lib.calling_conv) void { + read_count += 1; + last_read_granted = request.granted; + const contents = [_]ClipboardContent{.{ + .mime = .init(@as([]const u8, "text/plain")), + .data = .init(@as([]const u8, "Ghostty")), + }}; + request.reply(request, &.{ + .size = @sizeOf(ClipboardReadReply), + .result = .success, + .contents = &contents, + .contents_len = contents.len, + .available = null, + .available_len = 0, + .remember = false, + }); + } + + fn writtenSlice() []const u8 { + return written[0..written_len]; + } + + /// The representations a test paste serves: the MIME list for the + /// request and the data the read callback streams, in pieces. + const Contents = struct { + mimes: [2]lib.String = undefined, + data: [2][]const u8 = undefined, + len: usize = 0, + reads: [2]usize = @splat(0), + fail: bool = false, + + fn init(entries: []const struct { []const u8, []const u8 }) Contents { + var self: Contents = .{}; + for (entries) |entry| { + self.mimes[self.len] = .init(entry[0]); + self.data[self.len] = entry[1]; + self.len += 1; + } + return self; + } + + fn request(self: *Contents) Request { + return .{ + .location = .standard, + .source = .clipboard, + .mimes = &self.mimes, + .mimes_len = self.len, + .reader = .{ .read = &read, .userdata = self }, + .allow_unsafe = false, + }; + } + + fn read(userdata: ?*anyopaque, mime: lib.String, writer: io.Writer) callconv(lib.calling_conv) bool { + const self: *Contents = @ptrCast(@alignCast(userdata.?)); + // The mime is the exact string from the request's list, so + // identifying it by pointer works. + const index: usize = for (self.mimes[0..self.len], 0..) |m, i| { + if (m.ptr == mime.ptr and m.len == mime.len) break i; + } else return false; + self.reads[index] += 1; + if (self.fail) return false; + const data = self.data[index]; + var offset: usize = 0; + while (offset < data.len) { + const n = @min(3, data.len - offset); + if (!writer.write.?(writer.userdata, data[offset..].ptr, n)) return false; + offset += n; + } + return true; + } + }; +}; + +test "terminal_paste null handling" { + const testing = std.testing; + const S = TerminalPasteCapture; + + var t: Terminal = null; + try testing.expectEqual(Result.success, terminal_c.new(&lib.alloc.test_allocator, &t, 80, 24)); + defer terminal_c.free(t); + try testing.expectEqual(Result.success, terminal_c.set(t, .write_pty, @ptrCast(&S.writePty))); + + var written: bool = true; + var contents: S.Contents = .init(&.{.{ "text/plain", "hello" }}); + const req = contents.request(); + try testing.expectEqual(Result.invalid_value, terminal_paste(null, &req, &written)); + try testing.expectEqual(Result.invalid_value, terminal_paste(t, null, &written)); + try testing.expect(written); + + // A size smaller than the struct is rejected. + var small = req; + small.size = @sizeOf(usize); + try testing.expectEqual(Result.invalid_value, terminal_paste(t, &small, &written)); + + // MIME types without a reader are rejected; none at all is fine. + var unreadable = req; + unreadable.reader.read = null; + try testing.expectEqual(Result.invalid_value, terminal_paste(t, &unreadable, &written)); + unreadable.mimes = null; + unreadable.mimes_len = 0; + try testing.expectEqual(Result.success, terminal_paste(t, &unreadable, &written)); + try testing.expect(!written); +} + +test "terminal_paste without write_pty is invalid" { + const testing = std.testing; + const S = TerminalPasteCapture; + S.reset(); + + var t: Terminal = null; + try testing.expectEqual(Result.success, terminal_c.new(&lib.alloc.test_allocator, &t, 80, 24)); + defer terminal_c.free(t); + + var contents: S.Contents = .init(&.{.{ "text/plain", "hello" }}); + const req = contents.request(); + try testing.expectEqual(Result.invalid_value, terminal_paste(t, &req, null)); +} + +test "terminal_paste text and unsafe" { + const testing = std.testing; + const S = TerminalPasteCapture; + S.reset(); + + var t: Terminal = null; + try testing.expectEqual(Result.success, terminal_c.new(&lib.alloc.test_allocator, &t, 80, 24)); + defer terminal_c.free(t); + try testing.expectEqual(Result.success, terminal_c.set(t, .write_pty, @ptrCast(&S.writePty))); + + // Plain text, NULL out_written pointer is fine. The text is read + // once, the image never. + var contents: S.Contents = .init(&.{ + .{ "image/png", "\x89PNG" }, + .{ "text/plain", "hel\x1blo" }, + }); + const req = contents.request(); + try testing.expectEqual(Result.success, terminal_paste(t, &req, null)); + try testing.expectEqualStrings("hel lo", S.writtenSlice()); + try testing.expectEqual(@as(usize, 1), S.write_count); + try testing.expectEqual(@as(usize, 0), contents.reads[0]); + try testing.expectEqual(@as(usize, 1), contents.reads[1]); + + // Unsafe is refused with nothing written, then allowed. + S.reset(); + var written: bool = false; + var unsafe_contents: S.Contents = .init(&.{.{ "text/plain", "rm -rf /\n" }}); + var unsafe = unsafe_contents.request(); + try testing.expectEqual(Result.rejected, terminal_paste(t, &unsafe, &written)); + try testing.expectEqual(@as(usize, 0), S.write_count); + try testing.expect(!written); + try testing.expectEqual(@as(usize, 1), unsafe_contents.reads[0]); + + unsafe.allow_unsafe = true; + try testing.expectEqual(Result.success, terminal_paste(t, &unsafe, &written)); + try testing.expect(written); + try testing.expectEqualStrings("rm -rf /\r", S.writtenSlice()); + try testing.expectEqual(@as(usize, 2), unsafe_contents.reads[0]); + + // Bracketed paste mode frames the text through the real mode path. + S.reset(); + const decset = "\x1b[?2004h"; + terminal_c.vt_write(t, decset, decset.len); + try testing.expectEqual(Result.success, terminal_paste(t, &req, &written)); + try testing.expect(written); + try testing.expectEqualStrings("\x1b[200~hel lo\x1b[201~", S.writtenSlice()); + + // No text representation writes nothing and reads nothing. + S.reset(); + var image: S.Contents = .init(&.{.{ "image/png", "\x89PNG" }}); + const image_req = image.request(); + try testing.expectEqual(Result.success, terminal_paste(t, &image_req, &written)); + try testing.expect(!written); + try testing.expectEqual(@as(usize, 0), S.write_count); + try testing.expectEqual(@as(usize, 0), image.reads[0]); + + // A failing reader is an I/O error. + S.reset(); + contents.fail = true; + try testing.expectEqual(Result.io_error, terminal_paste(t, &req, &written)); + try testing.expectEqual(@as(usize, 0), S.write_count); +} + +test "terminal_paste event" { + const testing = std.testing; + const S = TerminalPasteCapture; + S.reset(); + + var t: Terminal = null; + try testing.expectEqual(Result.success, terminal_c.new(&lib.alloc.test_allocator, &t, 80, 24)); + defer terminal_c.free(t); + try testing.expectEqual(Result.success, terminal_c.set(t, .write_pty, @ptrCast(&S.writePty))); + t.?.terminal.modes.set(.kitty_paste_events, true); + + // Without a clipboard_read callback the paste stays text. + var written: bool = false; + var contents: S.Contents = .init(&.{ + .{ "text/plain", "secret" }, + .{ "image/png", "\x89PNG" }, + }); + var req = contents.request(); + req.location = .primary; + try testing.expectEqual(Result.success, terminal_paste(t, &req, &written)); + try testing.expect(written); + try testing.expectEqualStrings("secret", S.writtenSlice()); + + // With one, an event is sent listing every MIME type and no data + // is read, let alone written. + S.reset(); + contents.reads = @splat(0); + try testing.expectEqual(Result.success, terminal_c.set(t, .clipboard_read, @ptrCast(&S.clipboardRead))); + try testing.expectEqual(Result.success, terminal_paste(t, &req, &written)); + try testing.expect(written); + try testing.expectEqual(@as(usize, 1), S.write_count); + try testing.expectEqual(@as(usize, 3), std.mem.count(u8, S.writtenSlice(), "\x1b]5522;")); + try testing.expect(std.mem.startsWith(u8, S.writtenSlice(), "\x1b]5522;type=read:status=OK:loc=primary:pw=")); + try testing.expect(std.mem.indexOf(u8, S.writtenSlice(), "secret") == null); + try testing.expect(std.mem.indexOf(u8, S.writtenSlice(), ";dGV4dC9wbGFpbiBpbWFnZS9wbmcK\x1b\\") != null); + try testing.expectEqual(@as(usize, 0), contents.reads[0]); + try testing.expectEqual(@as(usize, 0), contents.reads[1]); + + // The program's read with the event password is granted once. + const ok_prefix = "\x1b]5522;type=read:status=OK:loc=primary:pw="; + const pw_end = std.mem.indexOfPos(u8, S.writtenSlice(), ok_prefix.len, "\x1b\\").?; + var read_buf: [256]u8 = undefined; + const read = try std.fmt.bufPrint( + &read_buf, + "\x1b]5522;type=read:pw={s}:name=UGFzdGUgZXZlbnQ=;dGV4dC9wbGFpbg==\x1b\\", + .{S.writtenSlice()[ok_prefix.len..pw_end]}, + ); + S.reset(); + terminal_c.vt_write(t, read.ptr, read.len); + try testing.expectEqual(@as(usize, 1), S.read_count); + try testing.expect(S.last_read_granted); + try testing.expect(std.mem.indexOf(u8, S.writtenSlice(), ";R2hvc3R0eQ==\x1b\\") != null); + + S.reset(); + terminal_c.vt_write(t, read.ptr, read.len); + try testing.expectEqual(@as(usize, 1), S.read_count); + try testing.expect(!S.last_read_granted); + + // Text sources never become events. + S.reset(); + var ime = req; + ime.source = .text; + try testing.expectEqual(Result.success, terminal_paste(t, &ime, &written)); + try testing.expect(written); + try testing.expectEqualStrings("secret", S.writtenSlice()); +} diff --git a/vendor/libghostty-vt/src/terminal/c/render.zig b/vendor/libghostty-vt/src/terminal/c/render.zig index e70e7a4..c108456 100644 --- a/vendor/libghostty-vt/src/terminal/c/render.zig +++ b/vendor/libghostty-vt/src/terminal/c/render.zig @@ -12,6 +12,7 @@ const terminal_c = @import("terminal.zig"); const ZigTerminal = @import("../Terminal.zig"); const renderpkg = @import("../render.zig"); const Result = @import("result.zig").Result; +const cell_c = @import("cell.zig"); const row = @import("row.zig"); const style_c = @import("style.zig"); @@ -22,11 +23,18 @@ const RenderStateWrapper = struct { state: renderpkg.RenderState = .empty, }; +/// The "before the first element" position for the iterator wrappers +/// below. Represented as a sentinel index rather than an optional because +/// optional codegens into something that is less efficient than this. +const position_none = std.math.maxInt(usize); + const RowIteratorWrapper = struct { alloc: std.mem.Allocator, - /// The current index (also y value) into the row list. - y: ?size.CellCountInt, + /// The current index (also y value) into the row list, or + /// `position_none` if iteration hasn't started. Always validate + /// against `raws.len` before use. + y: usize, /// These are the raw pointers into the render state data. raws: []const page.Row, @@ -34,6 +42,10 @@ const RowIteratorWrapper = struct { selection: []const ?[2]size.CellCountInt, dirty: []bool, + /// The global dirty state from the render state that populated this + /// iterator. This has the same borrowed lifetime as the row slices. + state_dirty: *const Dirty, + /// The color palette from the render state, needed to resolve /// palette-indexed background colors on cells. palette: *const colorpkg.Palette, @@ -41,7 +53,12 @@ const RowIteratorWrapper = struct { const RowCellsWrapper = struct { alloc: std.mem.Allocator, - x: ?size.CellCountInt, + + /// The current index (also x value) into the cell list, or + /// `position_none` if iteration hasn't started. Always validate + /// against `raws.len` before use. + x: usize, + raws: []const page.Cell, graphemes: []const []const u21, styles: []const Style, @@ -87,6 +104,19 @@ pub const CursorVisualStyle = enum(c_int) { } }; +/// C: GhosttyRenderStateCursor +pub const Cursor = extern struct { + size: usize = @sizeOf(Cursor), + viewport_has_value: bool, + viewport_x: u16, + viewport_y: u16, + wide_tail: bool, + visible: bool, + blinking: bool, + password_input: bool, + visual_style: CursorVisualStyle, +}; + /// C: GhosttyRenderStateData pub const Data = enum(c_int) { invalid = 0, @@ -107,6 +137,8 @@ pub const Data = enum(c_int) { cursor_viewport_x = 15, cursor_viewport_y = 16, cursor_viewport_wide_tail = 17, + cursor = 18, + colors = 19, /// Output type expected for querying the data of the given kind. pub fn OutType(comptime self: Data) type { @@ -122,6 +154,8 @@ pub const Data = enum(c_int) { .cursor_visible, .cursor_blinking, .cursor_password_input => bool, .cursor_viewport_has_value, .cursor_viewport_wide_tail => bool, .cursor_viewport_x, .cursor_viewport_y => size.CellCountInt, + .cursor => Cursor, + .colors => Colors, }; } }; @@ -207,26 +241,21 @@ pub fn end_update( return .success; } +pub fn clean( + state_: RenderState, +) callconv(lib.calling_conv) Result { + const state = state_ orelse return .invalid_value; + state.state.clean(); + return .success; +} + pub fn get( state_: RenderState, data: Data, out: ?*anyopaque, ) callconv(lib.calling_conv) Result { - if (comptime std.debug.runtime_safety) { - _ = std.enums.fromInt(Data, @intFromEnum(data)) orelse { - log.warn("render_state_get invalid data value={d}", .{@intFromEnum(data)}); - return .invalid_value; - }; - } - - return switch (data) { - .invalid => .invalid_value, - inline else => |comptime_data| getTyped( - state_, - comptime_data, - @ptrCast(@alignCast(out)), - ), - }; + const state = state_ orelse return .invalid_value; + return getDispatch(state, data, out); } pub fn get_multi( @@ -239,8 +268,16 @@ pub fn get_multi( const k = keys orelse return .invalid_value; const v = values orelse return .invalid_value; + // Unwrap the handle once for the whole batch rather than per key. + // A null handle fails on the first key, matching the per-call + // behavior. + const state: ?*RenderStateWrapper = state_; + for (0..count) |i| { - const result = get(state_, k[i], v[i]); + const result = if (state) |s| + getDispatch(s, k[i], v[i]) + else + Result.invalid_value; if (result != .success) { if (out_written) |w| w.* = i; return result; @@ -250,12 +287,34 @@ pub fn get_multi( return .success; } +inline fn getDispatch( + state: *RenderStateWrapper, + data: Data, + out: ?*anyopaque, +) Result { + if (comptime std.debug.runtime_safety) { + _ = std.enums.fromInt(Data, @intFromEnum(data)) orelse { + log.warn("render_state_get invalid data value={d}", .{@intFromEnum(data)}); + return .invalid_value; + }; + } + + const out_ptr = out orelse return .invalid_value; + return switch (data) { + .invalid => .invalid_value, + inline else => |comptime_data| getTyped( + state, + comptime_data, + @ptrCast(@alignCast(out_ptr)), + ), + }; +} + fn getTyped( - state_: RenderState, + state: *RenderStateWrapper, comptime data: Data, out: *data.OutType(), ) Result { - const state = state_ orelse return .invalid_value; switch (data) { .invalid => return .invalid_value, .cols => out.* = state.state.cols, @@ -266,11 +325,12 @@ fn getTyped( const row_data = state.state.row_data.slice(); it.* = .{ .alloc = it.alloc, - .y = null, + .y = position_none, .raws = row_data.items(.raw), .cells = row_data.items(.cells), .selection = row_data.items(.selection), .dirty = row_data.items(.dirty), + .state_dirty = &state.state.dirty, .palette = &state.state.colors.palette, }; }, @@ -299,6 +359,8 @@ fn getTyped( const vp = state.state.cursor.viewport orelse return .invalid_value; out.* = vp.wide_tail; }, + .cursor => return writeCursor(state, out), + .colors => return writeColors(state, out), } return .success; @@ -338,16 +400,91 @@ fn setTyped( return .success; } -pub fn colors_get( - state_: RenderState, - out_colors_: ?*Colors, -) callconv(lib.calling_conv) Result { - const state = state_ orelse return .invalid_value; - const out_colors = out_colors_ orelse return .invalid_value; +fn writeCursor( + state: *RenderStateWrapper, + out_cursor: *Cursor, +) Result { + const out_size = out_cursor.size; + if (out_size < @sizeOf(usize)) return .invalid_value; + + const cursor = &state.state.cursor; + if (lib.structSizedFieldFits( + Cursor, + out_size, + "viewport_has_value", + )) { + out_cursor.viewport_has_value = cursor.viewport != null; + } + + if (cursor.viewport) |viewport| { + if (lib.structSizedFieldFits( + Cursor, + out_size, + "viewport_x", + )) { + out_cursor.viewport_x = viewport.x; + } + + if (lib.structSizedFieldFits( + Cursor, + out_size, + "viewport_y", + )) { + out_cursor.viewport_y = viewport.y; + } + + if (lib.structSizedFieldFits( + Cursor, + out_size, + "wide_tail", + )) { + out_cursor.wide_tail = viewport.wide_tail; + } + } + + if (lib.structSizedFieldFits( + Cursor, + out_size, + "visible", + )) { + out_cursor.visible = cursor.visible; + } + + if (lib.structSizedFieldFits( + Cursor, + out_size, + "blinking", + )) { + out_cursor.blinking = cursor.blinking; + } + + if (lib.structSizedFieldFits( + Cursor, + out_size, + "password_input", + )) { + out_cursor.password_input = cursor.password_input; + } + + if (lib.structSizedFieldFits( + Cursor, + out_size, + "visual_style", + )) { + out_cursor.visual_style = CursorVisualStyle.fromCursorStyle(cursor.visual_style); + } + + return .success; +} + +fn writeColors( + state: *RenderStateWrapper, + out_colors: *Colors, +) Result { const out_size = out_colors.size; if (out_size < @sizeOf(usize)) return .invalid_value; - const colors = state.state.colors; + const colors = &state.state.colors; if (lib.structSizedFieldFits( Colors, out_size, @@ -387,9 +524,10 @@ pub fn colors_get( if (out_size > palette_offset) { const available = out_size - palette_offset; const max_entries = @min(colors.palette.len, available / @sizeOf(colorpkg.RGB.C)); - for (0..max_entries) |i| { - out_colors.palette[i] = colors.palette[i].cval(); - } + colorpkg.paletteCvalSlice( + colors.palette[0..max_entries], + out_colors.palette[0..max_entries], + ); } } @@ -412,6 +550,7 @@ pub fn row_iterator_new( .cells = undefined, .selection = undefined, .dirty = undefined, + .state_dirty = undefined, .palette = undefined, }; result.* = ptr; @@ -426,12 +565,43 @@ pub fn row_iterator_free(iterator_: RowIterator) callconv(lib.calling_conv) void pub fn row_iterator_next(iterator_: RowIterator) callconv(lib.calling_conv) bool { const it = iterator_ orelse return false; - const next_y: size.CellCountInt = if (it.y) |y| y + 1 else 0; + // The none sentinel wraps to zero. + const next_y = it.y +% 1; if (next_y >= it.raws.len) return false; it.y = next_y; return true; } +pub fn row_iterator_next_dirty( + iterator_: RowIterator, + out_y: ?*size.CellCountInt, +) callconv(lib.calling_conv) bool { + const it = iterator_ orelse return false; + const y_out = out_y orelse return false; + + switch (it.state_dirty.*) { + .false => return false, + .full => { + // The none sentinel wraps to zero. + const next_y = it.y +% 1; + if (next_y >= it.raws.len) return false; + it.y = next_y; + y_out.* = @intCast(next_y); + return true; + }, + .partial => { + var next_y = it.y +% 1; + while (next_y < it.raws.len) : (next_y += 1) { + if (!it.dirty[next_y]) continue; + it.y = next_y; + y_out.* = @intCast(next_y); + return true; + } + return false; + }, + } +} + pub fn row_cells_new( alloc_: ?*const CAllocator, result: *RowCells, @@ -456,7 +626,8 @@ pub fn row_cells_new( pub fn row_cells_next(cells_: RowCells) callconv(lib.calling_conv) bool { const cells = cells_ orelse return false; - const next_x: size.CellCountInt = if (cells.x) |x| x + 1 else 0; + // The none sentinel wraps to zero. + const next_x = cells.x +% 1; if (next_x >= cells.raws.len) return false; cells.x = next_x; return true; @@ -508,22 +679,10 @@ pub fn row_cells_get( data: RowCellsData, out: ?*anyopaque, ) callconv(lib.calling_conv) Result { - if (comptime std.debug.runtime_safety) { - _ = std.enums.fromInt(RowCellsData, @intFromEnum(data)) orelse { - log.warn("render_state_row_cells_get invalid data value={d}", .{@intFromEnum(data)}); - return .invalid_value; - }; - } - if (out == null) return .invalid_value; - - return switch (data) { - .invalid => .invalid_value, - inline else => |comptime_data| rowCellsGetTyped( - cells_, - comptime_data, - @ptrCast(@alignCast(out)), - ), - }; + const cells = cells_ orelse return .invalid_value; + const x = cells.x; + if (x >= cells.raws.len) return .invalid_value; + return rowCellsGetDispatch(cells, x, data, out); } pub fn row_cells_get_multi( @@ -536,8 +695,21 @@ pub fn row_cells_get_multi( const k = keys orelse return .invalid_value; const v = values orelse return .invalid_value; + // Unwrap the handle and position once for the whole batch rather + // than per key. An invalid handle/position fails on the first + // key, matching the per-call behavior. + const unwrapped: ?struct { *RowCellsWrapper, usize } = valid: { + const cells = cells_ orelse break :valid null; + const x = cells.x; + if (x >= cells.raws.len) break :valid null; + break :valid .{ cells, x }; + }; + for (0..count) |i| { - const result = row_cells_get(cells_, k[i], v[i]); + const result = if (unwrapped) |u| + rowCellsGetDispatch(u[0], u[1], k[i], v[i]) + else + Result.invalid_value; if (result != .success) { if (out_written) |w| w.* = i; return result; @@ -547,21 +719,46 @@ pub fn row_cells_get_multi( return .success; } -fn rowCellsGetTyped( - cells_: RowCells, +inline fn rowCellsGetDispatch( + cells: *const RowCellsWrapper, + x: usize, + data: RowCellsData, + out: ?*anyopaque, +) Result { + if (comptime std.debug.runtime_safety) { + _ = std.enums.fromInt(RowCellsData, @intFromEnum(data)) orelse { + log.warn("render_state_row_cells_get invalid data value={d}", .{@intFromEnum(data)}); + return .invalid_value; + }; + } + if (out == null) return .invalid_value; + + return switch (data) { + .invalid => .invalid_value, + inline else => |comptime_data| rowCellsGetTypedInner( + cells, + x, + comptime_data, + @ptrCast(@alignCast(out)), + ), + }; +} + +fn rowCellsGetTypedInner( + cells: *const RowCellsWrapper, + x: usize, comptime data: RowCellsData, out: *data.OutType(), ) Result { - const cells = cells_ orelse return .invalid_value; - const x = cells.x orelse return .invalid_value; const cell = cells.raws[x]; switch (data) { .invalid => return .invalid_value, .raw => out.* = cell.cval(), - .style => out.* = if (cell.hasStyling()) - style_c.Style.fromStyle(cells.styles[x]) - else - style_c.Style.fromStyle(.{}), + .style => if (cell.hasStyling()) { + style_c.Style.write(cells.styles[x], out); + } else { + out.* = style_c.Style.default; + }, .graphemes_len => { if (!cell.hasText()) { out.* = 0; @@ -580,15 +777,37 @@ fn rowCellsGetTyped( } }, .bg_color => { - const s: Style = if (cell.hasStyling()) cells.styles[x] else .{}; - const bg = s.bg(&cell, cells.palette) orelse return .invalid_value; - out.* = bg.cval(); + // Avoid copying the full struct when only partial changes happen. + switch (cell.content_tag) { + .bg_color_palette => { + out.* = cells.palette[cell.content.color_palette.data].cval(); + }, + + .bg_color_rgb => { + const rgb = cell.content.color_rgb; + out.* = .{ .r = rgb.r, .g = rgb.g, .b = rgb.b }; + }, + + .codepoint, + .codepoint_grapheme, + => { + // The default style has no background. + if (!cell.hasStyling()) return .invalid_value; + switch (cells.styles[x].bg_color) { + .none => return .invalid_value, + .palette => |idx| out.* = cells.palette[idx].cval(), + .rgb => |rgb| out.* = rgb.cval(), + } + }, + } }, .fg_color => { - const s: Style = if (cell.hasStyling()) cells.styles[x] else .{}; - if (s.fg_color == .none) return .invalid_value; - const fg = s.fg(.{ .default = .{}, .palette = cells.palette }); - out.* = fg.cval(); + if (!cell.hasStyling()) return .invalid_value; + switch (cells.styles[x].fg_color) { + .none => return .invalid_value, + .palette => |idx| out.* = cells.palette[idx].cval(), + .rgb => |rgb| out.* = rgb.cval(), + } }, .selected => out.* = if (cells.selection) |sel| x >= sel[0] and x <= sel[1] @@ -606,11 +825,32 @@ fn rowCellsGetGraphemesUtf8( extra: []const u21, out: *lib.Buffer, ) Result { - out.len = 0; + if (!cell.hasText()) { + out.len = 0; + return .success; + } + + const first = cell.codepoint(); + + // Fast path: a single ASCII codepoint written to an adequately + // sized buffer. This is the overwhelmingly common case for + // terminal content. + if (first < 0x80 and extra.len == 0) { + @branchHint(.likely); + if (out.ptr) |ptr| { + if (out.cap >= 1) { + ptr[0] = @intCast(first); + out.len = 1; + return .success; + } + } + out.len = 1; + return .out_of_space; + } - if (!cell.hasText()) return .success; + out.len = 0; - var needed: usize = std.unicode.utf8CodepointSequenceLength(cell.codepoint()) catch + var needed: usize = std.unicode.utf8CodepointSequenceLength(first) catch return .invalid_value; for (extra) |cp| { needed += std.unicode.utf8CodepointSequenceLength(cp) catch @@ -622,7 +862,7 @@ fn rowCellsGetGraphemesUtf8( const buf = out.ptr.?[0..out.cap]; var i: usize = 0; - i += std.unicode.utf8Encode(cell.codepoint(), buf[i..]) catch + i += std.unicode.utf8Encode(first, buf[i..]) catch return .invalid_value; for (extra) |cp| { i += std.unicode.utf8Encode(cp, buf[i..]) catch @@ -640,6 +880,7 @@ pub const RowData = enum(c_int) { raw = 2, cells = 3, selection = 4, + cells_raw = 5, /// Output type expected for querying the data of the given kind. pub fn OutType(comptime self: RowData) type { @@ -649,6 +890,7 @@ pub const RowData = enum(c_int) { .raw => row.CRow, .cells => RowCells, .selection => RowSelection, + .cells_raw => cell_c.CellsView, }; } }; @@ -670,21 +912,10 @@ pub fn row_get( data: RowData, out: ?*anyopaque, ) callconv(lib.calling_conv) Result { - if (comptime std.debug.runtime_safety) { - _ = std.enums.fromInt(RowData, @intFromEnum(data)) orelse { - log.warn("render_state_row_get invalid data value={d}", .{@intFromEnum(data)}); - return .invalid_value; - }; - } - - return switch (data) { - .invalid => .invalid_value, - inline else => |comptime_data| rowGetTyped( - iterator_, - comptime_data, - @ptrCast(@alignCast(out)), - ), - }; + const it = iterator_ orelse return .invalid_value; + const y = it.y; + if (y >= it.raws.len) return .invalid_value; + return rowGetDispatch(it, y, data, out); } pub fn row_get_multi( @@ -697,8 +928,21 @@ pub fn row_get_multi( const k = keys orelse return .invalid_value; const v = values orelse return .invalid_value; + // Unwrap the handle and position once for the whole batch rather + // than per key. An invalid handle/position fails on the first + // key, matching the per-call behavior. + const unwrapped: ?struct { *RowIteratorWrapper, usize } = valid: { + const it = iterator_ orelse break :valid null; + const y = it.y; + if (y >= it.raws.len) break :valid null; + break :valid .{ it, y }; + }; + for (0..count) |i| { - const result = row_get(iterator_, k[i], v[i]); + const result = if (unwrapped) |u| + rowGetDispatch(u[0], u[1], k[i], v[i]) + else + Result.invalid_value; if (result != .success) { if (out_written) |w| w.* = i; return result; @@ -708,13 +952,36 @@ pub fn row_get_multi( return .success; } +inline fn rowGetDispatch( + it: *RowIteratorWrapper, + y: usize, + data: RowData, + out: ?*anyopaque, +) Result { + if (comptime std.debug.runtime_safety) { + _ = std.enums.fromInt(RowData, @intFromEnum(data)) orelse { + log.warn("render_state_row_get invalid data value={d}", .{@intFromEnum(data)}); + return .invalid_value; + }; + } + + return switch (data) { + .invalid => .invalid_value, + inline else => |comptime_data| rowGetTyped( + it, + y, + comptime_data, + @ptrCast(@alignCast(out)), + ), + }; +} + fn rowGetTyped( - iterator_: RowIterator, + it: *RowIteratorWrapper, + y: usize, comptime data: RowData, out: *data.OutType(), ) Result { - const it = iterator_ orelse return .invalid_value; - const y = it.y orelse return .invalid_value; switch (data) { .invalid => return .invalid_value, .dirty => out.* = it.dirty[y], @@ -724,7 +991,7 @@ fn rowGetTyped( const cell_data = it.cells[y].slice(); cells.* = .{ .alloc = cells.alloc, - .x = null, + .x = position_none, .raws = cell_data.items(.raw), .graphemes = cell_data.items(.grapheme), .styles = cell_data.items(.style), @@ -740,6 +1007,13 @@ fn rowGetTyped( out.start_x = sel[0]; out.end_x = sel[1]; }, + .cells_raw => { + const raws: []const page.Cell = it.cells[y].items(.raw); + out.* = .{ + .ptr = @ptrCast(raws.ptr), + .len = raws.len, + }; + }, } return .success; @@ -772,7 +1046,8 @@ fn rowSetTyped( value: *const option.InType(), ) Result { const it = iterator_ orelse return .invalid_value; - const y = it.y orelse return .invalid_value; + const y = it.y; + if (y >= it.raws.len) return .invalid_value; switch (option) { .dirty => it.dirty[y] = value.*, } @@ -861,6 +1136,14 @@ test "render: begin/end update" { test "render: get invalid value" { var cols: size.CellCountInt = 0; try testing.expectEqual(Result.invalid_value, get(null, .cols, @ptrCast(&cols))); + + var state: RenderState = null; + try testing.expectEqual(Result.success, new( + &lib.alloc.test_allocator, + &state, + )); + defer free(state); + try testing.expectEqual(Result.invalid_value, get(state, .cols, null)); } test "render: get invalid data" { @@ -874,7 +1157,7 @@ test "render: get invalid data" { try testing.expectEqual(Result.invalid_value, get(state, .invalid, null)); } -test "render: colors get invalid value" { +test "render: aggregate get invalid value" { var state: RenderState = null; try testing.expectEqual(Result.success, new( &lib.alloc.test_allocator, @@ -885,11 +1168,20 @@ test "render: colors get invalid value" { var colors: Colors = std.mem.zeroes(Colors); colors.size = @sizeOf(Colors); - try testing.expectEqual(Result.invalid_value, colors_get(null, &colors)); - try testing.expectEqual(Result.invalid_value, colors_get(state, null)); + try testing.expectEqual(Result.invalid_value, get(null, .colors, &colors)); + try testing.expectEqual(Result.invalid_value, get(state, .colors, null)); colors.size = @sizeOf(usize) - 1; - try testing.expectEqual(Result.invalid_value, colors_get(state, &colors)); + try testing.expectEqual(Result.invalid_value, get(state, .colors, &colors)); + + var cursor: Cursor = std.mem.zeroes(Cursor); + cursor.size = @sizeOf(Cursor); + + try testing.expectEqual(Result.invalid_value, get(null, .cursor, &cursor)); + try testing.expectEqual(Result.invalid_value, get(state, .cursor, null)); + + cursor.size = @sizeOf(usize) - 1; + try testing.expectEqual(Result.invalid_value, get(state, .cursor, &cursor)); } test "render: get/set dirty invalid value" { @@ -922,6 +1214,42 @@ test "render: get/set dirty" { try testing.expectEqual(Dirty.full, dirty); } +test "render: clean" { + var terminal: terminal_c.Terminal = null; + try testing.expectEqual(Result.success, terminal_c.new( + &lib.alloc.test_allocator, + &terminal, + 10, + 3, + )); + defer terminal_c.free(terminal); + + var state: RenderState = null; + try testing.expectEqual(Result.success, new( + &lib.alloc.test_allocator, + &state, + )); + defer free(state); + + try testing.expectEqual(Result.invalid_value, clean(null)); + try testing.expectEqual(Result.success, update(state, terminal)); + try testing.expectEqual(Dirty.full, state.?.state.dirty); + + try testing.expectEqual(Result.success, clean(state)); + try testing.expectEqual(Dirty.false, state.?.state.dirty); + for (state.?.state.row_data.items(.dirty)) |dirty| { + try testing.expect(!dirty); + } + + // Cleaning is idempotent, and an unchanged update remains clean. + try testing.expectEqual(Result.success, clean(state)); + try testing.expectEqual(Result.success, update(state, terminal)); + try testing.expectEqual(Dirty.false, state.?.state.dirty); + for (state.?.state.row_data.items(.dirty)) |dirty| { + try testing.expect(!dirty); + } +} + test "render: set null value" { var state: RenderState = null; try testing.expectEqual(Result.success, new( @@ -977,7 +1305,7 @@ test "render: row iterator new/free" { const iterator_ptr = iterator.?; const row_data = state.?.state.row_data.slice(); - try testing.expectEqual(@as(?size.CellCountInt, null), iterator_ptr.y); + try testing.expectEqual(position_none, iterator_ptr.y); try testing.expectEqual(row_data.items(.raw).len, iterator_ptr.raws.len); try testing.expectEqual(row_data.items(.cells).len, iterator_ptr.cells.len); try testing.expectEqual(row_data.items(.selection).len, iterator_ptr.selection.len); @@ -1201,6 +1529,68 @@ test "render: row get selection" { try testing.expectEqual(Result.no_value, row_get(it, .selection, @ptrCast(&sel))); } +test "render: row get cells_raw" { + var terminal: terminal_c.Terminal = null; + try testing.expectEqual(Result.success, terminal_c.new( + &lib.alloc.test_allocator, + &terminal, + 10, + 3, + )); + defer terminal_c.free(terminal); + + terminal_c.vt_write(terminal, "AB", 2); + + var state: RenderState = null; + try testing.expectEqual(Result.success, new( + &lib.alloc.test_allocator, + &state, + )); + defer free(state); + + try testing.expectEqual(Result.success, update(state, terminal)); + + var it: RowIterator = null; + try testing.expectEqual(Result.success, row_iterator_new( + &lib.alloc.test_allocator, + &it, + )); + defer row_iterator_free(it); + + try testing.expectEqual(Result.success, get(state, .row_iterator, @ptrCast(&it))); + + // Not positioned on a row yet. + var view: cell_c.CellsView = undefined; + try testing.expectEqual(Result.invalid_value, row_get(it, .cells_raw, @ptrCast(&view))); + + try testing.expect(row_iterator_next(it)); + try testing.expectEqual(Result.success, row_get(it, .cells_raw, @ptrCast(&view))); + try testing.expectEqual(@as(usize, 10), view.len); + + // The view must match the per-cell raw reads. + var cells: RowCells = null; + try testing.expectEqual(Result.success, row_cells_new( + &lib.alloc.test_allocator, + &cells, + )); + defer row_cells_free(cells); + try testing.expectEqual(Result.success, row_get(it, .cells, @ptrCast(&cells))); + const ptr = view.ptr.?; + var x: u16 = 0; + while (x < view.len) : (x += 1) { + var raw: page.Cell.C = undefined; + try testing.expectEqual(Result.success, row_cells_select(cells, x)); + try testing.expectEqual(Result.success, row_cells_get(cells, .raw, @ptrCast(&raw))); + try testing.expectEqual(raw, ptr[x]); + } + + // Contents sanity: first two cells hold our text. + const first: page.Cell = @bitCast(ptr[0]); + const second: page.Cell = @bitCast(ptr[1]); + try testing.expectEqual(@as(u21, 'A'), first.codepoint()); + try testing.expectEqual(@as(u21, 'B'), second.codepoint()); +} + test "render: row cells get selected" { var terminal: terminal_c.Terminal = null; try testing.expectEqual(Result.success, terminal_c.new( @@ -1498,16 +1888,89 @@ test "render: row iterator next" { } try testing.expect(row_iterator_next(iterator)); - try testing.expectEqual(@as(?size.CellCountInt, 0), iterator.?.y); + try testing.expectEqual(@as(usize, 0), iterator.?.y); var i: size.CellCountInt = 1; while (i < rows) : (i += 1) { try testing.expect(row_iterator_next(iterator)); - try testing.expectEqual(@as(?size.CellCountInt, i), iterator.?.y); + try testing.expectEqual(i, iterator.?.y); } try testing.expect(!row_iterator_next(iterator)); - try testing.expectEqual(@as(?size.CellCountInt, rows - 1), iterator.?.y); + try testing.expectEqual(@as(usize, rows - 1), iterator.?.y); +} + +test "render: row iterator next dirty" { + var terminal: terminal_c.Terminal = null; + try testing.expectEqual(Result.success, terminal_c.new( + &lib.alloc.test_allocator, + &terminal, + 10, + 4, + )); + defer terminal_c.free(terminal); + + var state: RenderState = null; + try testing.expectEqual(Result.success, new( + &lib.alloc.test_allocator, + &state, + )); + defer free(state); + try testing.expectEqual(Result.success, update(state, terminal)); + + var iterator: RowIterator = null; + try testing.expectEqual(Result.success, row_iterator_new( + &lib.alloc.test_allocator, + &iterator, + )); + defer row_iterator_free(iterator); + + // Invalid arguments neither write the output nor advance the iterator. + try testing.expectEqual(Result.success, get(state, .row_iterator, @ptrCast(&iterator))); + var out_y: size.CellCountInt = 0xCAFE; + try testing.expect(!row_iterator_next_dirty(null, &out_y)); + try testing.expectEqual(@as(size.CellCountInt, 0xCAFE), out_y); + try testing.expect(!row_iterator_next_dirty(iterator, null)); + try testing.expectEqual(position_none, iterator.?.y); + + // A full redraw returns every row, even if its row flag was cleared. + @memset(state.?.state.row_data.items(.dirty), false); + var expected_y: size.CellCountInt = 0; + while (row_iterator_next_dirty(iterator, &out_y)) : (expected_y += 1) { + try testing.expectEqual(expected_y, out_y); + } + try testing.expectEqual(@as(size.CellCountInt, 4), expected_y); + try testing.expectEqual(@as(size.CellCountInt, 3), out_y); + + // A partial redraw skips clean rows without consuming dirty flags. + const dirty = state.?.state.row_data.items(.dirty); + state.?.state.dirty = .partial; + @memset(dirty, false); + dirty[1] = true; + dirty[3] = true; + try testing.expectEqual(Result.success, get(state, .row_iterator, @ptrCast(&iterator))); + try testing.expect(row_iterator_next_dirty(iterator, &out_y)); + try testing.expectEqual(@as(size.CellCountInt, 1), out_y); + try testing.expect(row_iterator_next_dirty(iterator, &out_y)); + try testing.expectEqual(@as(size.CellCountInt, 3), out_y); + try testing.expect(!row_iterator_next_dirty(iterator, &out_y)); + try testing.expectEqual(@as(size.CellCountInt, 3), out_y); + try testing.expect(dirty[1]); + try testing.expect(dirty[3]); + + // The global clean state is authoritative, even if stale row flags exist. + state.?.state.dirty = .false; + try testing.expectEqual(Result.success, get(state, .row_iterator, @ptrCast(&iterator))); + out_y = 0xCAFE; + try testing.expect(!row_iterator_next_dirty(iterator, &out_y)); + try testing.expectEqual(@as(size.CellCountInt, 0xCAFE), out_y); + + // Existing iterators observe cleaning through their borrowed state. + state.?.state.dirty = .partial; + dirty[1] = true; + try testing.expectEqual(Result.success, get(state, .row_iterator, @ptrCast(&iterator))); + try testing.expectEqual(Result.success, clean(state)); + try testing.expect(!row_iterator_next_dirty(iterator, &out_y)); } test "render: update" { @@ -1540,7 +2003,7 @@ test "render: update" { try testing.expectEqual(@as(size.CellCountInt, 24), rows_val); } -test "render: colors get" { +test "render: colors data get" { var terminal: terminal_c.Terminal = null; try testing.expectEqual(Result.success, terminal_c.new( &lib.alloc.test_allocator, @@ -1561,7 +2024,7 @@ test "render: colors get" { var colors: Colors = std.mem.zeroes(Colors); colors.size = @sizeOf(Colors); - try testing.expectEqual(Result.success, colors_get(state, &colors)); + try testing.expectEqual(Result.success, get(state, .colors, &colors)); const state_colors = &state.?.state.colors; try testing.expectEqual(state_colors.background.cval(), colors.background); @@ -1579,6 +2042,121 @@ test "render: colors get" { } } +test "render: cursor data get matches scalar getters" { + var terminal: terminal_c.Terminal = null; + try testing.expectEqual(Result.success, terminal_c.new( + &lib.alloc.test_allocator, + &terminal, + 80, + 24, + )); + defer terminal_c.free(terminal); + + var state: RenderState = null; + try testing.expectEqual(Result.success, new( + &lib.alloc.test_allocator, + &state, + )); + defer free(state); + + try testing.expectEqual(Result.success, update(state, terminal)); + + var cursor: Cursor = std.mem.zeroes(Cursor); + cursor.size = @sizeOf(Cursor); + try testing.expectEqual(Result.success, get(state, .cursor, &cursor)); + + var viewport_has_value: bool = undefined; + var viewport_x: u16 = undefined; + var viewport_y: u16 = undefined; + var wide_tail: bool = undefined; + var visible: bool = undefined; + var blinking: bool = undefined; + var password_input: bool = undefined; + var visual_style: CursorVisualStyle = undefined; + try testing.expectEqual(Result.success, get(state, .cursor_viewport_has_value, &viewport_has_value)); + try testing.expectEqual(Result.success, get(state, .cursor_viewport_x, &viewport_x)); + try testing.expectEqual(Result.success, get(state, .cursor_viewport_y, &viewport_y)); + try testing.expectEqual(Result.success, get(state, .cursor_viewport_wide_tail, &wide_tail)); + try testing.expectEqual(Result.success, get(state, .cursor_visible, &visible)); + try testing.expectEqual(Result.success, get(state, .cursor_blinking, &blinking)); + try testing.expectEqual(Result.success, get(state, .cursor_password_input, &password_input)); + try testing.expectEqual(Result.success, get(state, .cursor_visual_style, &visual_style)); + + try testing.expectEqual(viewport_has_value, cursor.viewport_has_value); + try testing.expectEqual(viewport_x, cursor.viewport_x); + try testing.expectEqual(viewport_y, cursor.viewport_y); + try testing.expectEqual(wide_tail, cursor.wide_tail); + try testing.expectEqual(visible, cursor.visible); + try testing.expectEqual(blinking, cursor.blinking); + try testing.expectEqual(password_input, cursor.password_input); + try testing.expectEqual(visual_style, cursor.visual_style); +} + +test "render: cursor data get without viewport" { + var state: RenderState = null; + try testing.expectEqual(Result.success, new( + &lib.alloc.test_allocator, + &state, + )); + defer free(state); + + state.?.state.cursor.viewport = null; + state.?.state.cursor.visible = true; + state.?.state.cursor.blinking = true; + state.?.state.cursor.password_input = true; + state.?.state.cursor.visual_style = .underline; + + var cursor: Cursor = std.mem.zeroes(Cursor); + cursor.size = @sizeOf(Cursor); + cursor.viewport_x = 0xAAAA; + cursor.viewport_y = 0xBBBB; + cursor.wide_tail = true; + try testing.expectEqual(Result.success, get(state, .cursor, &cursor)); + + try testing.expect(!cursor.viewport_has_value); + try testing.expectEqual(@as(u16, 0xAAAA), cursor.viewport_x); + try testing.expectEqual(@as(u16, 0xBBBB), cursor.viewport_y); + try testing.expect(cursor.wide_tail); + try testing.expect(cursor.visible); + try testing.expect(cursor.blinking); + try testing.expect(cursor.password_input); + try testing.expectEqual(CursorVisualStyle.underline, cursor.visual_style); +} + +test "render: cursor data get supports truncated sized struct" { + var terminal: terminal_c.Terminal = null; + try testing.expectEqual(Result.success, terminal_c.new( + &lib.alloc.test_allocator, + &terminal, + 80, + 24, + )); + defer terminal_c.free(terminal); + + var state: RenderState = null; + try testing.expectEqual(Result.success, new( + &lib.alloc.test_allocator, + &state, + )); + defer free(state); + + try testing.expectEqual(Result.success, update(state, terminal)); + const expected = state.?.state.cursor; + const viewport = expected.viewport.?; + + var cursor: Cursor = std.mem.zeroes(Cursor); + cursor.size = @offsetOf(Cursor, "viewport_y") + @sizeOf(u16); + cursor.wide_tail = !viewport.wide_tail; + cursor.visible = !expected.visible; + try testing.expectEqual(Result.success, get(state, .cursor, &cursor)); + + try testing.expect(cursor.viewport_has_value); + try testing.expectEqual(viewport.x, cursor.viewport_x); + try testing.expectEqual(viewport.y, cursor.viewport_y); + try testing.expectEqual(!viewport.wide_tail, cursor.wide_tail); + try testing.expectEqual(!expected.visible, cursor.visible); +} + test "render: row cells bg_color no background" { var terminal: terminal_c.Terminal = null; try testing.expectEqual(Result.success, terminal_c.new( @@ -1822,7 +2400,7 @@ test "render: row cells fg_color from style" { try testing.expectEqual(@as(u8, 30), fg.b); } -test "render: colors get supports truncated sized struct" { +test "render: colors data get supports truncated sized struct" { var terminal: terminal_c.Terminal = null; try testing.expectEqual(Result.success, terminal_c.new( &lib.alloc.test_allocator, @@ -1846,7 +2424,7 @@ test "render: colors get supports truncated sized struct" { for (&colors.palette) |*entry| entry.* = sentinel; colors.size = @offsetOf(Colors, "palette") + @sizeOf(colorpkg.RGB.C) * 2; - try testing.expectEqual(Result.success, colors_get(state, &colors)); + try testing.expectEqual(Result.success, get(state, .colors, &colors)); const state_colors = &state.?.state.colors; try testing.expectEqual(state_colors.palette[0].cval(), colors.palette[0]); diff --git a/vendor/libghostty-vt/src/terminal/c/result.zig b/vendor/libghostty-vt/src/terminal/c/result.zig index a127d43..174e973 100644 --- a/vendor/libghostty-vt/src/terminal/c/result.zig +++ b/vendor/libghostty-vt/src/terminal/c/result.zig @@ -7,4 +7,5 @@ pub const Result = enum(c_int) { no_value = -4, io_error = -5, limit_exceeded = -6, + rejected = -7, }; diff --git a/vendor/libghostty-vt/src/terminal/c/search.zig b/vendor/libghostty-vt/src/terminal/c/search.zig new file mode 100644 index 0000000..c513789 --- /dev/null +++ b/vendor/libghostty-vt/src/terminal/c/search.zig @@ -0,0 +1,1009 @@ +const std = @import("std"); +const testing = std.testing; +const lib = @import("../lib.zig"); +const CAllocator = lib.alloc.Allocator; +const searchpkg = @import("../search.zig"); +const TerminalSearch = searchpkg.Terminal; +const FlattenedHighlight = @import("../highlight.zig").Flattened; +const selection_c = @import("selection.zig"); +const terminal_c = @import("terminal.zig"); +const Result = @import("result.zig").Result; + +const log = std.log.scoped(.search_c); + +/// C: GhosttySearch +pub const Search = ?*SearchWrapper; + +pub const SearchWrapper = struct { + alloc: std.mem.Allocator, + + /// The terminal this search is bound to. This is borrowed, so the + /// search never frees it. If the terminal is freed first, it sets + /// this to null to detach the search: calls that need the terminal + /// then fail cleanly and free releases only search-owned memory. + terminal: terminal_c.Terminal, + + /// The search state. Null when no needle is set: the search + /// starts idle and returns to idle when the needle is cleared. + search: ?TerminalSearch = null, + + /// The scroll policy applied by the select options. Persistent + /// across selects, set via the select_scroll option. + select_scroll: TerminalSearch.SelectScroll = .if_needed, + + /// The Zig terminal this search is bound to, or null if the + /// terminal was freed before the search. + fn zigTerminal(self: *const SearchWrapper) ?*terminal_c.ZigTerminal { + const terminal_wrapper = self.terminal orelse return null; + return terminal_wrapper.terminal; + } + + /// The status of the search. A search with no needle reports + /// complete since there is nothing to look for. + fn status(self: *SearchWrapper) Status { + const s = if (self.search) |*s| s else return .complete; + return .fromZig(s.status()); + } +}; + +/// C: GhosttySearchStatus +pub const Status = enum(c_int) { + running = 0, + feed_required = 1, + complete = 2, + + fn fromZig(status: TerminalSearch.Status) Status { + return switch (status) { + .running => .running, + .feed_required => .feed_required, + .complete => .complete, + }; + } +}; + +/// C: GhosttySearchScroll +pub const Scroll = enum(c_int) { + if_needed = 0, + none = 1, + + fn toZig(self: Scroll) TerminalSearch.SelectScroll { + return switch (self) { + .if_needed => .if_needed, + .none => .none, + }; + } + + fn fromZig(scroll: TerminalSearch.SelectScroll) Scroll { + return switch (scroll) { + .if_needed => .if_needed, + .none => .none, + }; + } +}; + +/// C: GhosttySearchData +pub const Data = enum(c_int) { + status = 0, + needle = 1, + total_matches = 2, + selected_index = 3, + selected_match = 4, + matches = 5, + viewport_matches = 6, + select_scroll = 7, + + pub fn OutType(comptime self: Data) type { + return switch (self) { + .status => Status, + .needle => lib.String, + .total_matches => usize, + .selected_index => usize, + .selected_match => selection_c.CSelection, + .matches, .viewport_matches => selection_c.CSelectionBuffer, + .select_scroll => Scroll, + }; + } +}; + +/// C: GhosttySearchOption +pub const Option = enum(c_int) { + needle = 0, + select_next = 1, + select_prev = 2, + select_scroll = 3, + + pub fn Type(comptime self: Option) type { + return switch (self) { + .needle => lib.String, + // The value must be NULL. Reserved for future use. + .select_next, .select_prev => void, + .select_scroll => Scroll, + }; + } +}; + +pub fn new( + alloc_: ?*const CAllocator, + out_search: ?*Search, + terminal: terminal_c.Terminal, +) callconv(lib.calling_conv) Result { + const out = out_search orelse return .invalid_value; + out.* = null; + + const terminal_wrapper = terminal orelse return .invalid_value; + const alloc = lib.alloc.default(alloc_); + const wrapper = alloc.create(SearchWrapper) catch return .out_of_memory; + wrapper.* = .{ + .alloc = alloc, + .terminal = terminal_wrapper, + }; + + // Store the search in the terminal so that when the terminal is + // freed the search can be detached safely. + terminal_wrapper.searches.putNoClobber( + terminal_wrapper.terminal.gpa(), + wrapper, + {}, + ) catch { + alloc.destroy(wrapper); + return .out_of_memory; + }; + + out.* = wrapper; + return .success; +} + +pub fn free(search_: Search) callconv(lib.calling_conv) void { + const wrapper = search_ orelse return; + if (wrapper.terminal) |terminal_wrapper| { + _ = terminal_wrapper.searches.swapRemove(wrapper); + if (wrapper.search) |*s| s.deinit(terminal_wrapper.terminal); + } else { + // The terminal was freed first. Tracked state died with it, + // so only search-owned memory is freed. + if (wrapper.search) |*s| s.deinit(null); + } + const alloc = wrapper.alloc; + alloc.destroy(wrapper); +} + +pub fn tick( + search_: Search, + out_status: ?*Status, +) callconv(lib.calling_conv) Result { + const wrapper = search_ orelse return .invalid_value; + if (wrapper.search) |*s| _ = s.tick(); + if (out_status) |out| out.* = wrapper.status(); + return .success; +} + +pub fn feed(search_: Search) callconv(lib.calling_conv) Result { + const wrapper = search_ orelse return .invalid_value; + const t = wrapper.zigTerminal() orelse return .invalid_value; + const s = if (wrapper.search) |*s| s else return .success; + + // The C API has no renderer cooperation to know whether the active + // area changed, so it is always re-scanned. This is correct without + // any dirty tracking and cheap because the active area search was + // built for exactly this. + s.feed(t, true); + return .success; +} + +pub fn run(search_: Search) callconv(lib.calling_conv) Result { + const wrapper = search_ orelse return .invalid_value; + const t = wrapper.zigTerminal() orelse return .invalid_value; + const s = if (wrapper.search) |*s| s else return .success; + + // Always start with a feed: complete only means caught up as of + // the last feed, so run doubles as the "terminal changed, catch + // up" convenience for one-shot embedders. + s.feed(t, true); + while (true) { + switch (s.status()) { + .complete => return .success, + .feed_required => s.feed(t, true), + .running => _ = s.tick(), + } + } +} + +pub fn set( + search_: Search, + option: Option, + value: ?*const anyopaque, +) callconv(lib.calling_conv) Result { + if (comptime std.debug.runtime_safety) { + _ = std.enums.fromInt(Option, @intFromEnum(option)) orelse { + log.warn("search_set invalid option value={d}", .{@intFromEnum(option)}); + return .invalid_value; + }; + } + + return switch (option) { + inline else => |comptime_option| setTyped( + search_, + comptime_option, + if (value) |ptr| @ptrCast(@alignCast(ptr)) else null, + ), + }; +} + +fn setTyped( + search_: Search, + comptime option: Option, + value: ?*const option.Type(), +) Result { + const wrapper = search_ orelse return .invalid_value; + switch (option) { + .needle => { + // The needle touches the terminal (replacing or clearing + // an active search releases tracked pins through it), so + // it requires the terminal to still be alive. + const t = wrapper.zigTerminal() orelse return .invalid_value; + + // NULL and empty both clear the needle, returning the + // search to idle. This matches the internal engine, where + // an empty needle stops the search. + const v = value orelse return clearNeedle(wrapper, t); + if (v.len == 0) return clearNeedle(wrapper, t); + if (@intFromPtr(v.ptr) == 0) return .invalid_value; + const bytes = v.ptr[0..v.len]; + + // Setting the current needle again keeps existing results, + // using the same ASCII case-insensitive comparison as + // matching. This is what a find bar wants on resubmit. + if (wrapper.search) |*s| { + if (std.ascii.eqlIgnoreCase(s.needle(), bytes)) { + return .success; + } + } + + // Create the replacement before dropping the current + // search so an allocation failure keeps existing state. + const replacement = TerminalSearch.init( + wrapper.alloc, + bytes, + ) catch return .out_of_memory; + if (wrapper.search) |*s| s.deinit(t); + wrapper.search = replacement; + }, + + .select_next, .select_prev => { + // The value is reserved for future use and must be NULL. + if (value != null) return .invalid_value; + + const t = wrapper.zigTerminal() orelse return .invalid_value; + const s = if (wrapper.search) |*s| s else return .no_value; + const selected = s.select( + t, + switch (option) { + .select_next => .next, + .select_prev => .prev, + else => comptime unreachable, + }, + wrapper.select_scroll, + ) catch return .out_of_memory; + if (!selected) return .no_value; + }, + + .select_scroll => { + const v = value orelse { + wrapper.select_scroll = .if_needed; + return .success; + }; + const scroll = std.enums.fromInt(Scroll, @intFromEnum(v.*)) orelse + return .invalid_value; + wrapper.select_scroll = scroll.toZig(); + }, + } + + return .success; +} + +fn clearNeedle(wrapper: *SearchWrapper, t: *terminal_c.ZigTerminal) Result { + if (wrapper.search) |*s| { + s.deinit(t); + wrapper.search = null; + } + return .success; +} + +pub fn get( + search_: Search, + data: Data, + value: ?*anyopaque, +) callconv(lib.calling_conv) Result { + if (comptime std.debug.runtime_safety) { + _ = std.enums.fromInt(Data, @intFromEnum(data)) orelse { + log.warn("search_get invalid data value={d}", .{@intFromEnum(data)}); + return .invalid_value; + }; + } + + const out_ptr = value orelse return .invalid_value; + return switch (data) { + inline else => |comptime_data| getTyped( + search_, + comptime_data, + @ptrCast(@alignCast(out_ptr)), + ), + }; +} + +pub fn get_multi( + search_: Search, + count: usize, + keys: ?[*]const Data, + values: ?[*]?*anyopaque, + out_written: ?*usize, +) callconv(lib.calling_conv) Result { + const k = keys orelse return .invalid_value; + const v = values orelse return .invalid_value; + + for (0..count) |i| { + const result = get(search_, k[i], v[i]); + if (result != .success) { + if (out_written) |w| w.* = i; + return result; + } + } + if (out_written) |w| w.* = count; + return .success; +} + +fn getTyped( + search_: Search, + comptime data: Data, + out: *data.OutType(), +) Result { + const wrapper = search_ orelse return .invalid_value; + + switch (data) { + .status => out.* = wrapper.status(), + + .needle => { + const s = if (wrapper.search) |*s| s else return .no_value; + out.* = .init(s.needle()); + }, + + .total_matches => out.* = total: { + const s = if (wrapper.search) |*s| s else break :total 0; + const ss = s.activeScreenSearch() orelse break :total 0; + break :total ss.matchesLen(); + }, + + .selected_index => { + const s = if (wrapper.search) |*s| s else return .no_value; + const ss = s.activeScreenSearch() orelse return .no_value; + const selected = ss.selected orelse return .no_value; + out.* = selected.idx; + }, + + .selected_match => { + const s = if (wrapper.search) |*s| s else return .no_value; + const ss = s.activeScreenSearch() orelse return .no_value; + const hl = ss.selectedMatch() orelse return .no_value; + out.* = selectionFromHighlight(hl); + }, + + .matches => { + const ss = if (wrapper.search) |*s| + s.activeScreenSearch() + else + null; + const total = if (ss) |v| v.matchesLen() else 0; + if (out.cap < total) { + out.len = total; + return .out_of_space; + } + if (total > 0) { + const dst = (out.ptr orelse return .invalid_value)[0..total]; + for (dst, 0..) |*d, i| { + d.* = selectionFromHighlight(ss.?.matchAt(i).?); + } + } + out.len = total; + }, + + .viewport_matches => { + const matches: []const FlattenedHighlight = if (wrapper.search) |*s| + s.viewportMatches() catch return .out_of_memory + else + &.{}; + if (out.cap < matches.len) { + out.len = matches.len; + return .out_of_space; + } + if (matches.len > 0) { + const dst = (out.ptr orelse return .invalid_value)[0..matches.len]; + for (dst, matches) |*d, hl| d.* = selectionFromHighlight(hl); + } + out.len = matches.len; + }, + + .select_scroll => out.* = .fromZig(wrapper.select_scroll), + } + + return .success; +} + +fn selectionFromHighlight(hl: FlattenedHighlight) selection_c.CSelection { + const untracked = hl.untracked(); + return .{ + .start = .fromPin(untracked.start), + .end = .fromPin(untracked.end), + .rectangle = false, + }; +} + +fn testString(str: []const u8) lib.String { + return .init(str); +} + +fn testNewSearch(terminal: terminal_c.Terminal, needle_str: []const u8) !Search { + var search: Search = null; + try testing.expectEqual(Result.success, new( + &lib.alloc.test_allocator, + &search, + terminal, + )); + errdefer free(search); + const needle_value = testString(needle_str); + try testing.expectEqual(Result.success, set(search, .needle, &needle_value)); + return search; +} + +test "search lifecycle and run to complete" { + var terminal: terminal_c.Terminal = null; + try testing.expectEqual(Result.success, terminal_c.new( + &lib.alloc.test_allocator, + &terminal, + 10, + 4, + )); + defer terminal_c.free(terminal); + + terminal_c.vt_write(terminal, "Fizz\r\nBuzz\r\nFizz\r\nBang", 22); + + const search: Search = try testNewSearch(terminal, "Fizz"); + defer free(search); + + // Right after the needle is set the search must report + // feed_required, not complete. The internal completion check is + // trivially true before the search has seen the terminal. + var status: Status = .complete; + try testing.expectEqual(Result.success, get(search, .status, &status)); + try testing.expectEqual(Status.feed_required, status); + + try testing.expectEqual(Result.success, run(search)); + try testing.expectEqual(Result.success, get(search, .status, &status)); + try testing.expectEqual(Status.complete, status); + + var total: usize = 0; + try testing.expectEqual(Result.success, get(search, .total_matches, &total)); + try testing.expectEqual(@as(usize, 2), total); + + // The needle is borrowed and matches what we searched for. + var needle: lib.String = undefined; + try testing.expectEqual(Result.success, get(search, .needle, &needle)); + try testing.expectEqualStrings("Fizz", needle.ptr[0..needle.len]); +} + +test "search tick reports feed required before first feed" { + var terminal: terminal_c.Terminal = null; + try testing.expectEqual(Result.success, terminal_c.new( + &lib.alloc.test_allocator, + &terminal, + 10, + 4, + )); + defer terminal_c.free(terminal); + + const search: Search = try testNewSearch(terminal, "Fizz"); + defer free(search); + + var status: Status = .complete; + try testing.expectEqual(Result.success, tick(search, &status)); + try testing.expectEqual(Status.feed_required, status); + + // NULL status out is allowed. + try testing.expectEqual(Result.success, tick(search, null)); +} + +test "search feed after write updates results" { + var terminal: terminal_c.Terminal = null; + try testing.expectEqual(Result.success, terminal_c.new( + &lib.alloc.test_allocator, + &terminal, + 10, + 4, + )); + defer terminal_c.free(terminal); + + terminal_c.vt_write(terminal, "Fizz", 4); + + const search: Search = try testNewSearch(terminal, "Fizz"); + defer free(search); + + try testing.expectEqual(Result.success, run(search)); + var total: usize = 0; + try testing.expectEqual(Result.success, get(search, .total_matches, &total)); + try testing.expectEqual(@as(usize, 1), total); + + // Write another match: the caller-driven feed is the "terminal + // changed" signal. + terminal_c.vt_write(terminal, "\r\nFizz", 6); + try testing.expectEqual(Result.success, run(search)); + try testing.expectEqual(Result.success, get(search, .total_matches, &total)); + try testing.expectEqual(@as(usize, 2), total); +} + +test "search alt screen flip and return retains results" { + var terminal: terminal_c.Terminal = null; + try testing.expectEqual(Result.success, terminal_c.new( + &lib.alloc.test_allocator, + &terminal, + 10, + 4, + )); + defer terminal_c.free(terminal); + + terminal_c.vt_write(terminal, "Fizz\r\nFizz", 10); + + const search: Search = try testNewSearch(terminal, "Fizz"); + defer free(search); + + try testing.expectEqual(Result.success, run(search)); + var total: usize = 0; + try testing.expectEqual(Result.success, get(search, .total_matches, &total)); + try testing.expectEqual(@as(usize, 2), total); + + // Flip to the alternate screen: reads now reflect that screen. + terminal_c.vt_write(terminal, "\x1b[?1049h", 8); + try testing.expectEqual(Result.success, run(search)); + try testing.expectEqual(Result.success, get(search, .total_matches, &total)); + try testing.expectEqual(@as(usize, 0), total); + + terminal_c.vt_write(terminal, "Fizz", 4); + try testing.expectEqual(Result.success, run(search)); + try testing.expectEqual(Result.success, get(search, .total_matches, &total)); + try testing.expectEqual(@as(usize, 1), total); + + // Return to the primary screen: results are retained and restored. + terminal_c.vt_write(terminal, "\x1b[?1049l", 8); + try testing.expectEqual(Result.success, run(search)); + try testing.expectEqual(Result.success, get(search, .total_matches, &total)); + try testing.expectEqual(@as(usize, 2), total); +} + +test "search select wraps in both directions" { + var terminal: terminal_c.Terminal = null; + try testing.expectEqual(Result.success, terminal_c.new( + &lib.alloc.test_allocator, + &terminal, + 10, + 4, + )); + defer terminal_c.free(terminal); + + terminal_c.vt_write(terminal, "Fizz\r\nBuzz\r\nFizz", 16); + + const search: Search = try testNewSearch(terminal, "Fizz"); + defer free(search); + try testing.expectEqual(Result.success, run(search)); + + // No selection yet. + var idx: usize = 999; + try testing.expectEqual(Result.no_value, get(search, .selected_index, &idx)); + + // Next: newest match first (index 0), then older (1), then wrap + // back to 0. + try testing.expectEqual(Result.success, set(search, .select_next, null)); + try testing.expectEqual(Result.success, get(search, .selected_index, &idx)); + try testing.expectEqual(@as(usize, 0), idx); + + var match: selection_c.CSelection = undefined; + try testing.expectEqual(Result.success, get(search, .selected_match, &match)); + try testing.expect(match.start.toPin() != null); + + try testing.expectEqual(Result.success, set(search, .select_next, null)); + try testing.expectEqual(Result.success, get(search, .selected_index, &idx)); + try testing.expectEqual(@as(usize, 1), idx); + + try testing.expectEqual(Result.success, set(search, .select_next, null)); + try testing.expectEqual(Result.success, get(search, .selected_index, &idx)); + try testing.expectEqual(@as(usize, 0), idx); + + // Prev: wrap backward to the oldest match. + try testing.expectEqual(Result.success, set(search, .select_prev, null)); + try testing.expectEqual(Result.success, get(search, .selected_index, &idx)); + try testing.expectEqual(@as(usize, 1), idx); +} + +test "search select with no matches returns no value" { + var terminal: terminal_c.Terminal = null; + try testing.expectEqual(Result.success, terminal_c.new( + &lib.alloc.test_allocator, + &terminal, + 10, + 4, + )); + defer terminal_c.free(terminal); + + const search: Search = try testNewSearch(terminal, "Fizz"); + defer free(search); + + try testing.expectEqual(Result.no_value, set(search, .select_next, null)); + try testing.expectEqual(Result.no_value, set(search, .select_prev, null)); +} + +test "search select rejects a non-null reserved value" { + var terminal: terminal_c.Terminal = null; + try testing.expectEqual(Result.success, terminal_c.new( + &lib.alloc.test_allocator, + &terminal, + 10, + 4, + )); + defer terminal_c.free(terminal); + + const search: Search = try testNewSearch(terminal, "Fizz"); + defer free(search); + + const bogus: c_int = 0; + try testing.expectEqual(Result.invalid_value, set(search, .select_next, &bogus)); + try testing.expectEqual(Result.invalid_value, set(search, .select_prev, &bogus)); +} + +test "search select scroll option" { + var terminal: terminal_c.Terminal = null; + try testing.expectEqual(Result.success, terminal_c.new( + &lib.alloc.test_allocator, + &terminal, + 10, + 4, + )); + defer terminal_c.free(terminal); + + const search: Search = try testNewSearch(terminal, "Fizz"); + defer free(search); + + var scroll: Scroll = .none; + try testing.expectEqual(Result.success, get(search, .select_scroll, &scroll)); + try testing.expectEqual(Scroll.if_needed, scroll); + + const none: Scroll = .none; + try testing.expectEqual(Result.success, set(search, .select_scroll, &none)); + try testing.expectEqual(Result.success, get(search, .select_scroll, &scroll)); + try testing.expectEqual(Scroll.none, scroll); + + // NULL resets to the default. + try testing.expectEqual(Result.success, set(search, .select_scroll, null)); + try testing.expectEqual(Result.success, get(search, .select_scroll, &scroll)); + try testing.expectEqual(Scroll.if_needed, scroll); + + // Invalid enum values are rejected. + const bogus: c_int = 42; + try testing.expectEqual(Result.invalid_value, set(search, .select_scroll, @ptrCast(&bogus))); +} + +test "search selection dropped when reset prunes results" { + var terminal: terminal_c.Terminal = null; + try testing.expectEqual(Result.success, terminal_c.new( + &lib.alloc.test_allocator, + &terminal, + 10, + 4, + )); + defer terminal_c.free(terminal); + + terminal_c.vt_write(terminal, "Fizz\r\nBuzz", 10); + + const search: Search = try testNewSearch(terminal, "Fizz"); + defer free(search); + try testing.expectEqual(Result.success, run(search)); + try testing.expectEqual(Result.success, set(search, .select_next, null)); + + // A selection survives unrelated writes... + terminal_c.vt_write(terminal, "\r\nBang", 6); + try testing.expectEqual(Result.success, run(search)); + var match: selection_c.CSelection = undefined; + try testing.expectEqual(Result.success, get(search, .selected_match, &match)); + + // ...but a full reset prunes every result, dropping the selection. + terminal_c.vt_write(terminal, "\x1bc", 2); + try testing.expectEqual(Result.success, run(search)); + var total: usize = 999; + try testing.expectEqual(Result.success, get(search, .total_matches, &total)); + try testing.expectEqual(@as(usize, 0), total); + try testing.expectEqual(Result.no_value, get(search, .selected_match, &match)); +} + +test "search matches buffer semantics" { + var terminal: terminal_c.Terminal = null; + try testing.expectEqual(Result.success, terminal_c.new( + &lib.alloc.test_allocator, + &terminal, + 10, + 4, + )); + defer terminal_c.free(terminal); + + terminal_c.vt_write(terminal, "Fizz\r\nBuzz\r\nFizz", 16); + + const search: Search = try testNewSearch(terminal, "Fizz"); + defer free(search); + try testing.expectEqual(Result.success, run(search)); + + // NULL ptr with cap 0 queries the required capacity. + var buf: selection_c.CSelectionBuffer = .{}; + try testing.expectEqual(Result.out_of_space, get(search, .matches, &buf)); + try testing.expectEqual(@as(usize, 2), buf.len); + + // Too-small buffers report the required capacity. + var one: [1]selection_c.CSelection = undefined; + buf = .{ .ptr = &one, .cap = one.len }; + try testing.expectEqual(Result.out_of_space, get(search, .matches, &buf)); + try testing.expectEqual(@as(usize, 2), buf.len); + + // A large-enough buffer is filled newest to oldest. + var storage: [4]selection_c.CSelection = undefined; + buf = .{ .ptr = &storage, .cap = storage.len }; + try testing.expectEqual(Result.success, get(search, .matches, &buf)); + try testing.expectEqual(@as(usize, 2), buf.len); + const first = storage[0].start.toPin().?; + const second = storage[1].start.toPin().?; + try testing.expect(first.y != second.y or first.node != second.node); + + // Viewport matches use the same conventions and are cached across + // reads. + buf = .{}; + try testing.expectEqual(Result.out_of_space, get(search, .viewport_matches, &buf)); + try testing.expectEqual(@as(usize, 2), buf.len); + buf = .{ .ptr = &storage, .cap = storage.len }; + try testing.expectEqual(Result.success, get(search, .viewport_matches, &buf)); + try testing.expectEqual(@as(usize, 2), buf.len); + buf = .{ .ptr = &storage, .cap = storage.len }; + try testing.expectEqual(Result.success, get(search, .viewport_matches, &buf)); + try testing.expectEqual(@as(usize, 2), buf.len); +} + +test "search get_multi returns first failing index" { + var terminal: terminal_c.Terminal = null; + try testing.expectEqual(Result.success, terminal_c.new( + &lib.alloc.test_allocator, + &terminal, + 10, + 4, + )); + defer terminal_c.free(terminal); + + terminal_c.vt_write(terminal, "Fizz", 4); + + const search: Search = try testNewSearch(terminal, "Fizz"); + defer free(search); + try testing.expectEqual(Result.success, run(search)); + + // No selection: selected_index fails at index 1. + const keys = [_]Data{ .total_matches, .selected_index, .status }; + var total: usize = 0; + var idx: usize = 0; + var status: Status = .running; + var values = [_]?*anyopaque{ &total, &idx, &status }; + var written: usize = 999; + try testing.expectEqual(Result.no_value, get_multi( + search, + keys.len, + &keys, + &values, + &written, + )); + try testing.expectEqual(@as(usize, 1), written); + try testing.expectEqual(@as(usize, 1), total); + + // After selecting, the whole batch succeeds. + try testing.expectEqual(Result.success, set(search, .select_next, null)); + try testing.expectEqual(Result.success, get_multi( + search, + keys.len, + &keys, + &values, + &written, + )); + try testing.expectEqual(keys.len, written); + try testing.expectEqual(@as(usize, 0), idx); +} + +test "search new validates options" { + var terminal: terminal_c.Terminal = null; + try testing.expectEqual(Result.success, terminal_c.new( + &lib.alloc.test_allocator, + &terminal, + 10, + 4, + )); + defer terminal_c.free(terminal); + + var search: Search = null; + + // NULL out and NULL terminal are invalid. + try testing.expectEqual(Result.invalid_value, new( + &lib.alloc.test_allocator, + null, + terminal, + )); + try testing.expectEqual(Result.invalid_value, new( + &lib.alloc.test_allocator, + &search, + null, + )); + try testing.expect(search == null); +} + +test "search without a needle is idle" { + var terminal: terminal_c.Terminal = null; + try testing.expectEqual(Result.success, terminal_c.new( + &lib.alloc.test_allocator, + &terminal, + 10, + 4, + )); + defer terminal_c.free(terminal); + + terminal_c.vt_write(terminal, "Fizz", 4); + + var search: Search = null; + try testing.expectEqual(Result.success, new( + &lib.alloc.test_allocator, + &search, + terminal, + )); + defer free(search); + + // With nothing to look for, the search is complete and empty, and + // driving it is a harmless no-op. + var status: Status = .running; + try testing.expectEqual(Result.success, get(search, .status, &status)); + try testing.expectEqual(Status.complete, status); + try testing.expectEqual(Result.success, feed(search)); + try testing.expectEqual(Result.success, run(search)); + try testing.expectEqual(Result.success, tick(search, &status)); + try testing.expectEqual(Status.complete, status); + + var needle_out: lib.String = undefined; + try testing.expectEqual(Result.no_value, get(search, .needle, &needle_out)); + var total: usize = 999; + try testing.expectEqual(Result.success, get(search, .total_matches, &total)); + try testing.expectEqual(@as(usize, 0), total); + try testing.expectEqual(Result.no_value, set(search, .select_next, null)); + + var buf: selection_c.CSelectionBuffer = .{}; + try testing.expectEqual(Result.success, get(search, .matches, &buf)); + try testing.expectEqual(@as(usize, 0), buf.len); + buf = .{}; + try testing.expectEqual(Result.success, get(search, .viewport_matches, &buf)); + try testing.expectEqual(@as(usize, 0), buf.len); + + // Clearing an already-clear needle is fine. + try testing.expectEqual(Result.success, set(search, .needle, null)); +} + +test "search needle change and clear" { + var terminal: terminal_c.Terminal = null; + try testing.expectEqual(Result.success, terminal_c.new( + &lib.alloc.test_allocator, + &terminal, + 10, + 4, + )); + defer terminal_c.free(terminal); + + terminal_c.vt_write(terminal, "Fizz\r\nBuzz", 10); + + const search: Search = try testNewSearch(terminal, "Fizz"); + defer free(search); + try testing.expectEqual(Result.success, run(search)); + try testing.expectEqual(Result.success, set(search, .select_next, null)); + + var total: usize = 0; + try testing.expectEqual(Result.success, get(search, .total_matches, &total)); + try testing.expectEqual(@as(usize, 1), total); + + // Setting the same needle again (any ASCII case) keeps existing + // results and the selection. + const same = testString("fIZZ"); + try testing.expectEqual(Result.success, set(search, .needle, &same)); + var needle_out: lib.String = undefined; + try testing.expectEqual(Result.success, get(search, .needle, &needle_out)); + try testing.expectEqualStrings("Fizz", needle_out.ptr[0..needle_out.len]); + var idx: usize = 999; + try testing.expectEqual(Result.success, get(search, .selected_index, &idx)); + try testing.expectEqual(@as(usize, 0), idx); + + // Changing the needle restarts the search from scratch. + const changed = testString("Buzz"); + try testing.expectEqual(Result.success, set(search, .needle, &changed)); + var status: Status = .complete; + try testing.expectEqual(Result.success, get(search, .status, &status)); + try testing.expectEqual(Status.feed_required, status); + try testing.expectEqual(Result.no_value, get(search, .selected_index, &idx)); + try testing.expectEqual(Result.success, run(search)); + try testing.expectEqual(Result.success, get(search, .total_matches, &total)); + try testing.expectEqual(@as(usize, 1), total); + try testing.expectEqual(Result.success, get(search, .needle, &needle_out)); + try testing.expectEqualStrings("Buzz", needle_out.ptr[0..needle_out.len]); + + // An empty needle clears the search, same as NULL. + const empty = testString(""); + try testing.expectEqual(Result.success, set(search, .needle, &empty)); + try testing.expectEqual(Result.no_value, get(search, .needle, &needle_out)); + try testing.expectEqual(Result.success, get(search, .status, &status)); + try testing.expectEqual(Status.complete, status); + try testing.expectEqual(Result.success, get(search, .total_matches, &total)); + try testing.expectEqual(@as(usize, 0), total); +} + +test "search free after terminal free" { + var terminal: terminal_c.Terminal = null; + try testing.expectEqual(Result.success, terminal_c.new( + &lib.alloc.test_allocator, + &terminal, + 10, + 4, + )); + + terminal_c.vt_write(terminal, "Fizz\r\nFizz", 10); + + const search: Search = try testNewSearch(terminal, "Fizz"); + + // Run and select so the search holds tracked pins within the + // terminal's page storage. + try testing.expectEqual(Result.success, run(search)); + try testing.expectEqual(Result.success, set(search, .select_next, null)); + + // Free the terminal first. The search detaches: calls that need + // the terminal fail cleanly instead of touching freed memory. + terminal_c.free(terminal); + try testing.expectEqual(Result.invalid_value, feed(search)); + try testing.expectEqual(Result.invalid_value, run(search)); + try testing.expectEqual(Result.invalid_value, set(search, .select_next, null)); + const needle_value = testString("Buzz"); + try testing.expectEqual(Result.invalid_value, set(search, .needle, &needle_value)); + + // Reads that only touch search-owned state still answer. + var needle_out: lib.String = undefined; + try testing.expectEqual(Result.success, get(search, .needle, &needle_out)); + try testing.expectEqualStrings("Fizz", needle_out.ptr[0..needle_out.len]); + const scroll: Scroll = .none; + try testing.expectEqual(Result.success, set(search, .select_scroll, &scroll)); + + // The search can still be freed, releasing only its own memory. + free(search); +} + +test "search freed before terminal detaches from the registry" { + var terminal: terminal_c.Terminal = null; + try testing.expectEqual(Result.success, terminal_c.new( + &lib.alloc.test_allocator, + &terminal, + 10, + 4, + )); + defer terminal_c.free(terminal); + + // Create two searches and free one while the terminal is alive. + // The freed search must be unregistered so the later terminal free + // only detaches the survivor. + const a: Search = try testNewSearch(terminal, "Fizz"); + const b: Search = try testNewSearch(terminal, "Fizz"); + defer free(b); + + try testing.expectEqual(Result.success, run(a)); + free(a); + try testing.expectEqual(Result.success, run(b)); +} + +test "search free null" { + free(null); +} diff --git a/vendor/libghostty-vt/src/terminal/c/selection.zig b/vendor/libghostty-vt/src/terminal/c/selection.zig index 6dcfcc0..77d518d 100644 --- a/vendor/libghostty-vt/src/terminal/c/selection.zig +++ b/vendor/libghostty-vt/src/terminal/c/selection.zig @@ -38,6 +38,18 @@ pub const CSelection = extern struct { } }; +/// C: GhosttySelectionBuffer +/// +/// A caller-provided buffer of selections. This follows the same +/// conventions as GhosttyBuffer: ptr may be NULL with cap 0 to query +/// the required capacity, and len is set to the entries written on +/// success or to the required capacity on GHOSTTY_OUT_OF_SPACE. +pub const CSelectionBuffer = extern struct { + ptr: ?[*]CSelection = null, + cap: usize = 0, + len: usize = 0, +}; + /// C: GhosttyTerminalSelectWordOptions pub const SelectWordOptions = extern struct { size: usize = @sizeOf(SelectWordOptions), diff --git a/vendor/libghostty-vt/src/terminal/c/sgr.zig b/vendor/libghostty-vt/src/terminal/c/sgr.zig index b0ef23d..bb8830c 100644 --- a/vendor/libghostty-vt/src/terminal/c/sgr.zig +++ b/vendor/libghostty-vt/src/terminal/c/sgr.zig @@ -126,17 +126,6 @@ pub fn attribute_value( return &attr.value; } -pub fn wasm_alloc_attribute() callconv(lib.calling_conv) *sgr.Attribute.C { - const alloc = std.heap.wasm_allocator; - const ptr = alloc.create(sgr.Attribute.C) catch @panic("out of memory"); - return ptr; -} - -pub fn wasm_free_attribute(attr: *sgr.Attribute.C) callconv(lib.calling_conv) void { - const alloc = std.heap.wasm_allocator; - alloc.destroy(attr); -} - test "alloc" { var p: Parser = undefined; try testing.expectEqual(Result.success, new( diff --git a/vendor/libghostty-vt/src/terminal/c/snapshot.zig b/vendor/libghostty-vt/src/terminal/c/snapshot.zig index 45ac8a1..75cbc17 100644 --- a/vendor/libghostty-vt/src/terminal/c/snapshot.zig +++ b/vendor/libghostty-vt/src/terminal/c/snapshot.zig @@ -11,15 +11,18 @@ const apc = @import("../apc.zig"); /// Snapshot readers accept the largest default APC payload. Deriving this from /// the APC protocols keeps snapshot validation in sync as protocols change. -/// This remains independent of opt-in terminal-side continuation tracking. +/// Opt-in decoded-terminal retention reuses this configured validation bound +/// as its runtime tracking limit. const default_max_continuation_bytes = apc.Protocol.maxDefaultBytes(); const terminal_c = @import("terminal.zig"); /// C: GhosttySnapshotDecoderOption. /// -/// Options configure validation policy before snapshot decoding begins. +/// Options configure validation and returned-terminal policy before snapshot +/// decoding begins. pub const DecoderOption = enum(c_int) { max_continuation_bytes = 0, + retain_continuation = 1, _, }; @@ -35,6 +38,7 @@ pub const DecoderData = enum(c_int) { progress_screen = 5, progress_rows = 6, progress_remaining = 7, + retain_continuation = 8, _, /// Return the Zig type stored through the C API output pointer for a key. @@ -46,6 +50,7 @@ pub const DecoderData = enum(c_int) { .progress_screen => terminal_c.TerminalScreen, .progress_rows => usize, .progress_remaining => u32, + .retain_continuation => bool, _ => void, }; } @@ -102,6 +107,7 @@ const DecoderWrapper = struct { decoder: snapshot_core.Decoder, state: State, max_continuation_bytes: usize, + retain_continuation: bool, }; /// C: GhosttySnapshotDecoder, an opaque nullable decoder handle. @@ -164,6 +170,7 @@ fn decoderNewSource( wrapper.source = source; wrapper.state = .configuring; wrapper.max_continuation_bytes = default_max_continuation_bytes; + wrapper.retain_continuation = false; wrapper.decoder = .init(wrapper.source.reader()); out.* = wrapper; return .success; @@ -194,6 +201,8 @@ pub fn decoder_set( switch (option) { .max_continuation_bytes => decoder.max_continuation_bytes = @as(*const usize, @ptrCast(@alignCast(value))).*, + .retain_continuation => decoder.retain_continuation = + @as(*const bool, @ptrCast(@alignCast(value))).*, _ => return .invalid_value, } return .success; @@ -215,6 +224,7 @@ pub fn decoder_get( .progress_screen, .progress_rows, .progress_remaining, + .retain_continuation, => |comptime_data| decoderGetTyped( decoder_, comptime_data, @@ -242,6 +252,10 @@ fn decoderGetTyped( if (decoderFailed(decoder)) return .no_value; out.* = decoder.source.offset(); }, + .retain_continuation => { + if (decoderFailed(decoder)) return .no_value; + out.* = decoder.retain_continuation; + }, // READY metadata remains available after history decoding completes. .history_rows_primary => { @@ -340,7 +354,6 @@ pub fn decoder_ready( .configuring => {}, else => return .invalid_value, } - // Decode READY and transfer the decoded state into a C-owned terminal. const ready = decoderReadyTerminal(decoder) catch |err| { decoder.state = .{ .failed = null }; @@ -405,7 +418,6 @@ pub fn decoder_decode( .configuring => {}, else => return .invalid_value, } - // Build the terminal from the renderable READY prefix. const ready = decoderReadyTerminal(decoder) catch |err| { decoder.state = .{ .failed = null }; @@ -461,6 +473,10 @@ fn decoderReadyTerminal(decoder: *DecoderWrapper) anyerror!ReadyTerminal { decoder.alloc, io, &decoded, + if (decoder.retain_continuation) + decoder.max_continuation_bytes + else + 0, ); return .{ .terminal = terminal, .metadata = metadata }; } @@ -542,14 +558,18 @@ pub fn encode( if (continuation.result != .success) return continuation.result; defer native.gpa().free(continuation.bytes); - // Stream the snapshot directly through the callback adapter. - var adapter: io_c.WriterAdapter = .init(writer); + // Stream the snapshot through the callback adapter, batching the + // encoder's writes into few callback invocations. + var buffer: [io_c.WriterAdapter.recommended_buffer_len]u8 = undefined; + var adapter: io_c.WriterAdapter = .initBuffered(writer, &buffer); snapshot_core.encode( native.gpa(), &adapter.interface, native, .{ .continuation = continuationValue(continuation.bytes) }, ) catch |err| return mapEncodeError(err, &adapter); + adapter.interface.flush() catch + return mapEncodeError(error.WriteFailed, &adapter); return .success; } @@ -701,6 +721,27 @@ test "decoder option and empty source" { )); try testing.expectEqual(1234, limit); + var retain = true; + try testing.expectEqual(Result.success, decoder_get( + decoder, + .retain_continuation, + &retain, + )); + try testing.expect(!retain); + retain = true; + try testing.expectEqual(Result.success, decoder_set( + decoder, + .retain_continuation, + &retain, + )); + retain = false; + try testing.expectEqual(Result.success, decoder_get( + decoder, + .retain_continuation, + &retain, + )); + try testing.expect(retain); + var written: usize = 99; try testing.expectEqual(Result.invalid_value, decoder_get_multi( decoder, @@ -796,6 +837,81 @@ test "snapshot C API full round trip restores continuation" { try testing.expectEqual(encoded.len, offset); try testing.expectEqual(Result.no_value, decoder_next(decoder)); + var retained_decoder: Decoder = null; + try testing.expectEqual(Result.success, decoder_new_buf( + &lib.alloc.test_allocator, + &retained_decoder, + encoded.ptr, + encoded.len, + )); + defer if (retained_decoder != null) decoder_free(retained_decoder); + const retained_limit: usize = 1024; + try testing.expectEqual(Result.success, decoder_set( + retained_decoder, + .max_continuation_bytes, + &retained_limit, + )); + const retain = true; + try testing.expectEqual(Result.success, decoder_set( + retained_decoder, + .retain_continuation, + &retain, + )); + + // The returned tracker owns the exact decoded continuation, so the + // decoder can be freed before callers export those terminal-owned bytes. + var retained: terminal_c.Terminal = null; + try testing.expectEqual(Result.success, decoder_decode( + retained_decoder, + &retained, + )); + decoder_free(retained_decoder); + retained_decoder = null; + defer terminal_c.free(retained); + + var retained_terminal_limit: usize = 0; + try testing.expectEqual(Result.success, terminal_c.get( + retained, + .continuation_max_bytes, + &retained_terminal_limit, + )); + try testing.expectEqual(retained_limit, retained_terminal_limit); + var continuation_buf: [16]u8 = undefined; + var continuation_len: usize = 0; + try testing.expectEqual(Result.success, terminal_c.continuation_buf( + retained, + &continuation_buf, + continuation_buf.len, + &continuation_len, + )); + try testing.expectEqualStrings( + "\x1b[31", + continuation_buf[0..continuation_len], + ); + + // Disabling tracking releases the exported prefix without changing the + // unfinished parser state. The final byte still completes the SGR. + const disabled: usize = 0; + try testing.expectEqual(Result.success, terminal_c.set( + retained, + .continuation_max_bytes, + &disabled, + )); + try testing.expectEqual(Result.invalid_value, terminal_c.continuation_buf( + retained, + &continuation_buf, + continuation_buf.len, + &continuation_len, + )); + terminal_c.vt_write(retained, "m", 1); + var retained_ground = false; + try testing.expectEqual(Result.success, terminal_c.get( + retained, + .vt_ground, + &retained_ground, + )); + try testing.expect(retained_ground); + var limited: Decoder = null; try testing.expectEqual(Result.success, decoder_new_buf( &lib.alloc.test_allocator, @@ -828,7 +944,6 @@ test "snapshot C API full round trip restores continuation" { &failed_offset, )); - const disabled: usize = 0; try testing.expectEqual(Result.success, terminal_c.set( source, .continuation_max_bytes, @@ -843,6 +958,158 @@ test "snapshot C API full round trip restores continuation" { )); } +test "snapshot decoder retains every supported continuation class" { + const cases = [_]struct { + name: []const u8, + prefix: []const u8, + suffix: []const u8, + }{ + .{ .name = "ground", .prefix = "", .suffix = "X" }, + .{ .name = "CSI", .prefix = "\x1b[31", .suffix = "mX" }, + .{ .name = "OSC", .prefix = "\x1b]2;title", .suffix = "\x07" }, + .{ .name = "DCS", .prefix = "\x1bP$qm", .suffix = "\x1b\\" }, + .{ .name = "APC", .prefix = "\x1b_Ga=q;payload", .suffix = "\x1b\\" }, + .{ .name = "UTF-8", .prefix = "\xF0\x9F", .suffix = "\x98\x84" }, + }; + const limit: usize = 4096; + const disabled: usize = 0; + const retain = true; + + for (cases) |case| { + errdefer std.log.err("retained continuation case failed: {s}", .{case.name}); + + var source: terminal_c.Terminal = null; + try testing.expectEqual(Result.success, terminal_c.new( + &lib.alloc.test_allocator, + &source, + 20, + 4, + )); + defer terminal_c.free(source); + try testing.expectEqual(Result.success, terminal_c.set( + source, + .continuation_max_bytes, + &limit, + )); + terminal_c.vt_write(source, case.prefix.ptr, case.prefix.len); + + var snapshot_ptr: ?[*]u8 = null; + var snapshot_len: usize = 0; + try testing.expectEqual(Result.success, encode_alloc( + source, + &lib.alloc.test_allocator, + &snapshot_ptr, + &snapshot_len, + )); + const snapshot = snapshot_ptr.?[0..snapshot_len]; + defer lib.alloc.default(&lib.alloc.test_allocator).free(snapshot); + + var decoder: Decoder = null; + try testing.expectEqual(Result.success, decoder_new_buf( + &lib.alloc.test_allocator, + &decoder, + snapshot.ptr, + snapshot.len, + )); + defer if (decoder != null) decoder_free(decoder); + try testing.expectEqual(Result.success, decoder_set( + decoder, + .max_continuation_bytes, + &limit, + )); + try testing.expectEqual(Result.success, decoder_set( + decoder, + .retain_continuation, + &retain, + )); + + var restored: terminal_c.Terminal = null; + try testing.expectEqual(Result.success, decoder_decode( + decoder, + &restored, + )); + defer terminal_c.free(restored); + decoder_free(decoder); + decoder = null; + + var restored_limit: usize = 0; + try testing.expectEqual(Result.success, terminal_c.get( + restored, + .continuation_max_bytes, + &restored_limit, + )); + try testing.expectEqual(limit, restored_limit); + + var continuation: [4096]u8 = undefined; + var continuation_len: usize = 0; + try testing.expectEqual(Result.success, terminal_c.continuation_buf( + restored, + &continuation, + continuation.len, + &continuation_len, + )); + try testing.expectEqualStrings( + case.prefix, + continuation[0..continuation_len], + ); + + // Releasing the retained bytes must not reset the parser. Both the + // source and replica should reach the same fully serializable state + // after consuming the suffix that followed the snapshot cut. + try testing.expectEqual(Result.success, terminal_c.set( + restored, + .continuation_max_bytes, + &disabled, + )); + try testing.expectEqual(Result.invalid_value, terminal_c.continuation_buf( + restored, + &continuation, + continuation.len, + &continuation_len, + )); + terminal_c.vt_write(source, case.suffix.ptr, case.suffix.len); + terminal_c.vt_write(restored, case.suffix.ptr, case.suffix.len); + + var source_ground = false; + var restored_ground = false; + try testing.expectEqual(Result.success, terminal_c.get( + source, + .vt_ground, + &source_ground, + )); + try testing.expectEqual(Result.success, terminal_c.get( + restored, + .vt_ground, + &restored_ground, + )); + try testing.expect(source_ground); + try testing.expect(restored_ground); + + var source_final_ptr: ?[*]u8 = null; + var source_final_len: usize = 0; + try testing.expectEqual(Result.success, encode_alloc( + source, + &lib.alloc.test_allocator, + &source_final_ptr, + &source_final_len, + )); + const source_final = source_final_ptr.?[0..source_final_len]; + defer lib.alloc.default(&lib.alloc.test_allocator).free(source_final); + + var restored_final_ptr: ?[*]u8 = null; + var restored_final_len: usize = 0; + try testing.expectEqual(Result.success, encode_alloc( + restored, + &lib.alloc.test_allocator, + &restored_final_ptr, + &restored_final_len, + )); + const restored_final = restored_final_ptr.?[0..restored_final_len]; + defer lib.alloc.default(&lib.alloc.test_allocator).free(restored_final); + try testing.expectEqualSlices(u8, source_final, restored_final); + } +} + test "snapshot encoding accepts an untracked ground terminal" { var source: terminal_c.Terminal = null; try testing.expectEqual(Result.success, terminal_c.new( @@ -1108,6 +1375,12 @@ test "snapshot incremental decoder exposes READY and page progress" { .get(.primary).?.pages.pages.first.?.capacity().rows; for (0..@as(usize, first_page_rows) * 3) |_| terminal_c.vt_write(source, "x\r\n", 3); + const ready_continuation = "\x1b]2;ready"; + terminal_c.vt_write( + source, + ready_continuation, + ready_continuation.len, + ); var encoded_ptr: ?[*]u8 = null; var encoded_len: usize = 0; @@ -1128,11 +1401,45 @@ test "snapshot incremental decoder exposes READY and page progress" { encoded.len, )); defer decoder_free(decoder); + const retained_limit: usize = 1024; + try testing.expectEqual(Result.success, decoder_set( + decoder, + .max_continuation_bytes, + &retained_limit, + )); + const retain = true; + try testing.expectEqual(Result.success, decoder_set( + decoder, + .retain_continuation, + &retain, + )); var restored: terminal_c.Terminal = null; try testing.expectEqual(Result.success, decoder_ready(decoder, &restored)); defer terminal_c.free(restored); + // READY applies the same retention policy as one-shot decode and makes + // the unfinished snapshot continuation immediately exportable. + var restored_limit: usize = 0; + try testing.expectEqual(Result.success, terminal_c.get( + restored, + .continuation_max_bytes, + &restored_limit, + )); + try testing.expectEqual(retained_limit, restored_limit); + var ready_buf: [32]u8 = undefined; + var ready_len: usize = 0; + try testing.expectEqual(Result.success, terminal_c.continuation_buf( + restored, + &ready_buf, + ready_buf.len, + &ready_len, + )); + try testing.expectEqualStrings( + ready_continuation, + ready_buf[0..ready_len], + ); + var history_rows: u64 = 0; try testing.expectEqual(Result.success, decoder_get( decoder, diff --git a/vendor/libghostty-vt/src/terminal/c/style.zig b/vendor/libghostty-vt/src/terminal/c/style.zig index 82729fa..bea4d07 100644 --- a/vendor/libghostty-vt/src/terminal/c/style.zig +++ b/vendor/libghostty-vt/src/terminal/c/style.zig @@ -25,20 +25,32 @@ pub const Color = extern struct { tag: ColorTag, value: ColorValue, + // fromColor converts the tag as a plain integer, which requires + // the internal and C tag values to line up. + comptime { + const Tag = std.meta.Tag(style.Style.Color); + for (@typeInfo(Tag).@"enum".fields) |f| { + assert(f.value == @intFromEnum(@field(ColorTag, f.name))); + } + } + pub fn fromColor(c: style.Style.Color) Color { - return switch (c) { - .none => .{ - .tag = .none, - .value = .{ ._padding = 0 }, - }, - .palette => |idx| .{ - .tag = .palette, - .value = .{ .palette = idx }, - }, - .rgb => |rgb| .{ - .tag = .rgb, - .value = .{ .rgb = rgb.cval() }, - }, + // The value is built as a single integer rather than through + // the per-variant union fields: the C union layout puts + // .palette at byte 0 and .rgb at bytes 0-2, which is exactly + // the internal payload representation, so each variant is the + // payload zero-extended to the u64 union backing. This keeps + // the conversion to two stores (tag + value) per color, which + // matters for the per-cell style reads in the render state + // API. + const value: u64 = switch (c) { + .none => 0, + .palette => |idx| idx, + .rgb => |rgb| @as(u24, @bitCast(rgb)), + }; + return .{ + .tag = @enumFromInt(@intFromEnum(std.meta.activeTag(c))), + .value = .{ ._padding = value }, }; } }; @@ -59,27 +71,83 @@ pub const Style = extern struct { overline: bool, underline: c_int, - pub fn fromStyle(s: style.Style) Style { - return .{ - .fg_color = .fromColor(s.fg_color), - .bg_color = .fromColor(s.bg_color), - .underline_color = .fromColor(s.underline_color), - .bold = s.flags.bold, - .italic = s.flags.italic, - .faint = s.flags.faint, - .blink = s.flags.blink, - .inverse = s.flags.inverse, - .invisible = s.flags.invisible, - .strikethrough = s.flags.strikethrough, - .overline = s.flags.overline, - .underline = @intFromEnum(s.flags.underline), + /// The default (empty) style in C representation. C + pub const default: Style = .{ + .fg_color = .{ .tag = .none, .value = .{ ._padding = 0 } }, + .bg_color = .{ .tag = .none, .value = .{ ._padding = 0 } }, + .underline_color = .{ .tag = .none, .value = .{ ._padding = 0 } }, + .bold = false, + .italic = false, + .faint = false, + .blink = false, + .inverse = false, + .invisible = false, + .strikethrough = false, + .overline = false, + .underline = 0, + }; + + // fromStyle writes the eight bool fields as one 8-byte store, + // which requires them to be consecutive and to match the low + // eight flag bits in order. + comptime { + const flag_names = [8][:0]const u8{ + "bold", "italic", "faint", "blink", + "inverse", "invisible", "strikethrough", "overline", }; + const base = @offsetOf(Style, "bold"); + for (flag_names, 0..) |name, i| { + assert(@offsetOf(Style, name) == base + i); + assert(@bitOffsetOf(@FieldType(style.Style, "flags"), name) == i); + } + } + + /// Write the C representation of the given style directly through + /// the out pointer. The hot per-cell style read in the render + /// state API uses this rather than `out.* = fromStyle(s)`: the + /// by-value form materializes a stack temporary plus a + /// @sizeOf(Style)-byte copy that LLVM does not elide. + pub fn write(s: style.Style, out: *Style) void { + out.size = @sizeOf(Style); + out.fg_color = .fromColor(s.fg_color); + out.bg_color = .fromColor(s.bg_color); + out.underline_color = .fromColor(s.underline_color); + + // I know this looks scary, but this results in truly measurable + // performance improvements due to how often styles are written + // especially in render loops. We vectorize our 8-bit to 8-byte + // write. + // + // We have to align(1) because we're ptrCasting a u8 pointer. + @as(*align(1) u64, @ptrCast(&out.bold)).* = bytes: { + // Spread the low eight flag bits (the eight bool fields, in + // order) into one byte each and write them with a single + // 8-byte store. + const flag_bits: u8 = @truncate(@as(u16, @bitCast(s.flags))); + const masks: @Vector(8, u8) = .{ 1, 2, 4, 8, 16, 32, 64, 128 }; + const hits = (@as(@Vector(8, u8), @splat(flag_bits)) & masks) == masks; + const bytes: @Vector(8, u8) = @select( + u8, + hits, + @as(@Vector(8, u8), @splat(1)), + @as(@Vector(8, u8), @splat(0)), + ); + break :bytes @bitCast(bytes); + }; + + out.underline = @intFromEnum(s.flags.underline); + } + + pub fn fromStyle(s: style.Style) Style { + var result: Style = undefined; + write(s, &result); + return result; } }; /// Returns the default style. pub fn default_style(result: *Style) callconv(lib.calling_conv) void { - result.* = .fromStyle(.{}); + result.* = .default; assert(result.size == @sizeOf(Style)); } diff --git a/vendor/libghostty-vt/src/terminal/c/sys.zig b/vendor/libghostty-vt/src/terminal/c/sys.zig index 319f179..40e7e1c 100644 --- a/vendor/libghostty-vt/src/terminal/c/sys.zig +++ b/vendor/libghostty-vt/src/terminal/c/sys.zig @@ -23,6 +23,13 @@ pub const DecodePngFn = *const fn ( *Image, ) callconv(lib.calling_conv) bool; +/// C: GhosttySysRandomSecureFn +pub const RandomSecureFn = *const fn ( + ?*anyopaque, + [*]u8, + usize, +) callconv(lib.calling_conv) bool; + /// C: GhosttySysLogLevel pub const LogLevel = enum(c_int) { @"error" = 0, @@ -55,12 +62,14 @@ pub const Option = enum(c_int) { userdata = 0, decode_png = 1, log = 2, + random_secure = 3, pub fn InType(comptime self: Option) type { return switch (self) { .userdata => ?*const anyopaque, .decode_png => ?DecodePngFn, .log => ?LogFn, + .random_secure => ?RandomSecureFn, }; } }; @@ -71,6 +80,7 @@ const Global = struct { userdata: ?*anyopaque = null, decode_png: ?DecodePngFn = null, log: ?LogFn = null, + random_secure: ?RandomSecureFn = null, }; /// Global state for the C sys interface. @@ -98,6 +108,12 @@ fn decodePngWrapper( }; } +/// Zig-compatible wrapper that calls through to the stored C callback. +fn randomSecureWrapper(buffer: []u8) terminal_sys.RandomSecureError!void { + const func = global.random_secure orelse return error.EntropyUnavailable; + if (!func(global.userdata, buffer.ptr, buffer.len)) return error.EntropyUnavailable; +} + pub fn set( option: Option, value: ?*const anyopaque, @@ -127,6 +143,10 @@ fn setTyped( terminal_sys.decode_png = if (value != null) &decodePngWrapper else null; }, .log => global.log = value, + .random_secure => { + global.random_secure = value; + terminal_sys.random_secure = if (value != null) &randomSecureWrapper else null; + }, } return .success; } diff --git a/vendor/libghostty-vt/src/terminal/c/terminal.zig b/vendor/libghostty-vt/src/terminal/c/terminal.zig index 24c5edb..5bf2c75 100644 --- a/vendor/libghostty-vt/src/terminal/c/terminal.zig +++ b/vendor/libghostty-vt/src/terminal/c/terminal.zig @@ -24,10 +24,12 @@ const cell_c = @import("cell.zig"); const row_c = @import("row.zig"); const grid_ref_c = @import("grid_ref.zig"); const grid_ref_tracked_c = @import("grid_ref_tracked.zig"); +const search_c = @import("search.zig"); const selection_c = @import("selection.zig"); const style_c = @import("style.zig"); const color = @import("../color.zig"); const clipboard = @import("../clipboard.zig"); +const kitty_clipboard = @import("../kitty/clipboard.zig"); const c_io = @import("io.zig"); const snapshot_core = @import("../snapshot/main.zig"); const Result = @import("result.zig").Result; @@ -112,6 +114,7 @@ const TerminalWrapper = struct { stream: Stream, effects: Effects = .{}, tracked_grid_refs: std.AutoArrayHashMapUnmanaged(*grid_ref_tracked_c.TrackedGridRef, void) = .{}, + searches: std.AutoArrayHashMapUnmanaged(*search_c.SearchWrapper, void) = .{}, /// Fetches a `TerminalWrapper` reference from a `Handler`. fn fromHandler(handler: *Handler) *TerminalWrapper { @@ -129,6 +132,8 @@ pub const ClipboardContent = extern struct { }; /// A protocol-neutral request to replace or clear clipboard contents. +/// The embedder answers by calling `reply` with the request before the +/// callback returns. /// /// C: GhosttyClipboardWrite pub const ClipboardWrite = extern struct { @@ -136,8 +141,64 @@ pub const ClipboardWrite = extern struct { location: clipboard.Location, contents: ?[*]const ClipboardContent, contents_len: usize, + name: lib.String, + granted: bool, + can_remember: bool, + /// Terminal-owned reply state; opaque to the embedder. + ctx: *const anyopaque, + reply: ClipboardWriteReplyFn, }; +/// The reply to a clipboard write request. +/// +/// C: GhosttyClipboardWriteReply +pub const ClipboardWriteReply = extern struct { + size: usize, + result: clipboard.Write.Status, + remember: bool, +}; + +/// C function pointer type for replying to a clipboard write. +/// +/// C: GhosttyClipboardWriteReplyFn +pub const ClipboardWriteReplyFn = *const fn (*const ClipboardWrite, *const ClipboardWriteReply) callconv(lib.calling_conv) void; + +/// The reply to a clipboard read request. +/// +/// C: GhosttyClipboardReadReply +pub const ClipboardReadReply = extern struct { + size: usize, + result: clipboard.Read.Status, + contents: ?[*]const ClipboardContent, + contents_len: usize, + available: ?[*]const lib.String, + available_len: usize, + remember: bool, +}; + +/// A synchronous request to read clipboard contents. The embedder answers +/// by calling `reply` with the request before the callback returns. +/// +/// C: GhosttyClipboardRead +pub const ClipboardRead = extern struct { + size: usize, + location: clipboard.Location, + mimes: ?[*]const lib.String, + mimes_len: usize, + list: bool, + name: lib.String, + granted: bool, + can_remember: bool, + /// Terminal-owned reply state; opaque to the embedder. + ctx: *const anyopaque, + reply: ClipboardReadReplyFn, +}; + +/// C function pointer type for replying to a clipboard read. +/// +/// C: GhosttyClipboardReadReplyFn +pub const ClipboardReadReplyFn = *const fn (*const ClipboardRead, *const ClipboardReadReply) callconv(lib.calling_conv) void; + /// A request to show a desktop notification. /// /// C: GhosttyTerminalDesktopNotification @@ -182,7 +243,7 @@ pub const UnknownSequence = union(Tag) { // A future borrowed CSI payload may need parameter, separator, and // intermediate arrays. Reserve 128 bytes so that representation and // other structured sequence types can be added without an ABI break. - [16]u64, + .{ .padding = [16]u64 }, ); pub const C = c_union.C; pub const CValue = c_union.CValue; @@ -204,8 +265,12 @@ pub const ModeConfig = extern struct { /// C callback state for terminal effects. Most trampolines are always /// installed on the stream handler; they check these fields and no-op when -/// the corresponding callback is null. The unknown-sequence trampoline is -/// installed dynamically to preserve its null fast path. +/// the corresponding callback is null. The unknown-sequence and +/// clipboard trampolines are installed dynamically to preserve their +/// null fast paths (for clipboard_write, a null Zig-level effect makes +/// Kitty clipboard writes fail up front instead of spooling a +/// transaction that can never commit; for clipboard_read it keeps +/// reads denied). const Effects = struct { userdata: ?*anyopaque = null, write_pty: ?WritePtyFn = null, @@ -220,6 +285,7 @@ const Effects = struct { progress_report: ?ProgressReportFn = null, size_cb: ?SizeFn = null, clipboard_write: ?ClipboardWriteFn = null, + clipboard_read: ?ClipboardReadFn = null, unknown_sequence: ?UnknownSequenceFn = null, /// Scratch buffer for DA1 feature codes. The device attributes @@ -252,8 +318,14 @@ const Effects = struct { pub const XtversionFn = *const fn (Terminal, ?*anyopaque) callconv(lib.calling_conv) lib.String; /// C function pointer type for the clipboard_write callback. The request - /// and its contents are borrowed and only valid for the callback duration. - pub const ClipboardWriteFn = *const fn (Terminal, ?*anyopaque, *const ClipboardWrite) callconv(lib.calling_conv) clipboard.WriteResult; + /// is borrowed for the callback duration and must be answered through + /// its reply function before the callback returns. + pub const ClipboardWriteFn = *const fn (Terminal, ?*anyopaque, *const ClipboardWrite) callconv(lib.calling_conv) void; + + /// C function pointer type for the clipboard_read callback. The request + /// is borrowed for the callback duration and must be answered through + /// its reply function before the callback returns. + pub const ClipboardReadFn = *const fn (Terminal, ?*anyopaque, *const ClipboardRead) callconv(lib.calling_conv) void; /// C function pointer type for the desktop_notification callback. The /// request and its strings are borrowed for the callback duration. @@ -272,9 +344,10 @@ const Effects = struct { /// and its content are borrowed for the callback duration. pub const UnknownSequenceFn = *const fn (Terminal, ?*anyopaque, *const UnknownSequence.C) callconv(lib.calling_conv) void; - /// C function pointer type for the size callback. - /// Returns true and fills out_size if size is available, - /// or returns false to silently ignore the query. + /// C function pointer type for the size callback. Used by XTWINOPS queries + /// and VT-driven mode 2048 enable reports. Returns true and fills out_size + /// if size is available, or false to suppress the XTWINOPS response or + /// mode 2048 report. pub const SizeFn = *const fn (Terminal, ?*anyopaque, *size_report.Size) callconv(lib.calling_conv) bool; /// C function pointer type for the device_attributes callback. @@ -306,7 +379,7 @@ const Effects = struct { }; }; - fn writePtyTrampoline(handler: *Handler, data: [:0]const u8) void { + fn writePtyTrampoline(handler: *Handler, data: []const u8) void { const wrapper = TerminalWrapper.fromHandler(handler); const func = wrapper.effects.write_pty orelse return; func(@ptrCast(wrapper), wrapper.effects.userdata, data.ptr, data.len); @@ -318,9 +391,15 @@ const Effects = struct { func(@ptrCast(wrapper), wrapper.effects.userdata); } - fn clipboardWriteTrampoline(handler: *Handler, write: clipboard.Write) clipboard.WriteResult { + /// Opaque context behind ClipboardWrite.ctx for the reply trampoline. + const ClipboardWriteCtx = struct { + write: clipboard.Write, + }; + + fn clipboardWriteTrampoline(handler: *Handler, write: clipboard.Write) void { const wrapper = TerminalWrapper.fromHandler(handler); - const func = wrapper.effects.clipboard_write orelse return .unsupported; + const func = wrapper.effects.clipboard_write orelse + return write.reply(.unsupported); // Most protocols currently produce one representation, so keep that // path allocation-free while supporting arbitrary multi-MIME writes. @@ -329,7 +408,7 @@ const Effects = struct { stack_contents[0..write.contents.len] else wrapper.terminal.gpa().alloc(ClipboardContent, write.contents.len) catch - return .io_error; + return write.reply(.io_error); defer if (write.contents.len > stack_contents.len) wrapper.terminal.gpa().free(contents); @@ -346,13 +425,129 @@ const Effects = struct { }; } + const ctx: ClipboardWriteCtx = .{ .write = write }; const request: ClipboardWrite = .{ .size = @sizeOf(ClipboardWrite), .location = write.location, .contents = if (contents.len > 0) contents.ptr else null, .contents_len = contents.len, + .name = .init(write.name), + .granted = write.granted, + .can_remember = write.can_remember, + .ctx = &ctx, + .reply = &clipboardWriteReplyTrampoline, }; - return func(@ptrCast(wrapper), wrapper.effects.userdata, &request); + func(@ptrCast(wrapper), wrapper.effects.userdata, &request); + } + + fn clipboardWriteReplyTrampoline( + request: *const ClipboardWrite, + reply: *const ClipboardWriteReply, + ) callconv(lib.calling_conv) void { + const ctx: *const ClipboardWriteCtx = @ptrCast(@alignCast(request.ctx)); + const write = ctx.write; + switch (reply.result) { + .success => write.reply(.{ .success = .{ .remember = reply.remember } }), + .denied => write.reply(.denied), + .busy => write.reply(.busy), + .invalid_data => write.reply(.invalid_data), + .io_error => write.reply(.io_error), + .unsupported, _ => write.reply(.unsupported), + } + } + + /// Opaque context behind ClipboardRead.ctx for the reply trampoline. + const ClipboardReadCtx = struct { + read: clipboard.Read, + wrapper: *TerminalWrapper, + }; + + fn clipboardReadTrampoline(handler: *Handler, read: clipboard.Read) void { + const wrapper = TerminalWrapper.fromHandler(handler); + const func = wrapper.effects.clipboard_read orelse return; + + // Requests carry a handful of MIME types, so keep the common case + // allocation-free. On OOM the request goes unanswered and the + // handler replies with an empty clipboard. + var sfa = std.heap.stackFallback(128, wrapper.terminal.gpa()); + const alloc = sfa.get(); + const mimes = alloc.alloc(lib.String, read.mimes.len) catch { + log.warn("out of memory converting clipboard read request", .{}); + return; + }; + defer alloc.free(mimes); + for (mimes, read.mimes) |*c_mime, mime| c_mime.* = .init(mime); + + const ctx: ClipboardReadCtx = .{ .read = read, .wrapper = wrapper }; + const request: ClipboardRead = .{ + .size = @sizeOf(ClipboardRead), + .location = read.location, + .mimes = if (mimes.len > 0) mimes.ptr else null, + .mimes_len = mimes.len, + .list = read.list, + .name = .init(read.name), + .granted = read.granted, + .can_remember = read.can_remember, + .ctx = &ctx, + .reply = &clipboardReadReplyTrampoline, + }; + func(@ptrCast(wrapper), wrapper.effects.userdata, &request); + } + + fn clipboardReadReplyTrampoline( + request: *const ClipboardRead, + reply: *const ClipboardReadReply, + ) callconv(lib.calling_conv) void { + const ctx: *const ClipboardReadCtx = @ptrCast(@alignCast(request.ctx)); + const read = ctx.read; + switch (reply.result) { + .success => {}, + .denied => return read.reply(.denied), + .busy => return read.reply(.busy), + .io_error => return read.reply(.io_error), + .unsupported, _ => return read.reply(.unsupported), + } + + const c_contents: []const ClipboardContent = if (reply.contents) |ptr| + ptr[0..reply.contents_len] + else + &.{}; + const c_available: []const lib.String = if (reply.available) |ptr| + ptr[0..reply.available_len] + else + &.{}; + + // Most replies carry one representation, so keep that path + // allocation-free while supporting arbitrary multi-MIME replies. + // On OOM we don't reply and the handler answers with an empty + // clipboard. + var sfa = std.heap.stackFallback(256, ctx.wrapper.terminal.gpa()); + const alloc = sfa.get(); + const contents = alloc.alloc(clipboard.Content, c_contents.len) catch { + log.warn("out of memory converting clipboard read reply", .{}); + return; + }; + defer alloc.free(contents); + for (contents, c_contents) |*content, c_content| { + content.* = .{ + .mime = c_content.mime.ptr[0..c_content.mime.len], + .data = c_content.data.ptr[0..c_content.data.len], + }; + } + const available = alloc.alloc([]const u8, c_available.len) catch { + log.warn("out of memory converting clipboard read reply", .{}); + return; + }; + defer alloc.free(available); + for (available, c_available) |*mime, c_mime| { + mime.* = c_mime.ptr[0..c_mime.len]; + } + + read.reply(.{ .success = .{ + .contents = contents, + .available = available, + .remember = reply.remember, + } }); } fn desktopNotificationTrampoline( @@ -516,6 +711,7 @@ fn wrap( .bell = &Effects.bellTrampoline, .color_scheme = &Effects.colorSchemeTrampoline, .desktop_notification = &Effects.desktopNotificationTrampoline, + .drag_and_drop = null, .device_attributes = &Effects.deviceAttributesTrampoline, .enquiry = &Effects.enquiryTrampoline, .xtversion = &Effects.xtversionTrampoline, @@ -523,7 +719,10 @@ fn wrap( .pwd_changed = &Effects.pwdChangedTrampoline, .progress_report = &Effects.progressReportTrampoline, .size = &Effects.sizeTrampoline, - .clipboard_write = &Effects.clipboardWriteTrampoline, + + // Installed dynamically when the callback is set; see Effects. + .clipboard_write = null, + .clipboard_read = null, }; wrapper.* = .{ @@ -579,12 +778,15 @@ pub const FromDecodedError = error{ /// This function consumes `io` on every path. The decoded terminal is /// transferred only after its final heap address has been allocated; its /// continuation remains in `decoded` and is replayed before returning. -/// Replay uses a temporary exact-size tracker which is disabled before the -/// terminal crosses the C ABI, restoring the ordinary C default policy. +/// `continuation_max_bytes` selects the returned terminal's tracking policy: +/// zero uses a temporary exact-size tracker and restores the ordinary C +/// default before returning, while a nonzero value leaves tracking enabled +/// with that limit. pub fn fromDecoded( alloc: std.mem.Allocator, io: Io, decoded: *snapshot_core.Decoded, + continuation_max_bytes: usize, ) FromDecodedError!Terminal { const native = alloc.create(ZigTerminal) catch { io.deinit(alloc); @@ -596,11 +798,18 @@ pub fn fromDecoded( .ground => "", .bytes => |bytes| bytes, }; + assert(continuation_max_bytes == 0 or + continuation.len <= continuation_max_bytes); - // Non-ground state needs tracking only long enough to verify that replay - // reconstructed the exact canonical continuation. Ground state needs no - // tracker at all. - const terminal = wrap(alloc, native, io, continuation.len) catch |err| { + // Without opt-in retention, non-ground state needs tracking only long + // enough to verify that replay reconstructed the exact canonical + // continuation. With retention, even ground state needs a tracker so its + // continuation can be exported successfully as an empty slice. + const tracker_max_bytes = if (continuation_max_bytes > 0) + continuation_max_bytes + else + continuation.len; + const terminal = wrap(alloc, native, io, tracker_max_bytes) catch |err| { native.deinit(alloc); alloc.destroy(native); io.deinit(alloc); @@ -610,8 +819,8 @@ pub fn fromDecoded( if (continuation.len > 0) { restoreContinuation(terminal, continuation) catch |err| return switch (err) { - // The decoded bytes exactly fit this fresh tracker's cap, so - // losing them while replaying can only be an allocation failure. + // The decoded bytes fit this fresh tracker's cap, so losing them + // while replaying can only be an allocation failure. error.OutOfMemory, error.ContinuationUnavailable, => error.OutOfMemory, @@ -621,9 +830,12 @@ pub fn fromDecoded( }; } - // Decoder validation limits are not terminal runtime policy. A restored - // terminal always starts with the same disabled tracking default as new. - setContinuationMaxBytes(terminal.?, default_continuation_max_bytes); + // Unless the decoder opted into retention, restore the same disabled + // tracking default used by newly created C terminals. Disabling tracking + // does not alter the parser or UTF-8 state reconstructed above. + if (continuation_max_bytes == 0) { + setContinuationMaxBytes(terminal.?, default_continuation_max_bytes); + } return terminal; } @@ -810,15 +1022,20 @@ pub fn continuation_write( // The callback was validated above, so invalid_write can only mean output // accounting overflow. Keep that distinct from callback rejection. - var adapter: c_io.WriterAdapter = .init(writer); - continuationWriteIo(terminal_, &adapter.interface) catch |err| { - if (err == error.WriteFailed) { - if (adapter.invalid_write) return .limit_exceeded; - if (adapter.callback_failed) return .io_error; - } - return continuationErrorResult(err); - }; - return .success; + var buffer: [c_io.WriterAdapter.recommended_buffer_len]u8 = undefined; + var adapter: c_io.WriterAdapter = .initBuffered(writer, &buffer); + write: { + continuationWriteIo(terminal_, &adapter.interface) catch |err| switch (err) { + error.WriteFailed => break :write, + else => return continuationErrorResult(err), + }; + adapter.interface.flush() catch break :write; + return .success; + } + + if (adapter.invalid_write) return .limit_exceeded; + if (adapter.callback_failed) return .io_error; + return continuationErrorResult(error.WriteFailed); } pub fn continuation_buf( @@ -1002,6 +1219,8 @@ pub const Option = enum(c_int) { unknown_sequence = 35, unknown_max_bytes = 36, terminfo_name = 37, + clipboard_read = 38, + clipboard_write_max_bytes = 39, /// Input type expected for setting the option. pub fn InType(comptime self: Option) type { @@ -1019,6 +1238,7 @@ pub const Option = enum(c_int) { .progress_report => ?Effects.ProgressReportFn, .size_cb => ?Effects.SizeFn, .clipboard_write => ?Effects.ClipboardWriteFn, + .clipboard_read => ?Effects.ClipboardReadFn, .unknown_sequence => ?Effects.UnknownSequenceFn, .title, .pwd, .terminfo_name => ?*const lib.String, .color_foreground, .color_background, .color_cursor => ?*const color.RGB.C, @@ -1036,6 +1256,7 @@ pub const Option = enum(c_int) { .scrollback_max_lines, .continuation_max_bytes, .unknown_max_bytes, + .clipboard_write_max_bytes, => ?*const usize, .selection => ?*const selection_c.CSelection, .default_cursor_style => ?*const TerminalCursorStyle, @@ -1086,7 +1307,20 @@ fn setTyped( .pwd_changed => wrapper.effects.pwd_changed = value, .progress_report => wrapper.effects.progress_report = value, .size_cb => wrapper.effects.size_cb = value, - .clipboard_write => wrapper.effects.clipboard_write = value, + .clipboard_write => { + wrapper.effects.clipboard_write = value; + wrapper.stream.handler.effects.clipboard_write = if (value != null) + &Effects.clipboardWriteTrampoline + else + null; + }, + .clipboard_read => { + wrapper.effects.clipboard_read = value; + wrapper.stream.handler.effects.clipboard_read = if (value != null) + &Effects.clipboardReadTrampoline + else + null; + }, .unknown_sequence => { wrapper.effects.unknown_sequence = value; wrapper.stream.handler.unknown_sequence = if (value != null) @@ -1223,6 +1457,8 @@ fn setTyped( ), .unknown_max_bytes => wrapper.stream.handler.apc_handler.unknown_max_bytes = if (value) |ptr| ptr.* else 0, + .clipboard_write_max_bytes => wrapper.stream.handler.kitty_clipboard_write_max_bytes = + if (value) |ptr| ptr.* else kitty_clipboard.max_write_size, .mode, .mode_default => { const config = (value orelse return .invalid_value).*; const mode = config.toMode() orelse return .invalid_value; @@ -1356,6 +1592,7 @@ pub const TerminalData = enum(c_int) { mode = 37, vt_ground = 38, cursor_at_prompt = 39, + clipboard_write_max_bytes = 40, /// Output type expected for querying the data of the given kind. pub fn OutType(comptime self: TerminalData) type { @@ -1380,6 +1617,7 @@ pub const TerminalData = enum(c_int) { .scrollback_max_bytes, .scrollback_max_lines, .continuation_max_bytes, + .clipboard_write_max_bytes, => usize, .width_px, .height_px => u32, .color_foreground, @@ -1531,6 +1769,7 @@ fn getTyped( out.* = max; }, .continuation_max_bytes => out.* = continuationMaxBytes(wrapper), + .clipboard_write_max_bytes => out.* = wrapper.stream.handler.kitty_clipboard_write_max_bytes, .mode => { const mode = out.toMode() orelse return .invalid_value; out.value = t.modes.get(mode); @@ -1618,6 +1857,8 @@ pub fn free(terminal_: Terminal) callconv(lib.calling_conv) void { for (wrapper.tracked_grid_refs.keys()) |ref| ref.terminal = null; wrapper.tracked_grid_refs.deinit(alloc); + for (wrapper.searches.keys()) |search| search.terminal = null; + wrapper.searches.deinit(alloc); wrapper.stream.deinit(); t.deinit(alloc); wrapper.io.deinit(alloc); @@ -4429,13 +4670,16 @@ test "set clipboard_write callback" { var last_mime_lens: [8]usize = @splat(0); var last_data: [8][64]u8 = undefined; var last_data_lens: [8]usize = @splat(0); - var next_result: clipboard.WriteResult = .success; + var last_name_len: usize = 0; + var last_granted: bool = true; + var last_can_remember: bool = true; + var next_result: clipboard.Write.Status = .success; fn clipboardWrite( terminal_: Terminal, ud: ?*anyopaque, request: *const ClipboardWrite, - ) callconv(lib.calling_conv) clipboard.WriteResult { + ) callconv(lib.calling_conv) void { count += 1; last_terminal = terminal_; last_userdata = ud; @@ -4443,6 +4687,9 @@ test "set clipboard_write callback" { last_location = request.location; last_contents_null = request.contents == null; last_contents_len = request.contents_len; + last_name_len = request.name.len; + last_granted = request.granted; + last_can_remember = request.can_remember; if (request.contents) |ptr| { for (ptr[0..@min(request.contents_len, last_mimes.len)], 0..) |content, i| { @@ -4460,7 +4707,11 @@ test "set clipboard_write callback" { } } - return next_result; + request.reply(request, &.{ + .size = @sizeOf(ClipboardWriteReply), + .result = next_result, + .remember = false, + }); } }; S.count = 0; @@ -4487,6 +4738,11 @@ test "set clipboard_write callback" { try testing.expectEqualStrings("text/plain", S.last_mimes[0][0..S.last_mime_lens[0]]); try testing.expectEqualSlices(u8, "hello\x00world", S.last_data[0][0..S.last_data_lens[0]]); + // OSC 52 carries no program identity or password grant state. + try testing.expectEqual(@as(usize, 0), S.last_name_len); + try testing.expect(!S.last_granted); + try testing.expect(!S.last_can_remember); + // OSC 52 destinations are normalized rather than exposed as wire bytes. const location_cases = [_]struct { selector: u8, @@ -4526,8 +4782,9 @@ test "set clipboard_write callback" { try testing.expectEqualStrings("text/plain", S.last_mimes[0][0..S.last_mime_lens[0]]); try testing.expectEqualStrings("iTerm", S.last_data[0][0..S.last_data_lens[0]]); - // Every representation is converted, and callback results propagate - // through the C trampoline for protocols that can acknowledge writes. + // Every representation is converted, and callback replies propagate + // back through the C trampoline for protocols that can acknowledge + // writes. const internal_contents = [_]clipboard.Content{ .{ .mime = "text/plain", .data = "plain" }, .{ .mime = "application/octet-stream", .data = "a\x00b" }, @@ -4535,13 +4792,27 @@ test "set clipboard_write callback" { .{ .mime = "text/rtf", .data = "{\\rtf1 plain}" }, .{ .mime = "image/png", .data = "\x89PNG" }, }; + const Reply = struct { + var last: ?clipboard.Write.Result = null; + fn reply(_: *anyopaque, result: clipboard.Write.Result) void { + last = result; + } + }; S.next_result = .busy; const handler = &t.?.stream.handler; - const write_result = handler.effects.clipboard_write.?(handler, .{ + handler.effects.clipboard_write.?(handler, .{ .location = .primary, .contents = &internal_contents, + .name = "app", + .granted = true, + .can_remember = true, + .reply_ctx = handler, + .reply_fn = &Reply.reply, }); - try testing.expectEqual(clipboard.WriteResult.busy, write_result); + try testing.expect(Reply.last.? == .busy); + try testing.expectEqual(@as(usize, 3), S.last_name_len); + try testing.expect(S.last_granted); + try testing.expect(S.last_can_remember); try testing.expectEqual(@as(usize, 7), S.count); try testing.expectEqual(@as(usize, 5), S.last_contents_len); try testing.expectEqualStrings( @@ -4552,8 +4823,10 @@ test "set clipboard_write callback" { try testing.expectEqualStrings("image/png", S.last_mimes[4][0..S.last_mime_lens[4]]); try testing.expectEqualSlices(u8, "\x89PNG", S.last_data[4][0..S.last_data_lens[4]]); - // Removing the callback takes effect immediately. + // Removing the callback takes effect immediately and uninstalls + // the trampoline. try testing.expectEqual(Result.success, set(t, .clipboard_write, null)); + try testing.expect(t.?.stream.handler.effects.clipboard_write == null); const after_remove = "\x1B]52;c;eA==\x1B\\"; vt_write(t, after_remove, after_remove.len); try testing.expectEqual(@as(usize, 7), S.count); @@ -4573,12 +4846,400 @@ test "clipboard_write without callback is unsupported and silent" { const seq = "\x1B]52;c;aGVsbG8=\x1B\\"; vt_write(t, seq, seq.len); - const handler = &t.?.stream.handler; - const result = handler.effects.clipboard_write.?(handler, .{ - .location = .standard, - .contents = &.{.{ .mime = "text/plain", .data = "hello" }}, - }); - try testing.expectEqual(clipboard.WriteResult.unsupported, result); + // No trampoline is installed until a callback is set, so the + // stream skips clipboard work (and never spools a Kitty clipboard + // transaction it can't deliver). + try testing.expect(t.?.stream.handler.effects.clipboard_write == null); +} + +test "kitty clipboard write via C effects" { + var t: Terminal = null; + try testing.expectEqual(Result.success, new( + &lib.alloc.test_allocator, + &t, + 80, + 24, + )); + defer free(t); + + const S = struct { + var responses: [512]u8 = undefined; + var responses_len: usize = 0; + var write_count: usize = 0; + var last_location: clipboard.Location = .standard; + var last_contents_len: usize = 0; + var last_mimes: [4][64]u8 = undefined; + var last_mime_lens: [4]usize = @splat(0); + var last_data: [4][64]u8 = undefined; + var last_data_lens: [4]usize = @splat(0); + var last_granted: bool = true; + var last_can_remember: bool = true; + var next_remember: bool = false; + + fn writePty( + _: Terminal, + _: ?*anyopaque, + ptr: [*]const u8, + len: usize, + ) callconv(lib.calling_conv) void { + @memcpy(responses[responses_len..][0..len], ptr[0..len]); + responses_len += len; + } + + fn clipboardWrite( + _: Terminal, + _: ?*anyopaque, + request: *const ClipboardWrite, + ) callconv(lib.calling_conv) void { + write_count += 1; + last_location = request.location; + last_contents_len = request.contents_len; + if (request.contents) |ptr| { + for (ptr[0..@min(request.contents_len, last_mimes.len)], 0..) |content, i| { + last_mime_lens[i] = @min(content.mime.len, last_mimes[i].len); + @memcpy( + last_mimes[i][0..last_mime_lens[i]], + content.mime.ptr[0..last_mime_lens[i]], + ); + last_data_lens[i] = @min(content.data.len, last_data[i].len); + @memcpy( + last_data[i][0..last_data_lens[i]], + content.data.ptr[0..last_data_lens[i]], + ); + } + } + last_granted = request.granted; + last_can_remember = request.can_remember; + request.reply(request, &.{ + .size = @sizeOf(ClipboardWriteReply), + .result = .success, + .remember = next_remember, + }); + } + }; + S.responses_len = 0; + S.write_count = 0; + S.last_mime_lens = @splat(0); + S.last_data_lens = @splat(0); + S.last_granted = true; + S.last_can_remember = true; + S.next_remember = false; + + try testing.expectEqual(Result.success, set(t, .write_pty, @ptrCast(&S.writePty))); + try testing.expectEqual(Result.success, set(t, .clipboard_write, @ptrCast(&S.clipboardWrite))); + + // A full OSC 5522 write transaction: begin, chunked data for two + // representations, commit. Only the commit invokes the callback, + // and its result maps to the DONE response. + const seqs = [_][]const u8{ + "\x1B]5522;type=write:id=c1\x1B\\", + "\x1B]5522;type=wdata:mime=dGV4dC9wbGFpbg==;R2hvc3Q=\x1B\\", // "Ghost" + "\x1B]5522;type=wdata:mime=dGV4dC9wbGFpbg==;dHk=\x1B\\", // "ty" + "\x1B]5522;type=wdata:mime=dGV4dC9odG1s;PGI+aGk8L2I+\x1B\\", // "<b>hi</b>" + "\x1B]5522;type=wdata\x1B\\", + }; + for (seqs) |seq| vt_write(t, seq.ptr, seq.len); + + try testing.expectEqual(@as(usize, 1), S.write_count); + try testing.expectEqual(clipboard.Location.standard, S.last_location); + try testing.expectEqual(@as(usize, 2), S.last_contents_len); + try testing.expectEqualStrings("text/plain", S.last_mimes[0][0..S.last_mime_lens[0]]); + try testing.expectEqualStrings("Ghostty", S.last_data[0][0..S.last_data_lens[0]]); + try testing.expectEqualStrings("text/html", S.last_mimes[1][0..S.last_mime_lens[1]]); + try testing.expectEqualStrings("<b>hi</b>", S.last_data[1][0..S.last_data_lens[1]]); + try testing.expectEqualStrings( + "\x1B]5522;type=write:status=DONE:id=c1\x1B\\", + S.responses[0..S.responses_len], + ); + try testing.expect(!S.last_granted); + try testing.expect(!S.last_can_remember); + + // Password grants round-trip through the C reply: the first pw'd + // commit isn't granted and asks to remember, so the next one + // arrives granted. + S.responses_len = 0; + S.next_remember = true; + const grant_seqs = [_][]const u8{ + "\x1B]5522;type=write:id=g1:pw=c2VjcmV0:name=YXBw\x1B\\", + "\x1B]5522;type=wdata\x1B\\", + "\x1B]5522;type=write:id=g2:pw=c2VjcmV0:name=YXBw\x1B\\", + "\x1B]5522;type=wdata\x1B\\", + }; + for (grant_seqs) |seq| vt_write(t, seq.ptr, seq.len); + try testing.expectEqual(@as(usize, 3), S.write_count); + try testing.expect(S.last_granted); + try testing.expect(S.last_can_remember); + try testing.expectEqualStrings( + "\x1B]5522;type=write:status=DONE:id=g1\x1B\\" ++ + "\x1B]5522;type=write:status=DONE:id=g2\x1B\\", + S.responses[0..S.responses_len], + ); + S.next_remember = false; + + // Without a read callback reads are denied. + S.responses_len = 0; + const read = "\x1B]5522;type=read:id=r1;dGV4dC9wbGFpbg==\x1B\\"; + vt_write(t, read, read.len); + try testing.expectEqual(@as(usize, 3), S.write_count); + try testing.expectEqualStrings( + "\x1B]5522;type=read:status=EPERM:id=r1\x1B\\", + S.responses[0..S.responses_len], + ); + + // With a read callback the request is served through it. + const R = struct { + var count: usize = 0; + var last_mimes_len: usize = 0; + var last_mime_is_text: bool = false; + var last_list: bool = true; + var last_name_len: usize = 0; + var last_granted: bool = true; + var last_can_remember: bool = true; + + fn clipboardRead( + _: Terminal, + _: ?*anyopaque, + request: *const ClipboardRead, + ) callconv(lib.calling_conv) void { + count += 1; + last_mimes_len = request.mimes_len; + last_mime_is_text = request.mimes_len > 0 and std.mem.eql( + u8, + request.mimes.?[0].ptr[0..request.mimes.?[0].len], + "text/plain", + ); + last_list = request.list; + last_name_len = request.name.len; + last_granted = request.granted; + last_can_remember = request.can_remember; + + const mime: []const u8 = "text/plain"; + const data: []const u8 = "hello"; + const contents = [_]ClipboardContent{.{ + .mime = .init(mime), + .data = .init(data), + }}; + request.reply(request, &.{ + .size = @sizeOf(ClipboardReadReply), + .result = .success, + .contents = &contents, + .contents_len = contents.len, + .available = null, + .available_len = 0, + .remember = false, + }); + } + }; + try testing.expectEqual(Result.success, set(t, .clipboard_read, @ptrCast(&R.clipboardRead))); + S.responses_len = 0; + // name="app" without a password: forwarded for prompts, not + // rememberable. + const read2 = "\x1B]5522;type=read:id=r2:name=YXBw;dGV4dC9wbGFpbg==\x1B\\"; + vt_write(t, read2, read2.len); + try testing.expectEqual(@as(usize, 1), R.count); + try testing.expectEqual(@as(usize, 1), R.last_mimes_len); + try testing.expect(R.last_mime_is_text); + try testing.expect(!R.last_list); + try testing.expectEqual(@as(usize, 3), R.last_name_len); + try testing.expect(!R.last_granted); + try testing.expect(!R.last_can_remember); + try testing.expectEqualStrings( + "\x1B]5522;type=read:status=OK:id=r2\x1B\\" ++ + "\x1B]5522;type=read:status=DATA:id=r2:mime=dGV4dC9wbGFpbg==;aGVsbG8=\x1B\\" ++ + "\x1B]5522;type=read:status=DONE:id=r2\x1B\\", + S.responses[0..S.responses_len], + ); + + // Without a clipboard callback the transaction fails up front. + try testing.expectEqual(Result.success, set(t, .clipboard_write, null)); + S.responses_len = 0; + const begin = "\x1B]5522;type=write:id=c2\x1B\\"; + vt_write(t, begin, begin.len); + try testing.expectEqualStrings( + "\x1B]5522;type=write:status=ENOSYS:id=c2\x1B\\", + S.responses[0..S.responses_len], + ); +} + +test "set clipboard write max bytes" { + var t: Terminal = null; + try testing.expectEqual(Result.success, new( + &lib.alloc.test_allocator, + &t, + 80, + 24, + )); + defer free(t); + + const S = struct { + var responses: [512]u8 = undefined; + var responses_len: usize = 0; + var write_count: usize = 0; + + fn writePty( + _: Terminal, + _: ?*anyopaque, + ptr: [*]const u8, + len: usize, + ) callconv(lib.calling_conv) void { + @memcpy(responses[responses_len..][0..len], ptr[0..len]); + responses_len += len; + } + + fn clipboardWrite( + _: Terminal, + _: ?*anyopaque, + request: *const ClipboardWrite, + ) callconv(lib.calling_conv) void { + write_count += 1; + request.reply(request, &.{ + .size = @sizeOf(ClipboardWriteReply), + .result = .success, + .remember = false, + }); + } + }; + S.responses_len = 0; + S.write_count = 0; + + try testing.expectEqual(Result.success, set(t, .write_pty, @ptrCast(&S.writePty))); + try testing.expectEqual(Result.success, set(t, .clipboard_write, @ptrCast(&S.clipboardWrite))); + + // The built-in default reads back. + var max: usize = 0; + try testing.expectEqual(Result.success, get(t, .clipboard_write_max_bytes, @ptrCast(&max))); + try testing.expectEqual(@as(usize, kitty_clipboard.max_write_size), max); + + // Set a tiny limit; an oversized text write fails with EFBIG and + // never reaches the callback. + const limit: usize = 4; + try testing.expectEqual(Result.success, set(t, .clipboard_write_max_bytes, @ptrCast(&limit))); + try testing.expectEqual(Result.success, get(t, .clipboard_write_max_bytes, @ptrCast(&max))); + try testing.expectEqual(limit, max); + + const seqs = [_][]const u8{ + "\x1B]5522;type=write:id=c1\x1B\\", + "\x1B]5522;type=wdata:mime=dGV4dC9wbGFpbg==;SGVsbA==\x1B\\", // "Hell" + "\x1B]5522;type=wdata:mime=dGV4dC9wbGFpbg==;bw==\x1B\\", // "o" + "\x1B]5522;type=wdata\x1B\\", + }; + for (seqs) |seq| vt_write(t, seq.ptr, seq.len); + try testing.expectEqual(@as(usize, 0), S.write_count); + try testing.expectEqualStrings( + "\x1B]5522;type=write:status=EFBIG:id=c1\x1B\\", + S.responses[0..S.responses_len], + ); + + // A NULL value reverts to the built-in default. + try testing.expectEqual(Result.success, set(t, .clipboard_write_max_bytes, null)); + try testing.expectEqual(Result.success, get(t, .clipboard_write_max_bytes, @ptrCast(&max))); + try testing.expectEqual(@as(usize, kitty_clipboard.max_write_size), max); +} + +test "set clipboard_read callback" { + var t: Terminal = null; + try testing.expectEqual(Result.success, new( + &lib.alloc.test_allocator, + &t, + 80, + 24, + )); + defer free(t); + + const S = struct { + var last_data: ?[]u8 = null; + var count: usize = 0; + var last_size: usize = 0; + var last_location: clipboard.Location = .standard; + var last_mimes_len: usize = 0; + var last_mime_is_text: bool = false; + var last_list: bool = true; + var last_name_len: usize = 1; + var last_granted: bool = true; + var last_can_remember: bool = true; + var result: clipboard.Read.Status = .success; + + fn deinit() void { + if (last_data) |d| testing.allocator.free(d); + last_data = null; + } + + fn writePty(_: Terminal, _: ?*anyopaque, ptr: [*]const u8, len: usize) callconv(lib.calling_conv) void { + if (last_data) |d| testing.allocator.free(d); + last_data = testing.allocator.dupe(u8, ptr[0..len]) catch @panic("OOM"); + } + + fn clipboardRead( + _: Terminal, + _: ?*anyopaque, + request: *const ClipboardRead, + ) callconv(lib.calling_conv) void { + count += 1; + last_size = request.size; + last_location = request.location; + last_mimes_len = request.mimes_len; + last_mime_is_text = request.mimes_len > 0 and std.mem.eql( + u8, + request.mimes.?[0].ptr[0..request.mimes.?[0].len], + "text/plain", + ); + last_list = request.list; + last_name_len = request.name.len; + last_granted = request.granted; + last_can_remember = request.can_remember; + + const mime: []const u8 = "text/plain"; + const data: []const u8 = "hello"; + const contents = [_]ClipboardContent{.{ + .mime = .init(mime), + .data = .init(data), + }}; + request.reply(request, &.{ + .size = @sizeOf(ClipboardReadReply), + .result = result, + .contents = &contents, + .contents_len = contents.len, + .available = null, + .available_len = 0, + .remember = false, + }); + } + }; + defer S.deinit(); + + try testing.expectEqual(Result.success, set(t, .write_pty, @ptrCast(&S.writePty))); + + // Without a callback the handler effect is unset and reads are silent. + try testing.expect(t.?.stream.handler.effects.clipboard_read == null); + const read_st = "\x1B]52;c;?\x1B\\"; + vt_write(t, read_st, read_st.len); + try testing.expect(S.last_data == null); + + try testing.expectEqual(Result.success, set(t, .clipboard_read, @ptrCast(&S.clipboardRead))); + try testing.expect(t.?.stream.handler.effects.clipboard_read != null); + + const read_bel = "\x1B]52;p;?\x07"; + vt_write(t, read_bel, read_bel.len); + try testing.expectEqual(1, S.count); + try testing.expectEqual(@sizeOf(ClipboardRead), S.last_size); + try testing.expectEqual(clipboard.Location.primary, S.last_location); + try testing.expectEqual(1, S.last_mimes_len); + try testing.expect(S.last_mime_is_text); + try testing.expect(!S.last_list); + try testing.expectEqual(0, S.last_name_len); + try testing.expect(!S.last_granted); + try testing.expect(!S.last_can_remember); + try testing.expectEqualStrings("\x1B]52;p;aGVsbG8=\x07", S.last_data.?); + + // Denied replies with an empty clipboard. + S.result = .denied; + vt_write(t, read_st, read_st.len); + try testing.expectEqual(2, S.count); + try testing.expectEqualStrings("\x1B]52;c;\x1B\\", S.last_data.?); + + // Clearing the callback uninstalls the handler effect. + try testing.expectEqual(Result.success, set(t, .clipboard_read, null)); + try testing.expect(t.?.stream.handler.effects.clipboard_read == null); } test "pwd_changed without callback is silent" { @@ -4655,6 +5316,109 @@ test "size without callback is silent" { vt_write(t, "\x1B[18t", 5); } +test "mode 2048 enable and disable use C callbacks" { + var t: Terminal = null; + try testing.expectEqual(Result.success, new( + &lib.alloc.test_allocator, + &t, + 80, + 24, + )); + defer free(t); + + const S = struct { + var data: [128]u8 = undefined; + var len: usize = 0; + var calls: usize = 0; + + fn writePty(_: Terminal, _: ?*anyopaque, ptr: [*]const u8, length: usize) callconv(lib.calling_conv) void { + @memcpy(data[0..length], ptr[0..length]); + len = length; + calls += 1; + } + + fn sizeCb(_: Terminal, _: ?*anyopaque, out_size: *size_report.Size) callconv(lib.calling_conv) bool { + out_size.* = .{ + .rows = 24, + .columns = 80, + .cell_width = 8, + .cell_height = 16, + }; + return true; + } + }; + S.len = 0; + S.calls = 0; + + try testing.expectEqual(Result.success, set(t, .write_pty, @ptrCast(&S.writePty))); + try testing.expectEqual(Result.success, set(t, .size_cb, @ptrCast(&S.sizeCb))); + + const enable = "\x1B[?2048h"; + const disable = "\x1B[?2048l"; + vt_write(t, enable, enable.len); + vt_write(t, enable, enable.len); + + try testing.expectEqual(@as(usize, 2), S.calls); + try testing.expectEqualStrings("\x1B[48;24;80;384;640t", S.data[0..S.len]); + vt_write(t, disable, disable.len); + + try testing.expectEqual(@as(usize, 2), S.calls); + try testing.expect(!t.?.terminal.modes.get(.in_band_size_reports)); +} + +test "mode 2048 enable tolerates missing C callbacks" { + var t: Terminal = null; + try testing.expectEqual(Result.success, new( + &lib.alloc.test_allocator, + &t, + 80, + 24, + )); + defer free(t); + + const S = struct { + var writes: usize = 0; + var sizes: usize = 0; + + fn writePty(_: Terminal, _: ?*anyopaque, _: [*]const u8, _: usize) callconv(lib.calling_conv) void { + writes += 1; + } + + fn sizeCb(_: Terminal, _: ?*anyopaque, out_size: *size_report.Size) callconv(lib.calling_conv) bool { + sizes += 1; + out_size.* = .{ + .rows = 24, + .columns = 80, + .cell_width = 8, + .cell_height = 16, + }; + return true; + } + }; + S.writes = 0; + S.sizes = 0; + + try testing.expectEqual(Result.success, set(t, .write_pty, @ptrCast(&S.writePty))); + + const sequence = "\x1B[?2048h"; + vt_write(t, sequence, sequence.len); + + try testing.expectEqual(@as(usize, 0), S.writes); + try testing.expect(t.?.terminal.modes.get(.in_band_size_reports)); + var no_write: Terminal = null; + try testing.expectEqual(Result.success, new( + &lib.alloc.test_allocator, + &no_write, + 80, + 24, + )); + defer free(no_write); + try testing.expectEqual(Result.success, set(no_write, .size_cb, @ptrCast(&S.sizeCb))); + vt_write(no_write, sequence, sequence.len); + try testing.expectEqual(@as(usize, 1), S.sizes); + try testing.expect(no_write.?.terminal.modes.get(.in_band_size_reports)); +} + test "set device_attributes callback primary" { var t: Terminal = null; try testing.expectEqual(Result.success, new( @@ -5405,6 +6169,8 @@ test "set color sets dirty flag" { } test "set glyph protocol disables APC handling and clears glossary" { + if (comptime !build_options.glyph_protocol) return error.SkipZigTest; + var t: Terminal = null; try testing.expectEqual(Result.success, new( &lib.alloc.test_allocator, diff --git a/vendor/libghostty-vt/src/terminal/c/types-schema-verify.py b/vendor/libghostty-vt/src/terminal/c/types-schema-verify.py new file mode 100644 index 0000000..1a8e064 --- /dev/null +++ b/vendor/libghostty-vt/src/terminal/c/types-schema-verify.py @@ -0,0 +1,114 @@ +#!/usr/bin/env python3 +"""Validate the ABI manifest exported by a native or WebAssembly library.""" + +from __future__ import annotations + +import argparse +import ctypes +import json +import sys +from pathlib import Path +from typing import Any + +try: + import jsonschema +except ImportError as error: + raise SystemExit( + "missing ABI schema verifier dependencies; run this inside " + "`nix develop`" + ) from error + + +def load_native_manifest(path: Path) -> bytes: + """Call ghostty_type_json in a native shared library.""" + library = ctypes.CDLL(str(path.resolve())) + type_json = library.ghostty_type_json + type_json.argtypes = () + type_json.restype = ctypes.c_char_p + + result = type_json() + if result is None: + raise RuntimeError("ghostty_type_json returned NULL") + return result + + +def load_wasm_manifest(path: Path) -> bytes: + """Call ghostty_type_json and read its result from WebAssembly memory.""" + try: + import wasmtime + except ImportError as error: + raise SystemExit( + "missing WebAssembly verifier dependencies; run this inside " + "`nix develop`" + ) from error + + engine = wasmtime.Engine() + module = wasmtime.Module.from_file(engine, str(path)) + store = wasmtime.Store(engine) + instance = wasmtime.Instance(store, module, []) + exports = instance.exports(store) + memory = exports["memory"] + type_json = exports["ghostty_type_json"] + + pointer = type_json(store) + data = memory.read(store, pointer, memory.data_len(store)) + terminator = data.find(b"\0") + if terminator < 0: + raise RuntimeError("ghostty_type_json result is not NUL terminated") + return bytes(data[:terminator]) + + +def load_manifest(path: Path) -> dict[str, Any]: + """Execute the public export and decode its JSON result.""" + encoded = ( + load_wasm_manifest(path) + if path.suffix.lower() == ".wasm" + else load_native_manifest(path) + ) + value = json.loads(encoded) + if not isinstance(value, dict): + raise ValueError("ghostty_type_json did not return a JSON object") + return value + + +def format_path(parts: list[object]) -> str: + """Format a jsonschema error path for command-line output.""" + return "/" + "/".join(str(part) for part in parts) + + +def validate(schema_path: Path, library_path: Path) -> None: + """Validate the schema itself and the manifest returned by the library.""" + schema = json.loads(schema_path.read_bytes()) + validator_type = jsonschema.validators.validator_for(schema) + validator_type.check_schema(schema) + + manifest = load_manifest(library_path) + errors = sorted( + validator_type(schema).iter_errors(manifest), + key=lambda error: list(error.absolute_path), + ) + if errors: + for error in errors: + print( + f"{format_path(list(error.absolute_path))}: {error.message}", + file=sys.stderr, + ) + raise SystemExit(f"ABI manifest failed {schema_path}") + + abi = manifest["abi"] + print( + f"validated {abi['target']}-{abi['os']} ABI manifest " + f"({len(manifest['types'])} types)" + ) + + +def main() -> None: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("schema", type=Path) + parser.add_argument("library", type=Path) + args = parser.parse_args() + validate(args.schema, args.library) + + +if __name__ == "__main__": + main() diff --git a/vendor/libghostty-vt/src/terminal/c/types.schema.json b/vendor/libghostty-vt/src/terminal/c/types.schema.json new file mode 100644 index 0000000..38108eb --- /dev/null +++ b/vendor/libghostty-vt/src/terminal/c/types.schema.json @@ -0,0 +1,506 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "title": "libghostty-vt ABI type manifest", + "description": "Schema for the JSON returned by ghostty_type_json.", + "type": "object", + "additionalProperties": false, + "required": ["schema", "abi", "library_version", "commit", "dirty", "types"], + "properties": { + "schema": { + "const": 1 + }, + "abi": { + "$ref": "#/$defs/abi" + }, + "library_version": { + "type": "string", + "minLength": 1 + }, + "commit": { + "type": ["string", "null"], + "minLength": 1 + }, + "dirty": { + "type": ["boolean", "null"] + }, + "types": { + "type": "object", + "minProperties": 1, + "propertyNames": { + "pattern": "^Ghostty[A-Za-z0-9_]+$" + }, + "additionalProperties": { + "$ref": "#/$defs/typeDescriptor" + } + } + }, + "$defs": { + "nonNegativeInteger": { + "type": "integer", + "minimum": 0 + }, + "positiveInteger": { + "type": "integer", + "minimum": 1 + }, + "typeReference": { + "type": "string", + "pattern": "^(Ghostty[A-Za-z0-9_]+|bool|f32|f64|function|[iu][1-9][0-9]*|opaque|void)$" + }, + "integerTypeReference": { + "type": "string", + "pattern": "^[iu][1-9][0-9]*$" + }, + "abi": { + "type": "object", + "additionalProperties": false, + "required": [ + "target", + "os", + "environment", + "pointer_size", + "usize_size", + "max_alignment", + "endian" + ], + "properties": { + "target": { + "type": "string", + "minLength": 1 + }, + "os": { + "type": "string", + "minLength": 1 + }, + "environment": { + "type": "string", + "minLength": 1 + }, + "pointer_size": { + "$ref": "#/$defs/positiveInteger" + }, + "usize_size": { + "$ref": "#/$defs/positiveInteger" + }, + "max_alignment": { + "description": "Maximum fundamental C ABI alignment in bytes.", + "$ref": "#/$defs/positiveInteger" + }, + "endian": { + "enum": ["little", "big"] + } + } + }, + "fieldBase": { + "type": "object", + "required": ["offset", "size", "type"], + "properties": { + "offset": { + "$ref": "#/$defs/nonNegativeInteger" + }, + "size": { + "$ref": "#/$defs/nonNegativeInteger" + } + } + }, + "plainField": { + "allOf": [ + { + "$ref": "#/$defs/fieldBase" + }, + { + "properties": { + "type": { + "$ref": "#/$defs/typeReference" + } + } + } + ], + "unevaluatedProperties": false + }, + "arrayField": { + "allOf": [ + { + "$ref": "#/$defs/fieldBase" + }, + { + "required": ["elem", "count"], + "properties": { + "type": { + "const": "array" + }, + "elem": { + "$ref": "#/$defs/typeReference" + }, + "count": { + "$ref": "#/$defs/nonNegativeInteger" + } + } + } + ], + "unevaluatedProperties": false + }, + "pointerField": { + "allOf": [ + { + "$ref": "#/$defs/fieldBase" + }, + { + "required": ["elem", "const"], + "properties": { + "type": { + "const": "pointer" + }, + "elem": { + "$ref": "#/$defs/typeReference" + }, + "const": { + "type": "boolean" + }, + "nullable": { + "const": true + } + } + } + ], + "unevaluatedProperties": false + }, + "taggedUnionField": { + "allOf": [ + { + "$ref": "#/$defs/fieldBase" + }, + { + "required": ["tag", "arms"], + "properties": { + "type": { + "$ref": "#/$defs/typeReference" + }, + "tag": { + "type": "string", + "minLength": 1 + }, + "arms": { + "type": "object", + "minProperties": 1, + "propertyNames": { + "pattern": "^[A-Z][A-Z0-9_]*$" + }, + "additionalProperties": { + "type": ["string", "null"] + } + } + } + } + ], + "unevaluatedProperties": false + }, + "field": { + "oneOf": [ + { + "$ref": "#/$defs/plainField" + }, + { + "$ref": "#/$defs/arrayField" + }, + { + "$ref": "#/$defs/pointerField" + }, + { + "$ref": "#/$defs/taggedUnionField" + } + ] + }, + "fields": { + "type": "object", + "additionalProperties": { + "$ref": "#/$defs/field" + } + }, + "bitBase": { + "type": "object", + "required": ["lsb", "width"], + "properties": { + "lsb": { + "description": "Least-significant bit relative to the containing numerical value.", + "$ref": "#/$defs/nonNegativeInteger" + }, + "width": { + "description": "Physical width of this field in bits.", + "$ref": "#/$defs/positiveInteger" + } + } + }, + "scalarBit": { + "allOf": [ + { + "$ref": "#/$defs/bitBase" + }, + { + "required": ["type"], + "properties": { + "type": { + "$ref": "#/$defs/typeReference" + } + } + } + ], + "unevaluatedProperties": false + }, + "packedBit": { + "allOf": [ + { + "$ref": "#/$defs/bitBase" + }, + { + "required": ["kind", "bits"], + "properties": { + "kind": { + "const": "packed" + }, + "bits": { + "$ref": "#/$defs/bits" + } + } + } + ], + "unevaluatedProperties": false + }, + "packedArm": { + "type": "object", + "additionalProperties": false, + "required": ["kind", "width", "bits"], + "properties": { + "kind": { + "const": "packed" + }, + "width": { + "description": "Physical width of this inline arm in bits.", + "$ref": "#/$defs/positiveInteger" + }, + "bits": { + "$ref": "#/$defs/bits" + } + } + }, + "taggedUnionBit": { + "allOf": [ + { + "$ref": "#/$defs/bitBase" + }, + { + "required": ["kind", "tag", "arms"], + "properties": { + "kind": { + "const": "union" + }, + "tag": { + "type": "string", + "minLength": 1 + }, + "arms": { + "type": "object", + "minProperties": 1, + "propertyNames": { + "pattern": "^[A-Z][A-Z0-9_]*$" + }, + "additionalProperties": { + "oneOf": [ + { + "$ref": "#/$defs/packedArm" + }, + { + "type": "null" + } + ] + } + } + } + } + ], + "unevaluatedProperties": false + }, + "bit": { + "oneOf": [ + { + "$ref": "#/$defs/scalarBit" + }, + { + "$ref": "#/$defs/packedBit" + }, + { + "$ref": "#/$defs/taggedUnionBit" + } + ] + }, + "bits": { + "type": "object", + "minProperties": 1, + "additionalProperties": { + "$ref": "#/$defs/bit" + } + }, + "descriptorBase": { + "type": "object", + "required": ["kind", "size", "align"], + "properties": { + "size": { + "$ref": "#/$defs/nonNegativeInteger" + }, + "align": { + "$ref": "#/$defs/positiveInteger" + } + } + }, + "structDescriptor": { + "allOf": [ + { + "$ref": "#/$defs/descriptorBase" + }, + { + "required": ["fields"], + "properties": { + "kind": { + "const": "struct" + }, + "fields": { + "$ref": "#/$defs/fields" + } + } + } + ], + "unevaluatedProperties": false + }, + "unionDescriptor": { + "allOf": [ + { + "$ref": "#/$defs/descriptorBase" + }, + { + "required": ["fields"], + "properties": { + "kind": { + "const": "union" + }, + "fields": { + "$ref": "#/$defs/fields" + } + } + } + ], + "unevaluatedProperties": false + }, + "enumDescriptor": { + "allOf": [ + { + "$ref": "#/$defs/descriptorBase" + }, + { + "required": ["underlying", "prefix", "values"], + "properties": { + "kind": { + "const": "enum" + }, + "underlying": { + "const": "i32" + }, + "prefix": { + "type": "string", + "minLength": 1 + }, + "values": { + "type": "object", + "minProperties": 1, + "propertyNames": { + "pattern": "^[A-Z][A-Z0-9_]*$" + }, + "additionalProperties": { + "type": "integer" + } + } + } + } + ], + "unevaluatedProperties": false + }, + "packedDescriptor": { + "allOf": [ + { + "$ref": "#/$defs/descriptorBase" + }, + { + "required": ["underlying", "bits"], + "properties": { + "kind": { + "const": "packed" + }, + "underlying": { + "description": "Integer backing type used to interpret the packed value.", + "$ref": "#/$defs/integerTypeReference" + }, + "bits": { + "$ref": "#/$defs/bits" + } + } + } + ], + "unevaluatedProperties": false + }, + "aliasDescriptor": { + "allOf": [ + { + "$ref": "#/$defs/descriptorBase" + }, + { + "required": ["type"], + "properties": { + "kind": { + "const": "alias" + }, + "type": { + "$ref": "#/$defs/typeReference" + } + } + } + ], + "unevaluatedProperties": false + }, + "opaqueDescriptor": { + "allOf": [ + { + "$ref": "#/$defs/descriptorBase" + }, + { + "properties": { + "kind": { + "const": "opaque" + } + } + } + ], + "unevaluatedProperties": false + }, + "typeDescriptor": { + "oneOf": [ + { + "$ref": "#/$defs/structDescriptor" + }, + { + "$ref": "#/$defs/unionDescriptor" + }, + { + "$ref": "#/$defs/enumDescriptor" + }, + { + "$ref": "#/$defs/packedDescriptor" + }, + { + "$ref": "#/$defs/aliasDescriptor" + }, + { + "$ref": "#/$defs/opaqueDescriptor" + } + ] + } + } +} diff --git a/vendor/libghostty-vt/src/terminal/c/types.zig b/vendor/libghostty-vt/src/terminal/c/types.zig index df84d3e..4184de8 100644 --- a/vendor/libghostty-vt/src/terminal/c/types.zig +++ b/vendor/libghostty-vt/src/terminal/c/types.zig @@ -1,26 +1,54 @@ -//! Comptime-generated metadata describing the layout of all C API -//! extern structs for the current target. +//! Comptime-generated ABI metadata for the public libghostty-vt C types. //! -//! This is embedded in the binary as a const string and exposed via -//! `ghostty_type_json` so that WASM (and other FFI) consumers can -//! build structs without hardcoding byte offsets. +//! The manifest is embedded in the library and returned by +//! `ghostty_type_json`. It is intended for FFI consumers that cannot use the +//! C headers directly, most notably WebAssembly hosts. Its format is defined +//! by `types.schema.json`. const std = @import("std"); +const builtin = @import("builtin"); +const build_options = @import("terminal_options"); const lib = @import("../lib.zig"); +const c_abi = @import("../../lib/c_abi.zig"); + const color = @import("../color.zig"); -const color_c = @import("color.zig"); -const mouse_event = @import("mouse_event.zig"); +const clipboard = @import("../clipboard.zig"); +const device_status = @import("../device_status.zig"); +const focus_pkg = @import("../focus.zig"); +const formatter_pkg = @import("../formatter.zig"); +const modes_pkg = @import("../modes.zig"); +const mouse_pkg = @import("../mouse.zig"); +const page = @import("../page.zig"); const point = @import("../point.zig"); -const size_report = @import("size_report.zig"); +const Selection = @import("../Selection.zig"); +const sgr = @import("../sgr.zig"); -const terminal = @import("terminal.zig"); +const input_config = @import("../../input/config.zig"); +const input_key = @import("../../input/key.zig"); +const input_mouse = @import("../../input/mouse.zig"); + +const build_info = @import("build_info.zig"); +const cell = @import("cell.zig"); +const color_c = @import("color.zig"); const formatter = @import("formatter.zig"); -const selection = @import("selection.zig"); -const selection_gesture = @import("selection_gesture.zig"); -const render = @import("render.zig"); -const style_c = @import("style.zig"); -const mouse_encode = @import("mouse_encode.zig"); const grid_ref = @import("grid_ref.zig"); const io = @import("io.zig"); +const key_encode = @import("key_encode.zig"); +const kitty_graphics = @import("kitty_graphics.zig"); +const mouse_encode = @import("mouse_encode.zig"); +const mouse_event = @import("mouse_event.zig"); +const osc = @import("osc.zig"); +const paste = @import("paste.zig"); +const render = @import("render.zig"); +const result = @import("result.zig"); +const row = @import("row.zig"); +const search = @import("search.zig"); +const selection = @import("selection.zig"); +const selection_gesture = @import("selection_gesture.zig"); +const size_report = @import("size_report.zig"); +const snapshot = @import("snapshot.zig"); +const style = @import("style.zig"); +const sys = @import("sys.zig"); +const terminal = @import("terminal.zig"); /// C: GhosttySurfacePosition pub const SurfacePosition = extern struct { @@ -34,250 +62,1113 @@ pub const Codepoints = extern struct { len: usize = 0, }; -/// All C API structs and their Ghostty C names. -pub const structs: std.StaticStringMap(StructInfo) = structs: { - @setEvalBranchQuota(10_000); - break :structs .initComptime(.{ - .{ "GhosttyBuffer", StructInfo.init(lib.Buffer) }, - .{ "GhosttyClipboardContent", StructInfo.init(terminal.ClipboardContent) }, - .{ "GhosttyClipboardWrite", StructInfo.init(terminal.ClipboardWrite) }, - .{ "GhosttyCodepoints", StructInfo.init(Codepoints) }, - .{ "GhosttyColorPaletteMask", StructInfo.init(color_c.PaletteMask) }, - .{ "GhosttyColorRgb", StructInfo.init(color.RGB.C) }, - .{ "GhosttyColorX11Entry", StructInfo.init(color_c.X11Entry) }, - .{ "GhosttyDeviceAttributes", StructInfo.init(terminal.DeviceAttributes) }, - .{ "GhosttyDeviceAttributesPrimary", StructInfo.init(terminal.DeviceAttributes.Primary) }, - .{ "GhosttyDeviceAttributesSecondary", StructInfo.init(terminal.DeviceAttributes.Secondary) }, - .{ "GhosttyDeviceAttributesTertiary", StructInfo.init(terminal.DeviceAttributes.Tertiary) }, - .{ "GhosttyFormatterTerminalOptions", StructInfo.init(formatter.TerminalOptions) }, - .{ "GhosttySelection", StructInfo.init(selection.CSelection) }, - .{ "GhosttyTerminalSelectWordOptions", StructInfo.init(selection.SelectWordOptions) }, - .{ "GhosttyTerminalSelectWordBetweenOptions", StructInfo.init(selection.SelectWordBetweenOptions) }, - .{ "GhosttyTerminalSelectLineOptions", StructInfo.init(selection.SelectLineOptions) }, - .{ "GhosttyFormatterTerminalExtra", StructInfo.init(formatter.TerminalOptions.Extra) }, - .{ "GhosttyFormatterScreenExtra", StructInfo.init(formatter.ScreenOptions.Extra) }, - .{ "GhosttyGridRef", StructInfo.init(grid_ref.CGridRef) }, - .{ "GhosttyMouseEncoderSize", StructInfo.init(mouse_encode.Size) }, - .{ "GhosttyMousePosition", StructInfo.init(mouse_event.Position) }, - .{ "GhosttyPoint", StructInfo.init(point.Point.C) }, - .{ "GhosttyPointCoordinate", StructInfo.init(point.Coordinate) }, - .{ "GhosttyReader", StructInfo.init(io.Reader) }, - .{ "GhosttyRenderStateColors", StructInfo.init(render.Colors) }, - .{ "GhosttySelectionGestureBehaviors", StructInfo.init(selection_gesture.Behaviors) }, - .{ "GhosttySelectionGestureGeometry", StructInfo.init(selection_gesture.Geometry) }, - .{ "GhosttySizeReportSize", StructInfo.init(size_report.Size) }, - .{ "GhosttyString", StructInfo.init(lib.String) }, - .{ "GhosttySurfacePosition", StructInfo.init(SurfacePosition) }, - .{ "GhosttyStyle", StructInfo.init(style_c.Style) }, - .{ "GhosttyStyleColor", StructInfo.init(style_c.Color) }, - .{ "GhosttyTerminalDesktopNotification", StructInfo.init(terminal.DesktopNotification) }, - .{ "GhosttyTerminalModeConfig", StructInfo.init(terminal.ModeConfig) }, - .{ "GhosttyTerminalProgressReport", StructInfo.init(terminal.ProgressReport) }, - .{ "GhosttyTerminalScrollbar", StructInfo.init(terminal.TerminalScrollbar) }, - .{ "GhosttyTerminalScrollViewport", StructInfo.init(terminal.ScrollViewport) }, - .{ "GhosttyTerminalUnknownSequence", StructInfo.init(terminal.UnknownSequence.C) }, - .{ "GhosttyTerminalUnknownStringSequence", StructInfo.init(terminal.UnknownStringSequence) }, - .{ "GhosttyWriter", StructInfo.init(io.Writer) }, - }); +const TypeDecl = struct { + name: []const u8, + T: type, + kind: Kind, + prefix: []const u8 = "", + sentinel_suffix: []const u8 = "MAX_VALUE", + alias_type: []const u8 = "", + tagged_union: ?TaggedUnion = null, + union_field_names_T: ?type = null, + + const Kind = enum { + @"struct", + @"union", + @"enum", + @"packed", + alias, + @"opaque", + }; + + const TaggedUnion = struct { + tag_field: []const u8, + value_field: []const u8, + arm_source: ArmSource, + + const ArmSource = enum { + fields, + generated, + }; + }; + + fn initStruct(comptime name: []const u8, comptime T: type) TypeDecl { + return .{ .name = name, .T = T, .kind = .@"struct" }; + } + + fn initTaggedStruct( + comptime name: []const u8, + comptime T: type, + comptime tag_field: []const u8, + comptime value_field: []const u8, + comptime arm_source: TaggedUnion.ArmSource, + ) TypeDecl { + return .{ + .name = name, + .T = T, + .kind = .@"struct", + .tagged_union = .{ + .tag_field = tag_field, + .value_field = value_field, + .arm_source = arm_source, + }, + }; + } + + fn initUnion( + comptime name: []const u8, + comptime T: type, + comptime field_names_T: ?type, + ) TypeDecl { + return .{ + .name = name, + .T = T, + .kind = .@"union", + .union_field_names_T = field_names_T, + }; + } + + fn initEnum( + comptime name: []const u8, + comptime T: type, + comptime prefix: []const u8, + ) TypeDecl { + return .{ .name = name, .T = T, .kind = .@"enum", .prefix = prefix }; + } + + fn initEnumSentinel( + comptime name: []const u8, + comptime T: type, + comptime prefix: []const u8, + comptime sentinel_suffix: []const u8, + ) TypeDecl { + return .{ + .name = name, + .T = T, + .kind = .@"enum", + .prefix = prefix, + .sentinel_suffix = sentinel_suffix, + }; + } + + fn initPacked(comptime name: []const u8, comptime T: type) TypeDecl { + return .{ .name = name, .T = T, .kind = .@"packed" }; + } + + fn initAlias( + comptime name: []const u8, + comptime T: type, + comptime alias_type: []const u8, + ) TypeDecl { + return .{ .name = name, .T = T, .kind = .alias, .alias_type = alias_type }; + } + + fn initOpaque(comptime name: []const u8) TypeDecl { + return .{ .name = name, .T = *anyopaque, .kind = .@"opaque" }; + } }; -/// The comptime-generated JSON string of all structs. +/// Public C types. Names and enum prefixes are intentionally explicit because +/// Zig identifiers are not the C API contract. +const type_decls = [_]TypeDecl{ + .initStruct("GhosttyAllocator", lib.alloc.Allocator), + .initStruct("GhosttyAllocatorVtable", lib.alloc.VTable), + .initStruct("GhosttyBuffer", lib.Buffer), + .initStruct("GhosttyCellsView", cell.CellsView), + .initStruct("GhosttyClipboardContent", terminal.ClipboardContent), + .initStruct("GhosttyClipboardRead", terminal.ClipboardRead), + .initStruct("GhosttyClipboardReadReply", terminal.ClipboardReadReply), + .initStruct("GhosttyClipboardWrite", terminal.ClipboardWrite), + .initStruct("GhosttyClipboardWriteReply", terminal.ClipboardWriteReply), + .initStruct("GhosttyCodepoints", Codepoints), + .initStruct("GhosttyColorPaletteMask", color_c.PaletteMask), + .initStruct("GhosttyColorRgb", color.RGB.C), + .initStruct("GhosttyColorX11Entry", color_c.X11Entry), + .initStruct("GhosttyDeviceAttributes", terminal.DeviceAttributes), + .initStruct("GhosttyDeviceAttributesPrimary", terminal.DeviceAttributes.Primary), + .initStruct("GhosttyDeviceAttributesSecondary", terminal.DeviceAttributes.Secondary), + .initStruct("GhosttyDeviceAttributesTertiary", terminal.DeviceAttributes.Tertiary), + .initStruct("GhosttyFormatterScreenExtra", formatter.ScreenOptions.Extra), + .initStruct("GhosttyFormatterTerminalExtra", formatter.TerminalOptions.Extra), + .initStruct("GhosttyFormatterTerminalOptions", formatter.TerminalOptions), + .initStruct("GhosttyGridRef", grid_ref.CGridRef), + .initStruct("GhosttyKittyGraphicsPlacementRenderInfo", kitty_graphics.PlacementRenderInfo), + .initStruct("GhosttyMimeReader", io.MimeReader), + .initStruct("GhosttyMouseEncoderSize", mouse_encode.Size), + .initStruct("GhosttyMousePosition", mouse_event.Position), + .initStruct("GhosttyPaste", paste.Request), + .initTaggedStruct("GhosttyPoint", point.Point.C, "tag", "value", .generated), + .initStruct("GhosttyPointCoordinate", point.Coordinate), + .initUnion("GhosttyPointValue", point.Point.CValue, point.Point.C), + .initStruct("GhosttyReader", io.Reader), + .initStruct("GhosttyRenderStateColors", render.Colors), + .initStruct("GhosttyRenderStateCursor", render.Cursor), + .initStruct("GhosttyRenderStateRowSelection", render.RowSelection), + .initStruct("GhosttySelection", selection.CSelection), + .initStruct("GhosttySelectionBuffer", selection.CSelectionBuffer), + .initStruct("GhosttySelectionGestureBehaviors", selection_gesture.Behaviors), + .initStruct("GhosttySelectionGestureGeometry", selection_gesture.Geometry), + .initTaggedStruct("GhosttySgrAttribute", sgr.Attribute.C, "tag", "value", .generated), + .initStruct("GhosttySgrUnknown", sgr.Attribute.Unknown.C), + .initUnion("GhosttySgrAttributeValue", sgr.Attribute.CValue, sgr.Attribute.C), + .initStruct("GhosttySizeReportSize", size_report.Size), + .initStruct("GhosttyString", lib.String), + .initStruct("GhosttySurfacePosition", SurfacePosition), + .initStruct("GhosttyStyle", style.Style), + .initTaggedStruct("GhosttyStyleColor", style.Color, "tag", "value", .fields), + .initUnion("GhosttyStyleColorValue", style.ColorValue, null), + .initStruct("GhosttySysImage", sys.Image), + .initStruct("GhosttyTerminalDesktopNotification", terminal.DesktopNotification), + .initStruct("GhosttyTerminalModeConfig", terminal.ModeConfig), + .initStruct("GhosttyTerminalProgressReport", terminal.ProgressReport), + .initStruct("GhosttyTerminalScrollbar", terminal.TerminalScrollbar), + .initTaggedStruct("GhosttyTerminalScrollViewport", terminal.ScrollViewport, "tag", "value", .generated), + .initUnion( + "GhosttyTerminalScrollViewportValue", + terminal.ZigTerminal.ScrollViewport.CValue, + terminal.ZigTerminal.ScrollViewport.C, + ), + .initStruct("GhosttyTerminalSelectLineOptions", selection.SelectLineOptions), + .initStruct("GhosttyTerminalSelectWordBetweenOptions", selection.SelectWordBetweenOptions), + .initStruct("GhosttyTerminalSelectWordOptions", selection.SelectWordOptions), + .initStruct("GhosttyTerminalSelectionFormatOptions", selection.FormatOptions), + .initTaggedStruct("GhosttyTerminalUnknownSequence", terminal.UnknownSequence.C, "tag", "value", .generated), + .initStruct("GhosttyTerminalUnknownStringSequence", terminal.UnknownStringSequence), + .initUnion( + "GhosttyTerminalUnknownSequenceValue", + terminal.UnknownSequence.CValue, + terminal.UnknownSequence.C, + ), + .initStruct("GhosttyWriter", io.Writer), + + .initEnumSentinel("GhosttyResult", result.Result, "GHOSTTY_", "RESULT_MAX_VALUE"), + .initEnum("GhosttyBuildInfo", build_info.BuildInfo, "GHOSTTY_BUILD_INFO_"), + .initEnumSentinel("GhosttyOptimizeMode", build_info.OptimizeMode, "GHOSTTY_OPTIMIZE_", "MODE_MAX_VALUE"), + .initEnumSentinel("GhosttyCellContentTag", cell.ContentTag, "GHOSTTY_CELL_CONTENT_", "TAG_MAX_VALUE"), + .initEnum("GhosttyCellData", cell.CellData, "GHOSTTY_CELL_DATA_"), + .initEnum("GhosttyCellSemanticContent", cell.SemanticContent, "GHOSTTY_CELL_SEMANTIC_"), + .initEnum("GhosttyCellWide", cell.Wide, "GHOSTTY_CELL_WIDE_"), + .initEnum("GhosttyClipboardLocation", clipboard.Location, "GHOSTTY_CLIPBOARD_LOCATION_"), + .initEnum("GhosttyClipboardReadResult", clipboard.Read.Status, "GHOSTTY_CLIPBOARD_READ_RESULT_"), + .initEnum("GhosttyClipboardWriteResult", clipboard.Write.Status, "GHOSTTY_CLIPBOARD_WRITE_RESULT_"), + .initEnum("GhosttyColorScheme", device_status.ColorScheme, "GHOSTTY_COLOR_SCHEME_"), + .initEnum("GhosttyFocusEvent", focus_pkg.Event, "GHOSTTY_FOCUS_"), + .initEnum("GhosttyFormatterFormat", formatter_pkg.Format, "GHOSTTY_FORMATTER_FORMAT_"), + .initEnum("GhosttyKey", input_key.Key, "GHOSTTY_KEY_"), + .initEnum("GhosttyKeyAction", input_key.Action, "GHOSTTY_KEY_ACTION_"), + .initEnum("GhosttyKeyEncoderOption", key_encode.Option, "GHOSTTY_KEY_ENCODER_OPT_"), + .initEnum("GhosttyKittyGraphicsData", kitty_graphics.Data, "GHOSTTY_KITTY_GRAPHICS_DATA_"), + .initEnum("GhosttyKittyGraphicsImageData", kitty_graphics.ImageData, "GHOSTTY_KITTY_IMAGE_DATA_"), + .initEnum("GhosttyKittyGraphicsPlacementData", kitty_graphics.PlacementData, "GHOSTTY_KITTY_GRAPHICS_PLACEMENT_DATA_"), + .initEnum("GhosttyKittyGraphicsPlacementIteratorOption", kitty_graphics.PlacementIteratorOption, "GHOSTTY_KITTY_GRAPHICS_PLACEMENT_ITERATOR_OPTION_"), + .initEnum("GhosttyKittyImageCompression", kitty_graphics.ImageCompression, "GHOSTTY_KITTY_IMAGE_COMPRESSION_"), + .initEnum("GhosttyKittyImageFormat", kitty_graphics.ImageFormat, "GHOSTTY_KITTY_IMAGE_FORMAT_"), + .initEnum("GhosttyKittyPlacementLayer", kitty_graphics.PlacementLayer, "GHOSTTY_KITTY_PLACEMENT_LAYER_"), + .initEnum("GhosttyModeReportState", modes_pkg.Report.State, "GHOSTTY_MODE_REPORT_"), + .initEnum("GhosttyMouseAction", input_mouse.Action, "GHOSTTY_MOUSE_ACTION_"), + .initEnum("GhosttyMouseButton", input_mouse.Button, "GHOSTTY_MOUSE_BUTTON_"), + .initEnum("GhosttyMouseEncoderOption", mouse_encode.Option, "GHOSTTY_MOUSE_ENCODER_OPT_"), + .initEnum("GhosttyMouseFormat", mouse_pkg.Format, "GHOSTTY_MOUSE_FORMAT_"), + .initEnum("GhosttyMouseTrackingMode", mouse_pkg.Event, "GHOSTTY_MOUSE_TRACKING_"), + .initEnum("GhosttyOptionAsAlt", input_config.OptionAsAlt, "GHOSTTY_OPTION_AS_ALT_"), + .initEnum("GhosttyOscCommandData", osc.CommandData, "GHOSTTY_OSC_DATA_"), + .initEnumSentinel( + "GhosttyOscCommandType", + osc.CommandType, + "GHOSTTY_OSC_COMMAND_", + "TYPE_MAX_VALUE", + ), + .initEnum("GhosttyPasteSource", paste.Source, "GHOSTTY_PASTE_SOURCE_"), + .initEnum("GhosttyPointTag", point.Tag, "GHOSTTY_POINT_TAG_"), + .initEnum("GhosttyRenderStateCursorVisualStyle", render.CursorVisualStyle, "GHOSTTY_RENDER_STATE_CURSOR_VISUAL_STYLE_"), + .initEnum("GhosttyRenderStateData", render.Data, "GHOSTTY_RENDER_STATE_DATA_"), + .initEnum("GhosttyRenderStateDirty", render.Dirty, "GHOSTTY_RENDER_STATE_DIRTY_"), + .initEnum("GhosttyRenderStateOption", render.SetOption, "GHOSTTY_RENDER_STATE_OPTION_"), + .initEnum("GhosttyRenderStateRowCellsData", render.RowCellsData, "GHOSTTY_RENDER_STATE_ROW_CELLS_DATA_"), + .initEnum("GhosttyRenderStateRowData", render.RowData, "GHOSTTY_RENDER_STATE_ROW_DATA_"), + .initEnum("GhosttyRenderStateRowOption", render.RowOption, "GHOSTTY_RENDER_STATE_ROW_OPTION_"), + .initEnum("GhosttyRowData", row.RowData, "GHOSTTY_ROW_DATA_"), + .initEnum("GhosttyRowSemanticPrompt", row.SemanticPrompt, "GHOSTTY_ROW_SEMANTIC_"), + .initEnum("GhosttySearchData", search.Data, "GHOSTTY_SEARCH_DATA_"), + .initEnum("GhosttySearchOption", search.Option, "GHOSTTY_SEARCH_OPT_"), + .initEnum("GhosttySearchScroll", search.Scroll, "GHOSTTY_SEARCH_SCROLL_"), + .initEnum("GhosttySearchStatus", search.Status, "GHOSTTY_SEARCH_STATUS_"), + .initEnum("GhosttySelectionAdjust", Selection.Adjustment, "GHOSTTY_SELECTION_ADJUST_"), + .initEnum("GhosttySelectionGestureAutoscroll", selection_gesture.Autoscroll, "GHOSTTY_SELECTION_GESTURE_AUTOSCROLL_"), + .initEnum("GhosttySelectionGestureBehavior", selection_gesture.Behavior, "GHOSTTY_SELECTION_GESTURE_BEHAVIOR_"), + .initEnum("GhosttySelectionGestureData", selection_gesture.Data, "GHOSTTY_SELECTION_GESTURE_DATA_"), + .initEnum("GhosttySelectionGestureEventOption", selection_gesture.EventOption, "GHOSTTY_SELECTION_GESTURE_EVENT_OPT_"), + .initEnum("GhosttySelectionGestureEventType", selection_gesture.EventType, "GHOSTTY_SELECTION_GESTURE_EVENT_TYPE_"), + .initEnum("GhosttySelectionOrder", Selection.Order, "GHOSTTY_SELECTION_ORDER_"), + .initEnum("GhosttySgrAttributeTag", sgr.Attribute.Tag, "GHOSTTY_SGR_ATTR_"), + .initEnum("GhosttySgrUnderline", sgr.Attribute.Underline, "GHOSTTY_SGR_UNDERLINE_"), + .initEnumSentinel("GhosttySizeReportStyle", size_report.Style, "GHOSTTY_SIZE_REPORT_", "STYLE_MAX_VALUE"), + .initEnum("GhosttySnapshotDecoderData", snapshot.DecoderData, "GHOSTTY_SNAPSHOT_DECODER_DATA_"), + .initEnum("GhosttySnapshotDecoderOption", snapshot.DecoderOption, "GHOSTTY_SNAPSHOT_DECODER_OPT_"), + .initEnumSentinel("GhosttyStyleColorTag", style.ColorTag, "GHOSTTY_STYLE_COLOR_", "TAG_MAX_VALUE"), + .initEnum("GhosttySysLogLevel", sys.LogLevel, "GHOSTTY_SYS_LOG_LEVEL_"), + .initEnum("GhosttySysOption", sys.Option, "GHOSTTY_SYS_OPT_"), + .initEnum("GhosttyTerminalCompressionMode", terminal.CompressionMode, "GHOSTTY_TERMINAL_COMPRESSION_MODE_"), + .initEnum("GhosttyTerminalCompressionResult", terminal.CompressionResult, "GHOSTTY_TERMINAL_COMPRESSION_RESULT_"), + .initEnum("GhosttyTerminalCursorStyle", terminal.TerminalCursorStyle, "GHOSTTY_TERMINAL_CURSOR_STYLE_"), + .initEnum("GhosttyTerminalData", terminal.TerminalData, "GHOSTTY_TERMINAL_DATA_"), + .initEnum("GhosttyTerminalOption", terminal.Option, "GHOSTTY_TERMINAL_OPT_"), + .initEnum("GhosttyTerminalProgressState", terminal.ProgressState, "GHOSTTY_TERMINAL_PROGRESS_STATE_"), + .initEnum("GhosttyTerminalScreen", terminal.TerminalScreen, "GHOSTTY_TERMINAL_SCREEN_"), + .initEnum("GhosttyTerminalScrollViewportTag", terminal.ZigTerminal.ScrollViewport.Tag, "GHOSTTY_SCROLL_VIEWPORT_"), + .initEnum("GhosttyTerminalUnknownSequenceTag", terminal.UnknownSequence.Tag, "GHOSTTY_TERMINAL_UNKNOWN_SEQUENCE_"), + + .initPacked("GhosttyCell", page.Cell.CLayout), + .initAlias("GhosttyColorPaletteIndex", u8, "u8"), + .initAlias("GhosttyKittyKeyFlags", u8, "u8"), + .initAlias("GhosttyMode", u16, "u16"), + .initAlias("GhosttyMods", u16, "u16"), + .initAlias("GhosttyRow", u64, "u64"), + .initAlias("GhosttyStyleId", u16, "u16"), + + .initOpaque("GhosttyFormatter"), + .initOpaque("GhosttyKeyEncoder"), + .initOpaque("GhosttyKeyEvent"), + .initOpaque("GhosttyKittyGraphics"), + .initOpaque("GhosttyKittyGraphicsImage"), + .initOpaque("GhosttyKittyGraphicsPlacementIterator"), + .initOpaque("GhosttyMouseEncoder"), + .initOpaque("GhosttyMouseEvent"), + .initOpaque("GhosttyOscCommand"), + .initOpaque("GhosttyOscParser"), + .initOpaque("GhosttyRenderState"), + .initOpaque("GhosttyRenderStateRowCells"), + .initOpaque("GhosttyRenderStateRowIterator"), + .initOpaque("GhosttySearch"), + .initOpaque("GhosttySelectionGesture"), + .initOpaque("GhosttySelectionGestureEvent"), + .initOpaque("GhosttySgrParser"), + .initOpaque("GhosttySnapshotDecoder"), + .initOpaque("GhosttyTerminal"), + .initOpaque("GhosttyTrackedGridRef"), +}; + +comptime { + @setEvalBranchQuota(100_000); + for (type_decls, 0..) |decl, i| { + for (type_decls[0..i]) |previous| { + if (std.mem.eql(u8, decl.name, previous.name)) + @compileError("duplicate public ABI type: " ++ decl.name); + } + } +} + pub const json: [:0]const u8 = json: { - @setEvalBranchQuota(200_000); + @setEvalBranchQuota(1_000_000); var counter: std.Io.Writer.Discarding = .init(&.{}); - jsonWriteAll(&counter.writer) catch unreachable; + Json.writeAll(&counter.writer) catch unreachable; var buf: [counter.count:0]u8 = undefined; var writer: std.Io.Writer = .fixed(&buf); - jsonWriteAll(&writer) catch unreachable; + Json.writeAll(&writer) catch unreachable; const final = buf; break :json final[0..writer.end :0]; }; -/// Returns a pointer to the comptime-generated JSON string describing -/// the layout of all C API extern structs, and writes its length to `len`. -/// Exported as `ghostty_type_json` for FFI consumers. pub fn get_json() callconv(lib.calling_conv) [*:0]const u8 { return json.ptr; } -/// Meta information about a struct that we expose to ease writing -/// bindings in some languages, particularly WASM where we can't -/// easily share struct definitions and need to hardcode byte offsets. -pub const StructInfo = struct { - name: []const u8, - size: usize, - @"align": usize, - fields: []const FieldInfo, +const Json = struct { + fn writeAll(writer: *std.Io.Writer) std.Io.Writer.Error!void { + var jws: std.json.Stringify = .{ .writer = writer }; + try jws.beginObject(); + try jws.objectField("schema"); + try jws.write(1); - pub const FieldInfo = struct { - name: []const u8, - offset: usize, - size: usize, - type: []const u8, - }; + try jws.objectField("abi"); + try jws.beginObject(); + try jws.objectField("target"); + try jws.write(@tagName(builtin.target.cpu.arch)); + try jws.objectField("os"); + try jws.write(@tagName(builtin.target.os.tag)); + try jws.objectField("environment"); + try jws.write(@tagName(builtin.target.abi)); + try jws.objectField("pointer_size"); + try jws.write(@sizeOf(*anyopaque)); + try jws.objectField("usize_size"); + try jws.write(@sizeOf(usize)); + try jws.objectField("max_alignment"); + try jws.write(c_abi.max_alignment); + try jws.objectField("endian"); + try jws.write(@tagName(builtin.target.cpu.arch.endian())); + try jws.endObject(); - pub fn init(comptime T: type) StructInfo { - comptime { - const fields = @typeInfo(T).@"struct".fields; - const field_infos: [fields.len]FieldInfo = blk: { - var infos: [fields.len]FieldInfo = undefined; - for (fields, 0..) |field, i| infos[i] = .{ - .name = field.name, - .offset = @offsetOf(T, field.name), - .size = @sizeOf(field.type), - .type = typeName(field.type), - }; - break :blk infos; - }; + try jws.objectField("library_version"); + try jws.write(build_options.version_string); + try jws.objectField("commit"); + if (build_options.version_build) |commit| try jws.write(commit) else try jws.write(null); + try jws.objectField("dirty"); + try jws.write(null); - return .{ - .name = @typeName(T), - .size = @sizeOf(T), - .@"align" = @alignOf(T), - .fields = &field_infos, - }; + try jws.objectField("types"); + try jws.beginObject(); + inline for (type_decls) |decl| { + try jws.objectField(decl.name); + try writeType(decl, &jws); } + try jws.endObject(); + try jws.endObject(); } - pub fn jsonStringify( - self: *const StructInfo, - jws: anytype, - ) std.Io.Writer.Error!void { + fn writeType(comptime decl: TypeDecl, jws: *std.json.Stringify) std.Io.Writer.Error!void { try jws.beginObject(); - try jws.objectField("size"); - try jws.write(self.size); - try jws.objectField("align"); - try jws.write(self.@"align"); - try jws.objectField("fields"); + try jws.objectField("kind"); + try jws.write(@tagName(decl.kind)); + + switch (decl.kind) { + .@"struct", .@"union" => { + try writeSizeAlign(decl.T, jws); + try jws.objectField("fields"); + try jws.beginObject(); + if (decl.union_field_names_T != null) { + try writeMappedUnionFields(decl, jws); + } else { + const fields = switch (decl.kind) { + .@"struct" => @typeInfo(decl.T).@"struct".fields, + .@"union" => @typeInfo(decl.T).@"union".fields, + else => unreachable, + }; + inline for (fields) |field| { + try writeField( + decl, + field.name, + field.type, + if (decl.kind == .@"union") 0 else @offsetOf(decl.T, field.name), + jws, + ); + } + } + try jws.endObject(); + }, + .@"enum" => { + try writeSizeAlign(c_int, jws); + try jws.objectField("underlying"); + try jws.write("i32"); + try jws.objectField("prefix"); + try jws.write(decl.prefix); + try jws.objectField("values"); + try jws.beginObject(); + inline for (@typeInfo(decl.T).@"enum".fields) |field| { + try writeEnumObjectField(decl.name, field.name, jws); + try jws.write(field.value); + } + try jws.objectField(decl.sentinel_suffix); + try jws.write(std.math.maxInt(c_int)); + try jws.endObject(); + }, + .@"packed" => try writePackedType(decl.T, jws), + .alias => { + try writeSizeAlign(decl.T, jws); + try jws.objectField("type"); + try jws.write(decl.alias_type); + }, + .@"opaque" => try writeSizeAlign(*anyopaque, jws), + } + try jws.endObject(); + } + + fn writePackedType( + comptime Layout: type, + jws: *std.json.Stringify, + ) std.Io.Writer.Error!void { + try writeSizeAlign(Layout.Zig, jws); + try jws.objectField("underlying"); + try jws.write(publicTypeName(Layout.Backing)); + try jws.objectField("bits"); try jws.beginObject(); - for (self.fields) |field| { - try jws.objectField(field.name); + try writePackedBits(Layout, jws); + try jws.endObject(); + } + + fn writePackedBits( + comptime Layout: type, + jws: *std.json.Stringify, + ) std.Io.Writer.Error!void { + inline for (@typeInfo(Layout.Zig).@"struct".fields) |field| { + const field_tag = @field(Layout.Field, field.name); + const name = comptime Layout.fieldName(field_tag) orelse continue; + const options = Layout.fieldOptions(field_tag); + + try jws.objectField(name); try jws.beginObject(); - try jws.objectField("offset"); - try jws.write(field.offset); - try jws.objectField("size"); - try jws.write(field.size); - try jws.objectField("type"); - try jws.write(field.type); + try jws.objectField("lsb"); + try jws.write(Layout.bitOffset(field_tag)); + try jws.objectField("width"); + try jws.write(Layout.bitWidth(field_tag)); + + switch (options.encoding) { + .scalar => { + try jws.objectField("type"); + try jws.write(options.type_name orelse publicTypeName(field.type)); + }, + .@"packed" => |Nested| { + try jws.objectField("kind"); + try jws.write("packed"); + try jws.objectField("bits"); + try jws.beginObject(); + try writePackedBits(Nested, jws); + try jws.endObject(); + }, + .tagged_union => |UnionLayout| try writePackedTaggedUnion(Layout, UnionLayout, jws), + } try jws.endObject(); } + } + + fn writePackedTaggedUnion( + comptime Layout: type, + comptime UnionLayout: type, + jws: *std.json.Stringify, + ) std.Io.Writer.Error!void { + try jws.objectField("kind"); + try jws.write("union"); + try jws.objectField("tag"); + try jws.write(Layout.fieldName(UnionLayout.tag_field).?); + try jws.objectField("arms"); + try jws.beginObject(); + + const tag_options = Layout.fieldOptions(UnionLayout.tag_field); + const tag_type_name = tag_options.type_name orelse publicTypeName(UnionLayout.Tag); + inline for (@typeInfo(UnionLayout.Tag).@"enum".fields) |tag| { + try writeEnumObjectField(tag_type_name, tag.name, jws); + const arm = UnionLayout.arm(@field(UnionLayout.Tag, tag.name)) orelse { + try jws.write(null); + continue; + }; + switch (arm) { + inline else => |ArmLayout| try writePackedArm(ArmLayout, jws), + } + } + + try jws.endObject(); + } + + fn writePackedArm( + comptime Layout: type, + jws: *std.json.Stringify, + ) std.Io.Writer.Error!void { + try jws.beginObject(); + try jws.objectField("kind"); + try jws.write("packed"); + try jws.objectField("width"); + try jws.write(@bitSizeOf(Layout.Zig)); + try jws.objectField("bits"); + try jws.beginObject(); + try writePackedBits(Layout, jws); try jws.endObject(); try jws.endObject(); } -}; -fn jsonWriteAll(writer: *std.Io.Writer) std.Io.Writer.Error!void { - var jws: std.json.Stringify = .{ .writer = writer }; - try jws.beginObject(); - for (structs.keys(), structs.values()) |name, *info| { + fn writeField( + comptime decl: TypeDecl, + comptime name: []const u8, + comptime T: type, + comptime offset: usize, + jws: *std.json.Stringify, + ) std.Io.Writer.Error!void { try jws.objectField(name); - try info.jsonStringify(&jws); + try jws.beginObject(); + try jws.objectField("offset"); + try jws.write(offset); + try jws.objectField("size"); + try jws.write(@sizeOf(T)); + try writeFieldType(decl.name, name, T, jws); + if (decl.tagged_union) |tagged| { + if (std.mem.eql(u8, name, tagged.value_field)) { + try jws.objectField("tag"); + try jws.write(tagged.tag_field); + try writeTaggedArms(decl, tagged, jws); + } + } + try jws.endObject(); } - try jws.endObject(); -} -fn typeName(comptime T: type) []const u8 { - return switch (@typeInfo(T)) { - .bool => "bool", - .float => |info| switch (info.bits) { - 32 => "f32", - 64 => "f64", - else => @compileError("unsupported float size"), - }, - .int => |info| switch (info.signedness) { - .signed => switch (info.bits) { + fn writeMappedUnionFields( + comptime decl: TypeDecl, + jws: *std.json.Stringify, + ) std.Io.Writer.Error!void { + const FieldNames = decl.union_field_names_T.?; + const Tag = @FieldType(FieldNames, "tag"); + const tag_fields = @typeInfo(Tag).@"enum".fields; + + inline for (tag_fields, 0..) |field, i| { + const name = comptime FieldNames.cFieldRename(@field(Tag, field.name)) orelse continue; + if (comptime mappedUnionFieldIsDuplicate(decl, i, name)) continue; + try writeField(decl, name, @FieldType(decl.T, field.name), 0, jws); + } + + if (@hasField(decl.T, "_padding")) + try writeField(decl, "_padding", @FieldType(decl.T, "_padding"), 0, jws); + } + + fn mappedUnionFieldIsDuplicate( + comptime decl: TypeDecl, + comptime index: usize, + comptime name: []const u8, + ) bool { + const FieldNames = decl.union_field_names_T.?; + const Tag = @FieldType(FieldNames, "tag"); + const tag_fields = @typeInfo(Tag).@"enum".fields; + const T = @FieldType(decl.T, tag_fields[index].name); + + inline for (tag_fields[0..index]) |previous| { + const previous_name = FieldNames.cFieldRename(@field(Tag, previous.name)) orelse continue; + if (std.mem.eql(u8, previous_name, name)) { + if (@FieldType(decl.T, previous.name) != T) + @compileError("tagged union metadata maps different field types to " ++ name); + return true; + } + } + + return false; + } + + fn writeSizeAlign(comptime T: type, jws: *std.json.Stringify) std.Io.Writer.Error!void { + try jws.objectField("size"); + try jws.write(@sizeOf(T)); + try jws.objectField("align"); + try jws.write(@alignOf(T)); + } + + fn writeFieldType( + comptime owner: []const u8, + comptime field_name: []const u8, + comptime T: type, + jws: *std.json.Stringify, + ) std.Io.Writer.Error!void { + if (comptime std.mem.eql(u8, owner, "GhosttyCellsView") and + std.mem.eql(u8, field_name, "ptr")) + { + try writePointerTypeNamed(T, true, "GhosttyCell", jws); + return; + } + if (comptime std.mem.eql(u8, owner, "GhosttyGridRef") and + std.mem.eql(u8, field_name, "node")) + { + try writePointerTypeNamed(T, true, "opaque", jws); + return; + } + + if (comptime fieldTypeOverride(owner, field_name)) |name| { + try jws.objectField("type"); + try jws.write(name); + return; + } + + switch (@typeInfo(T)) { + .array => |info| { + try jws.objectField("type"); + try jws.write("array"); + try jws.objectField("elem"); + try jws.write(publicTypeName(info.child)); + try jws.objectField("count"); + try jws.write(info.len); + }, + .optional => |info| try writePointerType(info.child, true, jws), + .pointer => try writePointerType(T, false, jws), + else => { + try jws.objectField("type"); + try jws.write(publicTypeName(T)); + }, + } + } + + fn writePointerType( + comptime T: type, + comptime nullable: bool, + jws: *std.json.Stringify, + ) std.Io.Writer.Error!void { + const info = @typeInfo(T).pointer; + try jws.objectField("type"); + try jws.write("pointer"); + try jws.objectField("elem"); + try jws.write(publicTypeName(info.child)); + try jws.objectField("const"); + try jws.write(info.is_const); + if (nullable) { + try jws.objectField("nullable"); + try jws.write(true); + } + } + + fn writePointerTypeNamed( + comptime T: type, + comptime nullable: bool, + comptime elem: []const u8, + jws: *std.json.Stringify, + ) std.Io.Writer.Error!void { + const pointer_type = switch (@typeInfo(T)) { + .optional => |info| info.child, + .pointer => T, + else => unreachable, + }; + const info = @typeInfo(pointer_type).pointer; + try jws.objectField("type"); + try jws.write("pointer"); + try jws.objectField("elem"); + try jws.write(elem); + try jws.objectField("const"); + try jws.write(info.is_const); + if (nullable) { + try jws.objectField("nullable"); + try jws.write(true); + } + } + + fn fieldTypeOverride(comptime owner: []const u8, comptime field_name: []const u8) ?[]const u8 { + if (std.mem.eql(u8, field_name, "value")) { + if (std.mem.eql(u8, owner, "GhosttyPoint")) return "GhosttyPointValue"; + if (std.mem.eql(u8, owner, "GhosttySgrAttribute")) return "GhosttySgrAttributeValue"; + if (std.mem.eql(u8, owner, "GhosttyStyleColor")) return "GhosttyStyleColorValue"; + if (std.mem.eql(u8, owner, "GhosttyTerminalScrollViewport")) return "GhosttyTerminalScrollViewportValue"; + if (std.mem.eql(u8, owner, "GhosttyTerminalUnknownSequence")) return "GhosttyTerminalUnknownSequenceValue"; + } + if (std.mem.eql(u8, owner, "GhosttyStyleColorValue") and std.mem.eql(u8, field_name, "palette")) + return "GhosttyColorPaletteIndex"; + if (std.mem.eql(u8, owner, "GhosttySgrAttributeValue")) { + if (std.mem.eql(u8, field_name, "underline")) return "GhosttySgrUnderline"; + if (std.mem.endsWith(u8, field_name, "_8") or std.mem.endsWith(u8, field_name, "_256")) + return "GhosttyColorPaletteIndex"; + } + if (std.mem.eql(u8, owner, "GhosttyTerminalModeConfig") and std.mem.eql(u8, field_name, "mode")) + return "GhosttyMode"; + return null; + } + + fn writeTaggedArms( + comptime decl: TypeDecl, + comptime tagged: TypeDecl.TaggedUnion, + jws: *std.json.Stringify, + ) std.Io.Writer.Error!void { + const Tag = @FieldType(decl.T, tagged.tag_field); + try jws.objectField("arms"); + try jws.beginObject(); + inline for (@typeInfo(Tag).@"enum".fields) |field| { + try writeEnumObjectField(publicTypeName(Tag), field.name, jws); + if (comptime taggedArm(decl, field.name)) |arm| try jws.write(arm) else try jws.write(null); + } + try jws.endObject(); + } + + fn taggedArm(comptime decl: TypeDecl, comptime tag_name: []const u8) ?[]const u8 { + const tagged = decl.tagged_union.?; + const Tag = @FieldType(decl.T, tagged.tag_field); + if (tagged.arm_source == .generated) + return decl.T.cFieldRename(@field(Tag, tag_name)); + + const Value = @FieldType(decl.T, tagged.value_field); + if (!@hasField(Value, tag_name)) return null; + return if (@sizeOf(@FieldType(Value, tag_name)) == 0) null else tag_name; + } + + fn publicTypeName(comptime T: type) []const u8 { + inline for (type_decls) |decl| switch (decl.kind) { + .@"struct", .@"union", .@"enum" => if (T == decl.T) return decl.name, + .@"packed", .alias, .@"opaque" => {}, + }; + return switch (@typeInfo(T)) { + .bool => "bool", + .float => |info| switch (info.bits) { + 32 => "f32", + 64 => "f64", + else => "opaque", + }, + .int => |info| intName(info.signedness, info.bits), + .comptime_int => "comptime_int", + .void => "void", + .@"opaque" => "opaque", + .@"fn" => "function", + .@"enum" => "enum", + .@"struct" => "struct", + .@"union" => "union", + else => "opaque", + }; + } + + fn intName(comptime signedness: std.builtin.Signedness, comptime bits: u16) []const u8 { + return switch (signedness) { + .signed => switch (bits) { 8 => "i8", 16 => "i16", 32 => "i32", 64 => "i64", - else => @compileError("unsupported signed int size"), + else => std.fmt.comptimePrint("i{d}", .{bits}), }, - .unsigned => switch (info.bits) { + .unsigned => switch (bits) { 8 => "u8", 16 => "u16", 32 => "u32", 64 => "u64", - else => @compileError("unsupported unsigned int size"), + else => std.fmt.comptimePrint("u{d}", .{bits}), }, - }, - .@"enum" => "enum", - .@"struct" => "struct", - .pointer => "pointer", - .array => "array", - else => "opaque", - }; + }; + } + + fn writeUpperObjectField(comptime name: []const u8, jws: *std.json.Stringify) std.Io.Writer.Error!void { + var upper: [name.len]u8 = undefined; + for (name, 0..) |c, i| upper[i] = std.ascii.toUpper(c); + try jws.objectField(&upper); + } + + fn writeEnumObjectField( + comptime enum_name: []const u8, + comptime zig_name: []const u8, + jws: *std.json.Stringify, + ) std.Io.Writer.Error!void { + if (std.mem.eql(u8, enum_name, "GhosttyKey") and std.mem.startsWith(u8, zig_name, "key_")) + return writeUpperObjectField(zig_name["key_".len..], jws); + if (std.mem.eql(u8, enum_name, "GhosttySgrAttributeTag")) { + if (std.mem.eql(u8, zig_name, "256_underline_color")) return jws.objectField("UNDERLINE_COLOR_256"); + if (std.mem.eql(u8, zig_name, "8_bg")) return jws.objectField("BG_8"); + if (std.mem.eql(u8, zig_name, "8_fg")) return jws.objectField("FG_8"); + if (std.mem.eql(u8, zig_name, "8_bright_bg")) return jws.objectField("BRIGHT_BG_8"); + if (std.mem.eql(u8, zig_name, "8_bright_fg")) return jws.objectField("BRIGHT_FG_8"); + if (std.mem.eql(u8, zig_name, "256_bg")) return jws.objectField("BG_256"); + if (std.mem.eql(u8, zig_name, "256_fg")) return jws.objectField("FG_256"); + } + if (std.mem.eql(u8, enum_name, "GhosttyTerminalOption") and std.mem.eql(u8, zig_name, "size_cb")) + return jws.objectField("SIZE"); + return writeUpperObjectField(zig_name, jws); + } + + fn isBuiltinType(name: []const u8) bool { + const builtins = [_][]const u8{ + "array", "bool", "f32", "f64", "function", "i8", "i16", "i32", + "i64", "opaque", "pointer", "u8", "u16", "u32", "u64", "void", + }; + for (builtins) |builtin_name| { + if (std.mem.eql(u8, name, builtin_name)) return true; + } + if (name.len > 1 and (name[0] == 'i' or name[0] == 'u') and + name[1] >= '1' and name[1] <= '9') + { + for (name[2..]) |c| if (!std.ascii.isDigit(c)) return false; + return true; + } + return false; + } +}; + +test "manifest parses and is versioned" { + const parsed = try std.json.parseFromSlice(std.json.Value, std.testing.allocator, json, .{}); + defer parsed.deinit(); + const root = parsed.value.object; + try std.testing.expectEqual(@as(i64, 1), root.get("schema").?.integer); + try std.testing.expect(root.contains("abi")); + try std.testing.expectEqual( + @as(i64, c_abi.max_alignment), + root.get("abi").?.object.get("max_alignment").?.integer, + ); + try std.testing.expect(root.contains("library_version")); + const manifest_types = root.get("types").?.object; + try std.testing.expectEqual(type_decls.len, manifest_types.count()); + inline for (type_decls) |decl| try std.testing.expect(manifest_types.contains(decl.name)); + + const cursor_fields = manifest_types.get("GhosttyRenderStateCursor").?.object + .get("fields").?.object; + try std.testing.expect(cursor_fields.contains("size")); + try std.testing.expect(cursor_fields.contains("viewport_has_value")); + try std.testing.expect(cursor_fields.contains("viewport_x")); + try std.testing.expect(cursor_fields.contains("viewport_y")); + try std.testing.expect(cursor_fields.contains("wide_tail")); + try std.testing.expect(cursor_fields.contains("visible")); + try std.testing.expect(cursor_fields.contains("blinking")); + try std.testing.expect(cursor_fields.contains("password_input")); + try std.testing.expect(cursor_fields.contains("visual_style")); +} + +test "manifest describes enums, arrays, and tagged unions" { + const parsed = try std.json.parseFromSlice(std.json.Value, std.testing.allocator, json, .{}); + defer parsed.deinit(); + const manifest_types = parsed.value.object.get("types").?.object; + + const data = manifest_types.get("GhosttyRenderStateData").?.object; + try std.testing.expectEqualStrings("enum", data.get("kind").?.string); + try std.testing.expectEqual(@as(i64, @intFromEnum(render.Data.dirty)), data.get("values").?.object.get("DIRTY").?.integer); + + const colors = manifest_types.get("GhosttyRenderStateColors").?.object; + const palette = colors.get("fields").?.object.get("palette").?.object; + try std.testing.expectEqualStrings("array", palette.get("type").?.string); + try std.testing.expectEqualStrings("GhosttyColorRgb", palette.get("elem").?.string); + try std.testing.expectEqual(@as(i64, 256), palette.get("count").?.integer); + + const style_color = manifest_types.get("GhosttyStyleColor").?.object; + const value = style_color.get("fields").?.object.get("value").?.object; + try std.testing.expectEqualStrings("GhosttyStyleColorValue", value.get("type").?.string); + try std.testing.expectEqualStrings("tag", value.get("tag").?.string); + try std.testing.expectEqualStrings("palette", value.get("arms").?.object.get("PALETTE").?.string); + try std.testing.expect(value.get("arms").?.object.get("NONE").? == .null); } -test "json parses" { - const parsed = try std.json.parseFromSlice( - std.json.Value, - std.testing.allocator, - json, - .{}, +test "manifest describes the complete packed cell layout" { + const parsed = try std.json.parseFromSlice(std.json.Value, std.testing.allocator, json, .{}); + defer parsed.deinit(); + const manifest_types = parsed.value.object.get("types").?.object; + const descriptor = manifest_types.get("GhosttyCell").?.object; + + try std.testing.expectEqualStrings("packed", descriptor.get("kind").?.string); + try std.testing.expectEqual(@as(i64, @sizeOf(page.Cell.CLayout.Zig)), descriptor.get("size").?.integer); + try std.testing.expectEqualStrings("u64", descriptor.get("underlying").?.string); + + const bits = descriptor.get("bits").?.object; + inline for (@typeInfo(page.Cell.CLayout.Zig).@"struct".fields) |field| { + const field_tag = @field(page.Cell.CLayout.Field, field.name); + const name = comptime page.Cell.CLayout.fieldName(field_tag); + if (name) |public_name| { + const bit = bits.get(public_name).?.object; + try std.testing.expectEqual( + @as(i64, @intCast(@bitOffsetOf(page.Cell.CLayout.Zig, field.name))), + bit.get("lsb").?.integer, + ); + try std.testing.expectEqual( + @as(i64, @intCast(@bitSizeOf(field.type))), + bit.get("width").?.integer, + ); + } else { + try std.testing.expect(!bits.contains(field.name)); + } + } + + const content = bits.get("content").?.object; + try std.testing.expectEqualStrings("union", content.get("kind").?.string); + try std.testing.expectEqualStrings("content_tag", content.get("tag").?.string); + const arms = content.get("arms").?.object; + + const Content = @FieldType(page.Cell.CLayout.Zig, "content"); + const Codepoint = @FieldType(Content, "codepoint"); + const codepoint = arms.get("CODEPOINT").?.object; + const grapheme = arms.get("CODEPOINT_GRAPHEME").?.object; + try expectPackedArmField(codepoint, "codepoint", Codepoint, "data", "u21"); + try expectPackedArmField(grapheme, "codepoint", Codepoint, "data", "u21"); + try expectPackedArmField( + arms.get("BG_COLOR_PALETTE").?.object, + "index", + @FieldType(Content, "color_palette"), + "data", + "GhosttyColorPaletteIndex", ); + + const rgb = arms.get("BG_COLOR_RGB").?.object; + const Rgb = @FieldType(Content, "color_rgb"); + inline for (@typeInfo(Rgb).@"struct".fields) |field| + try expectPackedArmField(rgb, field.name, Rgb, field.name, "u8"); +} + +test "manifest packed cell layouts decode real values" { + const parsed = try std.json.parseFromSlice(std.json.Value, std.testing.allocator, json, .{}); defer parsed.deinit(); + const manifest_types = parsed.value.object.get("types").?.object; + const cell_bits = manifest_types.get("GhosttyCell").?.object.get("bits").?.object; - const root = parsed.value.object; + var codepoint = page.Cell.CLayout.Zig.init('A'); + try expectDecodedCellContent(manifest_types, cell_bits, @bitCast(codepoint), "codepoint", 'A'); - // Verify we have all expected structs - try std.testing.expect(root.contains("GhosttyClipboardContent")); - try std.testing.expect(root.contains("GhosttyClipboardWrite")); - try std.testing.expect(root.contains("GhosttyFormatterTerminalOptions")); - try std.testing.expect(root.contains("GhosttyTerminalModeConfig")); - try std.testing.expect(root.contains("GhosttyTerminalUnknownSequence")); - try std.testing.expect(root.contains("GhosttyTerminalUnknownStringSequence")); - try std.testing.expect(root.contains("GhosttyReader")); - try std.testing.expect(root.contains("GhosttyWriter")); + codepoint.content_tag = .codepoint_grapheme; + try expectDecodedCellContent(manifest_types, cell_bits, @bitCast(codepoint), "codepoint", 'A'); - const clipboard_content = root.get("GhosttyClipboardContent").?.object; - const clipboard_content_fields = clipboard_content.get("fields").?.object; - try std.testing.expect(clipboard_content_fields.contains("mime")); - try std.testing.expect(clipboard_content_fields.contains("data")); + var palette: page.Cell.CLayout.Zig = @bitCast(@as(u64, 0)); + palette.content_tag = .bg_color_palette; + palette.content = .{ .color_palette = .{ .data = 173 } }; + try expectDecodedCellContent(manifest_types, cell_bits, @bitCast(palette), "index", 173); - const clipboard_write = root.get("GhosttyClipboardWrite").?.object; - const clipboard_write_fields = clipboard_write.get("fields").?.object; - try std.testing.expect(clipboard_write_fields.contains("size")); - try std.testing.expect(clipboard_write_fields.contains("location")); - try std.testing.expect(clipboard_write_fields.contains("contents")); - try std.testing.expect(clipboard_write_fields.contains("contents_len")); + var rgb: page.Cell.CLayout.Zig = @bitCast(@as(u64, 0)); + rgb.content_tag = .bg_color_rgb; + rgb.content = .{ .color_rgb = .{ .r = 0x12, .g = 0x34, .b = 0x56 } }; + const rgb_arm = activeCellContentArm(manifest_types, cell_bits, @bitCast(rgb)); + const content = extractManifestBits(@bitCast(rgb), cell_bits.get("content").?.object); + try std.testing.expectEqual(@as(u64, 0x12), extractManifestBits(content, rgb_arm.get("bits").?.object.get("r").?.object)); + try std.testing.expectEqual(@as(u64, 0x34), extractManifestBits(content, rgb_arm.get("bits").?.object.get("g").?.object)); + try std.testing.expectEqual(@as(u64, 0x56), extractManifestBits(content, rgb_arm.get("bits").?.object.get("b").?.object)); +} - const unknown_sequence = root.get("GhosttyTerminalUnknownSequence").?.object; - const unknown_sequence_fields = unknown_sequence.get("fields").?.object; - try std.testing.expect(unknown_sequence_fields.contains("tag")); - try std.testing.expect(unknown_sequence_fields.contains("value")); +test "manifest uses public enum names" { + const parsed = try std.json.parseFromSlice(std.json.Value, std.testing.allocator, json, .{}); + defer parsed.deinit(); + const manifest_types = parsed.value.object.get("types").?.object; - const unknown_string = root.get("GhosttyTerminalUnknownStringSequence").?.object; - const unknown_string_fields = unknown_string.get("fields").?.object; - try std.testing.expect(unknown_string_fields.contains("truncated")); - try std.testing.expect(unknown_string_fields.contains("content")); + const cell_content = manifest_types.get("GhosttyCellContentTag").?.object.get("values").?.object; + try std.testing.expect(cell_content.contains("TAG_MAX_VALUE")); + try std.testing.expect(!cell_content.contains("MAX_VALUE")); - const reader_fields = root.get("GhosttyReader").?.object - .get("fields").?.object; - try std.testing.expect(reader_fields.contains("read")); - try std.testing.expect(reader_fields.contains("userdata")); + const sgr_values = manifest_types.get("GhosttySgrAttributeTag").?.object.get("values").?.object; + try std.testing.expectEqual(@as(i64, 9), sgr_values.get("UNDERLINE_COLOR_256").?.integer); + try std.testing.expectEqual(@as(i64, 23), sgr_values.get("BG_8").?.integer); + try std.testing.expect(!sgr_values.contains("256_UNDERLINE_COLOR")); - const writer_fields = root.get("GhosttyWriter").?.object - .get("fields").?.object; - try std.testing.expect(writer_fields.contains("write")); - try std.testing.expect(writer_fields.contains("userdata")); + const sgr_arms = manifest_types.get("GhosttySgrAttribute").?.object + .get("fields").?.object.get("value").?.object.get("arms").?.object; + try std.testing.expectEqualStrings("underline_color_256", sgr_arms.get("UNDERLINE_COLOR_256").?.string); + try std.testing.expectEqualStrings("bg_8", sgr_arms.get("BG_8").?.string); - try std.testing.expect(!root.contains("GhosttyTerminalOptions")); + const terminal_values = manifest_types.get("GhosttyTerminalOption").?.object.get("values").?.object; + try std.testing.expectEqual(@as(i64, 6), terminal_values.get("SIZE").?.integer); + try std.testing.expect(!terminal_values.contains("SIZE_CB")); + + const osc_values = manifest_types.get("GhosttyOscCommandType").?.object.get("values").?.object; + try std.testing.expectEqual(@as(i64, 22), osc_values.get("KITTY_TEXT_SIZING").?.integer); + try std.testing.expectEqual(@as(i64, 23), osc_values.get("KITTY_CLIPBOARD_PROTOCOL").?.integer); + try std.testing.expectEqual(@as(i64, 24), osc_values.get("KITTY_DND_PROTOCOL").?.integer); + try std.testing.expectEqual(@as(i64, 25), osc_values.get("CONTEXT_SIGNAL").?.integer); + try std.testing.expectEqual(@as(i64, 26), osc_values.get("KITTY_DESKTOP_NOTIFICATION").?.integer); + try std.testing.expect(osc_values.contains("TYPE_MAX_VALUE")); + try std.testing.expect(!osc_values.contains("MAX_VALUE")); + + const key_values = manifest_types.get("GhosttyKey").?.object.get("values").?.object; + try std.testing.expect(key_values.contains("A")); + try std.testing.expect(!key_values.contains("KEY_A")); + + const mode_values = manifest_types.get("GhosttyModeReportState").?.object.get("values").?.object; + try std.testing.expectEqual(@as(i64, 0), mode_values.get("NOT_RECOGNIZED").?.integer); + try std.testing.expectEqual(@as(i64, 4), mode_values.get("PERMANENTLY_RESET").?.integer); } -test "struct sizes are non-zero" { - const parsed = try std.json.parseFromSlice( - std.json.Value, - std.testing.allocator, - json, - .{}, - ); +test "manifest named references resolve" { + const parsed = try std.json.parseFromSlice(std.json.Value, std.testing.allocator, json, .{}); defer parsed.deinit(); + const manifest_types = parsed.value.object.get("types").?.object; + + var type_iterator = manifest_types.iterator(); + while (type_iterator.next()) |type_entry| { + const descriptor = type_entry.value_ptr.object; + if (descriptor.get("bits")) |bits_value| { + try expectManifestBitsValid( + manifest_types, + bits_value.object, + @intCast(descriptor.get("size").?.integer * 8), + ); + } + const fields_value = descriptor.get("fields") orelse continue; + var field_iterator = fields_value.object.iterator(); + while (field_iterator.next()) |field_entry| { + const field = field_entry.value_ptr.object; + const field_type = field.get("type").?.string; + if (!Json.isBuiltinType(field_type)) + try std.testing.expect(manifest_types.contains(field_type)); + + if (field.get("elem")) |elem_value| { + const elem = elem_value.string; + if (!Json.isBuiltinType(elem)) + try std.testing.expect(manifest_types.contains(elem)); + } - var it = parsed.value.object.iterator(); - while (it.next()) |entry| { - const struct_info = entry.value_ptr.object; - const size = struct_info.get("size").?.integer; - try std.testing.expect(size > 0); + if (field.get("arms")) |arms_value| { + const union_descriptor = manifest_types.get(field_type).?.object; + const union_fields = union_descriptor.get("fields").?.object; + const tag_field_name = field.get("tag").?.string; + const tag_type_name = fields_value.object.get(tag_field_name).?.object.get("type").?.string; + const tag_values = manifest_types.get(tag_type_name).?.object.get("values").?.object; + var arm_iterator = arms_value.object.iterator(); + while (arm_iterator.next()) |arm_entry| { + try std.testing.expect(tag_values.contains(arm_entry.key_ptr.*)); + if (arm_entry.value_ptr.* == .null) continue; + try std.testing.expect(union_fields.contains(arm_entry.value_ptr.string)); + } + } + } + } +} + +fn expectPackedArmField( + arm: std.json.ObjectMap, + manifest_name: []const u8, + comptime T: type, + comptime zig_name: []const u8, + expected_type: []const u8, +) !void { + try std.testing.expectEqualStrings("packed", arm.get("kind").?.string); + try std.testing.expectEqual(@as(i64, @bitSizeOf(T)), arm.get("width").?.integer); + const bit = arm.get("bits").?.object.get(manifest_name).?.object; + try std.testing.expectEqual(@as(i64, @bitOffsetOf(T, zig_name)), bit.get("lsb").?.integer); + try std.testing.expectEqual(@as(i64, @bitSizeOf(@FieldType(T, zig_name))), bit.get("width").?.integer); + try std.testing.expectEqualStrings(expected_type, bit.get("type").?.string); +} + +fn extractManifestBits(value: u64, bit: std.json.ObjectMap) u64 { + const lsb: u6 = @intCast(bit.get("lsb").?.integer); + const width: u7 = @intCast(bit.get("width").?.integer); + const mask = if (width == 64) + std.math.maxInt(u64) + else + (@as(u64, 1) << @intCast(width)) - 1; + return (value >> lsb) & mask; +} + +fn activeCellContentArm( + manifest_types: std.json.ObjectMap, + cell_bits: std.json.ObjectMap, + raw: u64, +) std.json.ObjectMap { + const content_tag = cell_bits.get("content_tag").?.object; + const value = extractManifestBits(raw, content_tag); + const enum_values = manifest_types.get(content_tag.get("type").?.string).?.object + .get("values").?.object; + var iterator = enum_values.iterator(); + while (iterator.next()) |entry| { + if (entry.value_ptr.integer == value) + return cell_bits.get("content").?.object.get("arms").?.object + .get(entry.key_ptr.*).?.object; + } + unreachable; +} + +fn expectDecodedCellContent( + manifest_types: std.json.ObjectMap, + cell_bits: std.json.ObjectMap, + raw: u64, + field_name: []const u8, + expected: u64, +) !void { + const arm = activeCellContentArm(manifest_types, cell_bits, raw); + const content = extractManifestBits(raw, cell_bits.get("content").?.object); + try std.testing.expectEqual( + expected, + extractManifestBits(content, arm.get("bits").?.object.get(field_name).?.object), + ); +} + +fn expectManifestBitsValid( + manifest_types: std.json.ObjectMap, + bits: std.json.ObjectMap, + container_width: usize, +) !void { + var iterator = bits.iterator(); + while (iterator.next()) |entry| { + const bit = entry.value_ptr.object; + const lsb: usize = @intCast(bit.get("lsb").?.integer); + const width: usize = @intCast(bit.get("width").?.integer); + try std.testing.expect(lsb + width <= container_width); + + if (bit.get("type")) |type_value| { + const type_name = type_value.string; + try std.testing.expect(Json.isBuiltinType(type_name) or manifest_types.contains(type_name)); + continue; + } + + const kind = bit.get("kind").?.string; + if (std.mem.eql(u8, kind, "packed")) { + try expectManifestBitsValid(manifest_types, bit.get("bits").?.object, width); + continue; + } + + try std.testing.expectEqualStrings("union", kind); + const tag_name = bit.get("tag").?.string; + const tag = bits.get(tag_name).?.object; + const enum_values = manifest_types.get(tag.get("type").?.string).?.object + .get("values").?.object; + var arm_iterator = bit.get("arms").?.object.iterator(); + while (arm_iterator.next()) |arm_entry| { + try std.testing.expect(enum_values.contains(arm_entry.key_ptr.*)); + if (arm_entry.value_ptr.* == .null) continue; + const arm = arm_entry.value_ptr.object; + const arm_width: usize = @intCast(arm.get("width").?.integer); + try std.testing.expect(arm_width <= width); + try expectManifestBitsValid(manifest_types, arm.get("bits").?.object, arm_width); + } } } diff --git a/vendor/libghostty-vt/src/terminal/clipboard.zig b/vendor/libghostty-vt/src/terminal/clipboard.zig index 554ca98..0a1c5a7 100644 --- a/vendor/libghostty-vt/src/terminal/clipboard.zig +++ b/vendor/libghostty-vt/src/terminal/clipboard.zig @@ -1,3 +1,5 @@ +const std = @import("std"); + /// The clipboard destination for a write. pub const Location = enum(c_int) { standard = 0, @@ -6,6 +8,21 @@ pub const Location = enum(c_int) { _, }; +/// MIME types that name plain text across the platforms terminals run +/// on. We accept the union everywhere since serving text under any of these +/// names is harmless. +pub fn isTextMime(mime: []const u8) bool { + const names: []const []const u8 = &.{ + "text/plain", + "text/plain;charset=utf-8", + "UTF8_STRING", + "TEXT", + "STRING", + }; + for (names) |n| if (std.mem.eql(u8, mime, n)) return true; + return false; +} + /// A single representation of clipboard data. /// /// The MIME type and data are borrowed and only valid for the duration of a @@ -15,22 +32,225 @@ pub const Content = struct { data: []const u8, }; -/// One atomic clipboard write. +/// Requests content of a specific mime-type. For now this is used for +/// on-demand clipboard access since content can be large (particularly +/// non-text content), but it is generic so that this could handle other +/// mime-typed sources in the future like maybe drag-and-drop. /// -/// Contents are borrowed and only valid for the duration of a clipboard write -/// callback. An empty contents slice clears the destination. +/// C: GhosttyMimeReader +pub const MimeReader = struct { + /// Passed through to `read_fn`. + ctx: ?*anyopaque = null, + + /// Write all the data of the representation named by `mime` to + /// `sink`, in as many writes as is convenient. The mime and sink + /// are borrowed, only valid for the duration of the call, and + /// nothing written to the sink is retained, so the data may be + /// borrowed from anywhere. Return error.ReadFailed if the data + /// can't be produced and propagate error.WriteFailed from the + /// sink. + read_fn: *const fn ( + ctx: ?*anyopaque, + mime: []const u8, + sink: *std.Io.Writer, + ) Error!void, + + pub const Error = error{ + /// The data could not be read. + ReadFailed, + } || std.Io.Writer.Error; + + pub fn read( + self: MimeReader, + mime: []const u8, + sink: *std.Io.Writer, + ) Error!void { + return self.read_fn(self.ctx, mime, sink); + } +}; + +/// A request from the running program to write a clipboard. +/// +/// Writes are synchronous: the effect callback must answer through `reply` +/// before it returns, and the request (including its reply context) is +/// invalid afterwards. An embedder that needs user consent must block until +/// it has an answer; the VT stream waits with it. Protocols without a write +/// acknowledgement (OSC 52, OSC 1337 Copy) discard the reply. +/// +/// Contents are borrowed and only valid for the duration of the callback. +/// An empty contents slice clears the destination. pub const Write = struct { location: Location, contents: []const Content, + + /// Name of the writing program for permission prompts, if the + /// protocol carries one. Empty otherwise. + name: []const u8, + + /// True if the terminal already holds a session grant for this + /// request (kitty clipboard protocol passwords). The embedder should + /// skip any permission prompt and perform the write. + granted: bool, + + /// True if the program supplied a session password, so the embedder + /// may offer to remember the user's decision via + /// Result.Success.remember. When false, remember is ignored. + can_remember: bool, + + /// Terminal-owned reply state, only valid during the callback. + reply_ctx: *anyopaque, + reply_fn: *const fn (*anyopaque, Result) void, + + /// Answer the write. May be called at most once; later calls are + /// ignored. + pub fn reply(self: Write, result: Result) void { + self.reply_fn(self.reply_ctx, result); + } + + /// The status of a clipboard write reply. + pub const Status = enum(c_int) { + success = 0, + denied = 1, + unsupported = 2, + busy = 3, + invalid_data = 4, + io_error = 5, + _, + }; + + /// The reply to a clipboard write. + pub const Result = union(enum) { + /// The write was denied by policy or the user. + denied, + + /// The embedder cannot write this clipboard. + unsupported, + + /// The clipboard is temporarily unavailable. + busy, + + /// One or more representations contain invalid data. + invalid_data, + + /// Writing the clipboard failed. + io_error, + + /// The write succeeded. + success: Success, + + pub const Success = struct { + /// Record a session grant so future requests from the same + /// program skip the permission prompt. Only honored when the + /// request set `can_remember`. + remember: bool = false, + }; + }; }; -/// The result of a clipboard write. -pub const WriteResult = enum(c_int) { - success = 0, - denied = 1, - unsupported = 2, - busy = 3, - invalid_data = 4, - io_error = 5, - _, +/// A request from the running program to read a clipboard. +/// +/// Reads are synchronous: the effect callback must answer through `reply` +/// before it returns, and the request (including its reply context) is +/// invalid afterwards. An embedder that needs user consent must block until +/// it has an answer; the VT stream waits with it. +pub const Read = struct { + location: Location, + + /// The MIME types the program wants, in order of preference. The + /// reply should carry every requested representation the clipboard + /// has; unrequested ones are ignored. Protocols that only carry text + /// (OSC 52) request "text/plain". + mimes: []const []const u8, + + /// The program also wants the list of MIME types available on the + /// clipboard, delivered as Result.Success.available. + list: bool, + + /// Name of the requesting program for permission prompts, if the + /// protocol carries one. Empty otherwise. + name: []const u8, + + /// True if the terminal already holds a session grant for this + /// request (kitty clipboard protocol passwords). The embedder should + /// skip any permission prompt and serve the read. + /// + /// Always false when mimes is empty: such a request is served + /// without a prompt (kitty's targets-listing exemption), so the + /// terminal never consults grants for it and a one-time password + /// is preserved for the follow-up data read. + granted: bool, + + /// True if the program supplied a session password, so the embedder + /// may offer to remember the user's decision via + /// Result.Success.remember. When false, remember is ignored. + can_remember: bool, + + /// Terminal-owned reply state, only valid during the callback. + /// + /// The result is delivered through a call rather than returned so + /// the terminal consumes it while the embedder's memory is still + /// alive; a returned slice would have to outlive the callback. + reply_ctx: *anyopaque, + reply_fn: *const fn (*anyopaque, Result) void, + + /// Answer the read. May be called at most once; later calls are + /// ignored. Result memory is borrowed only for the duration of this + /// call. + pub fn reply(self: Read, result: Result) void { + self.reply_fn(self.reply_ctx, result); + } + + /// The status of a clipboard read reply. + pub const Status = enum(c_int) { + success = 0, + denied = 1, + unsupported = 2, + busy = 3, + io_error = 4, + _, + }; + + /// The reply to a clipboard read. + pub const Result = union(enum) { + /// The read was denied by policy or the user. + denied, + + /// The embedder cannot read this clipboard. + unsupported, + + /// The clipboard is temporarily unavailable. + busy, + + /// Reading the clipboard failed. + io_error, + + /// The read succeeded. All memory is borrowed for the duration + /// of the reply call. + success: Success, + + pub const Success = struct { + /// Representations of the clipboard contents, one per + /// requested MIME type the clipboard has. Protocols that + /// carry a single text value use the first entry with a + /// text MIME type (see isTextMime). + contents: []const Content = &.{}, + + /// All MIME types available on the clipboard. Only used when + /// the request set `list`. + available: []const []const u8 = &.{}, + + /// Record a session grant so future requests from the same + /// program skip the permission prompt. Only honored when the + /// request set `can_remember`. + remember: bool = false, + }; + }; }; + +test isTextMime { + const testing = std.testing; + try testing.expect(isTextMime("text/plain")); + try testing.expect(isTextMime("UTF8_STRING")); + try testing.expect(!isTextMime("image/png")); + try testing.expect(!isTextMime(".")); +} diff --git a/vendor/libghostty-vt/src/terminal/color.zig b/vendor/libghostty-vt/src/terminal/color.zig index c04e19e..ce4433b 100644 --- a/vendor/libghostty-vt/src/terminal/color.zig +++ b/vendor/libghostty-vt/src/terminal/color.zig @@ -1,7 +1,9 @@ const colorpkg = @This(); const std = @import("std"); +const builtin = @import("builtin"); const assert = @import("../quirks.zig").inlineAssert; +const fraction = @import("fraction.zig"); const x11_color = @import("x11_color.zig"); /// The default palette. @@ -128,10 +130,59 @@ pub const PaletteC = [256]RGB.C; /// Convert a Palette to a PaletteC. pub fn paletteCval(palette: *const Palette) PaletteC { var result: PaletteC = undefined; - for (&result, palette) |*dst, src| dst.* = src.cval(); + paletteCvalSlice(palette[0..], result[0..]); return result; } +/// Convert a slice of palette entries to their C representation. +/// Asserts that both slices are the same length. +pub fn paletteCvalSlice(src: []const RGB, dst: []RGB.C) void { + assert(src.len == dst.len); + + var i: usize = 0; + + // For CPUs that are LE, we can do some clever byte shuffling + // with vectorization to do a 4-to-3 conversion. + if (comptime builtin.cpu.arch.endian() == .little) { + // Process 4 entries at a time: one 16-byte load, one byte + // shuffle dropping the padding byte of each entry, and one + // 16-byte store. The store intentionally overlaps the next + // group by 4 (garbage) bytes so that it stays a single + // vector store; the loop bound guarantees the overlap stays + // in bounds and every overlapped byte is rewritten by the + // next iteration or the scalar tail. + // + // Note the input load is a memory-level reinterpretation + // (pointer cast) because a value-level @bitCast of packed + // structs operates on the 24-bit value bits, not the 4-byte + // in-memory representation. + const V = @Vector(16, u8); + const dst_bytes: [*]u8 = @ptrCast(dst.ptr); + while (i + 6 <= src.len) : (i += 4) { + // All intermediaries have to also go through a Vector type + // to ensure LLVM lowers as vectorized ops. In Zig 0.16 the + // LLVM auto-vectorizer is disabled so this is required. + const in: V = @as(*align(4) const V, @ptrCast(src[i..][0..4])).*; + const out: V = @shuffle(u8, in, undefined, [16]i32{ + 0, 1, 2, + 4, 5, 6, + 8, 9, 10, + 12, 13, 14, + + // Overflow we don't care about + 3, 7, 11, + 15, + }); + + // Scary looking but safe, mapping a byte pointer to the full + // vector type (hence align 1). + @as(*align(1) V, @ptrCast(dst_bytes[i * 3 ..][0..16])).* = out; + } + } + + for (src[i..], dst[i..]) |entry, *out| out.* = entry.cval(); +} + /// Convert a PaletteC to a Palette. pub fn paletteZval(palette: *const PaletteC) Palette { var result: Palette = undefined; @@ -591,14 +642,10 @@ pub const RGB = packed struct(u24) { /// /// The value should be between 0.0 and 1.0, inclusive. fn fromIntensity(value: []const u8) error{InvalidFormat}!u8 { - const i = std.fmt.parseFloat(f64, value) catch { + const i = fraction.parse(value) orelse { @branchHint(.cold); return error.InvalidFormat; }; - if (i < 0.0 or i > 1.0) { - @branchHint(.cold); - return error.InvalidFormat; - } return @intFromFloat(i * std.math.maxInt(u8)); } @@ -1276,3 +1323,35 @@ test "LAB.toRgb" { try testing.expectEqual(expected.b, actual.b); } } + +test paletteCvalSlice { + const testing = std.testing; + + // Every length from empty through a full palette, so that the + // vectorized groups, the overlapping store bound, and the scalar + // tail are all exercised at their edges. + var src: Palette = undefined; + for (&src, 0..) |*rgb, i| rgb.* = .{ + .r = @intCast(i % 256), + .g = @intCast((i * 7 + 1) % 256), + .b = @intCast((i * 13 + 2) % 256), + }; + + var dst: PaletteC = undefined; + for (0..src.len + 1) |len| { + // Poison the destination so unwritten bytes are detected. + @memset(std.mem.asBytes(&dst), 0xAA); + + paletteCvalSlice(src[0..len], dst[0..len]); + for (src[0..len], dst[0..len]) |rgb, c| { + try testing.expectEqual(rgb.r, c.r); + try testing.expectEqual(rgb.g, c.g); + try testing.expectEqual(rgb.b, c.b); + } + + // Nothing beyond the requested length may be written. + for (std.mem.asBytes(&dst)[len * 3 ..]) |b| { + try testing.expectEqual(@as(u8, 0xAA), b); + } + } +} diff --git a/vendor/libghostty-vt/src/terminal/compress/Page.zig b/vendor/libghostty-vt/src/terminal/compress/Page.zig index b7ad802..8eb9caa 100644 --- a/vendor/libghostty-vt/src/terminal/compress/Page.zig +++ b/vendor/libghostty-vt/src/terminal/compress/Page.zig @@ -106,6 +106,15 @@ pub fn init( if (scratch.len < required) return error.OutputTooSmall; if (required == 0) return null; + // Page memory is an opaque snapshot containing structs whose padding has + // no semantic value. Assigning those structs can leave their padding + // undefined in Memcheck even though every byte is addressable and must be + // preserved by compression. Mark the complete byte representation as + // defined at this boundary so that undefined padding does not propagate + // through the codec and obscure real Valgrind failures. + if (std.valgrind.runningOnValgrind() > 0) + std.valgrind.memcheck.makeMemDefined(source.memory); + const encoded_len = lz4.compress( source.memory, scratch[0..required], @@ -209,6 +218,11 @@ test "compressed Page retained mapping round trip" { const expected = try testing.allocator.dupe(u8, resident.memory); defer testing.allocator.free(expected); + if (std.valgrind.runningOnValgrind() > 0) { + // This snapshot deliberately includes semantically irrelevant struct + // padding so the round trip can compare the complete representation. + std.valgrind.memcheck.makeMemDefined(expected); + } const memory_ptr = resident.memory.ptr; const memory_len = resident.memory.len; diff --git a/vendor/libghostty-vt/src/terminal/formatter.zig b/vendor/libghostty-vt/src/terminal/formatter.zig index ab9edbf..4feedda 100644 --- a/vendor/libghostty-vt/src/terminal/formatter.zig +++ b/vendor/libghostty-vt/src/terminal/formatter.zig @@ -485,7 +485,6 @@ pub const TerminalFormatter = struct { var screen_formatter: ScreenFormatter = .init(self.terminal.screens.active, self.opts); screen_formatter.content = self.content; - screen_formatter.extra = self.extra.screen; screen_formatter.pin_map = self.pin_map; try screen_formatter.format(writer); @@ -543,6 +542,13 @@ pub const TerminalFormatter = struct { ) catch return error.WriteFailed; } } + + // Emit extra screen state last because terminal state such + // as scrolling regions can move the cursor, so we have to set + // cursor last. + screen_formatter.content = .none; + screen_formatter.extra = self.extra.screen; + try screen_formatter.format(writer); } }; @@ -689,6 +695,43 @@ pub const ScreenFormatter = struct { .html => return, } + // Emit cursor position before the other extras because restoring a + // pending wrap requires reprinting the cell at the right edge. That + // print uses and changes active screen state, so the requested style, + // hyperlink, protection, and charset must be restored afterwards. + if (self.extra.cursor) cursor: { + const cursor = &self.screen.cursor; + + // If we don't have pending wrap, then we can just use CUP. + if (!cursor.pending_wrap or cursor.x != self.screen.pages.cols - 1) { + try writer.print("\x1b[{d};{d}H", .{ cursor.y + 1, cursor.x + 1 }); + break :cursor; + } + + // Pending wrap, we can't use CUP because it resets pending wrap. + const start_x = switch (cursor.page_cell.wide) { + .spacer_tail => cursor.x - 1, + .narrow, .wide, .spacer_head => cursor.x, + }; + + // Move cursor to the edge. + try writer.print( + "\x1b[{d};{d}H", + .{ cursor.y + 1, start_x + 1 }, + ); + + // Reformat the cell which sets the proper pending wrap state. + var cell_formatter: PageFormatter = .init( + cursor.page_pin.node.page(), + self.opts, + ); + cell_formatter.start_x = cursor.x; + cell_formatter.end_x = cursor.x; + cell_formatter.start_y = cursor.page_pin.y; + cell_formatter.end_y = cursor.page_pin.y; + try cell_formatter.format(writer); + } + // Emit current SGR style state if (self.extra.style) { const cursor = &self.screen.cursor; @@ -778,13 +821,6 @@ pub const ScreenFormatter = struct { } } - // Emit cursor position using CUP (CSI H) - if (self.extra.cursor) { - const cursor = &self.screen.cursor; - // CUP is 1-indexed - try writer.print("\x1b[{d};{d}H", .{ cursor.y + 1, cursor.x + 1 }); - } - // If we have a pin_map, we need to count how many bytes the extras // will emit so we can map them all to the same pin. We do this by // formatting to a discarding writer with content=none. @@ -1268,32 +1304,17 @@ pub const PageFormatter = struct { if (style_id == invalid_style_id) break :fast; } - // Specialized on point tracking so that the common - // non-tracking case has zero per-cell overhead. - const consumed = if (self.point_map == null) - try self.writeCellRun( - emit, - false, - writer, - cells_subset[cell_i..], - x, - y, - style_id, - current_hyperlink_id, - &blank_cells, - ) - else - try self.writeCellRun( - emit, - true, - writer, - cells_subset[cell_i..], - x, - y, - style_id, - current_hyperlink_id, - &blank_cells, - ); + const consumed = try self.writeCellRun( + emit, + self.point_map != null, + writer, + cells_subset[cell_i..], + x, + y, + style_id, + current_hyperlink_id, + &blank_cells, + ); // Zero cells consumed means the first cell isn't // eligible for the fast path; handle it below. @@ -1579,10 +1600,15 @@ pub const PageFormatter = struct { /// Blank cell accounting matches the slow path: accumulated blanks /// are only materialized once a non-blank cell is found, and any /// remainder is written back to `blank_cells`. - fn writeCellRun( + // Deliberately not inlined: this is instantiated per emit format and + // inlining every copy into formatWithStateEmit's row loop bloats the + // binary. track_points is a runtime bool for the same reason: a + // comptime bool doubles the instantiation count for one predictable + // branch per emitted cell. + noinline fn writeCellRun( self: *const PageFormatter, comptime emit: Format, - comptime track_points: bool, + track_points: bool, writer: *std.Io.Writer, cells: []const Cell, run_x: size.CellCountInt, @@ -1666,7 +1692,7 @@ pub const PageFormatter = struct { // This cell produces output: materialize accumulated blanks. if (pending > 0) { - if (comptime track_points) try self.appendBlankPoints( + if (track_points) try self.appendBlankPoints( &self.point_map.?, pending, x, @@ -1718,7 +1744,7 @@ pub const PageFormatter = struct { } // All of the cell's bytes map to the cell's coordinate. - if (comptime track_points) { + if (track_points) { const map = &self.point_map.?; map.map.appendNTimes( map.alloc, @@ -5226,6 +5252,65 @@ test "Screen vt with cursor position" { } } +test "Terminal vt cursor preserves pending wrap" { + const testing = std.testing; + const alloc = testing.allocator; + const io = testing.io; + + var builder: std.Io.Writer.Allocating = .init(alloc); + defer builder.deinit(); + + var source = try Terminal.init(io, alloc, .{ + .cols = 4, + .rows = 2, + }); + defer source.deinit(alloc); + + var source_stream = source.vtStream(); + defer source_stream.deinit(); + source_stream.nextSlice("abcd"); + try testing.expect(source.screens.active.cursor.pending_wrap); + + var pin_map: PinMap.Map = .empty; + defer pin_map.deinit(alloc); + + var formatter: TerminalFormatter = .init(&source, .vt); + formatter.extra = .none; + formatter.extra.screen.cursor = true; + formatter.pin_map = .{ .alloc = alloc, .map = &pin_map }; + try formatter.format(&builder.writer); + try testing.expectEqual(builder.writer.buffered().len, pin_map.count()); + + var target = try Terminal.init(io, alloc, .{ + .cols = 4, + .rows = 2, + }); + defer target.deinit(alloc); + + var target_stream = target.vtStream(); + defer target_stream.deinit(); + target_stream.nextSlice(builder.writer.buffered()); + + try testing.expectEqual( + source.screens.active.cursor.pending_wrap, + target.screens.active.cursor.pending_wrap, + ); + + source_stream.nextSlice("X"); + target_stream.nextSlice("X"); + const source_contents = try source.screens.active.dumpStringAlloc( + alloc, + .{ .screen = .{} }, + ); + defer alloc.free(source_contents); + const target_contents = try target.screens.active.dumpStringAlloc( + alloc, + .{ .screen = .{} }, + ); + defer alloc.free(target_contents); + try testing.expectEqualStrings(source_contents, target_contents); +} + test "Screen vt with style" { const testing = std.testing; const alloc = testing.allocator; diff --git a/vendor/libghostty-vt/src/terminal/fraction.zig b/vendor/libghostty-vt/src/terminal/fraction.zig new file mode 100644 index 0000000..6713441 --- /dev/null +++ b/vendor/libghostty-vt/src/terminal/fraction.zig @@ -0,0 +1,142 @@ +//! Parsing of decimal fractions in the unit interval [0, 1]. +//! +//! Escape sequences that carry fractional parameters (XTerm `rgbi:` +//! color specifications, glyph protocol padding) only ever need plain +//! decimal values such as "0.5". Using `std.fmt.parseFloat` for these +//! pulls the full correctly-rounded float parser and its lookup tables +//! into the binary (~26KB). This implements only the restricted subset +//! we need. +const std = @import("std"); +const assert = std.debug.assert; + +/// Parse a decimal fraction in the range [0, 1] inclusive. +/// +/// Accepts an optional leading `+` or `-` sign followed by decimal +/// digits with an optional decimal point ("0.5", ".5", "1.", "1"); at +/// least one digit is required. Exponent and hexadecimal float syntax +/// are not supported. Returns null for invalid syntax and for any value +/// outside [0, 1] (so negative values other than -0 are rejected). +/// +/// Results are correctly rounded for inputs of up to 15 fractional +/// digits; further digits are validated but ignored, which can move the +/// result by less than 1e-15. +pub fn parse(value: []const u8) ?f64 { + var v = value; + var negative = false; + if (v.len > 0) switch (v[0]) { + '+' => v = v[1..], + '-' => { + negative = true; + v = v[1..]; + }, + else => {}, + }; + + var i: usize = 0; + const int_part: f64 = int_part: { + var int_part: f64 = 0; + while (i < v.len and v[i] != '.') : (i += 1) { + const d = v[i]; + if (d < '0' or d > '9') return null; + int_part = int_part * 10 + @as(f64, @floatFromInt(d - '0')); + } + break :int_part int_part; + }; + + var digits: usize = i; + var frac: u64 = 0; + var scale: u64 = 1; + if (i < v.len) { + assert(v[i] == '.'); + i += 1; + while (i < v.len) : (i += 1) { + const d = v[i]; + if (d < '0' or d > '9') return null; + // Stop accumulating after 15 digits so that frac and scale + // both stay exactly representable in f64 (and cannot + // overflow), making the division below round only once. + if (scale < 1_000_000_000_000_000) { + frac = frac * 10 + (d - '0'); + scale *= 10; + } + digits += 1; + } + } + if (digits == 0) return null; + + // frac and scale are both exact in f64 (below 2^53 and a power of + // ten no greater than 1e15), so this division rounds only once. + const magnitude = int_part + + @as(f64, @floatFromInt(frac)) / @as(f64, @floatFromInt(scale)); + const result = if (negative) -magnitude else magnitude; + + // Range check. Written so that -0 passes and any out-of-range or + // non-finite value fails. + if (!(result >= 0 and result <= 1)) return null; + return result; +} + +test parse { + const testing = std.testing; + + // Plain values + try testing.expectEqual(0, parse("0").?); + try testing.expectEqual(1, parse("1").?); + try testing.expectEqual(0.5, parse("0.5").?); + try testing.expectEqual(0.25, parse("0.25").?); + try testing.expectEqual(1, parse("1.0").?); + try testing.expectEqual(1, parse("1.000000").?); + try testing.expectEqual(0, parse("0.0").?); + + // Partial forms + try testing.expectEqual(0.5, parse(".5").?); + try testing.expectEqual(1, parse("1.").?); + try testing.expectEqual(0, parse("00.00").?); + + // Signs + try testing.expectEqual(0.5, parse("+0.5").?); + try testing.expectEqual(0, parse("-0").?); + try testing.expectEqual(0, parse("-0.000").?); + try testing.expectEqual(null, parse("-0.5")); + try testing.expectEqual(null, parse("-1")); + + // Matches std.fmt.parseFloat rounding for typical inputs. + try testing.expectEqual( + std.fmt.parseFloat(f64, "0.3") catch unreachable, + parse("0.3").?, + ); + try testing.expectEqual( + std.fmt.parseFloat(f64, "0.123456789012345") catch unreachable, + parse("0.123456789012345").?, + ); + + // Digits past the 15th are ignored: long fractions stay finite and + // equal their 15-digit truncation. + try testing.expectEqual( + parse("0.333333333333333").?, + parse("0." ++ "3" ** 400).?, + ); + + // Out of range + try testing.expectEqual(null, parse("1.0000001")); + try testing.expectEqual(null, parse("2")); + try testing.expectEqual(null, parse("255")); + try testing.expectEqual(null, parse("1" ** 400)); + + // Invalid syntax + try testing.expectEqual(null, parse("")); + try testing.expectEqual(null, parse(".")); + try testing.expectEqual(null, parse("+")); + try testing.expectEqual(null, parse("-")); + try testing.expectEqual(null, parse("+.")); + try testing.expectEqual(null, parse("abc")); + try testing.expectEqual(null, parse("0.5x")); + try testing.expectEqual(null, parse("0x0.8")); + try testing.expectEqual(null, parse("0..5")); + try testing.expectEqual(null, parse("0.5.5")); + try testing.expectEqual(null, parse(" 0.5")); + try testing.expectEqual(null, parse("0.5 ")); + try testing.expectEqual(null, parse("1e-1")); + try testing.expectEqual(null, parse("nan")); + try testing.expectEqual(null, parse("inf")); +} diff --git a/vendor/libghostty-vt/src/terminal/hash_map.zig b/vendor/libghostty-vt/src/terminal/hash_map.zig index abef87e..6f3b8b3 100644 --- a/vendor/libghostty-vt/src/terminal/hash_map.zig +++ b/vendor/libghostty-vt/src/terminal/hash_map.zig @@ -40,6 +40,7 @@ //! by any removal. const std = @import("std"); +const builtin = @import("builtin"); const assert = @import("../quirks.zig").inlineAssert; const mem = std.mem; const Allocator = mem.Allocator; @@ -74,6 +75,29 @@ fn AutoContext(comptime K: type) type { pub fn hash(_: @This(), key: K) u64 { if (comptime std.meta.hasUniqueRepresentation(K)) { + // On wasm32, Wyhash's 64x64 -> 128 multiplies lower to + // __multi3 libcalls (wasm has no widening multiply + // instruction), which makes the hash itself the + // dominant cost of small-key map operations. + // + // Keys here fit in a u64, so use a splitmix64-style finalizer + // instead: two native i64.mul with full avalanche in + // both the low bits (slot index) and high bits + // (metadata fingerprint). + if (comptime builtin.cpu.arch.isWasm() and @sizeOf(K) <= 8) { + var x: u64 = 0; + @memcpy( + std.mem.asBytes(&x)[0..@sizeOf(K)], + std.mem.asBytes(&key), + ); + x ^= x >> 33; + x *%= 0xff51afd7ed558ccd; + x ^= x >> 33; + x *%= 0xc4ceb9fe1a85ec53; + x ^= x >> 33; + return x; + } + // LLVM 21 (Zig 0.16) failed to inline this which resulted // in a measurable almost 2x slowdown on our hyperlink map // benchmark. So, force it. diff --git a/vendor/libghostty-vt/src/terminal/kitty.zig b/vendor/libghostty-vt/src/terminal/kitty.zig index 0868a77..bbef8d7 100644 --- a/vendor/libghostty-vt/src/terminal/kitty.zig +++ b/vendor/libghostty-vt/src/terminal/kitty.zig @@ -3,7 +3,9 @@ const build_options = @import("terminal_options"); const key = @import("kitty/key.zig"); +pub const clipboard = @import("kitty/clipboard.zig"); pub const color = @import("kitty/color.zig"); +pub const dnd = @import("kitty/dnd.zig"); pub const graphics = if (build_options.kitty_graphics) @import("kitty/graphics.zig") else struct {}; pub const KeyFlags = key.Flags; diff --git a/vendor/libghostty-vt/src/terminal/kitty/clipboard.zig b/vendor/libghostty-vt/src/terminal/kitty/clipboard.zig new file mode 100644 index 0000000..32cf8b9 --- /dev/null +++ b/vendor/libghostty-vt/src/terminal/kitty/clipboard.zig @@ -0,0 +1,82 @@ +//! Kitty clipboard protocol (OSC 5522). +//! +//! This implements the protocol semantics on top of the raw OSC capture: +//! src/terminal/osc/parsers/kitty_clipboard_protocol.zig: +//! +//! The behavior here is modeled on the kitty reference implementation +//! (kitty/clipboard.py) rather than only the prose spec, since the two +//! disagree in places. Notable reference behaviors we reproduce: +//! +//! * Malformed metadata (any record without '=', including an empty +//! metadata section) and an unknown or missing `type` silently drop +//! the request with no response. +//! * All base64 (payloads and the `mime`, `name`, and `pw` metadata +//! values) is strict RFC 4648 per the spec's "Encoding of payloads" +//! section: characters outside the standard alphabet (including +//! whitespace) and incorrect padding are rejected, never silently +//! skipped. An invalid value on `wdata` or `walias` aborts an +//! in-flight write with EINVAL; an invalid `read` is dropped with +//! no response since reads have no error status. +//! * Decoded metadata and MIME-list payloads must be valid UTF-8. An +//! invalid value on `wdata` or `walias`, or a `walias` without a +//! target MIME type, aborts an in-flight write with EINVAL. +//! * The `wdata` payloads for one MIME type form a single base64 +//! stream split at arbitrary packet boundaries; only the +//! concatenation must be correctly padded. Like kitty (which +//! resets its streaming decoder on EOF), a packet ending exactly +//! at terminal padding restarts the stream, so independently +//! encoded chunks also work. +//! * `mime`, `name`, and `pw` metadata values are base64-encoded UTF-8; +//! everything else is verbatim. Unknown keys are ignored. +//! * `id` is sanitized by stripping characters outside [a-zA-Z0-9-_+.] +//! and truncating to 512 bytes, then echoed verbatim in every +//! response packet (omitted when empty). +//! * A `type=write` silently replaces any in-flight transaction. A +//! commit (`type=wdata` without a MIME type) with no in-flight +//! transaction is silently ignored. +//! * Responses never send a payload section for an empty payload, +//! except the targets ('.') listing DATA packet which is always sent. +//! +//! Specification: https://sw.kovidgoyal.net/kitty/clipboard/ + +const oscpkg = @import("../osc.zig"); +const protocol = @import("../osc/parsers/kitty_clipboard_protocol.zig"); +const command = @import("clipboard_command.zig"); +const write = @import("clipboard_write.zig"); +const response = @import("clipboard_response.zig"); +const grants = @import("clipboard_grants.zig"); + +pub const OSC = protocol.OSC; +pub const Operation = protocol.Operation; +pub const Status = protocol.Status; +pub const Terminator = oscpkg.Terminator; + +pub const Metadata = command.Metadata; +pub const Payload = command.Payload; +pub const readPromptExempt = command.readPromptExempt; +pub const max_id_len = command.max_id_len; +pub const max_pw_len = command.max_pw_len; +pub const max_mime_len = command.max_mime_len; +pub const max_name_len = command.max_name_len; + +pub const Content = write.Content; +pub const WriteState = write.WriteState; +pub const max_write_size = write.max_write_size; +pub const max_write_mimes = write.max_write_mimes; +pub const max_write_aliases = write.max_write_aliases; + +pub const Response = response.Response; +pub const ReadSuccess = response.ReadSuccess; +pub const PasteEvent = response.PasteEvent; +pub const read_chunk_size = response.read_chunk_size; +pub const max_read_mimes = response.max_read_mimes; +pub const max_listing_mimes = response.max_listing_mimes; +pub const targets_mime = response.targets_mime; + +pub const Grants = grants.Grants; +pub const otp_len = grants.otp_len; +pub const generateOtp = grants.generateOtp; + +test { + @import("std").testing.refAllDecls(@This()); +} diff --git a/vendor/libghostty-vt/src/terminal/kitty/clipboard_command.zig b/vendor/libghostty-vt/src/terminal/kitty/clipboard_command.zig new file mode 100644 index 0000000..67bc26f --- /dev/null +++ b/vendor/libghostty-vt/src/terminal/kitty/clipboard_command.zig @@ -0,0 +1,502 @@ +//! Kitty clipboard protocol (OSC 5522) request decoding. +//! +//! OSC 5522 format: `<OSC>5522;metadata;payload<ST>` +//! +//! Per the spec: "metadata is a colon separated list of key-value +//! pairs and payload is base64 encoded data." +//! +//! This contains the logic for parsing this. + +const std = @import("std"); +const Allocator = std.mem.Allocator; +const simd = @import("../../simd/main.zig"); +const clipboard = @import("../clipboard.zig"); +const protocol = @import("../osc/parsers/kitty_clipboard_protocol.zig"); + +const Operation = protocol.Operation; + +/// Maximum id length, nothing specified but this is the limit used +/// in Kitty's source so we'll match it. +pub const max_id_len = 512; + +/// Maximum decoded password length. Kitty has no limit. Passwords are +/// UUID-sized in practice so anything longer simply never matches a +/// stored grant anyway. +pub const max_pw_len = 128; + +/// Maximum decoded MIME type length. Kitty has no limit but real MIME +/// types are tiny; anything longer drops the packet. +pub const max_mime_len = 256; + +/// Maximum decoded name length. Kitty has no limit but names are shown +/// in permission prompts so anything longer drops the packet. +pub const max_name_len = 256; + +/// The decoded, validated metadata of one OSC 5522 sequence. +/// +/// All slice values are allocated from the allocator given to parse and +/// are sized to their contents. Callers are expected to pass an arena +/// scoped to handling the packet. There is no deinit. +pub const Metadata = struct { + op: Operation, + + /// The clipboard this operation targets. Per the spec: "To read + /// from the primary selection instead of the clipboard, add the + /// key `loc=primary` to the metadata section." Any other value + /// means the clipboard, so only standard and primary are possible + /// here. + loc: clipboard.Location = .standard, + + /// Sanitized id: invalid characters stripped, truncated to + /// max_id_len. Empty means no id. + id: []const u8 = "", + + /// Decoded mime metadata value. Empty means absent; kitty treats an + /// empty mime the same as a missing one everywhere it matters (a + /// wdata packet with either commits the transaction). + mime: []const u8 = "", + + /// Decoded password. Empty means absent. Per the spec: + /// "Specifying a password without a human friendly name is + /// equivalent to not specifying a password and the terminal must + /// treat the request as though it had no password." + pw: []const u8 = "", + + /// Decoded human friendly name of the requesting program, shown in + /// permission prompts. Empty means absent. Its presence opts into + /// password grants. + name: []const u8 = "", + + /// Parse the metadata field. The raw value is expected to be exactly + /// the metadata (prefix and payload and separators stripped out). + /// + /// A null result means the packet should be silently dropped. An + /// InvalidValue error means a decoded textual value was not valid + /// UTF-8 or exceeded a local safety limit. Callers use the raw + /// operation to decide whether that invalid value aborts an in-flight + /// write transaction. + pub fn parse( + alloc: Allocator, + raw: []const u8, + ) error{ OutOfMemory, InvalidValue }!?Metadata { + const fields = Raw.parse(raw) orelse return null; + var result: Metadata = .{ + .op = Operation.init(fields.op orelse return null) orelse return null, + .loc = if (std.mem.eql(u8, fields.loc, "primary")) + .primary + else + .standard, + .id = try sanitizeId(alloc, fields.id), + }; + + result.mime = decodeValue( + alloc, + fields.mime, + max_mime_len, + ) catch |err| switch (err) { + error.OutOfMemory => return error.OutOfMemory, + error.Overflow, + error.InvalidBase64, + error.InvalidUtf8, + => return error.InvalidValue, + }; + result.pw = decodeValue( + alloc, + fields.pw, + max_pw_len, + ) catch |err| switch (err) { + error.OutOfMemory => return error.OutOfMemory, + error.InvalidBase64, error.InvalidUtf8 => return error.InvalidValue, + // An over-long password behaves as if none was given: it can + // never match a stored grant. + error.Overflow => "", + }; + result.name = decodeValue( + alloc, + fields.name, + max_name_len, + ) catch |err| switch (err) { + error.OutOfMemory => return error.OutOfMemory, + error.Overflow, + error.InvalidBase64, + error.InvalidUtf8, + => return error.InvalidValue, + }; + return result; + } + + /// Return the last recognized operation from syntactically valid raw + /// metadata. This intentionally does not decode any values, so callers + /// can still classify an InvalidValue parse error. + pub fn operation(raw: []const u8) ?Operation { + const fields = Raw.parse(raw) orelse return null; + return Operation.init(fields.op orelse return null); + } + + const Raw = struct { + op: ?[]const u8 = null, + loc: []const u8 = "", + id: []const u8 = "", + mime: []const u8 = "", + pw: []const u8 = "", + name: []const u8 = "", + + fn parse(raw: []const u8) ?Raw { + var result: Raw = .{}; + + // This visits every record even though an empty raw string + // yields one empty record. Validating the complete structure + // before decoding also ensures the last duplicate value wins. + var it = std.mem.splitScalar(u8, raw, ':'); + while (it.next()) |record| { + const eql_idx = std.mem.indexOfScalar(u8, record, '=') orelse return null; + const key = record[0..eql_idx]; + const value = record[eql_idx + 1 ..]; + + if (std.mem.eql(u8, key, "type")) { + result.op = value; + } else if (std.mem.eql(u8, key, "loc")) { + result.loc = value; + } else if (std.mem.eql(u8, key, "id")) { + result.id = value; + } else if (std.mem.eql(u8, key, "mime")) { + result.mime = value; + } else if (std.mem.eql(u8, key, "pw")) { + result.pw = value; + } else if (std.mem.eql(u8, key, "name")) { + result.name = value; + } + // Unknown keys are ignored. + } + + return result; + } + }; + + /// Sanitize the ID according to the spec: + /// + /// Valid ids must include only characters from the set: [a-zA-Z0-9-_+.]. + /// Any other characters must be stripped out from the id by the terminal + /// emulator before retransmitting it. + fn sanitizeId(alloc: Allocator, value: []const u8) Allocator.Error![]const u8 { + var list: std.ArrayListUnmanaged(u8) = .empty; + defer list.deinit(alloc); + for (value) |c| { + switch (c) { + 'a'...'z', 'A'...'Z', '0'...'9', '-', '_', '+', '.' => {}, + else => continue, + } + if (list.items.len >= max_id_len) break; + try list.append(alloc, c); + } + return try list.toOwnedSlice(alloc); + } + + /// Base64-decode a metadata value. Per the spec these values "are + /// UTF-8 strings that are base64 encoded", so the decoded result + /// must be valid UTF-8, and the encoding is strict RFC 4648 with + /// required padding per the spec's "Encoding of payloads" section. + fn decodeValue(alloc: Allocator, value: []const u8, max_len: usize) error{ + OutOfMemory, + Overflow, + InvalidBase64, + InvalidUtf8, + }![]const u8 { + const Encoder = std.base64.standard.Encoder; + + // Avoid hostile large payloads. + if (value.len > Encoder.calcSize(max_len)) return error.Overflow; + + // Decode + const buf = try alloc.alloc(u8, simd.base64.maxLen(value)); + errdefer alloc.free(buf); + const decoded = simd.base64.decodeStrict( + value, + buf, + .required, + ) catch return error.InvalidBase64; + + // Must be valid UTF-8 + if (!std.unicode.utf8ValidateSlice(decoded)) return error.InvalidUtf8; + if (decoded.len > max_len) return error.Overflow; + return decoded; + } +}; + +/// A decoded base64 payload of one OSC 5522 sequence, e.g. the MIME +/// type list of a read request or the alias list of a walias packet. +/// The data slice aliases the allocated buf. +pub const Payload = struct { + buf: []u8, + data: []const u8, + + /// Decode a base64 payload into freshly allocated memory. The + /// encoding is strict RFC 4648 with required padding per the + /// spec's "Encoding of payloads" section; how an invalid payload + /// is reported (or not) depends on the packet type. + pub fn init( + alloc: Allocator, + payload: []const u8, + ) error{ OutOfMemory, Invalid }!Payload { + const buf = try alloc.alloc(u8, simd.base64.maxLen(payload)); + errdefer alloc.free(buf); + const data = simd.base64.decodeStrict( + payload, + buf, + .required, + ) catch return error.Invalid; + return .{ .buf = buf, .data = data }; + } + + pub fn deinit(self: *const Payload, alloc: Allocator) void { + alloc.free(self.buf); + } + + pub fn isValidUtf8(self: *const Payload) bool { + return std.unicode.utf8ValidateSlice(self.data); + } + + /// Iterate the whitespace-separated MIME types of the payload. + /// Matches Python str.split() used by kitty. + pub fn mimeIterator(self: *const Payload) std.mem.TokenIterator(u8, .any) { + return std.mem.tokenizeAny( + u8, + self.data, + &std.ascii.whitespace, + ); + } +}; + +/// Whether a read request with this many data MIME types (the targets +/// type '.' excluded) is exempt from the user permission prompt. +pub fn readPromptExempt(data_mimes: usize) bool { + return data_mimes == 0; +} + +test "metadata: empty is dropped" { + const testing = std.testing; + var arena: std.heap.ArenaAllocator = .init(testing.allocator); + defer arena.deinit(); + try testing.expect((try Metadata.parse(arena.allocator(), "")) == null); +} + +test "metadata: record without = is dropped" { + const testing = std.testing; + var arena: std.heap.ArenaAllocator = .init(testing.allocator); + defer arena.deinit(); + try testing.expect((try Metadata.parse(arena.allocator(), "type=read:bare")) == null); + try testing.expect((try Metadata.parse(arena.allocator(), "bare:type=read")) == null); +} + +test "metadata: missing or unknown type is dropped" { + const testing = std.testing; + var arena: std.heap.ArenaAllocator = .init(testing.allocator); + defer arena.deinit(); + try testing.expect((try Metadata.parse(arena.allocator(), "loc=primary")) == null); + try testing.expect((try Metadata.parse(arena.allocator(), "type=bobr")) == null); + try testing.expect((try Metadata.parse(arena.allocator(), "type=")) == null); +} + +test "metadata: duplicate keys keep the last occurrence" { + const testing = std.testing; + var arena: std.heap.ArenaAllocator = .init(testing.allocator); + defer arena.deinit(); + try testing.expectEqual( + Operation.read, + (try Metadata.parse(arena.allocator(), "type=bobr:type=read")).?.op, + ); + try testing.expect((try Metadata.parse(arena.allocator(), "type=read:type=bobr")) == null); +} + +test "metadata: basic read" { + const testing = std.testing; + var arena: std.heap.ArenaAllocator = .init(testing.allocator); + defer arena.deinit(); + const meta = (try Metadata.parse(arena.allocator(), "type=read")).?; + try testing.expectEqual(Operation.read, meta.op); + try testing.expect(meta.loc == .standard); + try testing.expectEqual(@as(usize, 0), meta.id.len); +} + +test "metadata: unknown keys ignored" { + const testing = std.testing; + var arena: std.heap.ArenaAllocator = .init(testing.allocator); + defer arena.deinit(); + const meta = (try Metadata.parse(arena.allocator(), "type=read:bobr=kurwa")).?; + try testing.expectEqual(Operation.read, meta.op); +} + +test "metadata: loc" { + const testing = std.testing; + var arena: std.heap.ArenaAllocator = .init(testing.allocator); + defer arena.deinit(); + try testing.expect((try Metadata.parse(arena.allocator(), "type=read:loc=primary")).?.loc == .primary); + // Anything other than "primary" means the clipboard; it is not an + // error. + try testing.expect((try Metadata.parse(arena.allocator(), "type=read:loc=bobr")).?.loc == .standard); +} + +test "metadata: id sanitized" { + const testing = std.testing; + var arena: std.heap.ArenaAllocator = .init(testing.allocator); + defer arena.deinit(); + { + const meta = (try Metadata.parse(arena.allocator(), "type=read:id=abc-123_x.Y+z")).?; + try testing.expectEqualStrings("abc-123_x.Y+z", meta.id); + } + { + // Invalid characters are stripped, not rejected. + const meta = (try Metadata.parse(arena.allocator(), "type=read:id=*4 2*")).?; + try testing.expectEqualStrings("42", meta.id); + } +} + +test "metadata: id truncated to max" { + const testing = std.testing; + var arena: std.heap.ArenaAllocator = .init(testing.allocator); + defer arena.deinit(); + const raw = "type=read:id=" ++ "a" ** (max_id_len + 100); + const meta = (try Metadata.parse(arena.allocator(), raw)).?; + try testing.expectEqual(@as(usize, max_id_len), meta.id.len); +} + +test "metadata: mime decoded" { + const testing = std.testing; + var arena: std.heap.ArenaAllocator = .init(testing.allocator); + defer arena.deinit(); + // "text/plain" + const meta = (try Metadata.parse(arena.allocator(), "type=wdata:mime=dGV4dC9wbGFpbg==")).?; + try testing.expectEqualStrings("text/plain", meta.mime); +} + +test "metadata: invalid mime base64 reported" { + const testing = std.testing; + var arena: std.heap.ArenaAllocator = .init(testing.allocator); + defer arena.deinit(); + + // Invalid base64 in a metadata value aborts an in-flight write + // per the spec, so like invalid UTF-8 it is reported rather than + // silently dropping the packet. Unpadded or whitespace-laced + // values are invalid too: metadata values use the same strict + // encoding as payloads. + const cases = [_][]const u8{ + "type=wdata:mime=!!!", + // "text/plain" without its padding. + "type=wdata:mime=dGV4dC9wbGFpbg", + // "text/plain" with its final byte replaced by '!'. + "type=wdata:mime=dGV4dC9wbGFpbg=!", + // A newline inside otherwise valid base64. + "type=wdata:mime=dGV4dC9w\nbGFpbg==", + }; + for (cases) |case| { + try testing.expectError( + error.InvalidValue, + Metadata.parse(arena.allocator(), case), + ); + try testing.expectEqual( + Operation.wdata, + Metadata.operation(case).?, + ); + } +} + +test "metadata: invalid mime utf8 reported" { + const testing = std.testing; + var arena: std.heap.ArenaAllocator = .init(testing.allocator); + defer arena.deinit(); + // base64 of 0xff 0xfe + try testing.expectError( + error.InvalidValue, + Metadata.parse(arena.allocator(), "type=wdata:mime=//4="), + ); + try testing.expectEqual( + Operation.wdata, + Metadata.operation("type=wdata:mime=//4=").?, + ); +} + +test "metadata: pw and name" { + const testing = std.testing; + var arena: std.heap.ArenaAllocator = .init(testing.allocator); + defer arena.deinit(); + // pw="secret", name="app" + const meta = (try Metadata.parse(arena.allocator(), "type=read:pw=c2VjcmV0:name=YXBw")).?; + try testing.expectEqualStrings("secret", meta.pw); + try testing.expectEqualStrings("app", meta.name); +} + +test "metadata: over-long name reported" { + const testing = std.testing; + var arena: std.heap.ArenaAllocator = .init(testing.allocator); + defer arena.deinit(); + const Encoder = std.base64.standard.Encoder; + const long = "n" ** (max_name_len + 1); + var buf: [Encoder.calcSize(long.len)]u8 = undefined; + const raw = try std.mem.concat(arena.allocator(), u8, &.{ + "type=read:name=", + Encoder.encode(&buf, long), + }); + try testing.expectError( + error.InvalidValue, + Metadata.parse(arena.allocator(), raw), + ); +} + +test "metadata: empty name" { + const testing = std.testing; + var arena: std.heap.ArenaAllocator = .init(testing.allocator); + defer arena.deinit(); + const meta = (try Metadata.parse(arena.allocator(), "type=read:pw=c2VjcmV0:name=")).?; + try testing.expectEqual(@as(usize, 0), meta.name.len); +} + +test "read prompt exemption: only requests without data types" { + const testing = std.testing; + + // A targets-only ('.') read parses to zero data MIME types and is + // served without a prompt; any data type, even alongside the + // targets listing, still prompts. + try testing.expect(readPromptExempt(0)); + try testing.expect(!readPromptExempt(1)); +} + +test "payload: mime iterator" { + const testing = std.testing; + // base64 of "text/plain text/html\ntext/uri-list" + const payload = try Payload.init( + testing.allocator, + "dGV4dC9wbGFpbiAgdGV4dC9odG1sCnRleHQvdXJpLWxpc3Q=", + ); + defer payload.deinit(testing.allocator); + var it = payload.mimeIterator(); + try testing.expectEqualStrings("text/plain", it.next().?); + try testing.expectEqualStrings("text/html", it.next().?); + try testing.expectEqualStrings("text/uri-list", it.next().?); + try testing.expect(it.next() == null); +} + +test "payload: invalid base64" { + const testing = std.testing; + const cases = [_][]const u8{ + "!!!", + // "text/plain" without its padding: payloads use the strict + // encoding, so missing padding is rejected. + "dGV4dC9wbGFpbg", + // A newline inside otherwise valid base64. + "dGV4dC9w\nbGFpbg==", + }; + for (cases) |case| { + try testing.expectError( + error.Invalid, + Payload.init(testing.allocator, case), + ); + } +} + +test "payload: decoded text must be valid utf8" { + const testing = std.testing; + // Valid base64 encoding of a single 0xff byte. + const payload = try Payload.init(testing.allocator, "/w=="); + defer payload.deinit(testing.allocator); + try testing.expect(!payload.isValidUtf8()); +} diff --git a/vendor/libghostty-vt/src/terminal/kitty/clipboard_grants.zig b/vendor/libghostty-vt/src/terminal/kitty/clipboard_grants.zig new file mode 100644 index 0000000..59b9bef --- /dev/null +++ b/vendor/libghostty-vt/src/terminal/kitty/clipboard_grants.zig @@ -0,0 +1,238 @@ +//! Kitty clipboard protocol (OSC 5522) session password grants: +//! requests carrying a granted password skip the permission prompt, and +//! paste events mint one-time passwords. + +const std = @import("std"); +const Allocator = std.mem.Allocator; +const clipboard_command = @import("clipboard_command.zig"); +const sys = @import("../sys.zig"); + +const max_pw_len = clipboard_command.max_pw_len; + +/// Session password grants, used to skip permission prompts for +/// requests carrying a known pw. Callers can choose to scope these +/// however they want, e.g. Kitty does it per window and the spec +/// doesn't demand anything. +pub const Grants = struct { + entries: std.ArrayListUnmanaged(Entry) = .empty, + + const max_entries = 32; + + pub const Direction = enum { read, write }; + + const Entry = struct { + /// Owned by the allocator passed to grant. + pw: []const u8, + read: bool = false, + write: bool = false, + one_time: bool = false, + }; + + pub fn deinit(self: *Grants, alloc: Allocator) void { + for (self.entries.items) |entry| alloc.free(entry.pw); + self.entries.deinit(alloc); + } + + /// Record a grant for pw. An existing grant for the same password + /// gains the new direction. + pub fn grant( + self: *Grants, + alloc: Allocator, + pw: []const u8, + dir: Direction, + one_time: bool, + ) Allocator.Error!void { + if (pw.len == 0 or pw.len > max_pw_len) return; + + const entry: *Entry = entry: { + if (self.findIndex(pw)) |idx| { + const entry = &self.entries.items[idx]; + entry.one_time = entry.one_time and one_time; + break :entry entry; + } + + // Evict the oldest grant once full. + if (self.entries.items.len >= max_entries) { + const oldest = self.entries.orderedRemove(0); + alloc.free(oldest.pw); + } + + const owned = try alloc.dupe(u8, pw); + errdefer alloc.free(owned); + try self.entries.append(alloc, .{ + .pw = owned, + .one_time = one_time, + }); + break :entry &self.entries.items[self.entries.items.len - 1]; + }; + + switch (dir) { + .read => entry.read = true, + .write => entry.write = true, + } + } + + /// Check whether pw grants the given direction. A one-time grant is + /// consumed by this check even when the direction doesn't match, + /// matching kitty's pop-on-check behavior. + pub fn use( + self: *Grants, + alloc: Allocator, + pw: []const u8, + dir: Direction, + ) bool { + if (pw.len == 0) return false; + const idx = self.findIndex(pw) orelse return false; + const entry = &self.entries.items[idx]; + const allowed = switch (dir) { + .read => entry.read, + .write => entry.write, + }; + if (entry.one_time) { + const removed = self.entries.swapRemove(idx); + alloc.free(removed.pw); + } + return allowed; + } + + fn findIndex(self: *const Grants, pw: []const u8) ?usize { + for (self.entries.items, 0..) |*entry, idx| { + if (std.mem.eql(u8, entry.pw, pw)) return idx; + } + return null; + } +}; + +/// The length of a one-time password generated for paste events. +pub const otp_len = 22; + +/// The one-time password alphabet. This matches kitty (alphanumeric +/// without easily-confused characters), but the spec doesn't demand +/// this. +pub const otp_alphabet = "23456789abcdefghijkmnopqrstuvwxyzABCDEFGHJKLMNPQRSTUVWXYZ"; + +/// Generate a one-time password for a paste event. +/// +/// The password is a secret: a program that learns it can read the +/// clipboard without a prompt. Entropy comes from `sys.random_secure` +/// if set, otherwise from the Io; see `sys.randomSecure`. +pub fn generateOtp(io: std.Io) std.Io.RandomSecureError![otp_len]u8 { + var result: [otp_len]u8 = undefined; + var len: usize = 0; + while (len < result.len) { + var raw: [2 * otp_len]u8 = undefined; + try sys.randomSecure(io, &raw); + const limit = (std.math.maxInt(u8) + 1) / otp_alphabet.len * otp_alphabet.len; + for (raw) |byte| { + if (byte >= limit) continue; + result[len] = otp_alphabet[byte % otp_alphabet.len]; + len += 1; + if (len == result.len) break; + } + } + + return result; +} + +test "grants: basic grant and use" { + const testing = std.testing; + const alloc = testing.allocator; + + var grants: Grants = .{}; + defer grants.deinit(alloc); + try testing.expect(!grants.use(alloc, "pw1", .read)); + + try grants.grant(alloc, "pw1", .read, false); + try testing.expect(grants.use(alloc, "pw1", .read)); + // Persistent grants survive use. + try testing.expect(grants.use(alloc, "pw1", .read)); + try testing.expect(!grants.use(alloc, "pw1", .write)); +} + +test "grants: one-time consumed on check" { + const testing = std.testing; + const alloc = testing.allocator; + + var grants: Grants = .{}; + defer grants.deinit(alloc); + try grants.grant(alloc, "otp", .read, true); + try testing.expect(grants.use(alloc, "otp", .read)); + try testing.expect(!grants.use(alloc, "otp", .read)); +} + +test "grants: one-time consumed even on direction mismatch" { + const testing = std.testing; + const alloc = testing.allocator; + + var grants: Grants = .{}; + defer grants.deinit(alloc); + try grants.grant(alloc, "otp", .read, true); + try testing.expect(!grants.use(alloc, "otp", .write)); + try testing.expect(!grants.use(alloc, "otp", .read)); +} + +test "grants: directions are independent" { + const testing = std.testing; + const alloc = testing.allocator; + + var grants: Grants = .{}; + defer grants.deinit(alloc); + try grants.grant(alloc, "pw", .read, false); + try grants.grant(alloc, "pw", .write, false); + try testing.expect(grants.use(alloc, "pw", .read)); + try testing.expect(grants.use(alloc, "pw", .write)); +} + +test "grants: capacity evicts the oldest" { + const testing = std.testing; + const alloc = testing.allocator; + + var grants: Grants = .{}; + defer grants.deinit(alloc); + + var buf: [8]u8 = undefined; + for (0..Grants.max_entries + 1) |i| { + const pw = try std.fmt.bufPrint(&buf, "pw{}", .{i}); + try grants.grant(alloc, pw, .read, false); + } + + // The oldest grant was evicted; the newest survives. + try testing.expect(!grants.use(alloc, "pw0", .read)); + const newest = try std.fmt.bufPrint(&buf, "pw{}", .{Grants.max_entries}); + try testing.expect(grants.use(alloc, newest, .read)); +} + +test "generateOtp: length and alphabet with a real Io" { + const testing = std.testing; + + const otp = try generateOtp(testing.io); + try testing.expectEqual(otp_len, otp.len); + for (otp) |c| try testing.expect(std.mem.indexOfScalar(u8, otp_alphabet, c) != null); + + // Two passwords don't collide (a repeat would mean no entropy). + const other = try generateOtp(testing.io); + try testing.expect(!std.mem.eql(u8, &otp, &other)); +} + +test "generateOtp: no entropy is an error, never a weak password" { + const testing = std.testing; + try testing.expectError(error.EntropyUnavailable, generateOtp(std.Io.failing)); +} + +test "generateOtp: sys override supplies entropy without an Io source" { + const testing = std.testing; + const S = struct { + var counter: u8 = 0; + fn fill(buffer: []u8) sys.RandomSecureError!void { + for (buffer) |*b| { + b.* = counter; + counter +%= 1; + } + } + }; + sys.random_secure = &S.fill; + defer sys.random_secure = null; + + const otp = try generateOtp(std.Io.failing); + for (otp) |c| try testing.expect(std.mem.indexOfScalar(u8, otp_alphabet, c) != null); +} diff --git a/vendor/libghostty-vt/src/terminal/kitty/clipboard_response.zig b/vendor/libghostty-vt/src/terminal/kitty/clipboard_response.zig new file mode 100644 index 0000000..50bbbc6 --- /dev/null +++ b/vendor/libghostty-vt/src/terminal/kitty/clipboard_response.zig @@ -0,0 +1,469 @@ +//! Kitty clipboard protocol (OSC 5522) response encoding. + +const std = @import("std"); +const clipboard = @import("../clipboard.zig"); +const oscpkg = @import("../osc.zig"); +const protocol = @import("../osc/parsers/kitty_clipboard_protocol.zig"); + +const b64 = std.base64.standard.Encoder; +const Content = clipboard.Content; +const Operation = protocol.Operation; +const Status = protocol.Status; +const Terminator = oscpkg.Terminator; + +/// Maximum raw (pre-base64) bytes per DATA packet in read responses. +/// This is specified by the protocol. +pub const read_chunk_size = 4096; + +/// Maximum requested MIME types served by a single read request. +/// Requests beyond this simply see no DATA packets for the extras, +/// which is how the protocol communicates an unavailable type anyway. +pub const max_read_mimes = 4; + +/// The special MIME type that requests the list of available types. +pub const targets_mime = "."; + +/// Maximum MIME types reported in a paste event's targets listing. +pub const max_listing_mimes = 16; + +/// A single response packet. +pub const Response = struct { + op: Operation, + status: Status, + primary: bool = false, + id: []const u8 = "", + mime: ?[]const u8 = null, + pw: ?[]const u8 = null, + /// The raw payload; the encoder base64-encodes it. An empty payload + /// emits no payload section at all (no ';'). + payload: []const u8 = "", + terminator: Terminator = .st, + + /// Encode the response. Errors may result in partially written data + /// so it is up to callers to buffer it if they need to. + pub fn encode( + self: *const Response, + writer: *std.Io.Writer, + ) std.Io.Writer.Error!void { + try self.encodeMetadata(writer); + if (self.payload.len > 0) { + try writer.writeAll(";"); + try b64.encodeWriter(writer, self.payload); + } + try writer.writeAll(self.terminator.string()); + } + + /// Encode the escape prefix and metadata section only: everything + /// up to (and not including) the payload section and terminator. + fn encodeMetadata( + self: *const Response, + writer: *std.Io.Writer, + ) std.Io.Writer.Error!void { + // The exact order of fields here matches what Kitty does. + try writer.print("\x1b]5522;type={t}:status={t}", .{ self.op, self.status }); + if (self.primary) try writer.writeAll(":loc=primary"); + if (self.id.len > 0) try writer.print(":id={s}", .{self.id}); + if (self.mime) |mime| { + try writer.writeAll(":mime="); + try b64.encodeWriter(writer, mime); + } + if (self.pw) |pw| { + try writer.writeAll(":pw="); + try b64.encodeWriter(writer, pw); + } + } +}; + +/// Encode a full successful read response: the OK packet, the targets +/// listing if requested, DATA chunks for each served representation, +/// and the final DONE packet. This is also the shape of an unsolicited +/// paste event (list=true, pw set to the one-time password). +pub const ReadSuccess = struct { + primary: bool = false, + id: []const u8 = "", + + /// One-time password echoed in every packet. Only used for + /// terminal-initiated paste events. + pw: ?[]const u8 = null, + + /// True when the targets ('.') listing was requested. + list: bool = false, + + /// The MIME types available on the clipboard, reported by the + /// targets listing. Kitty reports these space-separated in one + /// packet with a trailing newline when non-empty. + available: []const []const u8 = &.{}, + + /// The representations to serve, in request order. Each entry's + /// data is chunked into DATA packets under its own MIME type. An + /// entry with empty data produces no packets, which is how the + /// protocol communicates an unavailable type. + contents: []const Content = &.{}, + + terminator: Terminator = .st, + + pub fn encode( + self: *const ReadSuccess, + writer: *std.Io.Writer, + ) std.Io.Writer.Error!void { + // Initial read response + try (Response{ + .op = .read, + .status = .OK, + .primary = self.primary, + .id = self.id, + .pw = self.pw, + .terminator = self.terminator, + }).encode(writer); + + // Listing of mimes if requested + if (self.list) try self.encodeListing(writer); + + // Encoding of each mime-type + content + for (self.contents) |content| { + var i: usize = 0; + while (i < content.data.len) { + const n = @min(content.data.len - i, read_chunk_size); + try (Response{ + .op = .read, + .status = .DATA, + .id = self.id, + .mime = content.mime, + .pw = self.pw, + .payload = content.data[i..][0..n], + .terminator = self.terminator, + }).encode(writer); + i += n; + } + } + + // Trailing done. + try (Response{ + .op = .read, + .status = .DONE, + .id = self.id, + .pw = self.pw, + .terminator = self.terminator, + }).encode(writer); + } + + /// Encode the targets ('.') listing packet. The listing gets a + /// trailing newline when non-empty. + fn encodeListing( + self: *const ReadSuccess, + writer: *std.Io.Writer, + ) std.Io.Writer.Error!void { + const listing: Response = .{ + .op = .read, + .status = .DATA, + .id = self.id, + .mime = targets_mime, + .pw = self.pw, + .terminator = self.terminator, + }; + try listing.encodeMetadata(writer); + if (self.available.len > 0) { + try writer.writeAll(";"); + + // Join the types into one chunk before encoding. The + // listing is a DATA packet, so it shares the pre-encoding + // chunk size bound of any other data packet; types that + // don't fit are dropped (a listing that large doesn't + // happen in practice). + var raw: [read_chunk_size]u8 = undefined; + var fixed: std.Io.Writer = .fixed(&raw); + for (self.available, 0..) |mime, i| { + const sep: usize = if (i > 0) 1 else 0; + if (fixed.end + sep + mime.len + "\n".len > raw.len) break; + if (i > 0) fixed.writeAll(" ") catch unreachable; + fixed.writeAll(mime) catch unreachable; + } + fixed.writeAll("\n") catch unreachable; + try b64.encodeWriter(writer, fixed.buffered()); + } + try writer.writeAll(self.terminator.string()); + } +}; + +/// An unsolicited Kitty paste event (mode 5522): a read response that +/// lists the clipboard's available MIME types and carries the one-time +/// password the program uses for its follow-up read. +pub const PasteEvent = struct { + /// True if the paste came from the primary selection, reported as + /// `loc=primary` on the OK packet. + primary: bool = false, + + /// The one-time password, echoed in every packet. + pw: []const u8, + + /// The MIME types available on the clipboard. + available: []const []const u8, + + terminator: Terminator = .st, + + pub fn encode( + self: *const PasteEvent, + writer: *std.Io.Writer, + ) std.Io.Writer.Error!void { + try (ReadSuccess{ + .primary = self.primary, + .pw = self.pw, + .list = true, + .available = self.available, + .terminator = self.terminator, + }).encode(writer); + } +}; + +test "response: basic status packet" { + const testing = std.testing; + + var buf: [128]u8 = undefined; + var writer: std.Io.Writer = .fixed(&buf); + try (Response{ .op = .write, .status = .DONE }).encode(&writer); + try testing.expectEqualStrings( + "\x1b]5522;type=write:status=DONE\x1b\\", + writer.buffered(), + ); +} + +test "response: id echo and terminator" { + const testing = std.testing; + + var buf: [128]u8 = undefined; + var writer: std.Io.Writer = .fixed(&buf); + try (Response{ + .op = .write, + .status = .EPERM, + .id = "42", + .terminator = .bel, + }).encode(&writer); + try testing.expectEqualStrings( + "\x1b]5522;type=write:status=EPERM:id=42\x07", + writer.buffered(), + ); +} + +test "response: key order type,status,loc,id,mime,pw and payload" { + const testing = std.testing; + + var buf: [256]u8 = undefined; + var writer: std.Io.Writer = .fixed(&buf); + try (Response{ + .op = .read, + .status = .DATA, + .primary = true, + .id = "x", + .mime = "text/plain", + .pw = "otp", + .payload = "Ghostty", + }).encode(&writer); + try testing.expectEqualStrings( + "\x1b]5522;type=read:status=DATA:loc=primary:id=x" ++ + ":mime=dGV4dC9wbGFpbg==:pw=b3Rw;R2hvc3R0eQ==\x1b\\", + writer.buffered(), + ); +} + +test "read success: empty request is OK then DONE" { + const testing = std.testing; + + var buf: [256]u8 = undefined; + var writer: std.Io.Writer = .fixed(&buf); + try (ReadSuccess{ .id = "7" }).encode(&writer); + try testing.expectEqualStrings( + "\x1b]5522;type=read:status=OK:id=7\x1b\\" ++ + "\x1b]5522;type=read:status=DONE:id=7\x1b\\", + writer.buffered(), + ); +} + +test "read success: targets listing with text" { + const testing = std.testing; + + var buf: [512]u8 = undefined; + var writer: std.Io.Writer = .fixed(&buf); + try (ReadSuccess{ .list = true, .available = &.{"text/plain"} }).encode(&writer); + // "." => "Lg==", "text/plain\n" => "dGV4dC9wbGFpbgo=" + try testing.expectEqualStrings( + "\x1b]5522;type=read:status=OK\x1b\\" ++ + "\x1b]5522;type=read:status=DATA:mime=Lg==;dGV4dC9wbGFpbgo=\x1b\\" ++ + "\x1b]5522;type=read:status=DONE\x1b\\", + writer.buffered(), + ); +} + +test "read success: targets listing joins multiple types" { + const testing = std.testing; + + var buf: [512]u8 = undefined; + var writer: std.Io.Writer = .fixed(&buf); + try (ReadSuccess{ + .list = true, + .available = &.{ "text/plain", "image/png" }, + }).encode(&writer); + // Payload is base64 of "text/plain image/png\n". + try testing.expectEqualStrings( + "\x1b]5522;type=read:status=OK\x1b\\" ++ + "\x1b]5522;type=read:status=DATA:mime=Lg==;dGV4dC9wbGFpbiBpbWFnZS9wbmcK\x1b\\" ++ + "\x1b]5522;type=read:status=DONE\x1b\\", + writer.buffered(), + ); +} + +test "read success: empty targets listing packet still sent" { + const testing = std.testing; + + var buf: [512]u8 = undefined; + var writer: std.Io.Writer = .fixed(&buf); + try (ReadSuccess{ .list = true }).encode(&writer); + try testing.expectEqualStrings( + "\x1b]5522;type=read:status=OK\x1b\\" ++ + "\x1b]5522;type=read:status=DATA:mime=Lg==\x1b\\" ++ + "\x1b]5522;type=read:status=DONE\x1b\\", + writer.buffered(), + ); +} + +test "read success: data chunks under requested mime" { + const testing = std.testing; + + var buf: [512]u8 = undefined; + var writer: std.Io.Writer = .fixed(&buf); + try (ReadSuccess{ + .contents = &.{.{ .mime = "text/plain", .data = "Ghostty" }}, + }).encode(&writer); + try testing.expectEqualStrings( + "\x1b]5522;type=read:status=OK\x1b\\" ++ + "\x1b]5522;type=read:status=DATA:mime=dGV4dC9wbGFpbg==;R2hvc3R0eQ==\x1b\\" ++ + "\x1b]5522;type=read:status=DONE\x1b\\", + writer.buffered(), + ); +} + +test "read success: each representation carries its own data" { + const testing = std.testing; + + var buf: [512]u8 = undefined; + var writer: std.Io.Writer = .fixed(&buf); + try (ReadSuccess{ + .contents = &.{ + .{ .mime = "text/plain", .data = "hello" }, + .{ .mime = "image/png", .data = "\x89\x50\x4e\x47\x0d\x0a\x1a\x0a" }, + }, + }).encode(&writer); + try testing.expectEqualStrings( + "\x1b]5522;type=read:status=OK\x1b\\" ++ + "\x1b]5522;type=read:status=DATA:mime=dGV4dC9wbGFpbg==;aGVsbG8=\x1b\\" ++ + "\x1b]5522;type=read:status=DATA:mime=aW1hZ2UvcG5n;iVBORw0KGgo=\x1b\\" ++ + "\x1b]5522;type=read:status=DONE\x1b\\", + writer.buffered(), + ); +} + +test "read success: chunking at read_chunk_size" { + const testing = std.testing; + const alloc = testing.allocator; + + const data = "z" ** (read_chunk_size + 1); + var aw: std.Io.Writer.Allocating = .init(alloc); + defer aw.deinit(); + try (ReadSuccess{ + .contents = &.{.{ .mime = "text/plain", .data = data }}, + }).encode(&aw.writer); + + // OK + 2 DATA packets + DONE = 4 packets. + const count = std.mem.count(u8, aw.written(), "\x1b]5522;"); + try testing.expectEqual(@as(usize, 4), count); + + // The first chunk is exactly read_chunk_size bytes, base64 encoded + // with padding. + const Encoder = std.base64.standard.Encoder; + var chunk_buf: [Encoder.calcSize(read_chunk_size)]u8 = undefined; + const first = Encoder.encode(&chunk_buf, data[0..read_chunk_size]); + try testing.expect(std.mem.indexOf(u8, aw.written(), first) != null); +} + +test "read success: no data packets for empty clipboard" { + const testing = std.testing; + + var buf: [512]u8 = undefined; + var writer: std.Io.Writer = .fixed(&buf); + try (ReadSuccess{ + .contents = &.{.{ .mime = "text/plain", .data = "" }}, + }).encode(&writer); + try testing.expectEqualStrings( + "\x1b]5522;type=read:status=OK\x1b\\" ++ + "\x1b]5522;type=read:status=DONE\x1b\\", + writer.buffered(), + ); +} + +test "read success: paste event carries pw in every packet" { + const testing = std.testing; + + var buf: [512]u8 = undefined; + var writer: std.Io.Writer = .fixed(&buf); + try (ReadSuccess{ + .list = true, + .pw = "otp", + .available = &.{"text/plain"}, + }).encode(&writer); + try testing.expectEqualStrings( + "\x1b]5522;type=read:status=OK:pw=b3Rw\x1b\\" ++ + "\x1b]5522;type=read:status=DATA:mime=Lg==:pw=b3Rw;dGV4dC9wbGFpbgo=\x1b\\" ++ + "\x1b]5522;type=read:status=DONE:pw=b3Rw\x1b\\", + writer.buffered(), + ); +} + +test "paste event: pw in every packet, listing of every type" { + const testing = std.testing; + + var buf: [512]u8 = undefined; + var writer: std.Io.Writer = .fixed(&buf); + try (PasteEvent{ + .pw = "otp", + .available = &.{ "text/plain", "image/png" }, + }).encode(&writer); + // Payload is base64 of "text/plain image/png\n". + try testing.expectEqualStrings( + "\x1b]5522;type=read:status=OK:pw=b3Rw\x1b\\" ++ + "\x1b]5522;type=read:status=DATA:mime=Lg==:pw=b3Rw;dGV4dC9wbGFpbiBpbWFnZS9wbmcK\x1b\\" ++ + "\x1b]5522;type=read:status=DONE:pw=b3Rw\x1b\\", + writer.buffered(), + ); +} + +test "paste event: primary is reported only on the OK packet" { + const testing = std.testing; + + var buf: [512]u8 = undefined; + var writer: std.Io.Writer = .fixed(&buf); + try (PasteEvent{ + .primary = true, + .pw = "otp", + .available = &.{"text/plain"}, + .terminator = .bel, + }).encode(&writer); + try testing.expectEqualStrings( + "\x1b]5522;type=read:status=OK:loc=primary:pw=b3Rw\x07" ++ + "\x1b]5522;type=read:status=DATA:mime=Lg==:pw=b3Rw;dGV4dC9wbGFpbgo=\x07" ++ + "\x1b]5522;type=read:status=DONE:pw=b3Rw\x07", + writer.buffered(), + ); +} + +test "paste event: empty listing packet is still sent" { + const testing = std.testing; + + var buf: [512]u8 = undefined; + var writer: std.Io.Writer = .fixed(&buf); + try (PasteEvent{ .pw = "otp", .available = &.{} }).encode(&writer); + try testing.expectEqualStrings( + "\x1b]5522;type=read:status=OK:pw=b3Rw\x1b\\" ++ + "\x1b]5522;type=read:status=DATA:mime=Lg==:pw=b3Rw\x1b\\" ++ + "\x1b]5522;type=read:status=DONE:pw=b3Rw\x1b\\", + writer.buffered(), + ); +} diff --git a/vendor/libghostty-vt/src/terminal/kitty/clipboard_write.zig b/vendor/libghostty-vt/src/terminal/kitty/clipboard_write.zig new file mode 100644 index 0000000..5951dcb --- /dev/null +++ b/vendor/libghostty-vt/src/terminal/kitty/clipboard_write.zig @@ -0,0 +1,698 @@ +//! Kitty clipboard protocol (OSC 5522) write transactions: the +//! stateful accumulation of wdata chunks and walias aliases until the +//! commit packet arrives. See clipboard.zig for the protocol overview. + +const std = @import("std"); +const assert = @import("../../quirks.zig").inlineAssert; +const Allocator = std.mem.Allocator; +const simd = @import("../../simd/main.zig"); +const clipboard = @import("../clipboard.zig"); +const clipboard_command = @import("clipboard_command.zig"); + +const Metadata = clipboard_command.Metadata; +const Payload = clipboard_command.Payload; +const max_mime_len = clipboard_command.max_mime_len; + +const log = std.log.scoped(.kitty_clipboard); + +/// Default maximum total decoded bytes accumulated by one write +/// transaction. Embedders can override this per-transaction via +/// WriteState.Options (Ghostty exposes it as the +/// `clipboard-write-limit-bytes` configuration). +pub const max_write_size = 64 * 1024 * 1024; + +/// Maximum MIME types and aliases per write transaction. +pub const max_write_mimes = 64; +pub const max_write_aliases = 64; + +/// One MIME representation of committed clipboard data. This is the +/// same type the clipboard write effect consumes so committed contents +/// can be passed through directly. +pub const Content = clipboard.Content; + +/// The state of one in-flight write transaction: a single `type=write` +/// plus all the `wdata` chunks and `walias` aliases until completion +/// or error. +pub const WriteState = struct { + arena: std.heap.ArenaAllocator, + loc: clipboard.Location, + id: []const u8, + pw: []const u8, + name: []const u8, + spool: std.ArrayListUnmanaged(u8) = .empty, + entries: std.ArrayListUnmanaged(Entry) = .empty, + aliases: std.ArrayListUnmanaged(Alias) = .empty, + + /// Maximum total decoded bytes this transaction will accumulate. + /// Captured at init so a change to the configured limit doesn't + /// apply to a transaction already in flight. + max_size: usize, + + /// Index into entries currently receiving data. + current: ?usize = null, + + /// Decodes the concatenated payload stream of the entry currently + /// receiving data. Per the spec's "Encoding of payloads" section, + /// individual wdata packet payloads split one base64 stream at + /// arbitrary boundaries; only the concatenation per MIME type must + /// be valid, correctly padded base64. + decoder: simd.base64.Streaming = .{}, + + pub const Options = struct { + /// Maximum total decoded bytes accumulated by the transaction. + max_size: usize = max_write_size, + }; + + const Entry = struct { + /// Owned by the transaction arena. + mime: []const u8, + start: usize = 0, + len: usize = 0, + }; + + const Alias = struct { + /// Owned by the transaction arena. + alias: []const u8, + target: []const u8, + }; + + /// Begin a transaction from a type=write packet. + pub fn init( + alloc: Allocator, + meta: *const Metadata, + opts: Options, + ) Allocator.Error!WriteState { + assert(meta.op == .write); + var arena: std.heap.ArenaAllocator = .init(alloc); + errdefer arena.deinit(); + const id = try arena.allocator().dupe(u8, meta.id); + const pw = try arena.allocator().dupe(u8, meta.pw); + const name = try arena.allocator().dupe(u8, meta.name); + return .{ + .arena = arena, + .loc = meta.loc, + .id = id, + .pw = pw, + .name = name, + .max_size = opts.max_size, + }; + } + + pub fn deinit(self: *WriteState, alloc: Allocator) void { + self.spool.deinit(alloc); + self.entries.deinit(alloc); + self.aliases.deinit(alloc); + self.arena.deinit(); + } + + /// Accumulate one wdata chunk carrying data for meta.mime (which + /// must be non-empty; an empty mime is a commit, not data). + /// + /// Returns error.TooLarge when the transaction exceeds max_size + /// and error.Invalid when the payload stream is not valid base64. + /// The caller must fail the whole transaction with EFBIG or EINVAL + /// respectively and abort it, as required by the protocol. + pub fn data( + self: *WriteState, + alloc: Allocator, + meta: *const Metadata, + payload: []const u8, + ) error{ OutOfMemory, TooLarge, Invalid }!void { + assert(meta.op == .wdata); + assert(meta.mime.len > 0); + // Switch the receiving entry if this chunk is for a different + // MIME type than the last one. + entry: { + if (self.current) |idx| { + const entry = &self.entries.items[idx]; + if (std.mem.eql(u8, entry.mime, meta.mime)) { + break :entry; + } + + // Finalize the previous region. Its concatenated + // stream must have ended on a complete base64 group, + // otherwise the data was not correctly padded. + try self.finishCurrent(); + entry.len = self.spool.items.len - entry.start; + } + + // Re-using an earlier MIME type starts a fresh region, + // overwriting the previous mapping. + for (self.entries.items, 0..) |*entry, idx| { + if (std.mem.eql(u8, entry.mime, meta.mime)) { + entry.start = self.spool.items.len; + entry.len = 0; + self.current = idx; + break :entry; + } + } + + if (self.entries.items.len >= max_write_mimes) { + log.warn( + "clipboard write has too many MIME types, ignoring mime={s}", + .{meta.mime}, + ); + self.current = null; + return; + } + + try self.entries.append(alloc, .{ + .mime = try self.arena.allocator().dupe(u8, meta.mime), + .start = self.spool.items.len, + }); + self.current = self.entries.items.len - 1; + } + + // The payloads for one MIME region concatenate into a single + // strict base64 stream, decoded directly into the spool's + // unused capacity. Invalid data aborts the transaction; per + // the spec it must not be silently discarded "since that + // turns corrupted data into apparently valid data". + try self.spool.ensureUnusedCapacity(alloc, self.decoder.maxLen(payload)); + const decoded = self.decoder.feed( + payload, + self.spool.unusedCapacitySlice(), + ) catch return error.Invalid; + + // The limit covers all decoded data in the transaction. Going + // over it aborts the entire write; partial clipboard contents + // must never reach the embedder. + const remaining = self.max_size -| self.spool.items.len; + if (decoded.len > remaining) return error.TooLarge; + self.spool.items.len += decoded.len; + } + + /// Finish the decode stream of the entry currently receiving + /// data. Returns error.Invalid when the stream ends mid-group, + /// i.e. the concatenated payload was not correctly padded. + fn finishCurrent(self: *WriteState) error{Invalid}!void { + self.decoder.finish() catch return error.Invalid; + } + + /// Register aliases from a walias packet: meta.mime is the target + /// (the type that carries data) and the payload is a base64-encoded, + /// whitespace-separated list of aliases. Returns error.Invalid for + /// an undecodable payload, which aborts the transaction with EINVAL. + pub fn alias( + self: *WriteState, + alloc: Allocator, + meta: *const Metadata, + payload: []const u8, + ) error{ OutOfMemory, Invalid }!void { + assert(meta.op == .walias); + assert(meta.mime.len > 0); + const decoded = try Payload.init(alloc, payload); + defer decoded.deinit(alloc); + if (!decoded.isValidUtf8()) return error.Invalid; + var it = decoded.mimeIterator(); + + // Copy the target only if at least one valid alias exists. + const target: []const u8 = target: { + while (it.next()) |name| { + if (name.len > max_mime_len) continue; + break :target try self.arena.allocator().dupe(u8, meta.mime); + } + + // If we didn't find a target then ignore it. + return; + }; + + // Rewind so the alias that satisfied the check above is + // associated too. + it.reset(); + + // Associate the aliases + while (it.next()) |name| { + if (name.len > max_mime_len) continue; + + // A repeated alias overwrites its previous target. + for (self.aliases.items) |*a| { + if (std.mem.eql(u8, a.alias, name)) { + a.target = target; + break; + } + } else { + if (self.aliases.items.len >= max_write_aliases) { + log.warn("clipboard write has too many aliases, ignoring", .{}); + return; + } + + try self.aliases.append(alloc, .{ + .alias = try self.arena.allocator().dupe(u8, name), + .target = target, + }); + } + } + } + + /// The result of a committed transaction. All slices borrow the + /// WriteState's memory and are valid until it is deinited. + pub const Committed = struct { + loc: clipboard.Location, + id: []const u8, + pw: []const u8, + name: []const u8, + contents: []const Content, + + pub fn deinit(self: *const Committed, alloc: Allocator) void { + alloc.free(self.contents); + } + }; + + /// Commit the transaction (a wdata packet without a MIME type). + /// The caller must use the result, call Committed.deinit, and then + /// deinit this state. Returns error.Invalid when the last region's + /// concatenated payload was not correctly padded; the caller must + /// fail the transaction with EINVAL and abort it. + pub fn commit( + self: *WriteState, + alloc: Allocator, + ) error{ OutOfMemory, Invalid }!Committed { + // Finalize the region receiving data. + if (self.current) |idx| { + try self.finishCurrent(); + const entry = &self.entries.items[idx]; + entry.len = self.spool.items.len - entry.start; + self.current = null; + } + + // Resolve the final MIME map: entries in arrival order, then + // aliases applied sequentially against the evolving map so + // chained aliases work like kitty's dict iteration. An alias + // whose target has no mapping is dropped; an alias colliding + // with an existing name overwrites it. + var contents: std.ArrayListUnmanaged(Content) = .empty; + defer contents.deinit(alloc); + try contents.ensureTotalCapacity( + alloc, + self.entries.items.len + self.aliases.items.len, + ); + + for (self.entries.items) |*entry| { + contents.appendAssumeCapacity(.{ + .mime = entry.mime, + .data = self.spool.items[entry.start..][0..entry.len], + }); + } + + for (self.aliases.items) |*a| { + const target: Content = target: { + for (contents.items) |c| { + if (std.mem.eql(u8, c.mime, a.target)) { + break :target c; + } + } + continue; + }; + + for (contents.items) |*c| { + if (std.mem.eql(u8, c.mime, a.alias)) { + c.data = target.data; + break; + } + } else { + contents.appendAssumeCapacity(.{ + .mime = a.alias, + .data = target.data, + }); + } + } + + return .{ + .loc = self.loc, + .id = self.id, + .pw = self.pw, + .name = self.name, + .contents = try contents.toOwnedSlice(alloc), + }; + } +}; + +test "write: basic transaction" { + const testing = std.testing; + const alloc = testing.allocator; + + const begin_meta: Metadata = .{ .op = .write, .id = "42" }; + var state: WriteState = try .init(alloc, &begin_meta, .{}); + defer state.deinit(alloc); + + try state.data(alloc, &.{ .op = .wdata, .mime = "text/plain" }, "R2hvc3R0eQ=="); // "Ghostty" + + const committed = try state.commit(alloc); + defer committed.deinit(alloc); + try testing.expectEqualStrings("42", committed.id); + try testing.expect(committed.loc == .standard); + try testing.expectEqual(@as(usize, 1), committed.contents.len); + try testing.expectEqualStrings("text/plain", committed.contents[0].mime); + try testing.expectEqualStrings("Ghostty", committed.contents[0].data); +} + +test "write: chunked data accumulates" { + const testing = std.testing; + const alloc = testing.allocator; + + const begin_meta: Metadata = .{ .op = .write }; + var state: WriteState = try .init(alloc, &begin_meta, .{}); + defer state.deinit(alloc); + + try state.data(alloc, &.{ .op = .wdata, .mime = "text/plain" }, "SGVsbG8="); // "Hello" + try state.data(alloc, &.{ .op = .wdata, .mime = "text/plain" }, "V29ybGQ="); // "World" + + const committed = try state.commit(alloc); + defer committed.deinit(alloc); + try testing.expectEqualStrings("HelloWorld", committed.contents[0].data); +} + +test "write: multiple mimes in order" { + const testing = std.testing; + const alloc = testing.allocator; + + const begin_meta: Metadata = .{ .op = .write }; + var state: WriteState = try .init(alloc, &begin_meta, .{}); + defer state.deinit(alloc); + + try state.data(alloc, &.{ .op = .wdata, .mime = "text/plain" }, "YQ=="); // "a" + try state.data(alloc, &.{ .op = .wdata, .mime = "text/html" }, "Yg=="); // "b" + + const committed = try state.commit(alloc); + defer committed.deinit(alloc); + try testing.expectEqual(@as(usize, 2), committed.contents.len); + try testing.expectEqualStrings("text/plain", committed.contents[0].mime); + try testing.expectEqualStrings("a", committed.contents[0].data); + try testing.expectEqualStrings("text/html", committed.contents[1].mime); + try testing.expectEqualStrings("b", committed.contents[1].data); +} + +test "write: reused mime overwrites" { + const testing = std.testing; + const alloc = testing.allocator; + + const begin_meta: Metadata = .{ .op = .write }; + var state: WriteState = try .init(alloc, &begin_meta, .{}); + defer state.deinit(alloc); + + try state.data(alloc, &.{ .op = .wdata, .mime = "text/plain" }, "YQ=="); // "a" + try state.data(alloc, &.{ .op = .wdata, .mime = "text/html" }, "Yg=="); // "b" + try state.data(alloc, &.{ .op = .wdata, .mime = "text/plain" }, "Yw=="); // "c" + + const committed = try state.commit(alloc); + defer committed.deinit(alloc); + try testing.expectEqual(@as(usize, 2), committed.contents.len); + // Position preserved, data replaced. + try testing.expectEqualStrings("text/plain", committed.contents[0].mime); + try testing.expectEqualStrings("c", committed.contents[0].data); +} + +test "write: invalid base64 chunk aborts the transaction" { + const testing = std.testing; + const alloc = testing.allocator; + + // Invalid characters anywhere in the stream are error.Invalid, + // which the handlers turn into an EINVAL abort. These mirror + // kitty's own tests for the spec change. + const invalid_payloads = [_][]const u8{ + "!!!", + "SGVs!!!bG8=", + "\nZGF0YSB3aXRoIGEgbmV3bGluZQ==", + }; + for (invalid_payloads) |payload| { + const begin_meta: Metadata = .{ .op = .write }; + var state: WriteState = try .init(alloc, &begin_meta, .{}); + defer state.deinit(alloc); + try testing.expectError(error.Invalid, state.data( + alloc, + &.{ .op = .wdata, .mime = "text/plain" }, + payload, + )); + } + + // Also after an earlier valid chunk for the same MIME type. + { + const begin_meta: Metadata = .{ .op = .write }; + var state: WriteState = try .init(alloc, &begin_meta, .{}); + defer state.deinit(alloc); + try state.data(alloc, &.{ .op = .wdata, .mime = "text/plain" }, "Z29vZA=="); // "good" + try testing.expectError(error.Invalid, state.data( + alloc, + &.{ .op = .wdata, .mime = "text/plain" }, + "SGVs!!!bG8=", + )); + } +} + +test "write: chunks split one base64 stream at arbitrary boundaries" { + const testing = std.testing; + const alloc = testing.allocator; + + // The concatenation of all payloads per MIME type is the base64 + // stream; individual packets need not be a multiple of four bytes. + const encoded = "c29tZSBkYXRh"; // "some data" + const splits = [_][]const usize{ + &.{ 3, 7 }, + &.{ 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11 }, + }; + for (splits) |split| { + const begin_meta: Metadata = .{ .op = .write }; + var state: WriteState = try .init(alloc, &begin_meta, .{}); + defer state.deinit(alloc); + + var prev: usize = 0; + for (split) |end| { + try state.data( + alloc, + &.{ .op = .wdata, .mime = "text/plain" }, + encoded[prev..end], + ); + prev = end; + } + try state.data(alloc, &.{ .op = .wdata, .mime = "text/plain" }, encoded[prev..]); + + const committed = try state.commit(alloc); + defer committed.deinit(alloc); + try testing.expectEqualStrings("some data", committed.contents[0].data); + } +} + +test "write: incorrectly padded stream aborts at commit" { + const testing = std.testing; + const alloc = testing.allocator; + + const begin_meta: Metadata = .{ .op = .write }; + var state: WriteState = try .init(alloc, &begin_meta, .{}); + defer state.deinit(alloc); + + // "Hello" without its final padding byte: every chunk decodes, + // but the stream ends mid-group so the commit reports it. + try state.data(alloc, &.{ .op = .wdata, .mime = "text/plain" }, "SGVsbG8"); + try testing.expectError(error.Invalid, state.commit(alloc)); +} + +test "write: incorrectly padded stream aborts at MIME switch" { + const testing = std.testing; + const alloc = testing.allocator; + + const begin_meta: Metadata = .{ .op = .write }; + var state: WriteState = try .init(alloc, &begin_meta, .{}); + defer state.deinit(alloc); + + try state.data(alloc, &.{ .op = .wdata, .mime = "text/plain" }, "SGVsbG8"); + try testing.expectError(error.Invalid, state.data( + alloc, + &.{ .op = .wdata, .mime = "text/html" }, + "PGI+aGk8L2I+", + )); +} + +test "write: independently padded chunks accumulate" { + const testing = std.testing; + const alloc = testing.allocator; + + // A packet payload ending exactly at terminal padding resets the + // stream, so clients that base64-encode every chunk independently + // keep working (matching kitty, which resets its streaming + // decoder on EOF). Padding followed by more data within a single + // packet stays invalid. + const begin_meta: Metadata = .{ .op = .write }; + var state: WriteState = try .init(alloc, &begin_meta, .{}); + defer state.deinit(alloc); + + try state.data(alloc, &.{ .op = .wdata, .mime = "text/plain" }, "Z29vZA=="); // "good" + try state.data(alloc, &.{ .op = .wdata, .mime = "text/plain" }, "bW9yZQ=="); // "more" + + const committed = try state.commit(alloc); + defer committed.deinit(alloc); + try testing.expectEqualStrings("goodmore", committed.contents[0].data); +} + +test "write: data after padding within one chunk aborts" { + const testing = std.testing; + const alloc = testing.allocator; + + const begin_meta: Metadata = .{ .op = .write }; + var state: WriteState = try .init(alloc, &begin_meta, .{}); + defer state.deinit(alloc); + + try testing.expectError(error.Invalid, state.data( + alloc, + &.{ .op = .wdata, .mime = "text/plain" }, + "Z29vZA==bW9yZQ==", + )); +} + +test "write: aliases resolve at commit" { + const testing = std.testing; + const alloc = testing.allocator; + + const begin_meta: Metadata = .{ .op = .write }; + var state: WriteState = try .init(alloc, &begin_meta, .{}); + defer state.deinit(alloc); + + try state.data(alloc, &.{ .op = .wdata, .mime = "text/plain" }, "R2hvc3R0eQ=="); // "Ghostty" + + // Alias "TEXT UTF8_STRING" -> text/plain. + const alias_meta: Metadata = .{ .op = .walias, .mime = "text/plain" }; + try state.alias(alloc, &alias_meta, "VEVYVCBVVEY4X1NUUklORw=="); + + const committed = try state.commit(alloc); + defer committed.deinit(alloc); + try testing.expectEqual(@as(usize, 3), committed.contents.len); + try testing.expectEqualStrings("TEXT", committed.contents[1].mime); + try testing.expectEqualStrings("Ghostty", committed.contents[1].data); + try testing.expectEqualStrings("UTF8_STRING", committed.contents[2].mime); + try testing.expectEqualStrings("Ghostty", committed.contents[2].data); +} + +test "write: alias payload must be valid utf8" { + const testing = std.testing; + const alloc = testing.allocator; + + const begin_meta: Metadata = .{ .op = .write }; + var state: WriteState = try .init(alloc, &begin_meta, .{}); + defer state.deinit(alloc); + + const alias_meta: Metadata = .{ .op = .walias, .mime = "text/plain" }; + // Valid base64 encoding of a single 0xff byte. + try testing.expectError( + error.Invalid, + state.alias(alloc, &alias_meta, "/w=="), + ); +} + +test "write: default limit when unset" { + const testing = std.testing; + const alloc = testing.allocator; + + const begin_meta: Metadata = .{ .op = .write }; + var state: WriteState = try .init(alloc, &begin_meta, .{}); + defer state.deinit(alloc); + try testing.expectEqual(@as(usize, max_write_size), state.max_size); +} + +test "write: text over limit rejects transaction" { + const testing = std.testing; + const alloc = testing.allocator; + + const begin_meta: Metadata = .{ .op = .write }; + var state: WriteState = try .init(alloc, &begin_meta, .{ .max_size = 8 }); + defer state.deinit(alloc); + + try testing.expectError(error.TooLarge, state.data( + alloc, + &.{ .op = .wdata, .mime = "text/plain" }, + "SGVsbG9Xb3JsZA==", // "HelloWorld" + )); +} + +test "write: text crossing limit across chunks rejects transaction" { + const testing = std.testing; + const alloc = testing.allocator; + + const begin_meta: Metadata = .{ .op = .write }; + var state: WriteState = try .init(alloc, &begin_meta, .{ .max_size = 8 }); + defer state.deinit(alloc); + + try state.data(alloc, &.{ .op = .wdata, .mime = "text/plain" }, "SGVsbG8="); // "Hello" + try testing.expectError(error.TooLarge, state.data( + alloc, + &.{ .op = .wdata, .mime = "text/plain" }, + "V29ybGQ=", // "World" + )); +} + +test "write: data exactly at limit is accepted" { + const testing = std.testing; + const alloc = testing.allocator; + + const begin_meta: Metadata = .{ .op = .write }; + var state: WriteState = try .init(alloc, &begin_meta, .{ .max_size = 5 }); + defer state.deinit(alloc); + + try state.data(alloc, &.{ .op = .wdata, .mime = "text/plain" }, "SGVsbG8="); // "Hello" + + const committed = try state.commit(alloc); + defer committed.deinit(alloc); + try testing.expectEqualStrings("Hello", committed.contents[0].data); +} + +test "write: non-text under limit is unaffected" { + const testing = std.testing; + const alloc = testing.allocator; + + const begin_meta: Metadata = .{ .op = .write }; + var state: WriteState = try .init(alloc, &begin_meta, .{ .max_size = 8 }); + defer state.deinit(alloc); + + try state.data(alloc, &.{ .op = .wdata, .mime = "image/png" }, "iVBORw=="); // "\x89PNG" + + const committed = try state.commit(alloc); + defer committed.deinit(alloc); + try testing.expectEqualStrings("\x89PNG", committed.contents[0].data); +} + +test "write: non-text over limit rejects transaction" { + const testing = std.testing; + const alloc = testing.allocator; + + const begin_meta: Metadata = .{ .op = .write }; + var state: WriteState = try .init(alloc, &begin_meta, .{ .max_size = 8 }); + defer state.deinit(alloc); + + try testing.expectError(error.TooLarge, state.data( + alloc, + &.{ .op = .wdata, .mime = "image/png" }, + "SGVsbG9Xb3JsZA==", // "HelloWorld" + )); +} + +test "write: total data across MIME types is limited" { + const testing = std.testing; + const alloc = testing.allocator; + + const begin_meta: Metadata = .{ .op = .write }; + var state: WriteState = try .init(alloc, &begin_meta, .{ .max_size = 8 }); + defer state.deinit(alloc); + + try state.data(alloc, &.{ .op = .wdata, .mime = "text/plain" }, "SGVsbG8="); // "Hello" + + try testing.expectError(error.TooLarge, state.data( + alloc, + &.{ .op = .wdata, .mime = "image/png" }, + "V29ybGQ=", // "World" + )); +} + +test "write: alias without data target is dropped" { + const testing = std.testing; + const alloc = testing.allocator; + + const begin_meta: Metadata = .{ .op = .write }; + var state: WriteState = try .init(alloc, &begin_meta, .{}); + defer state.deinit(alloc); + + const alias_meta: Metadata = .{ .op = .walias, .mime = "text/plain" }; + try state.alias(alloc, &alias_meta, "VEVYVA=="); // "TEXT" + + const committed = try state.commit(alloc); + defer committed.deinit(alloc); + try testing.expectEqual(@as(usize, 0), committed.contents.len); +} diff --git a/vendor/libghostty-vt/src/terminal/kitty/dnd.zig b/vendor/libghostty-vt/src/terminal/kitty/dnd.zig new file mode 100644 index 0000000..3142a41 --- /dev/null +++ b/vendor/libghostty-vt/src/terminal/kitty/dnd.zig @@ -0,0 +1,69 @@ +//! Kitty drag and drop protocol (OSC 72). +//! +//! Specification: https://sw.kovidgoyal.net/kitty/dnd-protocol/ +//! +//! The protocol lets a program running in the terminal participate in +//! native OS drag and drop. A client registers to accept drops (t=a); +//! the terminal then forwards native drag movement (t=m) and drops +//! (t=M) to it and serves the dropped data on request (t=r), instead +//! of the traditional behavior of pasting dropped paths or text. +//! +//! ## Divergences +//! +//! These will be fixed in the future: +//! +//! * Dropped data is captured eagerly at drop time from a curated +//! set of representations the embedder can serve (typically +//! text/uri-list and text/plain), rather than fetched from the OS +//! on demand. Consequently the native drag session concludes at +//! drop time and the client's concluding operation (t=r with +//! x=y=Y=0) only frees the held data, and kitty's 128-entry +//! request queue and EMFILE overflow handling are unnecessary +//! because requests are served synchronously in order. +//! * Every client is treated as local: machine IDs (t=a:x=1) are +//! accepted and ignored, responses never carry the X=1 remote +//! marker, and remote file transfer requests (t=r with y or Y +//! keys) are answered with EINVAL. A remote client (e.g. over +//! ssh) can still receive text drops; only file-content transfer +//! is unavailable. +//! * The terminal never initiates drags (drag out): enabling and +//! disabling offers (t=o:x=1, t=o:x=2) are accepted and ignored, +//! and since the terminal never sends a drag start request a +//! conforming client never offers a drag. Direct offers (t=o:x=0) +//! and drag data/start commands (t=p, t=P) are refused with EPERM. +//! +//! These are on purpose forever: +//! +//! * Responses echo the requesting command's terminator (ST or BEL) +//! per ghostty convention; kitty always uses ST. Terminal- +//! initiated events always use ST. + +const dnd_command = @import("dnd_command.zig"); +const dnd_response = @import("dnd_response.zig"); +const dnd_drop = @import("dnd_drop.zig"); + +pub const EventType = dnd_command.EventType; +pub const Metadata = dnd_command.Metadata; +pub const Operation = dnd_command.Operation; +pub const Operations = dnd_command.Operations; +pub const Request = dnd_command.Request; +pub const Chunking = dnd_command.Chunking; + +pub const Errno = dnd_response.Errno; +pub const RequestKeys = dnd_response.RequestKeys; +pub const encode = dnd_response.encode; +pub const encodeError = dnd_response.encodeError; + +pub const State = dnd_drop.State; +pub const Item = dnd_drop.State.Item; +pub const MoveEvent = dnd_drop.State.MoveEvent; +pub const max_mime_list_bytes = dnd_drop.max_mime_list_bytes; +pub const handleCommand = dnd_drop.handleCommand; +pub const Event = dnd_drop.Event; + +test { + _ = dnd_command; + _ = dnd_response; + _ = dnd_drop; + _ = @import("dnd_test.zig"); +} diff --git a/vendor/libghostty-vt/src/terminal/kitty/dnd_command.zig b/vendor/libghostty-vt/src/terminal/kitty/dnd_command.zig new file mode 100644 index 0000000..b5f63d0 --- /dev/null +++ b/vendor/libghostty-vt/src/terminal/kitty/dnd_command.zig @@ -0,0 +1,436 @@ +const std = @import("std"); + +/// Decoded OSC 72 metadata. +pub const Metadata = struct { + /// Event type (`t`). Null when the metadata had no `t` key; such + /// commands parse successfully but are ignored, matching kitty. + type: ?EventType = null, + + /// Chunking flag (`m`): true when more chunks follow. + more: bool = false, + + /// Multiplexer client ID (`i`), echoed in every response so a + /// terminal multiplexer can route responses to the correct client. + client_id: u32 = 0, + + /// Operation (`o`): meaning depends on the event type, commonly + /// 0=none/reject, 1=copy, 2=move, 3=copy or move. + operation: u32 = 0, + + /// `x`, `y`, `X`, `Y` keys. + cell_x: i32 = 0, + cell_y: i32 = 0, + pixel_x: i32 = 0, + pixel_y: i32 = 0, + + /// Parse raw OSC 72 metadata (the part before the first `;`). + /// Returns null when malformed; callers should ignore the command, + /// matching kitty which rejects the entire command on any error. + pub fn parse(raw: []const u8) ?Metadata { + var result: Metadata = .{}; + var pos: usize = 0; + // The continue expression consumes the ':' separating a field + // from the next; the body advances past the field itself. + while (pos < raw.len) : (pos += 1) { + // Single-character key. + const key = raw[pos]; + pos += 1; + switch (key) { + 't', 'm', 'i', 'o', 'x', 'y', 'X', 'Y' => {}, + else => return null, + } + + // '=' separator. + if (pos >= raw.len) return null; + if (raw[pos] != '=') return null; + pos += 1; + + // Value. + if (pos >= raw.len) return null; + switch (key) { + 't' => { + result.type = std.enums.fromInt( + EventType, + raw[pos], + ) orelse return null; + pos += 1; + }, + + 'm', 'i', 'o' => { + const v = parseUnsigned(raw, &pos) orelse return null; + switch (key) { + 'm' => result.more = v != 0, + 'i' => result.client_id = v, + 'o' => result.operation = v, + else => unreachable, + } + }, + + 'x', 'y', 'X', 'Y' => { + const negative = raw[pos] == '-'; + if (negative) pos += 1; + const unsigned = parseUnsigned(raw, &pos) orelse return null; + // Matches kitty's cast of the u32 magnitude to i32, + // which wraps rather than erroring on overflow. + const magnitude: i32 = @bitCast(unsigned); + const v = if (negative) 0 -% magnitude else magnitude; + switch (key) { + 'x' => result.cell_x = v, + 'y' => result.cell_y = v, + 'X' => result.pixel_x = v, + 'Y' => result.pixel_y = v, + else => unreachable, + } + }, + + else => unreachable, + } + + // Values are separated by ':'. + if (pos >= raw.len) break; + if (raw[pos] != ':') return null; + } + + return result; + } + + /// Parse an unsigned decimal value at `pos`, advancing it. At most + /// 10 digits and at most maxInt(u32), matching kitty. Returns null + /// when there are no digits or the value is too large. + fn parseUnsigned(raw: []const u8, pos: *usize) ?u32 { + const start = pos.*; + var acc: u64 = 0; + var i = start; + while (i < raw.len and i < start + 10) : (i += 1) { + const d = raw[i] -% '0'; + if (d > 9) break; + acc = acc * 10 + d; + } + if (i == start) return null; + pos.* = i; + return std.math.cast(u32, acc) orelse null; + } +}; + +/// The event type, i.e. values for the `t` metadata key. A single OSC 72 +/// code is used for both directions of the protocol, so most types have +/// one meaning when received by the terminal from a client and another +/// when sent by the terminal to a client. +/// +/// The `drop` and `request_response` types are only ever sent by the +/// terminal; kitty parses but ignores them when received and we do the +/// same. +pub const EventType = enum(u8) { + /// 'a': (recv) client registers to accept drops. With x=1 the payload + /// is the client's machine ID for remote drop support instead. + register = 'a', + + /// 'A': (recv) client unregisters from accepting drops. + unregister = 'A', + + /// 'm': (recv) client reports acceptance status for the drag currently + /// over the terminal: `o` is the chosen operation and the payload is + /// the accepted MIME list. (send) pointer moved over the terminal + /// during a drag, or with x=-1,y=-1 the drag left the window. + status = 'm', + + /// 'M': (send only) items were dropped onto the terminal. + drop = 'M', + + /// 'r': (recv) client requests drop data, or with x=y=Y=0 concludes + /// the drop with `o` as the performed operation. (send) drop data + /// response chunks. + request = 'r', + + /// 'R': (send only) error response to a data request. + request_error = 'R', + + /// 'o': (recv) drag source control: x=1 enables offering drags (payload + /// optionally the client machine ID), x=2 disables, x=0 offers a MIME + /// list for a new drag. (send) request that the client start a drag at + /// the given position. + offer = 'o', + + /// 'p': (recv) pre-sent data for an offered drag: x>=0 is a 0-based + /// MIME index, x<0 attaches drag image -x. + present = 'p', + + /// 'P': (recv) x=-1 starts the offered drag, x>=0 changes the drag + /// image mid-drag. + start_drag = 'P', + + /// 'e': (recv) drag data for MIME index `y` of an in-progress drag. + /// (send) drag status events (accepted, dropped, finished, ...). + drag_event = 'e', + + /// 'E': (recv) client aborts the whole drag (y=-1) or reports an error + /// for MIME index `y`. (send) drag start response (OK or error). + drag_error = 'E', + + /// 'k': (recv) remote file data for a drag. (send) request for remote + /// file data. + remote_data = 'k', + + /// 'q': (recv) query protocol support. (send) the query response. + query = 'q', +}; + +/// A drop operation. Values match the protocol's `o` key. +pub const Operation = enum(u2) { + none = 0, + copy = 1, + move = 2, + + /// Convert a protocol `o` value the way kitty does: anything other + /// than copy or move means none. + pub fn fromProtocol(v: u32) Operation { + return switch (v) { + 1 => .copy, + 2 => .move, + else => .none, + }; + } +}; + +/// The set of operations allowed by a drag source, sent as a bitmask in +/// the `o` key of move and drop events. +pub const Operations = packed struct(u2) { + copy: bool = false, + move: bool = false, + + pub fn protocolValue(self: Operations) u2 { + return @bitCast(self); + } +}; + +/// A decoded `t=r` data request. The request form is disambiguated by +/// which keys are non-zero, mirroring kitty's drop_process_queue. +pub const Request = union(enum) { + /// x=y=Y=0: the drop is concluded with the given operation. + conclude: Operation, + + /// Y=0, y=0, x!=0: request data for the 1-based MIME index x. + mime: i32, + + /// Y=0, y!=0: request the contents of the y'th (1-based) file in the + /// text/uri-list MIME at 1-based index x. Remote drops only. + uri: struct { + mime_idx: i32, + uri_idx: i32, + }, + + /// Y!=0: request entry x (1-based) of directory handle Y, or close + /// the handle when x=0. Remote drops only. + dir: struct { + handle: i32, + entry: i32, + }, + + pub fn init(meta: Metadata) Request { + if (meta.pixel_y != 0) return .{ .dir = .{ + .handle = meta.pixel_y, + .entry = meta.cell_x, + } }; + if (meta.cell_y != 0) return .{ .uri = .{ + .mime_idx = meta.cell_x, + .uri_idx = meta.cell_y, + } }; + if (meta.cell_x != 0) return .{ .mime = meta.cell_x }; + return .{ .conclude = .fromProtocol(meta.operation) }; + } +}; + +/// Chunk reassembly state. +/// +/// While a chunked command is in progress, the metadata of the first +/// chunk is reused for all subsequent chunks; only the `more` flag is +/// taken from each continuation. +pub const Chunking = struct { + active: bool = false, + metadata: Metadata = .{}, + + /// Returns the effective metadata for a received chunk and updates + /// the reassembly state. + pub fn apply(self: *Chunking, meta: Metadata) Metadata { + if (self.active) { + var copy = self.metadata; + copy.more = meta.more; + self.active = meta.more; + return copy; + } + + if (meta.more) { + self.active = true; + self.metadata = meta; + } + + return meta; + } +}; + +test "Metadata: empty" { + const testing = std.testing; + const meta = Metadata.parse("").?; + try testing.expect(meta.type == null); + try testing.expect(!meta.more); + try testing.expectEqual(@as(u32, 0), meta.client_id); +} + +test "Metadata: all keys" { + const testing = std.testing; + const meta = Metadata.parse("t=m:m=1:i=3:o=2:x=10:y=5:X=320:Y=200").?; + try testing.expectEqual(EventType.status, meta.type.?); + try testing.expect(meta.more); + try testing.expectEqual(@as(u32, 3), meta.client_id); + try testing.expectEqual(@as(u32, 2), meta.operation); + try testing.expectEqual(@as(i32, 10), meta.cell_x); + try testing.expectEqual(@as(i32, 5), meta.cell_y); + try testing.expectEqual(@as(i32, 320), meta.pixel_x); + try testing.expectEqual(@as(i32, 200), meta.pixel_y); +} + +test "Metadata: all event types" { + const testing = std.testing; + const cases = .{ + .{ "t=a", EventType.register }, + .{ "t=A", EventType.unregister }, + .{ "t=m", EventType.status }, + .{ "t=M", EventType.drop }, + .{ "t=r", EventType.request }, + .{ "t=R", EventType.request_error }, + .{ "t=o", EventType.offer }, + .{ "t=p", EventType.present }, + .{ "t=P", EventType.start_drag }, + .{ "t=e", EventType.drag_event }, + .{ "t=E", EventType.drag_error }, + .{ "t=k", EventType.remote_data }, + .{ "t=q", EventType.query }, + }; + inline for (cases) |case| { + try testing.expectEqual(case[1], Metadata.parse(case[0]).?.type.?); + } +} + +test "Metadata: case-sensitive coordinate keys" { + const testing = std.testing; + const meta = Metadata.parse("x=10:Y=200").?; + try testing.expectEqual(@as(i32, 10), meta.cell_x); + try testing.expectEqual(@as(i32, 0), meta.cell_y); + try testing.expectEqual(@as(i32, 0), meta.pixel_x); + try testing.expectEqual(@as(i32, 200), meta.pixel_y); +} + +test "Metadata: negative coordinates" { + const testing = std.testing; + const meta = Metadata.parse("t=m:x=-1:y=-1").?; + try testing.expectEqual(@as(i32, -1), meta.cell_x); + try testing.expectEqual(@as(i32, -1), meta.cell_y); +} + +test "Metadata: malformed inputs rejected" { + const testing = std.testing; + // Unknown event type. + try testing.expect(Metadata.parse("t=z") == null); + // Unknown key. + try testing.expect(Metadata.parse("z=1") == null); + // Missing '=' mid-stream. + try testing.expect(Metadata.parse("x10") == null); + // No digits. + try testing.expect(Metadata.parse("x=notanumber") == null); + try testing.expect(Metadata.parse("x=-") == null); + // Too large. + try testing.expect(Metadata.parse("i=4294967296") == null); + try testing.expect(Metadata.parse("i=99999999999") == null); + // Garbage after value. + try testing.expect(Metadata.parse("x=1z") == null); + // No whitespace tolerance, matching kitty. + try testing.expect(Metadata.parse("t=a: x=1") == null); + try testing.expect(Metadata.parse("t = a") == null); + // Truncated mid-construct, matching kitty's final-state check. + // Nothing state-changing may be parsed out of these: e.g. "t=r:x=" + // must not be treated as a drop conclusion. + try testing.expect(Metadata.parse("t") == null); + try testing.expect(Metadata.parse("t=") == null); + try testing.expect(Metadata.parse("x=") == null); + try testing.expect(Metadata.parse("t=r:x=") == null); + try testing.expect(Metadata.parse("x=1:t=") == null); +} + +test "Metadata: trailing separator accepted" { + const testing = std.testing; + + // Kitty's state machine accepts the metadata ending right after a + // value separator. + const meta = Metadata.parse("t=a:").?; + try testing.expectEqual(EventType.register, meta.type.?); +} + +test "Metadata: u32 boundary accepted" { + const testing = std.testing; + const meta = Metadata.parse("i=4294967295").?; + try testing.expectEqual(@as(u32, std.math.maxInt(u32)), meta.client_id); +} + +test "Request: classification" { + const testing = std.testing; + + // Conclude. + { + const r: Request = .init(Metadata.parse("t=r:o=2").?); + try testing.expectEqual(Operation.move, r.conclude); + } + // Conclude with unknown operation is none (canceled). + { + const r: Request = .init(Metadata.parse("t=r:o=9").?); + try testing.expectEqual(Operation.none, r.conclude); + } + // MIME data request. + { + const r: Request = .init(Metadata.parse("t=r:x=2").?); + try testing.expectEqual(@as(i32, 2), r.mime); + } + // URI file request. + { + const r: Request = .init(Metadata.parse("t=r:x=1:y=3").?); + try testing.expectEqual(@as(i32, 1), r.uri.mime_idx); + try testing.expectEqual(@as(i32, 3), r.uri.uri_idx); + } + // Directory handle request. + { + const r: Request = .init(Metadata.parse("t=r:Y=2:x=1").?); + try testing.expectEqual(@as(i32, 2), r.dir.handle); + try testing.expectEqual(@as(i32, 1), r.dir.entry); + } +} + +test "Chunking: reassembly reuses first chunk metadata" { + const testing = std.testing; + var chunking: Chunking = .{}; + + // First chunk starts reassembly. + const first = chunking.apply(Metadata.parse("t=m:o=1:m=1").?); + try testing.expect(chunking.active); + try testing.expectEqual(EventType.status, first.type.?); + try testing.expect(first.more); + + // Continuation metadata is ignored except for `more`. + const second = chunking.apply(Metadata.parse("t=q:o=2:m=1").?); + try testing.expect(chunking.active); + try testing.expectEqual(EventType.status, second.type.?); + try testing.expectEqual(@as(u32, 1), second.operation); + try testing.expect(second.more); + + // Final chunk ends reassembly. + const last = chunking.apply(Metadata.parse("t=q:m=0").?); + try testing.expect(!chunking.active); + try testing.expectEqual(EventType.status, last.type.?); + try testing.expect(!last.more); +} + +test "Chunking: unchunked commands pass through" { + const testing = std.testing; + var chunking: Chunking = .{}; + const meta = chunking.apply(Metadata.parse("t=q").?); + try testing.expect(!chunking.active); + try testing.expectEqual(EventType.query, meta.type.?); +} diff --git a/vendor/libghostty-vt/src/terminal/kitty/dnd_drop.zig b/vendor/libghostty-vt/src/terminal/kitty/dnd_drop.zig new file mode 100644 index 0000000..e3c7ee5 --- /dev/null +++ b/vendor/libghostty-vt/src/terminal/kitty/dnd_drop.zig @@ -0,0 +1,715 @@ +//! Kitty drag and drop protocol (OSC 72) state machine. + +const std = @import("std"); +const Allocator = std.mem.Allocator; + +const assert = @import("../../quirks.zig").inlineAssert; +const osc = @import("../osc.zig"); +const command = @import("dnd_command.zig"); +const response = @import("dnd_response.zig"); + +const Metadata = command.Metadata; +const Operation = command.Operation; +const Operations = command.Operations; + +const log = std.log.scoped(.kitty_dnd); + +/// Maximum accumulated size of a client-sent MIME list (the accepted +/// list of a `t=m` status update). Matches kitty's MIME_LIST_SIZE_CAP. +pub const max_mime_list_bytes = 1024 * 1024; + +/// Process one OSC 72 command received from the client, writing any +/// responses to the writer. Returns the state change the embedder may +/// need to act on, if any. +pub fn handleCommand( + slot: *?*State, + alloc: Allocator, + writer: *std.Io.Writer, + v: osc.Command.KittyDndProtocol, +) (Allocator.Error || std.Io.Writer.Error)!?Event { + const raw = Metadata.parse(v.metadata) orelse { + log.debug("dropping malformed OSC 72 metadata", .{}); + return null; + }; + + // Chunk reassembly lives in the state, so before registration + // each command stands alone. The only legitimately chunked + // command before registration is t=a itself, which seeds the + // reassembly on its first chunk below. + const continuation = if (slot.*) |state| state.chunking.active else false; + const meta = if (slot.*) |state| state.chunking.apply(raw) else raw; + const payload = v.payload orelse ""; + const t = meta.type orelse return null; + + switch (t) { + .register => { + // x=1 declares the client's machine ID for remote drop + // support. We don't support remote drop yet, so accept and ignore. + if (meta.cell_x == 1) return null; + + // Setup our state if we haven't already + const state = slot.* orelse state: { + const state = try State.create(alloc); + slot.* = state; + _ = state.chunking.apply(raw); + break :state state; + }; + + // Update the client ID on every registration + state.drop.client_id = meta.client_id; + + return try state.register( + alloc, + payload, + continuation, + meta.more, + ); + }, + + .unregister => { + const state = slot.* orelse return null; + state.destroy(alloc); + slot.* = null; + return .registration; + }, + + .status => { + const state = slot.* orelse return null; + return try state.acceptStatus(alloc, meta, payload); + }, + + .request => return try dataRequest( + slot.*, + alloc, + writer, + meta, + v.terminator, + ), + + // Drag source control. Enabling (x=1, with an optional + // machine ID payload) and disabling (x=2) offers are + // accepted and ignored since the terminal never requests a + // drag start. Offering a MIME list (x=0) for a new drag is + // refused since drag-out is not implemented. + .offer => if (meta.cell_x == 0) try refuseDragOut( + writer, + meta, + v.terminator, + ), + + // Drag-out data and start commands. A conforming client + // never sends these because the terminal never requests a + // drag start, but refuse them properly if one does. + .present, .start_drag => try refuseDragOut( + writer, + meta, + v.terminator, + ), + + // Responses to drag-out requests the terminal never makes. + .drag_event, .drag_error, .remote_data => {}, + + .query => try response.encode( + writer, + "t=q", + meta.client_id, + "", + .plain, + v.terminator, + ), + + // Only ever sent by the terminal. Ignore. + .drop, .request_error => {}, + } + + return null; +} + +/// Handle a t=r data request or drop conclusion from the client. +/// Requests from an unregistered client (no state) get the same +/// errors kitty sends from its zeroed drop state. +fn dataRequest( + state: ?*State, + alloc: Allocator, + writer: *std.Io.Writer, + meta: Metadata, + terminator: osc.Terminator, +) (Allocator.Error || std.Io.Writer.Error)!?Event { + // Responses echo the registration's client ID, matching kitty. + const client_id = if (state) |s| s.drop.client_id else 0; + + switch (command.Request.init(meta)) { + .conclude => |op| { + // The client is done with the drop: free the held data and + // report the operation it performed. Kitty hands that to + // the still-open OS drag session; ours ended at drop time + // (see dnd.zig), so the embedder decides what to do with + // it. A conclusion with no drop in progress is a no-op. + const s = state orelse return null; + const dropped = s.drop.dropped; + s.resetDrop(alloc); + return if (dropped) Event.concluded(op) else null; + }, + + .mime => |idx| { + const keys: response.RequestKeys = .{ .x = idx }; + const items = (if (state) |s| s.drop.items else null) orelse { + try response.encodeError( + writer, + .drop, + keys, + client_id, + .ENOENT, + "no drop data available", + terminator, + ); + return null; + }; + if (idx < 1 or @as(usize, @intCast(idx)) > items.len) { + try response.encodeError( + writer, + .drop, + keys, + client_id, + .ENOENT, + "drop data request index out of bounds", + terminator, + ); + return null; + } + + var header_buf: [32]u8 = undefined; + const header = std.fmt.bufPrint( + &header_buf, + "t=r{f}", + .{keys}, + ) catch unreachable; + + // The data chunks followed by the empty end-of-data + // message, which is how the client detects completion. + // An empty item is just the end-of-data message alone; + // clients treat a duplicate as a second completion. + const item = items[@intCast(idx - 1)]; + if (item.data.len > 0) try response.encode( + writer, + header, + client_id, + item.data, + .base64, + terminator, + ); + try response.encode(writer, header, client_id, "", .base64, terminator); + return null; + }, + + // Remote drop transfers (URI file contents and directory + // handles). We never advertise remote support (no X=1 + // marker), so a conforming client never sends these. + .uri => |uri| try response.encodeError( + writer, + .drop, + .{ .x = uri.mime_idx, .y = uri.uri_idx }, + client_id, + .EINVAL, + "remote drop data is not supported", + terminator, + ), + .dir => |dir| try response.encodeError( + writer, + .drop, + .{ .x = dir.entry, .Y = dir.handle }, + client_id, + .EINVAL, + "remote drop data is not supported", + terminator, + ), + } + + return null; +} + +/// Refuse a drag-out command with an error, since ghostty does not +/// implement the terminal side of client-initiated drags yet. +fn refuseDragOut( + writer: *std.Io.Writer, + meta: Metadata, + terminator: osc.Terminator, +) std.Io.Writer.Error!void { + try response.encodeError( + writer, + .drag, + .{}, + meta.client_id, + .EPERM, + "drag out is not supported by this terminal", + terminator, + ); +} + +/// A protocol state change an embedder may need to act on, returned by +/// `handleCommand` and delivered through the stream handler's +/// `drag_and_drop` effect. This is a flat enum so it can cross a C API +/// unchanged; any details are read back from `Terminal.kitty_dnd`. +pub const Event = enum { + /// The client registered (t=a), re-registered, or unregistered + /// (t=A) to accept drops. An embedder may want to use this + /// to setup the proper mime types to accept (e.g. on macOS) + /// or not (unregistered). + registration, + + /// The client answered the drag currently over the terminal. + /// `State.clientAccepted` has the answer. Embedders can refresh the + /// OS drag feedback immediately rather than on the next move. + acceptance, + + /// The client concluded a drop, performing no operation (it + /// canceled), a copy, or a move. The held drop data has been freed. + concluded_none, + concluded_copy, + concluded_move, + + /// The conclusion event for a performed operation. + pub fn concluded(op: Operation) Event { + return switch (op) { + .none => .concluded_none, + .copy => .concluded_copy, + .move => .concluded_move, + }; + } +}; + +/// The per-terminal drop target state. +/// +/// The primary entrypoint is `handleCommand` which takes a `*?*State` +/// slot that it can heap allocate into when DnD activates and free when +/// it deactivates. +/// +/// The normal lifecycle: +/// +/// 1. The stream handler feeds every OSC 72 command received from the +/// client to `handleCommand`. The client registers (t=a), which +/// allocates the state into the slot and yields a `registration` +/// event so the embedder can register any declared MIME types +/// with the OS. Until then `handleCommand` only answers stateless +/// commands (queries, error responses). +/// 2. A native drag enters or moves over the terminal. When the slot +/// is non-null, the embedder calls `dragMove` with the pointer +/// position, the operations the drag source allows, and the MIME +/// types it can serve if dropped. This sends the client a t=m +/// move event; when the slot is null the embedder should handle +/// the drag as it would without the protocol. +/// 3. The client answers with its acceptance (t=m:o=N), recorded by +/// `handleCommand` which yields an `acceptance` event. The +/// embedder reads `clientAccepted` then and on subsequent moves +/// to give the OS drag session its feedback. +/// 4. The drag either leaves, and the embedder calls `dragLeave` to +/// send the t=m leave event, or drops: the embedder captures the +/// representations it advertised and calls `dragDrop`, which +/// copies and holds them and sends the client a t=M drop event. A +/// new drag entering before the client concludes discards the +/// held drop. +/// 5. The client requests data (t=r:x=N), which `handleCommand` +/// serves from the held copies, and then concludes the drop +/// (t=r:o=N), which frees them and yields a `concluded_*` event +/// naming the operation the client performed. +/// 6. The client unregisters (t=A) and `handleCommand` frees the +/// state, yielding a final `registration` event, or the terminal +/// is deinitialized and calls `destroy`. +/// +/// All calls must use the allocator the state was created with (the +/// terminal's) and require the same synchronization as any other +/// terminal mutation. +pub const State = struct { + /// Chunk reassembly for client commands. This is the only part of + /// the state cleared by a terminal reset (RIS), matching kitty. + chunking: command.Chunking = .{}, + + /// Drop target state for the registered client. + drop: DropTarget = .{}, + + pub const DropTarget = struct { + /// Multiplexer client ID from registration, echoed in every + /// drop-side message the terminal sends. + client_id: u32 = 0, + + /// The MIME list the client registered with (the t=a payload), + /// space-separated as received and accumulated across chunks. + /// Only needed by embedders that must register types with the + /// OS ahead of a drag; kitty frees it after doing so, we keep + /// it so the `registration` event can be acted on from here. + registered_mimes: std.ArrayListUnmanaged(u8) = .empty, + + /// True while the pointer of a native drag is over the terminal. + hovered: bool = false, + + /// True after the native drop until the client concludes it. + dropped: bool = false, + + /// The client's response to the current drag, null until the + /// client has responded. `none` means the client rejected it. + accepted: ?Operation = null, + + /// True while a chunked t=m acceptance is being accumulated. + accept_in_progress: bool = false, + + /// The client's accepted MIME list: space-separated while + /// accumulating, converted to NUL-separated (with a trailing + /// NUL) once complete, matching kitty's in-place conversion. + accepted_mimes: std.ArrayListUnmanaged(u8) = .empty, + + /// The MIME types of the current native drag, in the order + /// that data request indices refer to. + offered: ?Offered = null, + + /// The data captured at drop time, parallel to `offered`. + items: ?[]const Item = null, + }; + + /// One dropped representation: a MIME type and its data. + pub const Item = struct { + mime: []const u8, + data: []const u8, + }; + + /// The MIME list of the current drag plus the pre-joined move-event + /// payload ("mime1 mime2 " with a trailing space after every entry, + /// matching kitty) so per-move encoding is allocation-free. + const Offered = struct { + mimes: []const []const u8, + payload: []const u8, + + fn init(alloc: Allocator, mimes: []const []const u8) Allocator.Error!Offered { + const copies = try alloc.alloc([]const u8, mimes.len); + errdefer alloc.free(copies); + + var payload_len: usize = 0; + for (mimes) |m| payload_len += m.len + 1; + + const payload = try alloc.alloc(u8, payload_len); + errdefer alloc.free(payload); + + var offset: usize = 0; + for (mimes, copies) |m, *copy| { + @memcpy(payload[offset..][0..m.len], m); + payload[offset + m.len] = ' '; + copy.* = payload[offset..][0..m.len]; + offset += m.len + 1; + } + + return .{ .mimes = copies, .payload = payload }; + } + + fn deinit(self: *const Offered, alloc: Allocator) void { + alloc.free(self.mimes); + alloc.free(self.payload); + } + + fn eql(self: *const Offered, mimes: []const []const u8) bool { + if (self.mimes.len != mimes.len) return false; + for (self.mimes, mimes) |a, b| { + if (!std.mem.eql(u8, a, b)) return false; + } + return true; + } + }; + + /// The maximum number of dropped items. Embedders provide a small + /// curated set of representations (see dnd.zig), so this is a + /// generous bound that keeps the MIME list assembly on the stack. + pub const max_items = 16; + + /// Allocate a fresh state. Done by `handleCommand` on registration. + fn create(alloc: Allocator) Allocator.Error!*State { + const state = try alloc.create(State); + state.* = .{}; + return state; + } + + /// Free the state and everything it holds. + pub fn destroy(self: *State, alloc: Allocator) void { + self.deinit(alloc); + alloc.destroy(self); + } + + fn deinit(self: *State, alloc: Allocator) void { + self.freeDragData(alloc); + self.drop.accepted_mimes.deinit(alloc); + self.drop.registered_mimes.deinit(alloc); + } + + /// Iterate the MIME types the client registered with, in order. + /// Empty when the client declared none, which is the common case. + /// The list is only needed to register exotic types with the OS, + /// such as macOS pasteboard stuff. + pub fn registeredMimes(self: *const State) std.mem.TokenIterator(u8, .scalar) { + return std.mem.tokenizeScalar( + u8, + self.drop.registered_mimes.items, + ' ', + ); + } + + /// Record one chunk of a registration's MIME list. + /// + /// `continuation` is true for every chunk but the first of a chunked + /// registration. Returns the registration event once the list is complete. + fn register( + self: *State, + alloc: Allocator, + payload: []const u8, + continuation: bool, + more: bool, + ) Allocator.Error!?Event { + const list = &self.drop.registered_mimes; + if (!continuation) list.clearRetainingCapacity(); + + // Matching kitty, an over-cap chunk is dropped and does not + // complete the registration. + if (list.items.len + payload.len > max_mime_list_bytes) return null; + try list.appendSlice(alloc, payload); + + return if (more) null else .registration; + } + + /// The client's acceptance response for the drag currently over the + /// terminal, for OS drag feedback. Null when the client hasn't + /// responded yet (embedders should fall back to their default, + /// typically copy) or `none` when the client rejected the drag. + pub fn clientAccepted(self: *const State) ?Operation { + if (self.drop.accept_in_progress) return null; + return self.drop.accepted; + } + + /// Free the per-drag data (offered MIME list and held drop items). + fn freeDragData(self: *State, alloc: Allocator) void { + if (self.drop.offered) |*offered| { + offered.deinit(alloc); + self.drop.offered = null; + } + if (self.drop.items) |items| { + for (items) |item| { + alloc.free(item.mime); + alloc.free(item.data); + } + alloc.free(items); + self.drop.items = null; + } + } + + /// Clear the per-drag state while preserving the registration, + /// mirroring kitty's reset_drop. Called when a new drag enters and + /// when a drop concludes. + fn resetDrop(self: *State, alloc: Allocator) void { + self.freeDragData(alloc); + self.drop.accepted_mimes.clearAndFree(alloc); + self.drop.hovered = false; + self.drop.dropped = false; + self.drop.accepted = null; + self.drop.accept_in_progress = false; + } + + /// Handle a t=m acceptance status update from the client, mirroring + /// kitty's drop_set_status. + fn acceptStatus( + self: *State, + alloc: Allocator, + meta: Metadata, + payload: []const u8, + ) Allocator.Error!?Event { + const d = &self.drop; + if (!d.accept_in_progress) { + d.accepted_mimes.clearRetainingCapacity(); + d.accept_in_progress = true; + d.accepted = .fromProtocol(meta.operation); + } + + if (payload.len > 0) { + // Matching kitty, an over-cap list stops accumulating and + // never finalizes, leaving the acceptance unanswered. + if (d.accepted_mimes.items.len + payload.len > max_mime_list_bytes) return null; + try d.accepted_mimes.appendSlice(alloc, payload); + } + + if (meta.more) return null; + d.accept_in_progress = false; + if (d.accepted_mimes.items.len > 0) { + for (d.accepted_mimes.items) |*c| { + if (c.* == ' ') c.* = 0; + } + try d.accepted_mimes.append(alloc, 0); + } + return .acceptance; + } + + /// A native drag position report from the embedder. + pub const MoveEvent = struct { + /// Grid cell under the pointer, zero-based from the top-left. + cell_x: u32, + cell_y: u32, + + /// Pointer position in pixels relative to the top-left of the + /// terminal's content area. + pixel_x: i32, + pixel_y: i32, + + /// The operations the drag source allows. + operations: Operations, + }; + + /// Report a native drag moving over the terminal, sending a t=m + /// move event to the client. `mimes` is the list of MIME types the + /// terminal can provide for this drag, in the order data request + /// indices will refer to. + pub fn dragMove( + self: *State, + alloc: Allocator, + writer: *std.Io.Writer, + ev: MoveEvent, + mimes: []const []const u8, + ) (Allocator.Error || std.Io.Writer.Error)!void { + try self.moveEvent( + alloc, + writer, + ev, + mimes, + false, + ); + } + + /// Report a native drop onto the terminal. The items' data is + /// copied and held so the client's data requests can be served; it + /// is freed when the client concludes the drop, a new drag enters, + /// or the client unregisters. + /// + /// Sends a t=M drop event listing the items' MIME types. + pub fn dragDrop( + self: *State, + alloc: Allocator, + writer: *std.Io.Writer, + ev: MoveEvent, + items: []const Item, + ) (Allocator.Error || std.Io.Writer.Error)!void { + // Copy the items so they can be served after this call returns. + // Items beyond the cap are dropped so the held list always + // matches the advertised MIME list. + const accepted_items = items[0..@min(items.len, max_items)]; + const copies = try alloc.alloc(Item, accepted_items.len); + errdefer alloc.free(copies); + var copied: usize = 0; + errdefer for (copies[0..copied]) |item| { + alloc.free(item.mime); + alloc.free(item.data); + }; + for (accepted_items, copies) |item, *copy| { + const mime = try alloc.dupe(u8, item.mime); + errdefer alloc.free(mime); + const data = try alloc.dupe(u8, item.data); + copy.* = .{ .mime = mime, .data = data }; + copied += 1; + } + + // The move handling below resets per-drag state when this drop + // arrives without a preceding move, so the items are attached + // after it runs. Collect the MIME list first. + var mimes_buf: [max_items][]const u8 = undefined; + const mimes = mimes_buf[0..copies.len]; + for (mimes, copies) |*m, item| m.* = item.mime; + + try self.moveEvent( + alloc, + writer, + ev, + mimes, + true, + ); + + assert(self.drop.items == null); + self.drop.items = copies; + } + + /// Report the native drag leaving the terminal, sending the t=m + /// leave event (x=-1, y=-1). + /// + /// Ignored after a drop: some toolkits emit a leave notification + /// for the drop itself, and the held data must survive until the + /// client concludes. + pub fn dragLeave( + self: *State, + alloc: Allocator, + writer: *std.Io.Writer, + ) std.Io.Writer.Error!void { + if (self.drop.dropped) return; + const hovered = self.drop.hovered; + self.drop.hovered = false; + if (self.drop.offered) |*offered| { + offered.deinit(alloc); + self.drop.offered = null; + } + + // Only a client that saw the drag enter gets the leave event, + // matching kitty which notifies hovered windows only. + if (!hovered) return; + + try response.encode( + writer, + "t=m:x=-1:y=-1", + self.drop.client_id, + "", + .plain, + .st, + ); + } + + /// Shared implementation of move and drop events, mirroring kitty's + /// drop_move_on_child. + fn moveEvent( + self: *State, + alloc: Allocator, + writer: *std.Io.Writer, + ev: MoveEvent, + mimes: []const []const u8, + is_drop: bool, + ) (Allocator.Error || std.Io.Writer.Error)!void { + if (!self.drop.hovered) { + self.resetDrop(alloc); + self.drop.hovered = true; + } + if (is_drop) { + self.drop.dropped = true; + self.drop.hovered = false; + } + + // (Re)build the offered MIME list when it changed. + if (self.drop.offered == null or !self.drop.offered.?.eql(mimes)) { + if (self.drop.offered) |*offered| offered.deinit(alloc); + self.drop.offered = null; + self.drop.offered = try Offered.init(alloc, mimes); + } + + var header_buf: [96]u8 = undefined; + const header = std.fmt.bufPrint( + &header_buf, + "t={c}:x={d}:y={d}:X={d}:Y={d}:o={d}", + .{ + @as(u8, if (is_drop) 'M' else 'm'), + ev.cell_x, + ev.cell_y, + ev.pixel_x, + ev.pixel_y, + ev.operations.protocolValue(), + }, + ) catch unreachable; + + // The MIME list is sent with every move event, matching kitty + // (the spec suggests only the first, but kitty always sends it + // and clients depend on that). + try response.encode( + writer, + header, + self.drop.client_id, + self.drop.offered.?.payload, + .plain, + .st, + ); + } +}; diff --git a/vendor/libghostty-vt/src/terminal/kitty/dnd_response.zig b/vendor/libghostty-vt/src/terminal/kitty/dnd_response.zig new file mode 100644 index 0000000..1f7584f --- /dev/null +++ b/vendor/libghostty-vt/src/terminal/kitty/dnd_response.zig @@ -0,0 +1,248 @@ +const std = @import("std"); +const Terminator = @import("../osc.zig").Terminator; + +/// The maximum raw bytes per base64-encoded chunk, chosen by kitty so a +/// chunk is exactly 4096 base64 characters (the protocol's chunk limit). +pub const max_chunk_raw = 3072; + +/// The maximum bytes per plain-text (non-base64) chunk. +pub const max_chunk_plain = 4096; + +/// Error names used in protocol error payloads. The wire encoding is +/// the tag name itself. This matches kitty's get_errno_name vocabulary, +/// which extends the spec's list with EISDIR, ENOSPC, and OK. +pub const Errno = enum { + OK, + EPERM, + ENOENT, + EIO, + EINVAL, + EMFILE, + ENOMEM, + EFBIG, + EISDIR, + ENOSPC, + EUNKNOWN, +}; + +/// The payload encoding for a message. +pub const Encoding = enum { + /// Payload bytes are sent as-is (MIME lists, error strings). + plain, + + /// Payload bytes are base64-encoded (all binary data). + base64, +}; + +/// The `x`/`y`/`Y` keys of the data request currently being answered, +/// echoed in responses and errors so the client can match them up. Only +/// non-zero keys are written, matching kitty's drop_append_request_keys. +pub const RequestKeys = struct { + x: i32 = 0, + y: i32 = 0, + Y: i32 = 0, + + pub fn format(self: RequestKeys, writer: *std.Io.Writer) !void { + if (self.x != 0) try writer.print(":x={d}", .{self.x}); + if (self.y != 0) try writer.print(":y={d}", .{self.y}); + if (self.Y != 0) try writer.print(":Y={d}", .{self.Y}); + } +}; + +/// Encode a complete protocol message: one bare OSC when `data` is +/// empty, otherwise one complete OSC per chunk of `data`, each +/// repeating the header. The final chunk carries `m=0`, earlier +/// chunks `m=1`. +/// +/// `header` is the metadata without the OSC introducer, e.g. "t=q" or +/// "t=m:x=5:y=3". The client ID is appended as `:i=N` when non-zero. +pub fn encode( + writer: *std.Io.Writer, + header: []const u8, + client_id: u32, + data: []const u8, + encoding: Encoding, + terminator: Terminator, +) std.Io.Writer.Error!void { + // The client ID is part of the repeated header. + var id_buf: [16]u8 = undefined; + const id: []const u8 = if (client_id != 0) std.fmt.bufPrint( + &id_buf, + ":i={d}", + .{client_id}, + ) catch unreachable else ""; + + if (data.len == 0) { + try writer.print("\x1b]72;{s}{s}{s}", .{ + header, + id, + terminator.string(), + }); + return; + } + + const limit: usize = switch (encoding) { + .base64 => max_chunk_raw, + .plain => max_chunk_plain, + }; + + var offset: usize = 0; + while (offset < data.len) { + const chunk_len = @min(data.len - offset, limit); + const chunk = data[offset .. offset + chunk_len]; + offset += chunk_len; + const last: u8 = if (offset >= data.len) '0' else '1'; + + try writer.print("\x1b]72;{s}{s}:m={c};", .{ header, id, last }); + switch (encoding) { + .plain => try writer.writeAll(chunk), + .base64 => { + var b64_buf: [std.base64.standard.Encoder.calcSize(max_chunk_raw)]u8 = undefined; + try writer.writeAll(std.base64.standard.Encoder.encode( + &b64_buf, + chunk, + )); + }, + } + try writer.writeAll(terminator.string()); + } +} + +/// Encode an error response. `kind` selects the header: t=R for drop +/// data request errors, t=E for drag offer errors. The payload is +/// "NAME" or "NAME:description", sent plain (not base64). +pub fn encodeError( + writer: *std.Io.Writer, + kind: enum { drop, drag }, + keys: RequestKeys, + client_id: u32, + errno: Errno, + desc: []const u8, + terminator: Terminator, +) std.Io.Writer.Error!void { + var header_buf: [64]u8 = undefined; + const header = std.fmt.bufPrint(&header_buf, "t={c}{f}", .{ + @as(u8, switch (kind) { + .drop => 'R', + .drag => 'E', + }), + keys, + }) catch unreachable; + + // Description strings are short static messages; size the buffer + // for the longest error name plus a generous description. + var payload_buf: [256]u8 = undefined; + const payload = if (desc.len > 0) std.fmt.bufPrint( + &payload_buf, + "{t}:{s}", + .{ errno, desc }, + ) catch unreachable else @tagName(errno); + + try encode(writer, header, client_id, payload, .plain, terminator); +} + +test "encode: bare message" { + const testing = std.testing; + var buf: [128]u8 = undefined; + var writer: std.Io.Writer = .fixed(&buf); + try encode(&writer, "t=q", 0, "", .plain, .st); + try testing.expectEqualStrings("\x1b]72;t=q\x1b\\", writer.buffered()); +} + +test "encode: bare message with client id" { + const testing = std.testing; + var buf: [128]u8 = undefined; + var writer: std.Io.Writer = .fixed(&buf); + try encode(&writer, "t=q", 7, "", .plain, .st); + try testing.expectEqualStrings("\x1b]72;t=q:i=7\x1b\\", writer.buffered()); +} + +test "encode: plain payload single chunk" { + const testing = std.testing; + var buf: [128]u8 = undefined; + var writer: std.Io.Writer = .fixed(&buf); + try encode(&writer, "t=m:x=1:y=2", 0, "text/plain ", .plain, .st); + try testing.expectEqualStrings( + "\x1b]72;t=m:x=1:y=2:m=0;text/plain \x1b\\", + writer.buffered(), + ); +} + +test "encode: base64 payload chunking" { + const testing = std.testing; + const alloc = testing.allocator; + + var aw: std.Io.Writer.Allocating = .init(alloc); + defer aw.deinit(); + + // Exactly one byte more than a chunk to force two chunks. + const data = [_]u8{'A'} ** (max_chunk_raw + 1); + try encode(&aw.writer, "t=r:x=1", 3, &data, .base64, .st); + + const out = aw.written(); + + // First chunk: full header, m=1, 4096 base64 chars. + const prefix = "\x1b]72;t=r:x=1:i=3:m=1;"; + try testing.expect(std.mem.startsWith(u8, out, prefix)); + const first_end = std.mem.indexOf(u8, out, "\x1b\\").?; + try testing.expectEqual(@as(usize, prefix.len + 4096), first_end); + + // Second chunk: m=0 with the single remaining byte. + const rest = out[first_end + 2 ..]; + try testing.expect(std.mem.startsWith(u8, rest, "\x1b]72;t=r:x=1:i=3:m=0;")); + + // Decodes back to the original data. + var decoded: std.ArrayList(u8) = .empty; + defer decoded.deinit(alloc); + var it = std.mem.splitSequence(u8, out, "\x1b\\"); + while (it.next()) |osc| { + if (osc.len == 0) continue; + const payload_start = std.mem.indexOfScalar(u8, osc, ';').?; + const payload = osc[std.mem.indexOfScalarPos(u8, osc, payload_start + 1, ';').? + 1 ..]; + const n = try std.base64.standard.Decoder.calcSizeForSlice(payload); + const start = decoded.items.len; + try decoded.resize(alloc, start + n); + try std.base64.standard.Decoder.decode(decoded.items[start..], payload); + } + try testing.expectEqualSlices(u8, &data, decoded.items); +} + +test "encodeError: with and without description" { + const testing = std.testing; + var buf: [256]u8 = undefined; + + { + var writer: std.Io.Writer = .fixed(&buf); + try encodeError(&writer, .drop, .{ .x = 2 }, 0, .ENOENT, "drop data request index out of bounds", .st); + try testing.expectEqualStrings( + "\x1b]72;t=R:x=2:m=0;ENOENT:drop data request index out of bounds\x1b\\", + writer.buffered(), + ); + } + { + var writer: std.Io.Writer = .fixed(&buf); + try encodeError(&writer, .drag, .{}, 5, .EPERM, "", .st); + try testing.expectEqualStrings( + "\x1b]72;t=E:i=5:m=0;EPERM\x1b\\", + writer.buffered(), + ); + } +} + +test "RequestKeys: only non-zero keys written" { + const testing = std.testing; + var buf: [64]u8 = undefined; + + { + const s = try std.fmt.bufPrint(&buf, "{f}", .{RequestKeys{}}); + try testing.expectEqualStrings("", s); + } + { + const s = try std.fmt.bufPrint(&buf, "{f}", .{RequestKeys{ .x = 1, .y = 2, .Y = 3 }}); + try testing.expectEqualStrings(":x=1:y=2:Y=3", s); + } + { + const s = try std.fmt.bufPrint(&buf, "{f}", .{RequestKeys{ .Y = 4 }}); + try testing.expectEqualStrings(":Y=4", s); + } +} diff --git a/vendor/libghostty-vt/src/terminal/kitty/dnd_test.zig b/vendor/libghostty-vt/src/terminal/kitty/dnd_test.zig new file mode 100644 index 0000000..c78ce00 --- /dev/null +++ b/vendor/libghostty-vt/src/terminal/kitty/dnd_test.zig @@ -0,0 +1,626 @@ +//! End-to-end tests for the OSC 72 protocol state machine, validating +//! wire behavior against kitty's implementation (using kitty_tests/dnd.py as +//! an oracle for the expected bytes). +//! +//! It isn't normal for us to have dedicated test files but in this case +//! the dnd protocol is complicated enough that I wanted full e2e covering +//! the full machine. + +const std = @import("std"); +const testing = std.testing; + +const osc = @import("../osc.zig"); +const dnd = @import("dnd.zig"); + +/// A test harness holding the lazily allocated protocol state and an +/// output collector. +const Harness = struct { + state: ?*dnd.State = null, + output: std.Io.Writer.Allocating, + + fn init() Harness { + return .{ .output = .init(testing.allocator) }; + } + + fn deinit(self: *Harness) void { + if (self.state) |state| state.destroy(testing.allocator); + self.output.deinit(); + } + + /// The registered state; asserts a client has registered. + fn registered(self: *Harness) *dnd.State { + return self.state.?; + } + + /// Feed one client command, as it would arrive from the OSC parser, + /// returning the event the stream handler would pass to its effect. + fn command(self: *Harness, metadata: []const u8, payload: ?[]const u8) !?dnd.Event { + return try dnd.handleCommand(&self.state, testing.allocator, &self.output.writer, .{ + .metadata = metadata, + .payload = payload, + .terminator = .st, + }); + } + + /// Consume and return the collected output. + fn consume(self: *Harness) []const u8 { + const written = self.output.written(); + return written; + } + + fn clear(self: *Harness) void { + self.output.clearRetainingCapacity(); + } + + fn expectOutput(self: *Harness, expected: []const u8) !void { + try testing.expectEqualStrings(expected, self.output.written()); + self.clear(); + } +}; + +test "dnd: query response" { + var h: Harness = .init(); + defer h.deinit(); + + // Works without any registration, matching kitty, and allocates + // nothing. + _ = try h.command("t=q", null); + try h.expectOutput("\x1b]72;t=q\x1b\\"); + try testing.expect(h.state == null); +} + +test "dnd: query response echoes client id" { + var h: Harness = .init(); + defer h.deinit(); + + _ = try h.command("t=q:i=31", null); + try h.expectOutput("\x1b]72;t=q:i=31\x1b\\"); +} + +test "dnd: register and unregister" { + var h: Harness = .init(); + defer h.deinit(); + + try testing.expect(h.state == null); + + // Registration allocates the state and reports it, with the + // declared MIME list readable from the state. + try testing.expect((try h.command("t=a", "text/plain text/uri-list")).? == .registration); + try h.expectOutput(""); + { + var it = h.registered().registeredMimes(); + try testing.expectEqualStrings("text/plain", it.next().?); + try testing.expectEqualStrings("text/uri-list", it.next().?); + try testing.expect(it.next() == null); + } + + // Machine ID declaration is accepted and ignored. + try testing.expect((try h.command("t=a:x=1", "1:deadbeef")) == null); + try h.expectOutput(""); + try testing.expect(h.state != null); + + // Re-registration replaces the list. + try testing.expect((try h.command("t=a", "image/png")).? == .registration); + { + var it = h.registered().registeredMimes(); + try testing.expectEqualStrings("image/png", it.next().?); + try testing.expect(it.next() == null); + } + + // Registering without a list is the common case. + try testing.expect((try h.command("t=a", null)).? == .registration); + { + var it = h.registered().registeredMimes(); + try testing.expect(it.next() == null); + } + + // Unregistration frees it and reports the change. + try testing.expect((try h.command("t=A", null)).? == .registration); + try h.expectOutput(""); + try testing.expect(h.state == null); + + // Unregistering again changes nothing. + try testing.expect((try h.command("t=A", null)) == null); + try h.expectOutput(""); + try testing.expect(h.state == null); +} + +test "dnd: no state before registration" { + var h: Harness = .init(); + defer h.deinit(); + + // State-dependent commands from an unregistered client allocate + // nothing; a data request gets the error kitty sends from its + // zeroed state. + _ = try h.command("t=m:o=1", "text/plain"); + _ = try h.command("t=r", null); + try h.expectOutput(""); + _ = try h.command("t=r:x=1", null); + try h.expectOutput( + "\x1b]72;t=R:x=1:m=0;ENOENT:no drop data available\x1b\\", + ); + try testing.expect(h.state == null); +} + +test "dnd: move event carries position, operations, and mime list" { + var h: Harness = .init(); + defer h.deinit(); + + _ = try h.command("t=a", "text/plain"); + + try h.registered().dragMove(testing.allocator, &h.output.writer, .{ + .cell_x = 5, + .cell_y = 3, + .pixel_x = 100, + .pixel_y = 60, + .operations = .{ .copy = true }, + }, &.{ "text/plain", "text/uri-list" }); + + // Note the trailing space after every MIME entry, matching kitty. + try h.expectOutput( + "\x1b]72;t=m:x=5:y=3:X=100:Y=60:o=1:m=0;text/plain text/uri-list \x1b\\", + ); +} + +test "dnd: move event echoes registration client id" { + var h: Harness = .init(); + defer h.deinit(); + + _ = try h.command("t=a:i=7", ""); + try h.registered().dragMove(testing.allocator, &h.output.writer, .{ + .cell_x = 1, + .cell_y = 2, + .pixel_x = 8, + .pixel_y = 16, + .operations = .{ .copy = true, .move = true }, + }, &.{"text/plain"}); + try h.expectOutput("\x1b]72;t=m:x=1:y=2:X=8:Y=16:o=3:i=7:m=0;text/plain \x1b\\"); +} + +test "dnd: re-registration updates client id in place" { + var h: Harness = .init(); + defer h.deinit(); + + _ = try h.command("t=a:i=7", ""); + const state = h.registered(); + _ = try h.command("t=a:i=9", ""); + // Same allocation, new client ID. + try testing.expect(h.state.? == state); + try testing.expectEqual(@as(u32, 9), state.drop.client_id); +} + +test "dnd: mime list sent on every move" { + var h: Harness = .init(); + defer h.deinit(); + + _ = try h.command("t=a", ""); + const ev: dnd.MoveEvent = .{ + .cell_x = 0, + .cell_y = 0, + .pixel_x = 0, + .pixel_y = 0, + .operations = .{ .copy = true }, + }; + try h.registered().dragMove(testing.allocator, &h.output.writer, ev, &.{"text/plain"}); + h.clear(); + + // Kitty resends the list even when unchanged; clients depend on it. + try h.registered().dragMove(testing.allocator, &h.output.writer, ev, &.{"text/plain"}); + try h.expectOutput("\x1b]72;t=m:x=0:y=0:X=0:Y=0:o=1:m=0;text/plain \x1b\\"); +} + +test "dnd: leave event" { + var h: Harness = .init(); + defer h.deinit(); + + _ = try h.command("t=a", ""); + try h.registered().dragMove(testing.allocator, &h.output.writer, .{ + .cell_x = 0, + .cell_y = 0, + .pixel_x = 0, + .pixel_y = 0, + .operations = .{ .copy = true }, + }, &.{"text/plain"}); + h.clear(); + + try h.registered().dragLeave(testing.allocator, &h.output.writer); + try h.expectOutput("\x1b]72;t=m:x=-1:y=-1\x1b\\"); +} + +test "dnd: client acceptance recorded" { + var h: Harness = .init(); + defer h.deinit(); + + _ = try h.command("t=a", ""); + try testing.expect(h.registered().clientAccepted() == null); + + try testing.expect((try h.command("t=m:o=1", "text/plain")).? == .acceptance); + try h.expectOutput(""); + try testing.expectEqual(dnd.Operation.copy, h.registered().clientAccepted().?); + + // Rejection. + try testing.expect((try h.command("t=m:o=0", "")).? == .acceptance); + try testing.expectEqual(dnd.Operation.none, h.registered().clientAccepted().?); +} + +test "dnd: chunked client acceptance" { + var h: Harness = .init(); + defer h.deinit(); + + _ = try h.command("t=a", ""); + + // Chunked accept: continuation metadata is ignored, the acceptance + // is pending until the final chunk. + try testing.expect((try h.command("t=m:o=2:m=1", "text/pl")) == null); + try testing.expect(h.registered().clientAccepted() == null); + try testing.expect((try h.command("t=m:m=1", "ain text")) == null); + try testing.expect((try h.command("t=m:m=0", "/html")).? == .acceptance); + try testing.expectEqual(dnd.Operation.move, h.registered().clientAccepted().?); + + // The accumulated list was converted to NUL-separated entries. + try testing.expectEqualSlices( + u8, + "text/plain\x00text/html\x00", + h.registered().drop.accepted_mimes.items, + ); +} + +test "dnd: drop and data serving round trip" { + var h: Harness = .init(); + defer h.deinit(); + + _ = try h.command("t=a", "text/plain text/uri-list"); + + const ev: dnd.MoveEvent = .{ + .cell_x = 4, + .cell_y = 2, + .pixel_x = 40, + .pixel_y = 20, + .operations = .{ .copy = true }, + }; + try h.registered().dragDrop(testing.allocator, &h.output.writer, ev, &.{ + .{ .mime = "text/uri-list", .data = "file:///tmp/a.txt\r\n" }, + .{ .mime = "text/plain", .data = "hello" }, + }); + try h.expectOutput( + "\x1b]72;t=M:x=4:y=2:X=40:Y=20:o=1:m=0;text/uri-list text/plain \x1b\\", + ); + + // Request the second MIME's data: base64 chunk plus the empty + // end-of-data message. + _ = try h.command("t=r:x=2", null); + try h.expectOutput( + "\x1b]72;t=r:x=2:m=0;aGVsbG8=\x1b\\" ++ "\x1b]72;t=r:x=2\x1b\\", + ); + + // Out-of-bounds request. + _ = try h.command("t=r:x=3", null); + try h.expectOutput( + "\x1b]72;t=R:x=3:m=0;ENOENT:drop data request index out of bounds\x1b\\", + ); + + // Conclude: the performed operation is reported, held data is + // freed, and further requests fail. + try testing.expectEqual(dnd.Event.concluded_copy, (try h.command("t=r:o=1", null)).?); + try h.expectOutput(""); + try testing.expect((try h.command("t=r:o=1", null)) == null); + _ = try h.command("t=r:x=1", null); + try h.expectOutput( + "\x1b]72;t=R:x=1:m=0;ENOENT:no drop data available\x1b\\", + ); +} + +test "dnd: empty item served as a single end-of-data message" { + var h: Harness = .init(); + defer h.deinit(); + + _ = try h.command("t=a", ""); + try h.registered().dragDrop(testing.allocator, &h.output.writer, .{ + .cell_x = 0, + .cell_y = 0, + .pixel_x = 0, + .pixel_y = 0, + .operations = .{ .copy = true }, + }, &.{.{ .mime = "text/plain", .data = "" }}); + h.clear(); + + // Kitty's oracle (test_empty_data) asserts exactly one message: + // the empty response is itself the end-of-data signal, and a + // duplicate would be a second completion to the client. + _ = try h.command("t=r:x=1", null); + try h.expectOutput("\x1b]72;t=r:x=1\x1b\\"); +} + +test "dnd: leave without hover sends nothing" { + var h: Harness = .init(); + defer h.deinit(); + + // Client registered but no move was ever forwarded (e.g. it + // registered mid-drag): kitty only notifies hovered windows. + _ = try h.command("t=a", ""); + try h.registered().dragLeave(testing.allocator, &h.output.writer); + try h.expectOutput(""); +} + +test "dnd: data request with no drop" { + var h: Harness = .init(); + defer h.deinit(); + + _ = try h.command("t=a", ""); + _ = try h.command("t=r:x=1", null); + try h.expectOutput( + "\x1b]72;t=R:x=1:m=0;ENOENT:no drop data available\x1b\\", + ); +} + +test "dnd: leave after drop is ignored" { + var h: Harness = .init(); + defer h.deinit(); + + _ = try h.command("t=a", ""); + try h.registered().dragDrop(testing.allocator, &h.output.writer, .{ + .cell_x = 0, + .cell_y = 0, + .pixel_x = 0, + .pixel_y = 0, + .operations = .{ .copy = true }, + }, &.{.{ .mime = "text/plain", .data = "x" }}); + h.clear(); + + // Some toolkits emit a leave for the drop itself; the held data + // must survive so the client can still fetch it. + try h.registered().dragLeave(testing.allocator, &h.output.writer); + try h.expectOutput(""); + + _ = try h.command("t=r:x=1", null); + try h.expectOutput( + "\x1b]72;t=r:x=1:m=0;eA==\x1b\\" ++ "\x1b]72;t=r:x=1\x1b\\", + ); +} + +test "dnd: new drag resets held drop data" { + var h: Harness = .init(); + defer h.deinit(); + + _ = try h.command("t=a", ""); + const ev: dnd.MoveEvent = .{ + .cell_x = 0, + .cell_y = 0, + .pixel_x = 0, + .pixel_y = 0, + .operations = .{ .copy = true }, + }; + try h.registered().dragDrop(testing.allocator, &h.output.writer, ev, &.{ + .{ .mime = "text/plain", .data = "old" }, + }); + h.clear(); + + // A new drag entering resets the per-drag state including the held + // items from the unconcluded previous drop. + try h.registered().dragMove(testing.allocator, &h.output.writer, ev, &.{"text/plain"}); + h.clear(); + _ = try h.command("t=r:x=1", null); + try h.expectOutput( + "\x1b]72;t=R:x=1:m=0;ENOENT:no drop data available\x1b\\", + ); +} + +test "dnd: remote transfer requests refused" { + var h: Harness = .init(); + defer h.deinit(); + + _ = try h.command("t=a", ""); + + // URI file content request. + _ = try h.command("t=r:x=1:y=2", null); + try h.expectOutput( + "\x1b]72;t=R:x=1:y=2:m=0;EINVAL:remote drop data is not supported\x1b\\", + ); + + // Directory handle request. + _ = try h.command("t=r:Y=2:x=1", null); + try h.expectOutput( + "\x1b]72;t=R:x=1:Y=2:m=0;EINVAL:remote drop data is not supported\x1b\\", + ); +} + +test "dnd: drag out refused" { + var h: Harness = .init(); + defer h.deinit(); + + // Enabling and disabling offers is accepted silently and allocates + // nothing. + _ = try h.command("t=o:x=1", null); + _ = try h.command("t=o:x=2", null); + try h.expectOutput(""); + try testing.expect(h.state == null); + + // Offering a drag is refused. + _ = try h.command("t=o:x=1", null); + _ = try h.command("t=o:o=3", "text/plain"); + try h.expectOutput( + "\x1b]72;t=E:m=0;EPERM:drag out is not supported by this terminal\x1b\\", + ); + + // Starting a drag is refused, echoing the command's client id. + _ = try h.command("t=P:x=-1:i=9", null); + try h.expectOutput( + "\x1b]72;t=E:i=9:m=0;EPERM:drag out is not supported by this terminal\x1b\\", + ); + try testing.expect(h.state == null); +} + +test "dnd: unregister frees held drop data" { + var h: Harness = .init(); + defer h.deinit(); + + _ = try h.command("t=a", ""); + try h.registered().dragDrop(testing.allocator, &h.output.writer, .{ + .cell_x = 0, + .cell_y = 0, + .pixel_x = 0, + .pixel_y = 0, + .operations = .{ .copy = true }, + }, &.{.{ .mime = "text/plain", .data = "x" }}); + h.clear(); + + // The testing allocator would report the held data as leaked if + // unregistration didn't free the whole state. + _ = try h.command("t=A", null); + try testing.expect(h.state == null); +} + +test "dnd: chunked registration reuses first chunk metadata" { + var h: Harness = .init(); + defer h.deinit(); + + // Registration split over two chunks: the first chunk allocates + // the state and seeds chunk reassembly, so the continuation (which + // carries a different type) is still treated as the registration. + try testing.expect((try h.command("t=a:i=4:m=1", "text/pla")) == null); + try testing.expect(h.state != null); + try testing.expect((try h.command("t=q:m=0", "in")).? == .registration); + try h.expectOutput(""); + try testing.expectEqual(@as(u32, 4), h.registered().drop.client_id); + { + var it = h.registered().registeredMimes(); + try testing.expectEqualStrings("text/plain", it.next().?); + } + + // A query after the chunked command completes works again. + _ = try h.command("t=q", null); + try h.expectOutput("\x1b]72;t=q\x1b\\"); +} + +test "dnd: malformed metadata ignored" { + var h: Harness = .init(); + defer h.deinit(); + + _ = try h.command("t=a:zz=1", ""); + try h.expectOutput(""); + try testing.expect(h.state == null); + + // Command with no type is ignored, matching kitty (the spec's + // default of t=a is not honored by the reference implementation). + _ = try h.command("x=1", ""); + try h.expectOutput(""); + try testing.expect(h.state == null); +} + +test "dnd: bel terminator echoed in responses" { + var h: Harness = .init(); + defer h.deinit(); + + _ = try dnd.handleCommand(&h.state, testing.allocator, &h.output.writer, .{ + .metadata = "t=q", + .payload = null, + .terminator = .bel, + }); + try h.expectOutput("\x1b]72;t=q\x07"); +} + +test "dnd: kitten 0.47 conversation replay" { + // This replays a conversation recorded from the reference client + // (`kitten dnd --drop-anywhere=copy --drop text/plain:out.txt`, + // kitten 0.47.0) driven over a pty by a harness that sent exactly + // the bytes this engine produces. The kitten accepted the events, + // wrote the dropped payload to disk intact, and concluded; its + // client bytes are frozen here as an interop regression test. + var h: Harness = .init(); + defer h.deinit(); + + // Startup: register with MIME list and machine ID, then the test + // harness reset (unregister both directions, re-register). + _ = try h.command("t=a:m=0", "text/uri-list text/plain"); + _ = try h.command( + "t=a:x=1:m=0", + "1:5cff8247c477900a8727e2281fe890252f8848f87c224dd8dd7fb6303e94ddbd", + ); + _ = try h.command("t=A", null); + try testing.expect(h.state == null); + _ = try h.command("t=o:x=2", null); + _ = try h.command("t=a:m=0", "text/uri-list text/plain"); + _ = try h.command( + "t=a:x=1:m=0", + "1:5cff8247c477900a8727e2281fe890252f8848f87c224dd8dd7fb6303e94ddbd", + ); + try h.expectOutput(""); + try testing.expect(h.state != null); + + // Native drag moves over the terminal and drops. + const ev: dnd.MoveEvent = .{ + .cell_x = 2, + .cell_y = 1, + .pixel_x = 20, + .pixel_y = 18, + .operations = .{ .copy = true }, + }; + try h.registered().dragMove(testing.allocator, &h.output.writer, ev, &.{"text/plain"}); + try h.expectOutput("\x1b]72;t=m:x=2:y=1:X=20:Y=18:o=1:m=0;text/plain \x1b\\"); + + // The kitten accepts as a copy of text/plain. + _ = try h.command("t=m:o=1:m=0", "text/plain"); + try h.expectOutput(""); + try testing.expectEqual(dnd.Operation.copy, h.registered().clientAccepted().?); + + try h.registered().dragDrop(testing.allocator, &h.output.writer, ev, &.{ + .{ .mime = "text/plain", .data = "hello from ghostty\n" }, + }); + try h.expectOutput("\x1b]72;t=M:x=2:y=1:X=20:Y=18:o=1:m=0;text/plain \x1b\\"); + + // The kitten requests the data and concludes with a copy. + _ = try h.command("t=r:x=1", null); + try h.expectOutput( + "\x1b]72;t=r:x=1:m=0;aGVsbG8gZnJvbSBnaG9zdHR5Cg==\x1b\\" ++ + "\x1b]72;t=r:x=1\x1b\\", + ); + _ = try h.command("t=r:o=1", null); + try h.expectOutput(""); + try testing.expect(h.registered().drop.items == null); +} + +test "dnd: large data served in chunks" { + var h: Harness = .init(); + defer h.deinit(); + + _ = try h.command("t=a", ""); + + // 3073 bytes: one full chunk plus one byte. + const data = [_]u8{'Z'} ** 3073; + try h.registered().dragDrop(testing.allocator, &h.output.writer, .{ + .cell_x = 0, + .cell_y = 0, + .pixel_x = 0, + .pixel_y = 0, + .operations = .{ .copy = true }, + }, &.{.{ .mime = "application/octet-stream", .data = &data }}); + h.clear(); + + _ = try h.command("t=r:x=1", null); + const out = h.consume(); + + // First chunk is m=1 with 4096 base64 chars, second is m=0, and + // the final message is the bare end-of-data marker. + try testing.expect(std.mem.startsWith(u8, out, "\x1b]72;t=r:x=1:m=1;")); + try testing.expect(std.mem.indexOf(u8, out, "\x1b]72;t=r:x=1:m=0;") != null); + try testing.expect(std.mem.endsWith(u8, out, "\x1b]72;t=r:x=1\x1b\\")); + h.clear(); +} + +test "dnd: over-cap registration list never completes" { + var h: Harness = .init(); + defer h.deinit(); + + // Matching kitty, a chunk that would exceed the cap is dropped and + // the registration is not reported, though the client stays + // registered (the state exists). + const big = try testing.allocator.alloc(u8, dnd.max_mime_list_bytes + 1); + defer testing.allocator.free(big); + @memset(big, 'a'); + try testing.expect((try h.command("t=a", big)) == null); + try testing.expect(h.state != null); + { + var it = h.registered().registeredMimes(); + try testing.expect(it.next() == null); + } +} diff --git a/vendor/libghostty-vt/src/terminal/kitty/graphics.zig b/vendor/libghostty-vt/src/terminal/kitty/graphics.zig index 1f639fa..ef45ee8 100644 --- a/vendor/libghostty-vt/src/terminal/kitty/graphics.zig +++ b/vendor/libghostty-vt/src/terminal/kitty/graphics.zig @@ -3,11 +3,6 @@ //! Documentation: //! https://sw.kovidgoyal.net/kitty/graphics-protocol //! -//! Unimplemented features that are still todo: -//! - shared memory transmit -//! - virtual placement w/ unicode -//! - animation -//! //! Performance: //! The performance of this particular subsystem of Ghostty is not great. //! We can avoid a lot more allocations, we can replace some C code (which @@ -21,7 +16,10 @@ const command = @import("graphics_command.zig"); const exec = @import("graphics_exec.zig"); const image = @import("graphics_image.zig"); const storage = @import("graphics_storage.zig"); +pub const animation = @import("graphics_animation.zig"); +pub const pixel = @import("graphics_pixel.zig"); pub const unicode = @import("graphics_unicode.zig"); +pub const Animation = animation.Animation; pub const Command = command.Command; pub const CommandParser = command.Parser; pub const Image = image.Image; diff --git a/vendor/libghostty-vt/src/terminal/kitty/graphics_animation.zig b/vendor/libghostty-vt/src/terminal/kitty/graphics_animation.zig new file mode 100644 index 0000000..0822a45 --- /dev/null +++ b/vendor/libghostty-vt/src/terminal/kitty/graphics_animation.zig @@ -0,0 +1,149 @@ +//! Kitty graphics protocol animation support. +//! +//! https://sw.kovidgoyal.net/kitty/graphics-protocol/#animation +//! +//! An animation is a set of frames attached to an existing image. +//! Frame 1 (the "root frame") is the image's own base data. Frames +//! 2..N are stored here. Unlike Kitty, which stores frames as delta +//! rectangles in a disk cache and composes them lazily, we compose +//! every frame eagerly into a full image-sized RGBA buffer at load +//! time. This trades some memory for a much simpler model: a frame is +//! always ready to display and there are no reference chains to +//! maintain, coalesce, or garbage collect. Frame storage is bounded +//! by the same byte limit as image storage. +//! +//! To keep composition to a single pixel format, an image is +//! converted to RGBA the first time an animation command composes +//! into it, and all transmitted frame data is converted to RGBA +//! before composition. Blending an opaque (alpha=255) source pixel +//! degenerates to a copy, so this loses no information relative to +//! Kitty's separate RGB/RGBA paths. The pixel-level primitives +//! (conversion, fill, rectangle composition) live in +//! graphics_pixel.zig. + +const std = @import("std"); +const Allocator = std.mem.Allocator; + +/// The gap assigned to a newly created frame when the command doesn't +/// specify one (z omitted or z=0). Taken from Kitty (DEFAULT_GAP). +pub const default_gap_ms: u32 = 40; + +/// Animation state for a single image. Heap-allocated and owned by +/// the Image. This is created lazily by the first animation command that +/// needs it. +pub const Animation = struct { + /// The frames following the root frame: `frames.items[i]` is + /// protocol frame number `i + 2`. The root frame (frame 1) is the + /// image's base data and is not stored here. + frames: std.ArrayListUnmanaged(Frame) = .empty, + + /// The gap of the root frame in milliseconds. Zero means gapless: + /// the frame is skipped during playback. Kitty creates the root + /// frame with a zero gap; the client gives it one with a=a,r=1,z=N. + root_gap_ms: u32 = 0, + + /// The zero-based index of the frame currently displayed: 0 is + /// the root frame, i >= 1 is frames.items[i - 1]. + current_index: u32 = 0, + + /// Playback state. Every image starts stopped (client-driven). + state: State = .stopped, + + /// Maximum number of loops to play (0 = infinite). Set from the + /// a=a v key as v-1, per the protocol's off-by-one encoding. + max_loops: u32 = 0, + + /// Number of completed loops since playback started. + current_loop: u32 = 0, + + /// Timestamp in milliseconds (on the ticker's clock, see + /// ImageStorage.animationTick) when the current frame was shown. + /// Null means "not yet shown"; the next tick stamps it. + frame_shown_at_ms: ?u64 = null, + + pub const State = enum { + /// Not advancing; frames change only via a=a c=N (client-driven). + stopped, + /// Advancing, but playback parks on the last frame waiting + /// for more frames instead of looping (a=a s=2). + loading, + /// Advancing and looping (a=a s=3). + running, + }; + + pub const Frame = struct { + /// Fully composed pixel data, always image width * height * 4 + /// bytes of RGBA. + data: []u8, + + /// Milliseconds this frame is displayed before advancing. + /// Zero means gapless: skipped during playback. + gap_ms: u32, + }; + + pub fn deinit(self: *Animation, alloc: Allocator) void { + for (self.frames.items) |frame| alloc.free(frame.data); + self.frames.deinit(alloc); + } + + /// The total number of frames including the root frame. + pub fn frameCount(self: *const Animation) u32 { + return @intCast(self.frames.items.len + 1); + } + + /// The gap of the frame at the given zero-based index. + pub fn gapAt(self: *const Animation, index: u32) u32 { + if (index == 0) return self.root_gap_ms; + return self.frames.items[index - 1].gap_ms; + } + + /// Set the gap of the frame at the given zero-based index. + pub fn setGapAt(self: *Animation, index: u32, gap_ms: u32) void { + if (index == 0) { + self.root_gap_ms = gap_ms; + } else { + self.frames.items[index - 1].gap_ms = gap_ms; + } + } + + /// The sum of all frame gaps. An animation with a zero duration + /// (every frame gapless) never advances; this doubles as the + /// guard that keeps the gapless-skip loop in animationTick from + /// spinning forever, exactly like Kitty's animation_duration. + pub fn durationMs(self: *const Animation) u64 { + var total: u64 = self.root_gap_ms; + for (self.frames.items) |frame| total += frame.gap_ms; + return total; + } + + /// Bytes of frame data held by this animation, counted against + /// the image storage limit. + pub fn frameBytes(self: *const Animation) usize { + var total: usize = 0; + for (self.frames.items) |frame| total += frame.data.len; + return total; + } +}; + +test "animation gap helpers" { + const testing = std.testing; + const alloc = testing.allocator; + + var anim: Animation = .{}; + defer anim.deinit(alloc); + try anim.frames.append(alloc, .{ + .data = try alloc.alloc(u8, 4), + .gap_ms = 100, + }); + + try testing.expectEqual(@as(u32, 2), anim.frameCount()); + try testing.expectEqual(@as(u32, 0), anim.gapAt(0)); + try testing.expectEqual(@as(u32, 100), anim.gapAt(1)); + try testing.expectEqual(@as(u64, 100), anim.durationMs()); + try testing.expectEqual(@as(usize, 4), anim.frameBytes()); + + anim.setGapAt(0, 40); + anim.setGapAt(1, 60); + try testing.expectEqual(@as(u64, 100), anim.durationMs()); + try testing.expectEqual(@as(u32, 40), anim.root_gap_ms); +} diff --git a/vendor/libghostty-vt/src/terminal/kitty/graphics_command.zig b/vendor/libghostty-vt/src/terminal/kitty/graphics_command.zig index aa1bef5..e57ad17 100644 --- a/vendor/libghostty-vt/src/terminal/kitty/graphics_command.zig +++ b/vendor/libghostty-vt/src/terminal/kitty/graphics_command.zig @@ -237,18 +237,18 @@ pub const Parser = struct { 'p' => .{ .display = try .parse(self.kv) }, 'd' => .{ .delete = try .parse(self.kv) }, 'f' => .{ .transmit_animation_frame = try .parse(self.kv) }, - 'a' => .{ .control_animation = try .parse(self.kv) }, - 'c' => .{ .compose_animation = try .parse(self.kv) }, + 'a' => .{ .control_animation = .parse(self.kv) }, + 'c' => .{ .compose_animation = .parse(self.kv) }, else => return error.InvalidFormat, }; - // Determine our quiet value + // Determine our quiet value. The spec specifies 0 and 1 but Kitty + // allows anything greater than 1 to suppress. const quiet: Command.Quiet = if (self.kv.get('q')) |v| quiet: { break :quiet switch (v) { 0 => .no, 1 => .ok, - 2 => .failures, - else => return error.InvalidFormat, + else => .failures, }; } else .no; @@ -333,6 +333,10 @@ pub const Response = struct { id: u32 = 0, image_number: u32 = 0, placement_id: u32 = 0, + /// The 1-based animation frame number, echoed as "r=" for the + /// animation frame actions. This is how clients learn the frame + /// number assigned to a newly created frame. + frame: u32 = 0, message: []const u8 = "OK", pub fn encode(self: Response, writer: *std.Io.Writer) !void { @@ -356,6 +360,10 @@ pub const Response = struct { if (prior) try writer.writeByte(',') else prior = true; try writer.print("p={}", .{self.placement_id}); } + if (self.frame > 0) { + if (prior) try writer.writeByte(',') else prior = true; + try writer.print("r={}", .{self.frame}); + } try writer.writeByte(';'); try writer.writeAll(self.message); try writer.writeAll("\x1b\\"); @@ -406,6 +414,53 @@ pub const Command = struct { transmit_animation_frame: AnimationFrameLoading, control_animation: AnimationControl, compose_animation: AnimationFrameComposition, + + pub const Identifiers = struct { + image_id: u32 = 0, + image_number: u32 = 0, + placement_id: u32 = 0, + }; + + /// Returns the image and placement identifiers for any action. + pub fn identifiers(self: Control) Identifiers { + return switch (self) { + .query, .transmit => |t| .{ + .image_id = t.image_id, + .image_number = t.image_number, + .placement_id = t.placement_id, + }, + .transmit_and_display => |t| .{ + .image_id = t.transmission.image_id, + .image_number = t.transmission.image_number, + .placement_id = t.transmission.placement_id, + }, + .display => |d| .{ + .image_id = d.image_id, + .image_number = d.image_number, + .placement_id = d.placement_id, + }, + .delete => |d| .{ + .image_id = d.image_id, + .image_number = d.image_number, + .placement_id = d.placement_id, + }, + .transmit_animation_frame => |f| .{ + .image_id = f.transmission.image_id, + .image_number = f.transmission.image_number, + .placement_id = f.transmission.placement_id, + }, + .control_animation => |a| .{ + .image_id = a.image_id, + .image_number = a.image_number, + .placement_id = a.placement_id, + }, + .compose_animation => |c| .{ + .image_id = c.image_id, + .image_number = c.image_number, + .placement_id = c.placement_id, + }, + }; + } }; /// Take ownership over the data in this command. If the returned value @@ -422,6 +477,7 @@ pub const Command = struct { .query => |t| t, .transmit => |t| t, .transmit_and_display => |t| t.transmission, + .transmit_animation_frame => |f| f.transmission, else => null, }; } @@ -442,6 +498,11 @@ pub const Command = struct { pub const Transmission = struct { format: Format = .rgba, // f + /// Set when "f" carried a value we don't recognize. Kitty rejects these + /// while handling the command so the EINVAL response can carry the image + /// id, so we defer to `LoadingImage.init`. `format` is meaningless when + /// this is set. + format_unknown: bool = false, medium: Medium = .direct, // t width: u32 = 0, // s height: u32 = 0, // v @@ -505,9 +566,13 @@ pub const Transmission = struct { if (kv.get('f')) |v| { result.format = switch (v) { 24 => .rgb, - 32 => .rgba, + 0, 32 => .rgba, 100 => .png, - else => return error.InvalidFormat, + else => unknown: { + // Defer returning an error until the image is processed. + result.format_unknown = true; + break :unknown .rgba; + }, }; } @@ -654,19 +719,13 @@ pub const Display = struct { } if (kv.get('C')) |v| { - result.cursor_movement = switch (v) { - 0 => .after, - 1 => .none, - else => return error.InvalidFormat, - }; + // Kitty only tests this against 1 so any other value moves the cursor. + result.cursor_movement = if (v == 1) .none else .after; } if (kv.get('U')) |v| { - result.virtual_placement = switch (v) { - 0 => false, - 1 => true, - else => return error.InvalidFormat, - }; + // Kitty stores this in a bool, so any non-zero value is virtual. + result.virtual_placement = v != 0; } if (kv.get('z')) |v| { @@ -697,23 +756,51 @@ pub const Display = struct { }; pub const AnimationFrameLoading = struct { + /// Frame data is transmitted exactly like image data, so an a=f + /// command carries a full set of transmission keys: medium (t), + /// format (f), the frame rectangle size (s/v), chunking (m), etc. + /// The image id/number here identify the (existing) image the + /// frame belongs to. + transmission: Transmission = .{}, + + /// The left/top edge (pixels) of the rectangle within the frame + /// that the transmitted data updates. x: u32 = 0, // x y: u32 = 0, // y + + /// The 1-based frame number whose pixels serve as the base canvas + /// for a newly created frame. Zero means no base frame: the + /// canvas is filled with `background` instead. create_frame: u32 = 0, // c + + /// The 1-based frame number of an existing frame to edit. Zero + /// (or any value past the next frame number) creates a new frame. edit_frame: u32 = 0, // r - gap_ms: u32 = 0, // z + + /// The gap in milliseconds before the next frame is shown. For + /// new frames, zero selects the default gap and a negative value + /// creates a gapless (never displayed) frame. For edits, zero + /// leaves the gap unchanged. + gap_ms: i32 = 0, // z + composition_mode: CompositionMode = .alpha_blend, // X background: Background = .{}, // Y + /// The canvas background color as a 32-bit RGBA value where R is + /// the most significant byte (0xRRGGBBAA), e.g. Y=4278190335 + /// (0xff0000ff) is opaque red. Bit-cast from the little-endian + /// u32 value, so the field order is reversed from the byte order. pub const Background = packed struct(u32) { - r: u8 = 0, - g: u8 = 0, - b: u8 = 0, a: u8 = 0, + b: u8 = 0, + g: u8 = 0, + r: u8 = 0, }; - fn parse(kv: KV) !AnimationFrameLoading { - var result: AnimationFrameLoading = .{}; + fn parse(kv: KV) error{InvalidFormat}!AnimationFrameLoading { + var result: AnimationFrameLoading = .{ + .transmission = try .parse(kv), + }; if (kv.get('x')) |v| { result.x = v; @@ -732,15 +819,15 @@ pub const AnimationFrameLoading = struct { } if (kv.get('z')) |v| { - result.gap_ms = v; + // 'z' is one of the keys Parser.finishValue parses as an + // i32, so the u32 in the KV table is the bit pattern of + // that i32 and this reconstructs it exactly. + result.gap_ms = @bitCast(v); } + // Tested only against 1 like Kitty. if (kv.get('X')) |v| { - result.composition_mode = switch (v) { - 0 => .alpha_blend, - 1 => .overwrite, - else => return error.InvalidFormat, - }; + result.composition_mode = if (v == 1) .overwrite else .alpha_blend; } if (kv.get('Y')) |v| { @@ -752,25 +839,56 @@ pub const AnimationFrameLoading = struct { }; pub const AnimationFrameComposition = struct { - frame: u32 = 0, // c - edit_frame: u32 = 0, // r + image_id: u32 = 0, // i + image_number: u32 = 0, // I + placement_id: u32 = 0, // p + + /// The 1-based frame number being edited (the destination). + dest_frame: u32 = 0, // c + + /// The 1-based frame number whose pixels are composed onto the + /// destination (the source). + source_frame: u32 = 0, // r + + /// The left/top edge (pixels) of the destination rectangle. x: u32 = 0, // x y: u32 = 0, // y + + /// The size of the rectangle to compose. Zero selects the full + /// image width/height. width: u32 = 0, // w height: u32 = 0, // h + + /// The left/top edge (pixels) of the source rectangle. left_edge: u32 = 0, // X top_edge: u32 = 0, // Y + composition_mode: CompositionMode = .alpha_blend, // C - fn parse(kv: KV) !AnimationFrameComposition { + // Cannot fail: every key is stored as parsed, with no range + // validation (Kitty range-checks these while handling the + // command; see the comment above the execution-deferral tests). + fn parse(kv: KV) AnimationFrameComposition { var result: AnimationFrameComposition = .{}; + if (kv.get('i')) |v| { + result.image_id = v; + } + + if (kv.get('I')) |v| { + result.image_number = v; + } + + if (kv.get('p')) |v| { + result.placement_id = v; + } + if (kv.get('c')) |v| { - result.frame = v; + result.dest_frame = v; } if (kv.get('r')) |v| { - result.edit_frame = v; + result.source_frame = v; } if (kv.get('x')) |v| { @@ -798,11 +916,8 @@ pub const AnimationFrameComposition = struct { } if (kv.get('C')) |v| { - result.composition_mode = switch (v) { - 0 => .alpha_blend, - 1 => .overwrite, - else => return error.InvalidFormat, - }; + // Kitty tests this against zero for compose so any non-zero overwrites + result.composition_mode = if (v == 0) .alpha_blend else .overwrite; } return result; @@ -810,10 +925,24 @@ pub const AnimationFrameComposition = struct { }; pub const AnimationControl = struct { + image_id: u32 = 0, // i + image_number: u32 = 0, // I + placement_id: u32 = 0, // p action: AnimationAction = .invalid, // s + + /// The 1-based frame number whose gap is set from gap_ms. frame: u32 = 0, // r - gap_ms: u32 = 0, // z + + /// The new gap for `frame` in milliseconds. Zero is ignored and a + /// negative value makes the frame gapless. + gap_ms: i32 = 0, // z + + /// The 1-based frame number to make current (client-driven + /// animation). Zero is ignored. current_frame: u32 = 0, // c + + /// The loop count: zero is ignored, 1 loops forever, and any + /// larger value plays that many minus one loops. loops: u32 = 0, // v pub const AnimationAction = enum { @@ -823,16 +952,32 @@ pub const AnimationControl = struct { run, // 3 }; - fn parse(kv: KV) !AnimationControl { + // Cannot fail: every key is stored as parsed. Out-of-range values + // (frame numbers, states) are silently ignored during execution, + // matching Kitty. + fn parse(kv: KV) AnimationControl { var result: AnimationControl = .{}; + if (kv.get('i')) |v| { + result.image_id = v; + } + + if (kv.get('I')) |v| { + result.image_number = v; + } + + if (kv.get('p')) |v| { + result.placement_id = v; + } + if (kv.get('s')) |v| { + // Kitty ignores values it doesn't know, leaving the animation + // state untouched, which is what `.invalid` means here. result.action = switch (v) { - 0 => .invalid, 1 => .stop, 2 => .run_wait, 3 => .run, - else => return error.InvalidFormat, + else => .invalid, }; } @@ -841,7 +986,10 @@ pub const AnimationControl = struct { } if (kv.get('z')) |v| { - result.gap_ms = v; + // 'z' is one of the keys Parser.finishValue parses as an + // i32, so the u32 in the KV table is the bit pattern of + // that i32 and this reconstructs it exactly. + result.gap_ms = @bitCast(v); } if (kv.get('c')) |v| { @@ -856,180 +1004,218 @@ pub const AnimationControl = struct { } }; -pub const Delete = union(enum) { - // a/A - all: bool, +pub const Delete = struct { + image_id: u32 = 0, // i + image_number: u32 = 0, // I + placement_id: u32 = 0, // p + action: Action, - // i/I - id: struct { - delete: bool = false, // uppercase - image_id: u32 = 0, // i - placement_id: u32 = 0, // p - }, + fn parse(kv: KV) !Delete { + return .{ + .image_id = kv.get('i') orelse 0, + .image_number = kv.get('I') orelse 0, + .placement_id = kv.get('p') orelse 0, + .action = try .parse(kv), + }; + } - // n/N - newest: struct { - delete: bool = false, // uppercase - image_number: u32 = 0, // I - placement_id: u32 = 0, // p - }, + pub const Action = union(enum) { + // a/A + all: bool, - // c/C, - intersect_cursor: bool, + // i/I + id: struct { + delete: bool = false, // uppercase + image_id: u32 = 0, // i + placement_id: u32 = 0, // p + }, - // f/F - animation_frames: bool, + // n/N + newest: struct { + delete: bool = false, // uppercase + image_number: u32 = 0, // I + placement_id: u32 = 0, // p + }, - // p/P - intersect_cell: struct { - delete: bool = false, // uppercase - x: u32 = 0, // x - y: u32 = 0, // y - }, + // c/C, + intersect_cursor: bool, - // q/Q - intersect_cell_z: struct { - delete: bool = false, // uppercase - x: u32 = 0, // x - y: u32 = 0, // y - z: i32 = 0, // z - }, + // f/F + animation_frames: AnimationFrames, - // r/R - range: struct { - delete: bool = false, // uppercase - first: u32 = 0, // x - last: u32 = 0, // y - }, + // p/P + intersect_cell: struct { + delete: bool = false, // uppercase + x: u32 = 0, // x + y: u32 = 0, // y + }, - // x/X - column: struct { - delete: bool = false, // uppercase - x: u32 = 0, // x - }, + // q/Q + intersect_cell_z: struct { + delete: bool = false, // uppercase + x: u32 = 0, // x + y: u32 = 0, // y + z: i32 = 0, // z + }, - // y/Y - row: struct { - delete: bool = false, // uppercase - y: u32 = 0, // y - }, + // r/R + range: struct { + delete: bool = false, // uppercase + first: u32 = 0, // x + last: u32 = 0, // y + }, - // z/Z - z: struct { - delete: bool = false, // uppercase - z: i32 = 0, // z - }, + // x/X + column: struct { + delete: bool = false, // uppercase + x: u32 = 0, // x + }, - fn parse(kv: KV) !Delete { - const what: u8 = what: { - const value = kv.get('d') orelse break :what 'a'; - const c = std.math.cast(u8, value) orelse return error.InvalidFormat; - break :what c; + // y/Y + row: struct { + delete: bool = false, // uppercase + y: u32 = 0, // y + }, + + // z/Z + z: struct { + delete: bool = false, // uppercase + z: i32 = 0, // z + }, + + pub const AnimationFrames = struct { + delete: bool = false, // uppercase + image_id: u32 = 0, // i + image_number: u32 = 0, // I + frame: u32 = 0, // r }; - return switch (what) { - 'a', 'A' => .{ .all = what == 'A' }, + fn parse(kv: KV) error{InvalidFormat}!Action { + const what: u8 = what: { + const value = kv.get('d') orelse break :what 'a'; + const c = std.math.cast(u8, value) orelse return error.InvalidFormat; + break :what c; + }; - 'i', 'I' => blk: { - var result: Delete = .{ .id = .{ .delete = what == 'I' } }; - if (kv.get('i')) |v| { - result.id.image_id = v; - } - if (kv.get('p')) |v| { - result.id.placement_id = v; - } + return switch (what) { + 'a', 'A' => .{ .all = what == 'A' }, - break :blk result; - }, + 'i', 'I' => blk: { + var result: Action = .{ .id = .{ .delete = what == 'I' } }; + if (kv.get('i')) |v| { + result.id.image_id = v; + } + if (kv.get('p')) |v| { + result.id.placement_id = v; + } - 'n', 'N' => blk: { - var result: Delete = .{ .newest = .{ .delete = what == 'N' } }; - if (kv.get('I')) |v| { - result.newest.image_number = v; - } - if (kv.get('p')) |v| { - result.newest.placement_id = v; - } + break :blk result; + }, - break :blk result; - }, + 'n', 'N' => blk: { + var result: Action = .{ .newest = .{ .delete = what == 'N' } }; + if (kv.get('I')) |v| { + result.newest.image_number = v; + } + if (kv.get('p')) |v| { + result.newest.placement_id = v; + } - 'c', 'C' => .{ .intersect_cursor = what == 'C' }, + break :blk result; + }, - 'f', 'F' => .{ .animation_frames = what == 'F' }, + 'c', 'C' => .{ .intersect_cursor = what == 'C' }, - 'p', 'P' => blk: { - var result: Delete = .{ .intersect_cell = .{ .delete = what == 'P' } }; - if (kv.get('x')) |v| { - result.intersect_cell.x = v; - } - if (kv.get('y')) |v| { - result.intersect_cell.y = v; - } + 'f', 'F' => blk: { + var result: Action = .{ .animation_frames = .{ .delete = what == 'F' } }; + if (kv.get('i')) |v| { + result.animation_frames.image_id = v; + } + if (kv.get('I')) |v| { + result.animation_frames.image_number = v; + } + if (kv.get('r')) |v| { + result.animation_frames.frame = v; + } - break :blk result; - }, + break :blk result; + }, - 'q', 'Q' => blk: { - var result: Delete = .{ .intersect_cell_z = .{ .delete = what == 'Q' } }; - if (kv.get('x')) |v| { - result.intersect_cell_z.x = v; - } - if (kv.get('y')) |v| { - result.intersect_cell_z.y = v; - } - if (kv.get('z')) |v| { - // We can bitcast here because of how we parse it earlier. - result.intersect_cell_z.z = @bitCast(v); - } + 'p', 'P' => blk: { + var result: Action = .{ .intersect_cell = .{ .delete = what == 'P' } }; + if (kv.get('x')) |v| { + result.intersect_cell.x = v; + } + if (kv.get('y')) |v| { + result.intersect_cell.y = v; + } - break :blk result; - }, + break :blk result; + }, - 'r', 'R' => blk: { - const x = kv.get('x') orelse return error.InvalidFormat; - const y = kv.get('y') orelse return error.InvalidFormat; - if (x > y) return error.InvalidFormat; - break :blk .{ - .range = .{ - .delete = what == 'R', - .first = x, - .last = y, - }, - }; - }, + 'q', 'Q' => blk: { + var result: Action = .{ .intersect_cell_z = .{ .delete = what == 'Q' } }; + if (kv.get('x')) |v| { + result.intersect_cell_z.x = v; + } + if (kv.get('y')) |v| { + result.intersect_cell_z.y = v; + } + if (kv.get('z')) |v| { + // We can bitcast here because of how we parse it earlier. + result.intersect_cell_z.z = @bitCast(v); + } - 'x', 'X' => blk: { - var result: Delete = .{ .column = .{ .delete = what == 'X' } }; - if (kv.get('x')) |v| { - result.column.x = v; - } + break :blk result; + }, - break :blk result; - }, + 'r', 'R' => blk: { + // Both bounds default to zero when omitted and no + // validation is performed; an inverted or zero range + // simply matches no images. + var result: Action = .{ .range = .{ .delete = what == 'R' } }; + if (kv.get('x')) |v| { + result.range.first = v; + } + if (kv.get('y')) |v| { + result.range.last = v; + } - 'y', 'Y' => blk: { - var result: Delete = .{ .row = .{ .delete = what == 'Y' } }; - if (kv.get('y')) |v| { - result.row.y = v; - } + break :blk result; + }, - break :blk result; - }, + 'x', 'X' => blk: { + var result: Action = .{ .column = .{ .delete = what == 'X' } }; + if (kv.get('x')) |v| { + result.column.x = v; + } - 'z', 'Z' => blk: { - var result: Delete = .{ .z = .{ .delete = what == 'Z' } }; - if (kv.get('z')) |v| { - // We can bitcast here because of how we parse it earlier. - result.z.z = @bitCast(v); - } + break :blk result; + }, - break :blk result; - }, + 'y', 'Y' => blk: { + var result: Action = .{ .row = .{ .delete = what == 'Y' } }; + if (kv.get('y')) |v| { + result.row.y = v; + } - else => return error.InvalidFormat, - }; - } + break :blk result; + }, + + 'z', 'Z' => blk: { + var result: Action = .{ .z = .{ .delete = what == 'Z' } }; + if (kv.get('z')) |v| { + // We can bitcast here because of how we parse it earlier. + result.z.z = @bitCast(v); + } + + break :blk result; + }, + + else => return error.InvalidFormat, + }; + } + }; }; pub const CompositionMode = enum { @@ -1210,7 +1396,7 @@ test "delete command" { defer command.deinit(alloc); try testing.expect(command.control == .delete); - const v = command.control.delete; + const v = command.control.delete.action; try testing.expect(v == .intersect_cell); const dv = v.intersect_cell; try testing.expect(!dv.delete); @@ -1428,7 +1614,7 @@ test "delete range command 1" { defer command.deinit(alloc); try testing.expect(command.control == .delete); - const v = command.control.delete; + const v = command.control.delete.action; try testing.expect(v == .range); const range = v.range; try testing.expect(!range.delete); @@ -1448,7 +1634,7 @@ test "delete range command 2" { defer command.deinit(alloc); try testing.expect(command.control == .delete); - const v = command.control.delete; + const v = command.control.delete.action; try testing.expect(v == .range); const range = v.range; try testing.expect(range.delete); @@ -1457,6 +1643,9 @@ test "delete range command 2" { } test "delete range command 3" { + // An inverted range is accepted; it simply matches no images. This + // matches kitty, which does no validation and filters with + // `x <= image_id <= y`. const testing = std.testing; const alloc = testing.allocator; var p = Parser.init(alloc, 1024 * 1024); @@ -1464,10 +1653,21 @@ test "delete range command 3" { const input = "a=d,d=R,x=5,y=4"; for (input) |c| try p.feed(c); - try testing.expectError(error.InvalidFormat, p.complete(alloc)); + const command = try p.complete(alloc); + defer command.deinit(alloc); + + try testing.expect(command.control == .delete); + const v = command.control.delete.action; + try testing.expect(v == .range); + const range = v.range; + try testing.expect(range.delete); + try testing.expectEqual(@as(u32, 5), range.first); + try testing.expectEqual(@as(u32, 4), range.last); } test "delete range command 4" { + // An omitted upper bound defaults to zero, which matches no images + // since image IDs are always non-zero. const testing = std.testing; const alloc = testing.allocator; var p = Parser.init(alloc, 1024 * 1024); @@ -1475,7 +1675,16 @@ test "delete range command 4" { const input = "a=d,d=R,x=5"; for (input) |c| try p.feed(c); - try testing.expectError(error.InvalidFormat, p.complete(alloc)); + const command = try p.complete(alloc); + defer command.deinit(alloc); + + try testing.expect(command.control == .delete); + const v = command.control.delete.action; + try testing.expect(v == .range); + const range = v.range; + try testing.expect(range.delete); + try testing.expectEqual(@as(u32, 5), range.first); + try testing.expectEqual(@as(u32, 0), range.last); } test "delete range command 5" { @@ -1486,5 +1695,178 @@ test "delete range command 5" { const input = "a=d,d=R,y=5"; for (input) |c| try p.feed(c); - try testing.expectError(error.InvalidFormat, p.complete(alloc)); + const command = try p.complete(alloc); + defer command.deinit(alloc); + + try testing.expect(command.control == .delete); + const v = command.control.delete.action; + try testing.expect(v == .range); + const range = v.range; + try testing.expect(range.delete); + try testing.expectEqual(@as(u32, 0), range.first); + try testing.expectEqual(@as(u32, 5), range.last); +} + +test "delete range command 6" { + // Both bounds omitted defaults to the empty range [0, 0]. + const testing = std.testing; + const alloc = testing.allocator; + var p = Parser.init(alloc, 1024 * 1024); + defer p.deinit(); + + const input = "a=d,d=r"; + for (input) |c| try p.feed(c); + const command = try p.complete(alloc); + defer command.deinit(alloc); + + try testing.expect(command.control == .delete); + const v = command.control.delete.action; + try testing.expect(v == .range); + const range = v.range; + try testing.expect(!range.delete); + try testing.expectEqual(@as(u32, 0), range.first); + try testing.expectEqual(@as(u32, 0), range.last); +} + +// Kitty range-checks non-flag keys while handling the command, not while +// parsing, so a bad value never drops the command (which would make a +// response impossible even when the client gave an id). The flag keys +// "a", "d", "t" and "o" are the exception. + +test "unknown format value is deferred to execution" { + const testing = std.testing; + const alloc = testing.allocator; + + const command = try Parser.parseString(alloc, "a=t,f=42,i=31,s=1,v=1"); + defer command.deinit(alloc); + + try testing.expect(command.control == .transmit); + try testing.expect(command.control.transmit.format_unknown); + try testing.expectEqual(@as(u32, 31), command.control.transmit.image_id); +} + +test "zero format value is rgba" { + const testing = std.testing; + const alloc = testing.allocator; + + const command = try Parser.parseString(alloc, "a=t,f=0,i=31,s=1,v=1"); + defer command.deinit(alloc); + + try testing.expect(command.control == .transmit); + const v = command.control.transmit; + try testing.expect(!v.format_unknown); + try testing.expectEqual(Transmission.Format.rgba, v.format); +} + +test "known format values are not unknown" { + const testing = std.testing; + const alloc = testing.allocator; + + for ([_]struct { []const u8, Transmission.Format }{ + .{ "f=24", .rgb }, + .{ "f=32", .rgba }, + .{ "f=100", .png }, + }) |entry| { + const command = try Parser.parseString(alloc, entry[0]); + defer command.deinit(alloc); + + const v = command.control.transmit; + try testing.expect(!v.format_unknown); + try testing.expectEqual(entry[1], v.format); + } +} + +test "quiet value above two suppresses all responses" { + const testing = std.testing; + const alloc = testing.allocator; + + const command = try Parser.parseString(alloc, "a=t,q=3,i=31,s=1,v=1"); + defer command.deinit(alloc); + + try testing.expectEqual(Command.Quiet.failures, command.quiet); +} + +test "cursor movement value above one moves the cursor" { + const testing = std.testing; + const alloc = testing.allocator; + + const command = try Parser.parseString(alloc, "a=p,i=31,C=2"); + defer command.deinit(alloc); + + try testing.expect(command.control == .display); + try testing.expectEqual( + Display.CursorMovement.after, + command.control.display.cursor_movement, + ); +} + +test "virtual placement value above one is virtual" { + const testing = std.testing; + const alloc = testing.allocator; + + const command = try Parser.parseString(alloc, "a=p,i=31,U=2"); + defer command.deinit(alloc); + + try testing.expect(command.control == .display); + try testing.expect(command.control.display.virtual_placement); +} + +test "animation frame composition value above one alpha blends" { + const testing = std.testing; + const alloc = testing.allocator; + + const command = try Parser.parseString(alloc, "a=f,i=31,X=2"); + defer command.deinit(alloc); + + try testing.expect(command.control == .transmit_animation_frame); + try testing.expectEqual( + CompositionMode.alpha_blend, + command.control.transmit_animation_frame.composition_mode, + ); +} + +test "animation compose value above one overwrites" { + const testing = std.testing; + const alloc = testing.allocator; + + const command = try Parser.parseString(alloc, "a=c,i=31,C=2"); + defer command.deinit(alloc); + + try testing.expect(command.control == .compose_animation); + try testing.expectEqual( + CompositionMode.overwrite, + command.control.compose_animation.composition_mode, + ); +} + +test "animation control action above three is ignored" { + const testing = std.testing; + const alloc = testing.allocator; + + const command = try Parser.parseString(alloc, "a=a,i=31,s=4"); + defer command.deinit(alloc); + + try testing.expect(command.control == .control_animation); + try testing.expectEqual( + AnimationControl.AnimationAction.invalid, + command.control.control_animation.action, + ); +} + +test "unknown flag key values still fail parsing" { + const testing = std.testing; + const alloc = testing.allocator; + + // Kitty rejects these while parsing too. See gen/apc_parsers.py. + for ([_][]const u8{ + "a=z,i=31", // action + "a=d,d=w,i=31", // delete action + "a=t,t=q,i=31", // transmission medium + "a=t,o=q,i=31", // compression + }) |input| { + try testing.expectError( + error.InvalidFormat, + Parser.parseString(alloc, input), + ); + } } diff --git a/vendor/libghostty-vt/src/terminal/kitty/graphics_exec.zig b/vendor/libghostty-vt/src/terminal/kitty/graphics_exec.zig index 03a0882..b1b968b 100644 --- a/vendor/libghostty-vt/src/terminal/kitty/graphics_exec.zig +++ b/vendor/libghostty-vt/src/terminal/kitty/graphics_exec.zig @@ -5,6 +5,8 @@ const Allocator = std.mem.Allocator; const Terminal = @import("../Terminal.zig"); const command = @import("graphics_command.zig"); const image = @import("graphics_image.zig"); +const animation = @import("graphics_animation.zig"); +const pixel = @import("graphics_pixel.zig"); const Command = command.Command; const Response = command.Response; const LoadingImage = image.LoadingImage; @@ -44,12 +46,32 @@ pub fn execute( // this can change. var quiet = cmd.quiet; + // The protocol makes i and I mutually exclusive for every action, so this + // must happen before dispatch and before an action can mutate storage. + // https://sw.kovidgoyal.net/kitty/graphics-protocol/#requesting-image-ids-from-the-terminal + const identifiers = cmd.control.identifiers(); + if (identifiers.image_id > 0 and identifiers.image_number > 0) { + const resp: Response = .{ + .id = identifiers.image_id, + .image_number = identifiers.image_number, + .placement_id = identifiers.placement_id, + .message = "EINVAL: image ID and number are mutually exclusive", + }; + log.warn("erroneous kitty graphics response: {s}", .{resp.message}); + + return switch (quiet) { + .no => resp, + .ok => resp, + .failures => null, + }; + } + const resp_: ?Response = switch (cmd.control) { .query => query(io, alloc, terminal, cmd), .display => display(io, alloc, terminal, cmd), .delete => delete(io, alloc, terminal, cmd), - .transmit, .transmit_and_display => resp: { + .transmit, .transmit_and_display, .transmit_animation_frame => resp: { // If we're transmitting, then our `q` setting value is complicated. // The `q` setting inherits the value from the starting command // unless `q` is set >= 1 on this command. If it is, then we save @@ -66,13 +88,35 @@ pub fn execute( }, }; - break :resp transmit(io, alloc, terminal, cmd); + break :resp switch (cmd.control) { + .transmit_animation_frame => transmitAnimationFrame( + io, + alloc, + terminal, + cmd, + ), + else => transmit( + io, + alloc, + terminal, + cmd, + ), + }; }, - .transmit_animation_frame, - .control_animation, - .compose_animation, - => .{ .message = "ERROR: unimplemented action" }, + .control_animation => controlAnimation( + io, + alloc, + terminal, + cmd, + ), + + .compose_animation => composeAnimation( + io, + alloc, + terminal, + cmd, + ), }; // Handle the quiet settings @@ -90,6 +134,7 @@ pub fn execute( return null; } + /// Execute a "query" command. /// /// This command is used to attempt to load an image and respond with @@ -117,13 +162,21 @@ fn query( .placement_id = t.placement_id, }; - // Attempt to load the image. If we cannot, then set an appropriate error. + // A query must attempt a complete load, then discard the result without + // changing image storage. + // https://sw.kovidgoyal.net/kitty/graphics-protocol/#querying-support-and-available-transmission-mediums const storage = &terminal.screens.active.kitty_images; var loading = LoadingImage.init(io, alloc, cmd, storage.image_limits) catch |err| { encodeError(&result, err); return result; }; - loading.deinit(alloc); + defer loading.deinit(alloc); + + var img = loading.complete(alloc) catch |err| { + encodeError(&result, err); + return result; + }; + img.deinit(alloc); return result; } @@ -139,14 +192,27 @@ fn transmit( cmd: *const Command, ) Response { const t = cmd.transmission().?; - var result: Response = .{ + const storage = &terminal.screens.active.kitty_images; + + var result: Response = if (storage.loading) |loading| loading: { + // Any transmit-like command received while a load is in progress + // is a continuation chunk of that load, and the load completes + // according to how it started. If an animation frame load (a=f) + // is in progress, this chunk belongs to it even when the client + // didn't repeat a=f on the chunk. + if (loading.frame != null) return transmitAnimationFrame( + io, + alloc, + terminal, + cmd, + ); + + break :loading loading.response; + } else .{ .id = t.image_id, .image_number = t.image_number, .placement_id = t.placement_id, }; - if (t.image_id > 0 and t.image_number > 0) { - return .{ .message = "EINVAL: image ID and number are mutually exclusive" }; - } const load = loadAndAddImage(io, alloc, terminal, cmd) catch |err| { encodeError(&result, err); @@ -202,6 +268,13 @@ fn display( .placement_id = d.placement_id, }; + // A virtual placement (U=1) cannot also be a relative placement. + // Kitty checks this before even looking up the image. + if (d.virtual_placement and d.parent_id > 0) { + result.message = "EINVAL: virtual placement cannot refer to a parent"; + return result; + } + // Verify the requested image exists if we have an ID const storage = &terminal.screens.active.kitty_images; const img_: ?Image = if (d.image_id != 0) @@ -219,39 +292,79 @@ fn display( // Location where the placement will go. const location: ImageStorage.Placement.Location = location: { // Virtual placements are not tracked - if (d.virtual_placement) { - if (d.parent_id > 0) { - result.message = "EINVAL: virtual placement cannot refer to a parent"; - return result; - } + if (d.virtual_placement) break :location .virtual; - break :location .{ .virtual = {} }; + // No parent reference (P=): the placement is pinned to the + // cursor. The cursor is always tracked but we don't want + // this pin to move with the cursor. + if (d.parent_id == 0) { + const pin = terminal.screens.active.pages.trackPin( + terminal.screens.active.cursor.page_pin.*, + ) catch |err| { + log.warn("failed to create pin for Kitty graphics err={}", .{err}); + result.message = "EINVAL: failed to prepare terminal state"; + return result; + }; + break :location .{ .pin = pin }; } - // Track a new pin for our cursor. The cursor is always tracked but we - // don't want this one to move with the cursor. - const pin = terminal.screens.active.pages.trackPin( - terminal.screens.active.cursor.page_pin.*, + // A parent reference makes this a relative placement: it is + // positioned relative to the parent placement instead of the + // cursor. + + // The key of the placement being created, when it is + // addressable (an explicit placement ID). Needed for + // self-parent and cycle detection. + const child: ?ImageStorage.PlacementKey = if (d.placement_id > 0) .{ + .image_id = img.id, + .placement_id = .{ .tag = .external, .id = d.placement_id }, + } else null; + + const parent = storage.resolveParent( + io, + terminal.screens.active, + child, + d.parent_id, + d.parent_placement_id, ) catch |err| { - log.warn("failed to create pin for Kitty graphics err={}", .{err}); - result.message = "EINVAL: failed to prepare terminal state"; + result.message = switch (err) { + error.ParentImageNotFound => "ENOPARENT: parent image not found", + error.ParentPlacementNotFound => "ENOPARENT: parent placement not found", + error.SelfParent => "EINVAL: placement cannot be its own parent", + error.Cycle => "ECYCLE: parent chain creates a cycle", + error.TooDeep => "ETOODEEP: parent chain too deep", + error.AncestorNotFound => "ENOENT: parent chain ancestor not found", + }; return result; }; - break :location .{ .pin = pin }; + + break :location .{ .relative = .{ + .parent = parent, + .horizontal_offset = d.horizontal_offset, + .vertical_offset = d.vertical_offset, + } }; }; // Add the placement - const p: ImageStorage.Placement = .{ - .location = location, - .x_offset = d.x_offset, - .y_offset = d.y_offset, - .source_x = d.x, - .source_y = d.y, - .source_width = d.width, - .source_height = d.height, - .columns = d.columns, - .rows = d.rows, - .z = d.z, + const p: ImageStorage.Placement = placement: { + var p: ImageStorage.Placement = .{ + .location = location, + .x_offset = d.x_offset, + .y_offset = d.y_offset, + .source_x = d.x, + .source_y = d.y, + .source_width = d.width, + .source_height = d.height, + .columns = d.columns, + .rows = d.rows, + .z = d.z, + }; + + const cell_offset = p.cellOffset(terminal); + if (terminal.width_px / terminal.cols > 0) p.x_offset = cell_offset.x; + if (terminal.height_px / terminal.rows > 0) p.y_offset = cell_offset.y; + + break :placement p; }; storage.addPlacement( io, @@ -266,29 +379,54 @@ fn display( return result; }; - // Apply cursor movement setting. This only applies to pin placements. + // Apply cursor movement setting. This only applies to pin placements: + // relative placements never move the cursor regardless of C=, just + // like kitty. switch (p.location) { - .virtual => {}, + .virtual, .relative => {}, .pin => |pin| switch (d.cursor_movement) { .none => {}, .after => { // We use terminal.index to properly handle scroll regions. + const screen = terminal.screens.active; const size = p.gridSize(img, terminal); - // Once the requested movement leaves the screen, its exact - // position is undefined by the Kitty graphics protocol. Bound - // the work so an untrusted row count can't make us spin. + const target_x = @as(usize, pin.x) +| @as(usize, size.cols); + const wraps = target_x >= @as(usize, terminal.cols); + const requested_rows = + (@as(usize, size.rows) -| 1) +| @intFromBool(wraps); + + // The requested row count comes from the application and can + // be as large as u32. Calling terminal.index once for every + // row could therefore leave the terminal unresponsive for a + // very long time. + // + // First allow enough calls to reach the bottom of the scroll + // region. Each call after that scrolls the region by one row, + // so limit those extra calls to one screen. This follows + // Kitty's behavior while keeping the amount of work bounded. + const region = terminal.scrolling_region; + const rows_before_scroll: usize = if (screen.cursor.y >= region.top and + screen.cursor.y <= region.bottom and + screen.cursor.x >= region.left and + screen.cursor.x <= region.right) + @as(usize, region.bottom - screen.cursor.y) + else + 0; const rows_to_move: usize = @min( - @as(usize, size.rows), - @as(usize, terminal.rows), + requested_rows, + rows_before_scroll +| @as(usize, terminal.rows), ); for (0..rows_to_move) |_| terminal.index() catch |err| { log.warn("failed to move cursor: {}", .{err}); break; }; - terminal.setCursorPos( - terminal.screens.active.cursor.y, - @as(usize, pin.x) +| @as(usize, size.cols) +| 1, + // Kitty wraps once when the movement reaches the right edge; + // otherwise it leaves the cursor immediately to the right of + // the placement. + screen.cursor.pending_wrap = false; + screen.cursorHorizontalAbsolute( + if (wraps) 0 else @intCast(target_x), ); }, }, @@ -297,6 +435,527 @@ fn display( return result; } +/// Transmit animation frame data (a=f). +/// +/// Frame data is loaded exactly like image data, including chunking +/// and every transmission medium, and once complete it is composed +/// into a frame of an existing image's animation. +fn transmitAnimationFrame( + io: std.Io, + alloc: Allocator, + terminal: *Terminal, + cmd: *const Command, +) Response { + const storage = &terminal.screens.active.kitty_images; + + // A chunk arriving while a load is in progress continues that load. + if (storage.loading) |loading| { + // If this is the first frame, then we treat it like a normal image + // because the first frame is just the image. + if (loading.frame == null) return transmit( + io, + alloc, + terminal, + cmd, + ); + + // Not the first frame, so continue loading similar to transmit + // but this is for a subsequent frame. + var result = loading.response; + loading.addData(alloc, cmd.data) catch |err| { + encodeError(&result, err); + return result; + }; + + // If more chunks are expected we don't respond yet. + const t = cmd.transmission().?; + if (t.more_chunks) return .{}; + + // Final chunk: copy the loading state out and free the + // pointer, mirroring loadAndAddImage. + var loading_copy = loading.*; + alloc.destroy(loading); + storage.loading = null; + defer loading_copy.deinit(alloc); + return completeAnimationFrame( + io, + alloc, + terminal, + &loading_copy, + ); + } + + // We are starting our load, either the first frame or a subsequent frame. + const f = cmd.control.transmit_animation_frame; + const t = f.transmission; + var result: Response = .{ + .id = t.image_id, + .image_number = t.image_number, + .placement_id = t.placement_id, + // Errors echo the client's frame number; on success this is + // replaced with the resolved (possibly newly assigned) one. + .frame = f.edit_frame, + }; + + // A frame can only be added to an existing image. + if (t.image_id == 0 and t.image_number == 0) { + result.message = "EINVAL: image ID or number required"; + return result; + } + const img = storage.imagePtrByIdOrNumber( + t.image_id, + t.image_number, + ) orelse { + result.message = "ENOENT: image not found"; + return result; + }; + result.id = img.id; + + var loading = LoadingImage.init( + io, + alloc, + cmd, + storage.image_limits, + ) catch |err| { + encodeError(&result, err); + return result; + }; + loading.frame = .{ + .cmd = f, + .image_generation = img.generation, + }; + loading.response.id = img.id; + + // Chunked: store the loading state and wait for the rest. The + // frame parameters above are saved with it; continuation chunks + // contribute only payload bytes. + if (t.more_chunks) { + const loading_ptr = alloc.create(LoadingImage) catch |err| { + loading.deinit(alloc); + encodeError(&result, err); + return result; + }; + loading_ptr.* = loading; + storage.loading = loading_ptr; + return .{}; + } + + defer loading.deinit(alloc); + return completeAnimationFrame( + io, + alloc, + terminal, + &loading, + ); +} + +/// Complete a fully loaded animation frame: validate it against its +/// image and compose it into the image's animation. +fn completeAnimationFrame( + io: std.Io, + alloc: Allocator, + terminal: *Terminal, + loading: *LoadingImage, +) Response { + const storage = &terminal.screens.active.kitty_images; + var result = loading.response; + const f = loading.frame.?.cmd; + + // Re-resolve the image: it may have been deleted, evicted, or + // replaced while the frame data was being transmitted. The saved + // generation pins the exact image the load started against. + var img = storage.imagePtrByIdOrNumber( + loading.image.id, + loading.image.number, + ) orelse { + result.message = "ENOENT: image not found"; + return result; + }; + if (img.generation != loading.frame.?.image_generation) { + result.message = "ENOENT: image not found"; + return result; + } + if (img.data.bytes() == null) { + result.message = "EINVAL: image data incomplete"; + return result; + } + + // Finish decoding the frame data: decompression, PNG decoding, + // and length validation all match image loading. + var frame_img = loading.complete(alloc) catch |err| { + encodeError(&result, err); + return result; + }; + defer frame_img.deinit(alloc); + + // The frame rectangle may not exceed the image's size. The x/y + // offsets are deliberately not validated: composition clips, + // matching Kitty. + if (frame_img.width > img.width or frame_img.height > img.height) { + result.message = "EINVAL: frame dimensions exceed image"; + return result; + } + + // All composition happens in RGBA; convert both sides as needed. + if (frame_img.format != .rgba) { + const rgba = pixel.rgbaFromFormat( + alloc, + frame_img.format, + frame_img.data.bytes().?, + ) catch |err| { + encodeError(&result, err); + return result; + }; + frame_img.data.deinit(alloc); + frame_img.data = .{ .complete = rgba }; + frame_img.format = .rgba; + } + storage.convertImageToRgba(io, alloc, img) catch |err| { + encodeError(&result, err); + return result; + }; + + const anim = ensureAnimation(alloc, img) catch |err| { + encodeError(&result, err); + return result; + }; + + // Resolve the frame number: r in 1..count edits that existing + // frame, anything else (including omitted) creates a new frame + // appended at count+1. The resolved number is echoed in the + // response so clients can learn assigned frame numbers. + const count: u32 = anim.frameCount(); + const number: u32 = number: { + const r = f.edit_frame; + if (r == 0 or r > count + 1) break :number count + 1; + break :number r; + }; + result.frame = number; + + const src = frame_img.data.bytes().?; + if (number == count + 1) { + // Creating a new frame. The gap defaults to 40ms when omitted + // and a negative gap creates a gapless (never shown) frame. + const gap: u32 = if (f.gap_ms > 0) + @intCast(f.gap_ms) + else if (f.gap_ms < 0) + 0 + else + animation.default_gap_ms; + + // The base canvas frame must exist before we reserve space. + if (f.create_frame > 0 and img.frameData(f.create_frame) == null) { + result.message = "EINVAL: base frame not found"; + return result; + } + + // Reserve room for the new frame, evicting other images if + // needed. Eviction can remove images (never this one), so we + // re-resolve our pointer afterwards to be safe. + const frame_len: usize = @as(usize, img.width) * img.height * 4; + const image_id = img.id; + storage.reserveAnimationBytes( + io, + alloc, + terminal.screens.active, + image_id, + frame_len, + ) catch { + result.message = "ENOSPC: animation frame storage full"; + return result; + }; + img = storage.imagePtrByIdOrNumber(image_id, 0).?; + + const canvas = alloc.alloc(u8, frame_len) catch { + storage.releaseAnimationBytes(frame_len); + result.message = "ENOMEM: out of memory"; + return result; + }; + if (f.create_frame > 0) { + @memcpy(canvas, img.frameData(f.create_frame).?); + } else { + pixel.fillBackground(canvas, f.background); + } + pixel.composeRect( + canvas, + img.width, + img.height, + src, + frame_img.width, + frame_img.height, + f.x, + f.y, + f.composition_mode, + ); + + anim.frames.append(alloc, .{ + .data = canvas, + .gap_ms = gap, + }) catch { + alloc.free(canvas); + storage.releaseAnimationBytes(frame_len); + result.message = "ENOMEM: out of memory"; + return result; + }; + + // A new frame never changes the displayed frame (playback + // reaches it later), but the storage content changed. + storage.markMutated(io); + } else { + // Editing an existing frame. A nonzero gap also updates the + // frame's gap; the 40ms default doesn't apply to edits. + // + // Unlike frame creation there is no byte reservation here: + // frames are always stored at full image size, so the edit + // composes into the existing buffer in place and storage + // usage cannot change. Kitty likewise exempts frame edits + // from its quota check. + if (f.gap_ms != 0) anim.setGapAt( + number - 1, + if (f.gap_ms > 0) @intCast(f.gap_ms) else 0, + ); + + // The frame data is owned by this storage, so the const cast + // is safe (same reasoning as the renderer's image uploads). + const dst = @constCast(img.frameData(number).?); + pixel.composeRect( + dst, + img.width, + img.height, + src, + frame_img.width, + frame_img.height, + f.x, + f.y, + f.composition_mode, + ); + + if (number - 1 == anim.current_index) { + // The displayed pixels changed; restart the frame's gap + // timer like Kitty's re-upload does. + anim.frame_shown_at_ms = null; + storage.markImageContentChanged(io, img); + } else { + storage.markMutated(io); + } + } + + return result; +} + +/// Control animation playback (a=a). +/// +/// Successful commands never respond. The only possible responses are +/// for a missing image or identifier. Invalid key values are silently +/// ignored, matching Kitty. +fn controlAnimation( + io: std.Io, + alloc: Allocator, + terminal: *Terminal, + cmd: *const Command, +) Response { + const a = cmd.control.control_animation; + const storage = &terminal.screens.active.kitty_images; + + var result: Response = .{ + .id = a.image_id, + .image_number = a.image_number, + .placement_id = a.placement_id, + }; + if (a.image_id == 0 and a.image_number == 0) { + result.message = "EINVAL: image ID or number required"; + return result; + } + const img = storage.imagePtrByIdOrNumber( + a.image_id, + a.image_number, + ) orelse { + result.message = "ENOENT: image not found"; + return result; + }; + + const anim = ensureAnimation(alloc, img) catch |err| { + encodeError(&result, err); + return result; + }; + + // The keys below are applied independently and in the same order as Kitty. + + // Set a frame's gap (r= together with z=). This is the only way + // to give the root frame a gap, since it is created gapless. + if (a.frame != 0 and a.frame <= anim.frameCount() and a.gap_ms != 0) { + anim.setGapAt( + a.frame - 1, + if (a.gap_ms > 0) @intCast(a.gap_ms) else 0, + ); + storage.markMutated(io); + } + + // Set the current frame (c=), the client-driven animation + // primitive. + if (a.current_frame != 0 and a.current_frame <= anim.frameCount() and + a.current_frame - 1 != anim.current_index) + { + anim.current_index = a.current_frame - 1; + anim.frame_shown_at_ms = null; + storage.markImageContentChanged(io, img); + } + + // Set the playback state (s=). Any state change resets the loop + // counter; leaving the stopped state restarts the gap timer. + if (a.action != .invalid) { + const old = anim.state; + anim.state = switch (a.action) { + .invalid => unreachable, + .stop => .stopped, + .run_wait => .loading, + .run => .running, + }; + if (old == .stopped and anim.state != .stopped) { + anim.frame_shown_at_ms = null; + } + anim.current_loop = 0; + storage.markMutated(io); + } + + // Set the loop count (v=), stored off by one per the protocol: + // v=1 loops forever, v=n plays n-1 loops. + if (a.loops != 0) { + anim.max_loops = a.loops - 1; + storage.markMutated(io); + } + + // Successful animation control commands never respond. + return .{}; +} + +/// Compose a rectangle of pixels from one animation frame onto +/// another (a=c). Frame 1 (the root frame) always exists, so this also works +/// on images without any animation state. +fn composeAnimation( + io: std.Io, + alloc: Allocator, + terminal: *Terminal, + cmd: *const Command, +) Response { + const c = cmd.control.compose_animation; + const storage = &terminal.screens.active.kitty_images; + + var result: Response = .{ + .id = c.image_id, + .image_number = c.image_number, + .placement_id = c.placement_id, + }; + if (c.image_id == 0 and c.image_number == 0) { + result.message = "EINVAL: image ID or number required"; + return result; + } + const img = storage.imagePtrByIdOrNumber( + c.image_id, + c.image_number, + ) orelse { + result.message = "ENOENT: image not found"; + return result; + }; + result.id = img.id; + if (img.data.bytes() == null) { + result.message = "EINVAL: image data incomplete"; + return result; + } + + // Both frames must exist. Note that r is the source frame and c + // the destination; the spec's reference table describes these + // backwards (see AnimationFrameComposition). + if (img.frameData(c.source_frame) == null) { + result.message = "ENOENT: source frame not found"; + return result; + } + if (img.frameData(c.dest_frame) == null) { + result.message = "ENOENT: destination frame not found"; + return result; + } + + // Rectangle validation is done in u64 so untrusted 32-bit values + // can't overflow. Out-of-bounds rectangles are errors here, + // unlike a=f which clips. + const width: u64 = if (c.width > 0) c.width else img.width; + const height: u64 = if (c.height > 0) c.height else img.height; + if (@as(u64, c.x) + width > img.width or + @as(u64, c.y) + height > img.height) + { + result.message = "EINVAL: destination rectangle out of bounds"; + return result; + } + if (@as(u64, c.left_edge) + width > img.width or + @as(u64, c.top_edge) + height > img.height) + { + result.message = "EINVAL: source rectangle out of bounds"; + return result; + } + + // Composing a frame onto itself requires non-overlapping + // rectangles. + if (c.source_frame == c.dest_frame) { + const x_overlaps = @max(c.left_edge, c.x) < @min(c.left_edge, c.x) + width; + const y_overlaps = @max(c.top_edge, c.y) < @min(c.top_edge, c.y) + height; + if (x_overlaps and y_overlaps) { + result.message = "EINVAL: source and destination rectangles overlap"; + return result; + } + } + + // All composition happens in RGBA. + storage.convertImageToRgba( + io, + alloc, + img, + ) catch |err| { + encodeError(&result, err); + return result; + }; + + // The frame data is owned by this storage, so the const cast is + // safe. The rectangles were validated disjoint above, so in-place + // composition within one frame is well-defined. + const src = img.frameData(c.source_frame).?; + const dst = @constCast(img.frameData(c.dest_frame).?); + pixel.composeCanvasRect( + dst, + src, + img.width, + @intCast(width), + @intCast(height), + c.left_edge, + c.top_edge, + c.x, + c.y, + c.composition_mode, + ); + + // If the destination is the displayed frame then the on-screen + // content changed. + const current: u32 = if (img.animation) |anim| anim.current_index else 0; + if (c.dest_frame - 1 == current) { + storage.markImageContentChanged(io, img); + } else { + storage.markMutated(io); + } + + return result; +} + +/// Get or lazily create the animation state for an image. +fn ensureAnimation( + alloc: Allocator, + img: *Image, +) Allocator.Error!*animation.Animation { + if (img.animation) |anim| return anim; + const anim = try alloc.create(animation.Animation); + anim.* = .{}; + img.animation = anim; + return anim; +} + /// Display a previously transmitted image. fn delete( io: std.Io, @@ -305,7 +964,20 @@ fn delete( cmd: *const Command, ) Response { const storage = &terminal.screens.active.kitty_images; - storage.delete(io, alloc, terminal, cmd.control.delete); + + // Every delete command aborts an incomplete chunked upload. + if (storage.loading) |loading| { + loading.destroy(alloc); + storage.loading = null; + } + + // Then perform the actual deletion request, too. + storage.delete( + io, + alloc, + terminal, + cmd.control.delete.action, + ); // Delete never responds on success return .{}; @@ -342,7 +1014,18 @@ fn loadAndAddImage( } break :loading loading.*; - } else try .init(io, alloc, cmd, storage.image_limits); + } else loading: { + // Reusing a specific image ID deletes the old image and all of its + // placements when the new transmission begins, not when it completes. + if (t.image_id > 0) { + storage.delete(io, alloc, terminal, .{ .id = .{ + .image_id = t.image_id, + .delete = true, + } }); + } + + break :loading try .init(io, alloc, cmd, storage.image_limits); + }; // We only want to deinit on error. If we're chunking, then we don't // want to deinit at all. If we're not chunking, then we'll deinit @@ -351,12 +1034,12 @@ fn loadAndAddImage( // If the image has no ID, we assign one if (loading.image.id == 0) { - loading.image.id = storage.next_image_id; - storage.next_image_id +%= 1; - - // If the image also has no number then its auto-ID is "implicit". - // See the doc comment on Image.metadata.implicit_id for more detail. - if (loading.image.number == 0) loading.image.metadata.implicit_id = true; + if (loading.image.number > 0) { + loading.image.id = storage.nextImageId(.explicit); + } else { + loading.image.id = storage.nextImageId(.implicit); + loading.image.metadata.implicit_id = true; + } } // If this is chunked, this is the beginning of a new chunked transmission. @@ -395,6 +1078,7 @@ const EncodeableError = Image.Error || Allocator.Error; fn encodeError(r: *Response, err: EncodeableError) void { switch (err) { error.OutOfMemory => r.message = "ENOMEM: out of memory", + error.InsufficientData => r.message = "ENODATA: insufficient data", error.InvalidData => r.message = "EINVAL: invalid data", error.DecompressionFailed => r.message = "EINVAL: decompression failed", error.FilePathTooLong => r.message = "EINVAL: file path too long", @@ -408,6 +1092,256 @@ fn encodeError(r: *Response, err: EncodeableError) void { } } +test "kittygfx query validates image data" { + const testing = std.testing; + const alloc = testing.allocator; + const io = testing.io; + + var terminal = try Terminal.init(io, alloc, .{ .rows = 5, .cols = 5 }); + defer terminal.deinit(alloc); + + var cmd: Command = .{ + .control = .{ .query = .{ + .format = .rgb, + .width = 1, + .height = 1, + .image_id = 31, + } }, + // A 1x1 RGB image requires three bytes. + .data = try alloc.dupe(u8, &.{ 0, 0 }), + }; + defer cmd.deinit(alloc); + + const resp = execute(io, alloc, &terminal, &cmd).?; + try testing.expect(!resp.ok()); + try testing.expectEqual(@as(u32, 31), resp.id); + try testing.expectEqualStrings("EINVAL: invalid data", resp.message); + try testing.expectEqual( + @as(usize, 0), + terminal.screens.active.kitty_images.images.count(), + ); +} + +test "kittygfx valid query does not replace or store image" { + const testing = std.testing; + const alloc = testing.allocator; + const io = testing.io; + + var terminal = try Terminal.init(io, alloc, .{ .rows = 5, .cols = 5 }); + defer terminal.deinit(alloc); + const storage = &terminal.screens.active.kitty_images; + + // Store a red pixel under the same ID used by the query. + { + const cmd = try command.Parser.parseString( + alloc, + "a=t,f=24,s=1,v=1,i=31;/wAA", + ); + defer cmd.deinit(alloc); + try testing.expect(execute(io, alloc, &terminal, &cmd).?.ok()); + } + + // Successfully validate a black pixel without replacing the red one. + var cmd: Command = .{ + .control = .{ .query = .{ + .format = .rgb, + .width = 1, + .height = 1, + .image_id = 31, + } }, + .data = try alloc.dupe(u8, &.{ 0, 0, 0 }), + }; + defer cmd.deinit(alloc); + + const resp = execute(io, alloc, &terminal, &cmd).?; + try testing.expect(resp.ok()); + try testing.expectEqual(@as(usize, 1), storage.images.count()); + try testing.expectEqualSlices( + u8, + &.{ 255, 0, 0 }, + storage.imageById(31).?.data.bytes().?, + ); +} + +test "kittygfx image id and number are mutually exclusive for every action" { + const testing = std.testing; + const alloc = testing.allocator; + const io = testing.io; + + var t = try Terminal.init(io, alloc, .{ .rows = 5, .cols = 5 }); + defer t.deinit(alloc); + + const inputs = [_][]const u8{ + "a=q,f=24,s=1,v=1,i=1,I=2,p=3;AAAA", + "a=t,f=24,s=1,v=1,i=1,I=2,p=3;AAAA", + "a=T,f=24,s=1,v=1,i=1,I=2,p=3;AAAA", + "a=p,i=1,I=2,p=3", + "a=d,d=a,i=1,I=2,p=3", + "a=f,i=1,I=2,p=3;AAAA", + "a=a,i=1,I=2,p=3", + "a=c,i=1,I=2,p=3", + }; + + for (inputs) |input| { + const cmd = try command.Parser.parseString(alloc, input); + defer cmd.deinit(alloc); + + const resp = execute(io, alloc, &t, &cmd).?; + try testing.expect(!resp.ok()); + try testing.expectEqual(@as(u32, 1), resp.id); + try testing.expectEqual(@as(u32, 2), resp.image_number); + try testing.expectEqual(@as(u32, 3), resp.placement_id); + try testing.expectEqualStrings( + "EINVAL: image ID and number are mutually exclusive", + resp.message, + ); + + var buf: [128]u8 = undefined; + var writer: std.Io.Writer = .fixed(&buf); + try resp.encode(&writer); + try testing.expectEqualStrings( + "\x1b_Gi=1,I=2,p=3;EINVAL: image ID and number are mutually exclusive\x1b\\", + writer.buffered(), + ); + } + + try testing.expectEqual(@as(usize, 0), t.screens.active.kitty_images.images.count()); + try testing.expectEqual(@as(usize, 0), t.screens.active.kitty_images.placements.count()); +} + +test "kittygfx conflicting identifiers are rejected before mutation" { + const testing = std.testing; + const alloc = testing.allocator; + const io = testing.io; + + var t = try Terminal.init(io, alloc, .{ .rows = 5, .cols = 5 }); + defer t.deinit(alloc); + const storage = &t.screens.active.kitty_images; + + // Store an image so a buggy put would succeed and mutate placement state. + { + const cmd = try command.Parser.parseString( + alloc, + "a=t,f=24,s=1,v=1,i=1;/wAA", + ); + defer cmd.deinit(alloc); + try testing.expect(execute(io, alloc, &t, &cmd).?.ok()); + } + + // Directly constructed commands must receive the same validation as + // commands produced by the parser. + { + const cmd: Command = .{ .control = .{ .display = .{ + .image_id = 1, + .image_number = 2, + .placement_id = 7, + .cursor_movement = .none, + } } }; + const resp = execute(io, alloc, &t, &cmd).?; + try testing.expect(!resp.ok()); + try testing.expectEqual(@as(u32, 1), resp.id); + try testing.expectEqual(@as(u32, 2), resp.image_number); + try testing.expectEqual(@as(u32, 7), resp.placement_id); + try testing.expectEqual(@as(usize, 0), storage.placements.count()); + } + + // Add a real placement so a buggy delete would remove it. + { + const cmd = try command.Parser.parseString(alloc, "a=p,i=1,p=7,C=1"); + defer cmd.deinit(alloc); + try testing.expect(execute(io, alloc, &t, &cmd).?.ok()); + try testing.expectEqual(@as(usize, 1), storage.placements.count()); + } + + { + const cmd = try command.Parser.parseString( + alloc, + "a=d,d=i,i=1,I=2,p=7", + ); + defer cmd.deinit(alloc); + const resp = execute(io, alloc, &t, &cmd).?; + try testing.expect(!resp.ok()); + try testing.expectEqual(@as(usize, 1), storage.placements.count()); + } + + // q=2 suppresses the required error response but not the validation. + { + const cmd = try command.Parser.parseString( + alloc, + "a=d,d=i,i=1,I=2,p=7,q=2", + ); + defer cmd.deinit(alloc); + try testing.expect(execute(io, alloc, &t, &cmd) == null); + try testing.expectEqual(@as(usize, 1), storage.placements.count()); + } +} + +test "kittygfx chunked success response uses initial identifiers" { + const testing = std.testing; + const alloc = testing.allocator; + const io = testing.io; + + var t = try Terminal.init(io, alloc, .{ .rows = 5, .cols = 5 }); + defer t.deinit(alloc); + + { + const cmd = try command.Parser.parseString( + alloc, + "a=t,f=24,s=1,v=2,I=93,p=7,m=1;AAAA", + ); + defer cmd.deinit(alloc); + try testing.expect(execute(io, alloc, &t, &cmd) == null); + } + + { + const cmd = try command.Parser.parseString(alloc, "m=0;AAAA"); + defer cmd.deinit(alloc); + const resp = execute(io, alloc, &t, &cmd).?; + + try testing.expect(resp.ok()); + try testing.expectEqual(@as(u32, 1), resp.id); + try testing.expectEqual(@as(u32, 93), resp.image_number); + try testing.expectEqual(@as(u32, 7), resp.placement_id); + + var buf: [128]u8 = undefined; + var writer: std.Io.Writer = .fixed(&buf); + try resp.encode(&writer); + try testing.expectEqualStrings( + "\x1b_Gi=1,I=93,p=7;OK\x1b\\", + writer.buffered(), + ); + } +} + +test "kittygfx chunked error response uses initial identifiers" { + const testing = std.testing; + const alloc = testing.allocator; + const io = testing.io; + + var t = try Terminal.init(io, alloc, .{ .rows = 5, .cols = 5 }); + defer t.deinit(alloc); + + { + const cmd = try command.Parser.parseString( + alloc, + "a=t,f=24,s=1,v=1,i=41,p=7,m=1;AA==", + ); + defer cmd.deinit(alloc); + try testing.expect(execute(io, alloc, &t, &cmd) == null); + } + + { + const cmd = try command.Parser.parseString(alloc, "m=0;AA=="); + defer cmd.deinit(alloc); + const resp = execute(io, alloc, &t, &cmd).?; + + try testing.expect(!resp.ok()); + try testing.expectEqual(@as(u32, 41), resp.id); + try testing.expectEqual(@as(u32, 7), resp.placement_id); + try testing.expectEqualStrings("EINVAL: invalid data", resp.message); + } +} + test "kittygfx more chunks with q=1" { const testing = std.testing; const alloc = testing.allocator; @@ -501,6 +1435,82 @@ test "kittygfx more chunks with chunk increasing q" { } } +test "kittygfx delete aborts chunked image load" { + const testing = std.testing; + const alloc = testing.allocator; + const io = testing.io; + + var t = try Terminal.init(io, alloc, .{ .rows = 5, .cols = 5 }); + defer t.deinit(alloc); + const storage = &t.screens.active.kitty_images; + + // Begin a two-chunk RGB image, then interrupt it with a delete. + { + const cmd = try command.Parser.parseString( + alloc, + "a=t,f=24,s=1,v=2,i=1,m=1;AAAA", + ); + defer cmd.deinit(alloc); + try testing.expect(execute(io, alloc, &t, &cmd) == null); + } + try testing.expect(storage.loading != null); + + { + const cmd = try command.Parser.parseString(alloc, "a=d"); + defer cmd.deinit(alloc); + try testing.expect(execute(io, alloc, &t, &cmd) == null); + } + try testing.expect(storage.loading == null); + + // A fresh chunked upload must start from empty state after the delete. + { + const cmd = try command.Parser.parseString( + alloc, + "a=t,f=24,s=1,v=2,i=1,m=1;AAAA", + ); + defer cmd.deinit(alloc); + try testing.expect(execute(io, alloc, &t, &cmd) == null); + } + { + const cmd = try command.Parser.parseString(alloc, "m=0;AAAA"); + defer cmd.deinit(alloc); + try testing.expect(execute(io, alloc, &t, &cmd).?.ok()); + } + try testing.expectEqual(@as(usize, 6), storage.imageById(1).?.data.len()); +} + +test "kittygfx uppercase id delete preserves image when placement does not match" { + const testing = std.testing; + const alloc = testing.allocator; + const io = testing.io; + + var t = try Terminal.init(io, alloc, .{ .rows = 5, .cols = 5 }); + defer t.deinit(alloc); + const storage = &t.screens.active.kitty_images; + + // Store an unplaced 1x1 RGB image. + { + const cmd = try command.Parser.parseString( + alloc, + "a=t,f=24,s=1,v=1,i=1;AAAA", + ); + defer cmd.deinit(alloc); + try testing.expect(execute(io, alloc, &t, &cmd).?.ok()); + } + + // Uppercase deletion may free data only if the named placement matched. + { + const cmd = try command.Parser.parseString( + alloc, + "a=d,d=I,i=1,p=7", + ); + defer cmd.deinit(alloc); + try testing.expect(execute(io, alloc, &t, &cmd) == null); + } + + try testing.expect(storage.imageById(1) != null); +} + test "kittygfx default format is rgba" { const testing = std.testing; const alloc = testing.allocator; @@ -716,6 +1726,48 @@ test "kittygfx retransmit same id removes existing placements" { ); } +test "kittygfx retransmit same id removes image on first chunk" { + const testing = std.testing; + const io = testing.io; + const alloc = testing.allocator; + + var t = try Terminal.init(io, alloc, .{ .rows = 5, .cols = 5 }); + defer t.deinit(alloc); + const storage = &t.screens.active.kitty_images; + const tracked = t.screens.active.pages.countTrackedPins(); + + // Store and display the old image. + { + const cmd = try command.Parser.parseString( + alloc, + "a=T,t=d,f=24,i=1,s=1,v=2,C=1;////////", + ); + defer cmd.deinit(alloc); + try testing.expect(execute(io, alloc, &t, &cmd).?.ok()); + } + try testing.expect(storage.imageById(1) != null); + try testing.expectEqual(@as(usize, 1), storage.placements.count()); + try testing.expectEqual( + tracked + 1, + t.screens.active.pages.countTrackedPins(), + ); + + // Starting a replacement for the same explicit ID removes the old image + // and placements before the replacement's final chunk arrives. + { + const cmd = try command.Parser.parseString( + alloc, + "a=t,t=d,f=24,i=1,s=1,v=2,m=1;AAAA", + ); + defer cmd.deinit(alloc); + try testing.expect(execute(io, alloc, &t, &cmd) == null); + } + try testing.expect(storage.loading != null); + try testing.expect(storage.imageById(1) == null); + try testing.expectEqual(@as(usize, 0), storage.placements.count()); + try testing.expectEqual(tracked, t.screens.active.pages.countTrackedPins()); +} + test "kittygfx delete then retransmit same id gets fresh generation" { const testing = std.testing; const io = testing.io; @@ -762,6 +1814,36 @@ test "kittygfx delete then retransmit same id gets fresh generation" { try testing.expect(gen2 > gen_delete); } +test "kittygfx display clamps cell offsets" { + const testing = std.testing; + const alloc = testing.allocator; + const io = testing.io; + + var t = try Terminal.init(io, alloc, .{ .rows = 5, .cols = 5 }); + defer t.deinit(alloc); + t.width_px = 50; // 10 px per col + t.height_px = 100; // 20 px per row + + const cmd = try command.Parser.parseString( + alloc, + "a=T,t=d,f=24,i=1,s=1,v=1,c=2,r=1,X=99,Y=99,C=1;AAAA", + ); + defer cmd.deinit(alloc); + + const resp = execute(io, alloc, &t, &cmd).?; + try testing.expect(resp.ok()); + + const storage = &t.screens.active.kitty_images; + var it = storage.placements.iterator(); + const placement = it.next().?.value_ptr; + try testing.expectEqual(@as(u32, 9), placement.x_offset); + try testing.expectEqual(@as(u32, 19), placement.y_offset); + + const actual = placement.pixelSize(storage.imageById(1).?, &t); + try testing.expectEqual(@as(u32, 11), actual.width); + try testing.expectEqual(@as(u32, 1), actual.height); +} + test "kittygfx placement bounds cursor movement for untrusted dimensions" { const testing = std.testing; const alloc = testing.allocator; @@ -779,4 +1861,1743 @@ test "kittygfx placement bounds cursor movement for untrusted dimensions" { const resp = execute(io, alloc, &t, &cmd).?; try testing.expect(resp.ok()); try testing.expectEqual(@as(usize, 1), t.screens.active.kitty_images.placements.count()); + // Reaching the bottom takes four rows, then scrolling is capped to one + // screen (five rows), matching Kitty's bounded screen-scroll behavior. + try testing.expectEqual(@as(usize, 10), t.screens.active.pages.scrollbar().total); +} + +test "kittygfx placement moves cursor past a tall image" { + const testing = std.testing; + const alloc = testing.allocator; + const io = testing.io; + + var t = try Terminal.init(io, alloc, .{ .rows = 5, .cols = 5 }); + defer t.deinit(alloc); + + // Load a one-pixel RGB image, then place it over the full width and eight + // rows. Eight rows are taller than the screen but still within Kitty's + // scroll budget. + { + const cmd = try command.Parser.parseString( + alloc, + "a=t,t=d,f=24,i=1,s=1,v=1;////", + ); + defer cmd.deinit(alloc); + const resp = execute(io, alloc, &t, &cmd).?; + try testing.expect(resp.ok()); + } + { + const cmd = try command.Parser.parseString( + alloc, + "a=p,i=1,p=1,c=5,r=8", + ); + defer cmd.deinit(alloc); + const resp = execute(io, alloc, &t, &cmd).?; + try testing.expect(resp.ok()); + } + + // A subsequent placement begins immediately after the first one instead + // of overlapping it at the old one-screen movement cap. + { + const cmd = try command.Parser.parseString( + alloc, + "a=p,i=1,p=2,C=1", + ); + defer cmd.deinit(alloc); + const resp = execute(io, alloc, &t, &cmd).?; + try testing.expect(resp.ok()); + } + + const storage = &t.screens.active.kitty_images; + const first = storage.placements.get(.{ + .image_id = 1, + .placement_id = .{ .tag = .external, .id = 1 }, + }).?; + const second = storage.placements.get(.{ + .image_id = 1, + .placement_id = .{ .tag = .external, .id = 2 }, + }).?; + const first_pin = switch (first.location) { + .pin => |pin| pin, + .virtual, .relative => unreachable, + }; + const second_pin = switch (second.location) { + .pin => |pin| pin, + .virtual, .relative => unreachable, + }; + const first_y = t.screens.active.pages.pointFromPin( + .screen, + first_pin.*, + ).?.screen.y; + const second_y = t.screens.active.pages.pointFromPin( + .screen, + second_pin.*, + ).?.screen.y; + try testing.expectEqual(first_y + 8, second_y); +} + +test "kittygfx unknown format responds with EINVAL" { + const testing = std.testing; + const alloc = testing.allocator; + const io = testing.io; + + var t = try Terminal.init(io, alloc, .{ .rows = 5, .cols = 5 }); + defer t.deinit(alloc); + + const cmd = try command.Parser.parseString( + alloc, + "a=t,f=42,t=d,i=31,s=1,v=1;AAAA", + ); + defer cmd.deinit(alloc); + + const resp = execute(io, alloc, &t, &cmd).?; + try testing.expect(!resp.ok()); + try testing.expectEqual(@as(u32, 31), resp.id); + try testing.expectEqualStrings("EINVAL: unsupported format", resp.message); + try testing.expectEqual( + @as(usize, 0), + t.screens.active.kitty_images.images.count(), + ); +} + +test "kittygfx unknown format on query responds with EINVAL" { + const testing = std.testing; + const alloc = testing.allocator; + const io = testing.io; + + var t = try Terminal.init(io, alloc, .{ .rows = 5, .cols = 5 }); + defer t.deinit(alloc); + + const cmd = try command.Parser.parseString( + alloc, + "a=q,f=42,t=d,i=31,s=1,v=1;AAAA", + ); + defer cmd.deinit(alloc); + + const resp = execute(io, alloc, &t, &cmd).?; + try testing.expect(!resp.ok()); + try testing.expectEqual(@as(u32, 31), resp.id); + try testing.expectEqualStrings("EINVAL: unsupported format", resp.message); +} + +test "kittygfx zero format is rgba" { + const testing = std.testing; + const alloc = testing.allocator; + const io = testing.io; + + var t = try Terminal.init(io, alloc, .{ .rows = 5, .cols = 5 }); + defer t.deinit(alloc); + + // Kitty treats f=0 as an absent f, i.e. RGBA. + const cmd = try command.Parser.parseString( + alloc, + "a=t,f=0,t=d,i=1,s=1,v=2,c=10,r=1;///////////", + ); + defer cmd.deinit(alloc); + + const resp = execute(io, alloc, &t, &cmd).?; + try testing.expect(resp.ok()); + + const img = t.screens.active.kitty_images.imageById(1).?; + try testing.expectEqual(command.Transmission.Format.rgba, img.format); +} + +test "kittygfx unknown format with q=3 has no response" { + const testing = std.testing; + const alloc = testing.allocator; + const io = testing.io; + + var t = try Terminal.init(io, alloc, .{ .rows = 5, .cols = 5 }); + defer t.deinit(alloc); + + // q above 2 is out of range in the spec but Kitty suppresses + // everything for anything above 1, so we must still parse it. + const cmd = try command.Parser.parseString( + alloc, + "a=t,f=42,t=d,i=31,q=3,s=1,v=1;AAAA", + ); + defer cmd.deinit(alloc); + + try testing.expect(execute(io, alloc, &t, &cmd) == null); +} + +test "kittygfx out of range display keys are tolerated" { + const testing = std.testing; + const alloc = testing.allocator; + const io = testing.io; + + var t = try Terminal.init(io, alloc, .{ .rows = 5, .cols = 5 }); + defer t.deinit(alloc); + + { + const cmd = try command.Parser.parseString( + alloc, + "a=T,f=24,t=d,i=31,s=1,v=1,C=2,U=2;AAAA", + ); + defer cmd.deinit(alloc); + + const resp = execute(io, alloc, &t, &cmd).?; + try testing.expect(resp.ok()); + try testing.expectEqual(@as(u32, 31), resp.id); + } + + // U=2 must behave like U=1, so the placement is virtual. + const storage = &t.screens.active.kitty_images; + var it = storage.placements.iterator(); + const entry = it.next().?; + try testing.expect(entry.value_ptr.location == .virtual); +} + +test "kittygfx number-based transmission assigns smallest free id" { + const testing = std.testing; + const alloc = testing.allocator; + const io = testing.io; + + var t = try Terminal.init(io, alloc, .{ .rows = 5, .cols = 5 }); + defer t.deinit(alloc); + const storage = &t.screens.active.kitty_images; + + // Empty storage: the first free ID is 1, as in Kitty. + { + const cmd = try command.Parser.parseString( + alloc, + "a=t,f=24,s=1,v=1,I=42;/wAA", + ); + defer cmd.deinit(alloc); + const resp = execute(io, alloc, &t, &cmd).?; + try testing.expect(resp.ok()); + try testing.expectEqual(@as(u32, 1), resp.id); + try testing.expectEqual(@as(u32, 42), resp.image_number); + } + + // Occupy ID 2 explicitly; the next number gets 3. + { + const cmd = try command.Parser.parseString( + alloc, + "a=t,f=24,s=1,v=1,i=2;/wAA", + ); + defer cmd.deinit(alloc); + try testing.expect(execute(io, alloc, &t, &cmd).?.ok()); + } + { + const cmd = try command.Parser.parseString( + alloc, + "a=t,f=24,s=1,v=1,I=43;/wAA", + ); + defer cmd.deinit(alloc); + const resp = execute(io, alloc, &t, &cmd).?; + try testing.expect(resp.ok()); + try testing.expectEqual(@as(u32, 3), resp.id); + } + + // Deleting ID 1 opens a gap that the next number fills. + { + const cmd = try command.Parser.parseString(alloc, "a=d,d=I,i=1"); + defer cmd.deinit(alloc); + try testing.expect(execute(io, alloc, &t, &cmd) == null); + } + { + const cmd = try command.Parser.parseString( + alloc, + "a=t,f=24,s=1,v=1,I=44;/wAA", + ); + defer cmd.deinit(alloc); + const resp = execute(io, alloc, &t, &cmd).?; + try testing.expect(resp.ok()); + try testing.expectEqual(@as(u32, 1), resp.id); + } + + try testing.expectEqual(@as(usize, 3), storage.images.count()); + try testing.expectEqual(@as(u32, 44), storage.imageById(1).?.number); + try testing.expectEqual(@as(u32, 0), storage.imageById(2).?.number); + try testing.expectEqual(@as(u32, 43), storage.imageById(3).?.number); +} + +test "kittygfx number-based id assignment does not replace client image" { + const testing = std.testing; + const alloc = testing.allocator; + const io = testing.io; + + var t = try Terminal.init(io, alloc, .{ .rows = 5, .cols = 5 }); + defer t.deinit(alloc); + const storage = &t.screens.active.kitty_images; + + // A client stores and places a red pixel on the ID the old wrapping + // counter would assign first. + { + const cmd = try command.Parser.parseString( + alloc, + "a=t,f=24,s=1,v=1,i=2147483647;/wAA", + ); + defer cmd.deinit(alloc); + try testing.expect(execute(io, alloc, &t, &cmd).?.ok()); + } + { + const cmd = try command.Parser.parseString(alloc, "a=p,i=2147483647,C=1"); + defer cmd.deinit(alloc); + try testing.expect(execute(io, alloc, &t, &cmd).?.ok()); + } + + // A number-based transmission must not collide with it. + { + const cmd = try command.Parser.parseString( + alloc, + "a=t,f=24,s=1,v=1,I=42;AAD/", + ); + defer cmd.deinit(alloc); + const resp = execute(io, alloc, &t, &cmd).?; + try testing.expect(resp.ok()); + try testing.expectEqual(@as(u32, 1), resp.id); + } + + // The client's image and placement are untouched. + try testing.expectEqual(@as(usize, 2), storage.images.count()); + try testing.expectEqual(@as(usize, 1), storage.placements.count()); + try testing.expectEqualSlices( + u8, + &.{ 255, 0, 0 }, + storage.imageById(2147483647).?.data.bytes().?, + ); +} + +test "kittygfx implicit id assignment does not replace client image" { + const testing = std.testing; + const alloc = testing.allocator; + const io = testing.io; + + var t = try Terminal.init(io, alloc, .{ .rows = 5, .cols = 5 }); + defer t.deinit(alloc); + const storage = &t.screens.active.kitty_images; + + // A client stores and places a red pixel on the ID the implicit + // counter assigns first. + { + const cmd = try command.Parser.parseString( + alloc, + "a=t,f=24,s=1,v=1,i=2147483647;/wAA", + ); + defer cmd.deinit(alloc); + try testing.expect(execute(io, alloc, &t, &cmd).?.ok()); + } + { + const cmd = try command.Parser.parseString(alloc, "a=p,i=2147483647,C=1"); + defer cmd.deinit(alloc); + try testing.expect(execute(io, alloc, &t, &cmd).?.ok()); + } + + // Transmit without an ID or number: no response, and the counter + // skips over the in-use ID instead of replacing that image. + { + const cmd = try command.Parser.parseString( + alloc, + "a=t,f=24,s=1,v=1;AAD/", + ); + defer cmd.deinit(alloc); + try testing.expect(execute(io, alloc, &t, &cmd) == null); + } + + try testing.expectEqual(@as(usize, 2), storage.images.count()); + try testing.expectEqual(@as(usize, 1), storage.placements.count()); + try testing.expectEqualSlices( + u8, + &.{ 255, 0, 0 }, + storage.imageById(2147483647).?.data.bytes().?, + ); + const implicit = storage.imageById(2147483648).?; + try testing.expect(implicit.metadata.implicit_id); + try testing.expectEqualSlices(u8, &.{ 0, 0, 255 }, implicit.data.bytes().?); +} + +test "kittygfx relative placement with missing parent image" { + const testing = std.testing; + const alloc = testing.allocator; + const io = testing.io; + + var t = try Terminal.init(io, alloc, .{ .rows = 5, .cols = 5 }); + defer t.deinit(alloc); + const storage = &t.screens.active.kitty_images; + + { + const cmd = try command.Parser.parseString( + alloc, + "a=t,f=24,s=1,v=1,i=1;AAAA", + ); + defer cmd.deinit(alloc); + try testing.expect(execute(io, alloc, &t, &cmd).?.ok()); + } + + // The parent image does not exist: the placement must be rejected + // with ENOPARENT, nothing may be created, and the cursor must not + // move (a relative placement never moves the cursor, and a failed + // one certainly must not). + { + const cmd = try command.Parser.parseString( + alloc, + "a=p,i=1,p=1,P=42,Q=1,H=2,V=2", + ); + defer cmd.deinit(alloc); + const resp = execute(io, alloc, &t, &cmd).?; + try testing.expect(!resp.ok()); + try testing.expectEqualStrings( + "ENOPARENT: parent image not found", + resp.message, + ); + } + + try testing.expectEqual(@as(usize, 0), storage.placements.count()); + try testing.expectEqual(0, t.screens.active.cursor.x); + try testing.expectEqual(0, t.screens.active.cursor.y); +} + +test "kittygfx relative placement with missing parent placement" { + const testing = std.testing; + const alloc = testing.allocator; + const io = testing.io; + + var t = try Terminal.init(io, alloc, .{ .rows = 5, .cols = 5 }); + defer t.deinit(alloc); + const storage = &t.screens.active.kitty_images; + + for ([_][]const u8{ + "a=t,f=24,s=1,v=1,i=1;AAAA", + "a=t,f=24,s=1,v=1,i=2;AAAA", + }) |input| { + const cmd = try command.Parser.parseString(alloc, input); + defer cmd.deinit(alloc); + try testing.expect(execute(io, alloc, &t, &cmd).?.ok()); + } + + // The parent image exists but has no placements at all. + { + const cmd = try command.Parser.parseString(alloc, "a=p,i=1,P=2"); + defer cmd.deinit(alloc); + const resp = execute(io, alloc, &t, &cmd).?; + try testing.expect(!resp.ok()); + try testing.expectEqualStrings( + "ENOPARENT: parent placement not found", + resp.message, + ); + } + + // The parent image has a placement, but not the requested one. + { + const cmd = try command.Parser.parseString(alloc, "a=p,i=2,p=1,C=1"); + defer cmd.deinit(alloc); + try testing.expect(execute(io, alloc, &t, &cmd).?.ok()); + } + { + const cmd = try command.Parser.parseString(alloc, "a=p,i=1,P=2,Q=9"); + defer cmd.deinit(alloc); + const resp = execute(io, alloc, &t, &cmd).?; + try testing.expect(!resp.ok()); + try testing.expectEqualStrings( + "ENOPARENT: parent placement not found", + resp.message, + ); + } + + try testing.expectEqual(@as(usize, 1), storage.placements.count()); +} + +test "kittygfx relative placement cannot parent itself" { + const testing = std.testing; + const alloc = testing.allocator; + const io = testing.io; + + var t = try Terminal.init(io, alloc, .{ .rows = 5, .cols = 5 }); + defer t.deinit(alloc); + + for ([_][]const u8{ + "a=t,f=24,s=1,v=1,i=1;AAAA", + "a=p,i=1,p=1,C=1", + }) |input| { + const cmd = try command.Parser.parseString(alloc, input); + defer cmd.deinit(alloc); + try testing.expect(execute(io, alloc, &t, &cmd).?.ok()); + } + + // Explicitly via Q, and implicitly when the Q=0 fallback selects + // the placement being replaced. + for ([_][]const u8{ + "a=p,i=1,p=1,P=1,Q=1", + "a=p,i=1,p=1,P=1", + }) |input| { + const cmd = try command.Parser.parseString(alloc, input); + defer cmd.deinit(alloc); + const resp = execute(io, alloc, &t, &cmd).?; + try testing.expect(!resp.ok()); + try testing.expectEqualStrings( + "EINVAL: placement cannot be its own parent", + resp.message, + ); + } + + // The original placement must be untouched. + const storage = &t.screens.active.kitty_images; + const p = storage.placements.get(.{ + .image_id = 1, + .placement_id = .{ .tag = .external, .id = 1 }, + }).?; + try testing.expect(p.location == .pin); +} + +test "kittygfx relative placement cycle" { + const testing = std.testing; + const alloc = testing.allocator; + const io = testing.io; + + var t = try Terminal.init(io, alloc, .{ .rows = 5, .cols = 5 }); + defer t.deinit(alloc); + + for ([_][]const u8{ + "a=t,f=24,s=1,v=1,i=1;AAAA", + "a=p,i=1,p=1,C=1", + "a=p,i=1,p=2,P=1,Q=1", + }) |input| { + const cmd = try command.Parser.parseString(alloc, input); + defer cmd.deinit(alloc); + try testing.expect(execute(io, alloc, &t, &cmd).?.ok()); + } + + // Replacing placement 1 with a parent of placement 2 would create + // the cycle 1 -> 2 -> 1. + { + const cmd = try command.Parser.parseString(alloc, "a=p,i=1,p=1,P=1,Q=2"); + defer cmd.deinit(alloc); + const resp = execute(io, alloc, &t, &cmd).?; + try testing.expect(!resp.ok()); + try testing.expectEqualStrings( + "ECYCLE: parent chain creates a cycle", + resp.message, + ); + } + + // The original placement must be untouched, keeping the stored + // chains acyclic. + const storage = &t.screens.active.kitty_images; + const p = storage.placements.get(.{ + .image_id = 1, + .placement_id = .{ .tag = .external, .id = 1 }, + }).?; + try testing.expect(p.location == .pin); +} + +test "kittygfx relative placement chain depth limit" { + const testing = std.testing; + const alloc = testing.allocator; + const io = testing.io; + + var t = try Terminal.init(io, alloc, .{ .rows = 5, .cols = 5 }); + defer t.deinit(alloc); + + for ([_][]const u8{ + "a=t,f=24,s=1,v=1,i=1;AAAA", + "a=p,i=1,p=1,C=1", + }) |input| { + const cmd = try command.Parser.parseString(alloc, input); + defer cmd.deinit(alloc); + try testing.expect(execute(io, alloc, &t, &cmd).?.ok()); + } + + // Chains of up to parent_chain_limit (8) links must work: these + // placements form the chain 9 -> 8 -> ... -> 2 -> 1. + for (2..10) |id| { + var buf: [64]u8 = undefined; + const cmd = try command.Parser.parseString( + alloc, + try std.fmt.bufPrint(&buf, "a=p,i=1,p={},P=1,Q={}", .{ id, id - 1 }), + ); + defer cmd.deinit(alloc); + try testing.expect(execute(io, alloc, &t, &cmd).?.ok()); + } + + // One more link exceeds the limit. + { + const cmd = try command.Parser.parseString(alloc, "a=p,i=1,p=10,P=1,Q=9"); + defer cmd.deinit(alloc); + const resp = execute(io, alloc, &t, &cmd).?; + try testing.expect(!resp.ok()); + try testing.expectEqualStrings( + "ETOODEEP: parent chain too deep", + resp.message, + ); + } + + const storage = &t.screens.active.kitty_images; + try testing.expectEqual(@as(usize, 9), storage.placements.count()); +} + +test "kittygfx relative placement does not move cursor" { + const testing = std.testing; + const alloc = testing.allocator; + const io = testing.io; + + var t = try Terminal.init(io, alloc, .{ .rows = 5, .cols = 5 }); + defer t.deinit(alloc); + const storage = &t.screens.active.kitty_images; + + // C is left at its default (move after) on the relative placement + // but it must never move the cursor. + for ([_][]const u8{ + "a=t,f=24,s=1,v=1,i=1;AAAA", + "a=p,i=1,p=1,C=1", + "a=p,i=1,p=2,P=1,Q=1,H=3,V=2,c=2,r=2", + }) |input| { + const cmd = try command.Parser.parseString(alloc, input); + defer cmd.deinit(alloc); + try testing.expect(execute(io, alloc, &t, &cmd).?.ok()); + } + try testing.expectEqual(0, t.screens.active.cursor.x); + try testing.expectEqual(0, t.screens.active.cursor.y); + + // The stored placement carries the parent link and offsets. + const p = storage.placements.get(.{ + .image_id = 1, + .placement_id = .{ .tag = .external, .id = 2 }, + }).?; + const rel = p.location.relative; + try testing.expect(rel.parent.eql(.{ + .image_id = 1, + .placement_id = .{ .tag = .external, .id = 1 }, + })); + try testing.expectEqual(@as(i32, 3), rel.horizontal_offset); + try testing.expectEqual(@as(i32, 2), rel.vertical_offset); +} + +test "kittygfx virtual placement with parent rejected before image lookup" { + const testing = std.testing; + const alloc = testing.allocator; + const io = testing.io; + + var t = try Terminal.init(io, alloc, .{ .rows = 5, .cols = 5 }); + defer t.deinit(alloc); + + // Kitty rejects U=1 + P= before checking that the image exists, + // so this must not be ENOENT. + const cmd = try command.Parser.parseString(alloc, "a=p,i=42,U=1,P=1"); + defer cmd.deinit(alloc); + const resp = execute(io, alloc, &t, &cmd).?; + try testing.expect(!resp.ok()); + try testing.expectEqualStrings( + "EINVAL: virtual placement cannot refer to a parent", + resp.message, + ); +} + +test "kittygfx deleting a parent deletes relative placements transitively" { + const testing = std.testing; + const alloc = testing.allocator; + const io = testing.io; + + var t = try Terminal.init(io, alloc, .{ .rows = 5, .cols = 5 }); + defer t.deinit(alloc); + const storage = &t.screens.active.kitty_images; + + // Root (pin) <- child <- grandchild + for ([_][]const u8{ + "a=t,f=24,s=1,v=1,i=1;AAAA", + "a=p,i=1,p=1,C=1", + "a=p,i=1,p=2,P=1,Q=1", + "a=p,i=1,p=3,P=1,Q=2", + }) |input| { + const cmd = try command.Parser.parseString(alloc, input); + defer cmd.deinit(alloc); + try testing.expect(execute(io, alloc, &t, &cmd).?.ok()); + } + try testing.expectEqual(@as(usize, 3), storage.placements.count()); + + // Deleting the middle placement takes the grandchild with it but + // leaves the root. + { + const cmd = try command.Parser.parseString(alloc, "a=d,d=i,i=1,p=2"); + defer cmd.deinit(alloc); + try testing.expect(execute(io, alloc, &t, &cmd) == null); + } + try testing.expectEqual(@as(usize, 1), storage.placements.count()); + try testing.expect(storage.placements.contains(.{ + .image_id = 1, + .placement_id = .{ .tag = .external, .id = 1 }, + })); + + // Deleting the root removes the remaining placement, and the + // image itself is retained (lowercase delete). + { + const cmd = try command.Parser.parseString(alloc, "a=d,d=i,i=1,p=1"); + defer cmd.deinit(alloc); + try testing.expect(execute(io, alloc, &t, &cmd) == null); + } + try testing.expectEqual(@as(usize, 0), storage.placements.count()); + try testing.expect(storage.imageById(1) != null); +} + +test "kittygfx retransmitting parent image deletes relative placements" { + const testing = std.testing; + const alloc = testing.allocator; + const io = testing.io; + + var t = try Terminal.init(io, alloc, .{ .rows = 5, .cols = 5 }); + defer t.deinit(alloc); + const storage = &t.screens.active.kitty_images; + + // Image 2's placement is relative to image 1's placement. + for ([_][]const u8{ + "a=t,f=24,s=1,v=1,i=1;AAAA", + "a=t,f=24,s=1,v=1,i=2;AAAA", + "a=p,i=1,p=1,C=1", + "a=p,i=2,p=1,P=1,Q=1", + }) |input| { + const cmd = try command.Parser.parseString(alloc, input); + defer cmd.deinit(alloc); + try testing.expect(execute(io, alloc, &t, &cmd).?.ok()); + } + try testing.expectEqual(@as(usize, 2), storage.placements.count()); + + // Retransmitting image 1 removes its placements, which orphans + // and removes image 2's placement too. Image 2 is left without + // any placement so it is freed as well: retransmission deletes + // with uppercase semantics, and kitty also removes an image whose + // last placement died from a broken parent chain. + { + const cmd = try command.Parser.parseString( + alloc, + "a=t,f=24,s=1,v=1,i=1;AAAA", + ); + defer cmd.deinit(alloc); + try testing.expect(execute(io, alloc, &t, &cmd).?.ok()); + } + try testing.expectEqual(@as(usize, 0), storage.placements.count()); + try testing.expect(storage.imageById(2) == null); +} + +test "kittygfx relative placement parent fallback picks lowest external id" { + const testing = std.testing; + const alloc = testing.allocator; + const io = testing.io; + + var t = try Terminal.init(io, alloc, .{ .rows = 5, .cols = 5 }); + defer t.deinit(alloc); + const storage = &t.screens.active.kitty_images; + + for ([_][]const u8{ + "a=t,f=24,s=1,v=1,i=1;AAAA", + "a=t,f=24,s=1,v=1,i=2;AAAA", + "a=p,i=1,p=7,C=1", + "a=p,i=1,p=3,C=1", + "a=p,i=2,P=1", + }) |input| { + const cmd = try command.Parser.parseString(alloc, input); + defer cmd.deinit(alloc); + try testing.expect(execute(io, alloc, &t, &cmd).?.ok()); + } + + var it = storage.placements.iterator(); + const rel = while (it.next()) |entry| { + if (entry.key_ptr.image_id == 2) break entry.value_ptr.location.relative; + } else return error.PlacementNotFound; + try testing.expect(rel.parent.eql(.{ + .image_id = 1, + .placement_id = .{ .tag = .external, .id = 3 }, + })); +} + +test "kittygfx placements created after a full reset are retained" { + // Regression test: Screen.reset reuses the cursor's tracked pin + // but PageList.reset marked it garbage. Placements copy the cursor + // pin, so every placement created after a reset was born garbage + // and silently swept by the next placement command. + const testing = std.testing; + const alloc = testing.allocator; + const io = testing.io; + + var t = try Terminal.init(io, alloc, .{ .rows = 24, .cols = 80 }); + defer t.deinit(alloc); + const storage = &t.screens.active.kitty_images; + + t.fullReset(); + + for ([_][]const u8{ + "a=t,f=24,s=1,v=1,i=1;AAAA", + "a=p,i=1,p=1,C=1", + "a=p,i=1,p=2,C=1", + "a=p,i=1,p=3,P=1,Q=2", + }) |input| { + const cmd = try command.Parser.parseString(alloc, input); + defer cmd.deinit(alloc); + try testing.expect(execute(io, alloc, &t, &cmd).?.ok()); + } + try testing.expectEqual(@as(usize, 3), storage.placements.count()); +} + +test "kittygfx relative placement with pruned parent is ENOPARENT" { + const testing = std.testing; + const alloc = testing.allocator; + const io = testing.io; + + var t = try Terminal.init(io, alloc, .{ .rows = 5, .cols = 5 }); + defer t.deinit(alloc); + const storage = &t.screens.active.kitty_images; + + for ([_][]const u8{ + "a=t,f=24,s=1,v=1,i=1;AAAA", + "a=p,i=1,p=1,C=1", + }) |input| { + const cmd = try command.Parser.parseString(alloc, input); + defer cmd.deinit(alloc); + try testing.expect(execute(io, alloc, &t, &cmd).?.ok()); + } + + // The parent's tracked content is pruned from history but the + // placement hasn't been swept yet. The put must reap it and answer + // ENOPARENT rather than storing an orphan against it. + const parent = storage.placements.getPtr(.{ + .image_id = 1, + .placement_id = .{ .tag = .external, .id = 1 }, + }).?; + parent.location.pin.garbage = true; + + { + const cmd = try command.Parser.parseString(alloc, "a=p,i=1,p=2,P=1,Q=1"); + defer cmd.deinit(alloc); + const resp = execute(io, alloc, &t, &cmd).?; + try testing.expect(!resp.ok()); + try testing.expectEqualStrings( + "ENOPARENT: parent placement not found", + resp.message, + ); + } + try testing.expectEqual(@as(usize, 0), storage.placements.count()); +} + +test "kittygfx uppercase delete frees image of cascaded placements" { + const testing = std.testing; + const alloc = testing.allocator; + const io = testing.io; + + var t = try Terminal.init(io, alloc, .{ .rows = 5, .cols = 5 }); + defer t.deinit(alloc); + const storage = &t.screens.active.kitty_images; + + for ([_][]const u8{ + "a=t,f=24,s=1,v=1,i=1;AAAA", + "a=p,i=1,p=1,C=1", + "a=p,i=1,p=2,P=1,Q=1", + }) |input| { + const cmd = try command.Parser.parseString(alloc, input); + defer cmd.deinit(alloc); + try testing.expect(execute(io, alloc, &t, &cmd).?.ok()); + } + + // The uppercase delete only matches placement 1. The cascade + // removes placement 2, leaving the image without placements, so + // the image must be freed too. + { + const cmd = try command.Parser.parseString(alloc, "a=d,d=I,i=1,p=1"); + defer cmd.deinit(alloc); + try testing.expect(execute(io, alloc, &t, &cmd) == null); + } + try testing.expectEqual(@as(usize, 0), storage.placements.count()); + try testing.expect(storage.imageById(1) == null); +} + +// Animation tests frequently start from a 1x1 RGB red image with +// id=1 ("/wAA" is FF0000). Composition converts images to RGBA, so +// after the first frame command the base data is FF0000FF. + +test "kittygfx animation: new frame with default gap responds with frame number" { + const testing = std.testing; + const alloc = testing.allocator; + const io = testing.io; + + var t = try Terminal.init(io, alloc, .{ .rows = 5, .cols = 5 }); + defer t.deinit(alloc); + const storage = &t.screens.active.kitty_images; + { + const cmd = try command.Parser.parseString( + alloc, + "a=t,f=24,s=1,v=1,i=1;/wAA", + ); + defer cmd.deinit(alloc); + try testing.expect(execute(io, alloc, &t, &cmd).?.ok()); + } + + // Transmit a 1x1 RGB blue frame ("AAD/" is 0000FF). + const cmd = try command.Parser.parseString( + alloc, + "a=f,i=1,f=24,s=1,v=1;AAD/", + ); + defer cmd.deinit(alloc); + const resp = execute(io, alloc, &t, &cmd).?; + try testing.expect(resp.ok()); + try testing.expectEqual(@as(u32, 1), resp.id); + try testing.expectEqual(@as(u32, 2), resp.frame); + + var buf: [128]u8 = undefined; + var writer: std.Io.Writer = .fixed(&buf); + try resp.encode(&writer); + try testing.expectEqualStrings("\x1b_Gi=1,r=2;OK\x1b\\", writer.buffered()); + + const img = storage.imagePtrByIdOrNumber(1, 0).?; + try testing.expectEqual(command.Transmission.Format.rgba, img.format); + try testing.expectEqualSlices(u8, &.{ 255, 0, 0, 255 }, img.data.bytes().?); + + const anim = img.animation.?; + try testing.expectEqual(@as(u32, 2), anim.frameCount()); + try testing.expectEqual(@as(u32, 0), anim.root_gap_ms); + try testing.expectEqual(@as(u32, 40), anim.frames.items[0].gap_ms); + try testing.expectEqualSlices(u8, &.{ 0, 0, 255, 255 }, anim.frames.items[0].data); + + // The displayed frame is still the root: renderData is the base. + try testing.expectEqualSlices(u8, &.{ 255, 0, 0, 255 }, img.renderData().bytes().?); + + // Frame bytes count against the storage total. + try testing.expectEqual(@as(usize, 8), storage.total_bytes); +} + +test "kittygfx animation: frame gap normalization on create" { + const testing = std.testing; + const alloc = testing.allocator; + const io = testing.io; + + var t = try Terminal.init(io, alloc, .{ .rows = 5, .cols = 5 }); + defer t.deinit(alloc); + const storage = &t.screens.active.kitty_images; + { + const cmd = try command.Parser.parseString( + alloc, + "a=t,f=24,s=1,v=1,i=1;/wAA", + ); + defer cmd.deinit(alloc); + try testing.expect(execute(io, alloc, &t, &cmd).?.ok()); + } + + for ([_][]const u8{ + "a=f,i=1,f=24,s=1,v=1,z=100;AAD/", + "a=f,i=1,f=24,s=1,v=1,z=-5;AAD/", + }) |input| { + const cmd = try command.Parser.parseString(alloc, input); + defer cmd.deinit(alloc); + try testing.expect(execute(io, alloc, &t, &cmd).?.ok()); + } + + const anim = storage.imagePtrByIdOrNumber(1, 0).?.animation.?; + try testing.expectEqual(@as(u32, 100), anim.frames.items[0].gap_ms); + try testing.expectEqual(@as(u32, 0), anim.frames.items[1].gap_ms); +} + +test "kittygfx animation: background fill and offset composition" { + const testing = std.testing; + const alloc = testing.allocator; + const io = testing.io; + + var t = try Terminal.init(io, alloc, .{ .rows = 5, .cols = 5 }); + defer t.deinit(alloc); + const storage = &t.screens.active.kitty_images; + + // 2x1 RGB white image ("////////" is FFFFFF FFFFFF). + { + const cmd = try command.Parser.parseString( + alloc, + "a=t,f=24,s=2,v=1,i=1;////////", + ); + defer cmd.deinit(alloc); + try testing.expect(execute(io, alloc, &t, &cmd).?.ok()); + } + + // New frame: 1x1 RGB white transmitted at x=1 over an opaque red + // background (Y=4278190335 is 0xff0000ff, R in the MSB). + { + const cmd = try command.Parser.parseString( + alloc, + "a=f,i=1,f=24,s=1,v=1,x=1,Y=4278190335;////", + ); + defer cmd.deinit(alloc); + try testing.expect(execute(io, alloc, &t, &cmd).?.ok()); + } + + const anim = storage.imagePtrByIdOrNumber(1, 0).?.animation.?; + try testing.expectEqualSlices( + u8, + &.{ 255, 0, 0, 255, 255, 255, 255, 255 }, + anim.frames.items[0].data, + ); +} + +test "kittygfx animation: create from base frame with overwrite" { + const testing = std.testing; + const alloc = testing.allocator; + const io = testing.io; + + var t = try Terminal.init(io, alloc, .{ .rows = 5, .cols = 5 }); + defer t.deinit(alloc); + const storage = &t.screens.active.kitty_images; + { + const cmd = try command.Parser.parseString( + alloc, + "a=t,f=24,s=1,v=1,i=1;/wAA", + ); + defer cmd.deinit(alloc); + try testing.expect(execute(io, alloc, &t, &cmd).?.ok()); + } + + // Frame 2 based on the root (c=1) with a semi-transparent blue + // pixel ("AAD/gA==" is 0000FF80) in overwrite mode: the source + // replaces the canvas including alpha. + { + const cmd = try command.Parser.parseString( + alloc, + "a=f,i=1,f=32,s=1,v=1,c=1,X=1;AAD/gA==", + ); + defer cmd.deinit(alloc); + const resp = execute(io, alloc, &t, &cmd).?; + try testing.expect(resp.ok()); + } + + const anim = storage.imagePtrByIdOrNumber(1, 0).?.animation.?; + try testing.expectEqualSlices(u8, &.{ 0, 0, 255, 128 }, anim.frames.items[0].data); +} + +test "kittygfx animation: alpha blend composes over base frame" { + const testing = std.testing; + const alloc = testing.allocator; + const io = testing.io; + + var t = try Terminal.init(io, alloc, .{ .rows = 5, .cols = 5 }); + defer t.deinit(alloc); + const storage = &t.screens.active.kitty_images; + { + const cmd = try command.Parser.parseString( + alloc, + "a=t,f=24,s=1,v=1,i=1;/wAA", + ); + defer cmd.deinit(alloc); + try testing.expect(execute(io, alloc, &t, &cmd).?.ok()); + } + + // Blend a fully opaque blue pixel over the root: alpha blending + // an opaque source is equivalent to a copy. + { + const cmd = try command.Parser.parseString( + alloc, + "a=f,i=1,f=32,s=1,v=1,c=1;AAD//w==", + ); + defer cmd.deinit(alloc); + try testing.expect(execute(io, alloc, &t, &cmd).?.ok()); + } + + // Blend a fully transparent pixel over the root: the canvas is + // unchanged (still the red base). + { + const cmd = try command.Parser.parseString( + alloc, + "a=f,i=1,f=32,s=1,v=1,c=1;AAD/AA==", + ); + defer cmd.deinit(alloc); + try testing.expect(execute(io, alloc, &t, &cmd).?.ok()); + } + + const anim = storage.imagePtrByIdOrNumber(1, 0).?.animation.?; + try testing.expectEqualSlices(u8, &.{ 0, 0, 255, 255 }, anim.frames.items[0].data); + try testing.expectEqualSlices(u8, &.{ 255, 0, 0, 255 }, anim.frames.items[1].data); +} + +test "kittygfx animation: edit root frame bumps generation" { + const testing = std.testing; + const alloc = testing.allocator; + const io = testing.io; + + var t = try Terminal.init(io, alloc, .{ .rows = 5, .cols = 5 }); + defer t.deinit(alloc); + const storage = &t.screens.active.kitty_images; + { + const cmd = try command.Parser.parseString( + alloc, + "a=t,f=24,s=1,v=1,i=1;/wAA", + ); + defer cmd.deinit(alloc); + try testing.expect(execute(io, alloc, &t, &cmd).?.ok()); + } + const gen1 = storage.imagePtrByIdOrNumber(1, 0).?.generation; + + // r=1 edits the root frame in place. The root is the displayed + // frame so the image generation must change. + const cmd = try command.Parser.parseString( + alloc, + "a=f,i=1,f=24,s=1,v=1,r=1;AAD/", + ); + defer cmd.deinit(alloc); + const resp = execute(io, alloc, &t, &cmd).?; + try testing.expect(resp.ok()); + try testing.expectEqual(@as(u32, 1), resp.frame); + + const img = storage.imagePtrByIdOrNumber(1, 0).?; + try testing.expectEqualSlices(u8, &.{ 0, 0, 255, 255 }, img.data.bytes().?); + try testing.expect(img.generation > gen1); +} + +test "kittygfx animation: edit frame gap without data change" { + const testing = std.testing; + const alloc = testing.allocator; + const io = testing.io; + + var t = try Terminal.init(io, alloc, .{ .rows = 5, .cols = 5 }); + defer t.deinit(alloc); + const storage = &t.screens.active.kitty_images; + { + const cmd = try command.Parser.parseString( + alloc, + "a=t,f=24,s=1,v=1,i=1;/wAA", + ); + defer cmd.deinit(alloc); + try testing.expect(execute(io, alloc, &t, &cmd).?.ok()); + } + + // Create frame 2, then edit it with z=-1: gap becomes gapless. + // Editing with z=0 leaves the gap unchanged (no 40ms default). + { + const cmd = try command.Parser.parseString( + alloc, + "a=f,i=1,f=24,s=1,v=1,z=77;AAD/", + ); + defer cmd.deinit(alloc); + try testing.expect(execute(io, alloc, &t, &cmd).?.ok()); + } + { + const cmd = try command.Parser.parseString( + alloc, + "a=f,i=1,f=24,s=1,v=1,r=2;/wAA", + ); + defer cmd.deinit(alloc); + try testing.expect(execute(io, alloc, &t, &cmd).?.ok()); + } + const anim = storage.imagePtrByIdOrNumber(1, 0).?.animation.?; + try testing.expectEqual(@as(u32, 77), anim.frames.items[0].gap_ms); + + { + const cmd = try command.Parser.parseString( + alloc, + "a=f,i=1,f=24,s=1,v=1,r=2,z=-1;/wAA", + ); + defer cmd.deinit(alloc); + try testing.expect(execute(io, alloc, &t, &cmd).?.ok()); + } + try testing.expectEqual(@as(u32, 0), anim.frames.items[0].gap_ms); +} + +test "kittygfx animation: frame errors" { + const testing = std.testing; + const alloc = testing.allocator; + const io = testing.io; + + var t = try Terminal.init(io, alloc, .{ .rows = 5, .cols = 5 }); + defer t.deinit(alloc); + { + const cmd = try command.Parser.parseString( + alloc, + "a=t,f=24,s=1,v=1,i=1;/wAA", + ); + defer cmd.deinit(alloc); + try testing.expect(execute(io, alloc, &t, &cmd).?.ok()); + } + + const cases = [_]struct { + input: []const u8, + message: []const u8, + }{ + // Unknown image. + .{ + .input = "a=f,i=9,f=24,s=1,v=1;AAD/", + .message = "ENOENT: image not found", + }, + // Frame bigger than the image. + .{ + .input = "a=f,i=1,f=24,s=2,v=1;////////", + .message = "EINVAL: frame dimensions exceed image", + }, + // Nonexistent base frame. + .{ + .input = "a=f,i=1,f=24,s=1,v=1,c=5;AAD/", + .message = "EINVAL: base frame not found", + }, + // Insufficient data for the declared rectangle. + .{ + .input = "a=f,i=1,f=24,s=1,v=1;AA==", + .message = "ENODATA: insufficient data", + }, + }; + for (cases) |case| { + const cmd = try command.Parser.parseString(alloc, case.input); + defer cmd.deinit(alloc); + const resp = execute(io, alloc, &t, &cmd).?; + try testing.expect(!resp.ok()); + try testing.expectEqualStrings(case.message, resp.message); + } + + // None of the failures may have created a frame. + const storage = &t.screens.active.kitty_images; + const img = storage.imagePtrByIdOrNumber(1, 0).?; + if (img.animation) |anim| { + try testing.expectEqual(@as(usize, 0), anim.frames.items.len); + } +} + +test "kittygfx animation: excess frame data is truncated" { + const testing = std.testing; + const alloc = testing.allocator; + const io = testing.io; + + var t = try Terminal.init(io, alloc, .{ .rows = 5, .cols = 5 }); + defer t.deinit(alloc); + const storage = &t.screens.active.kitty_images; + { + const cmd = try command.Parser.parseString( + alloc, + "a=t,f=24,s=1,v=1,i=1;/wAA", + ); + defer cmd.deinit(alloc); + try testing.expect(execute(io, alloc, &t, &cmd).?.ok()); + } + + // Six bytes for a 1x1 RGB frame: Kitty tolerates the excess. + const cmd = try command.Parser.parseString( + alloc, + "a=f,i=1,f=24,s=1,v=1;AAD/////", + ); + defer cmd.deinit(alloc); + try testing.expect(execute(io, alloc, &t, &cmd).?.ok()); + + const anim = storage.imagePtrByIdOrNumber(1, 0).?.animation.?; + try testing.expectEqualSlices(u8, &.{ 0, 0, 255, 255 }, anim.frames.items[0].data); +} + +test "kittygfx animation: chunked frame transmission" { + const testing = std.testing; + const alloc = testing.allocator; + const io = testing.io; + + var t = try Terminal.init(io, alloc, .{ .rows = 5, .cols = 5 }); + defer t.deinit(alloc); + const storage = &t.screens.active.kitty_images; + + // 2x1 RGB white image. + { + const cmd = try command.Parser.parseString( + alloc, + "a=t,f=24,s=2,v=1,i=1;////////", + ); + defer cmd.deinit(alloc); + try testing.expect(execute(io, alloc, &t, &cmd).?.ok()); + } + + // Transmit a 2x1 frame in two chunks of one pixel each. The + // chunks repeat a=f per the protocol. No response until the + // final chunk. + { + const cmd = try command.Parser.parseString( + alloc, + "a=f,i=1,f=24,s=2,v=1,z=60,m=1;/wAA", + ); + defer cmd.deinit(alloc); + try testing.expect(execute(io, alloc, &t, &cmd) == null); + } + try testing.expect(storage.loading != null); + { + const cmd = try command.Parser.parseString(alloc, "a=f,m=0;AAD/"); + defer cmd.deinit(alloc); + const resp = execute(io, alloc, &t, &cmd).?; + try testing.expect(resp.ok()); + try testing.expectEqual(@as(u32, 1), resp.id); + try testing.expectEqual(@as(u32, 2), resp.frame); + } + try testing.expect(storage.loading == null); + + const anim = storage.imagePtrByIdOrNumber(1, 0).?.animation.?; + try testing.expectEqual(@as(u32, 60), anim.frames.items[0].gap_ms); + try testing.expectEqualSlices( + u8, + &.{ 255, 0, 0, 255, 0, 0, 255, 255 }, + anim.frames.items[0].data, + ); +} + +test "kittygfx animation: chunked frame continuation without a=f" { + const testing = std.testing; + const alloc = testing.allocator; + const io = testing.io; + + var t = try Terminal.init(io, alloc, .{ .rows = 5, .cols = 5 }); + defer t.deinit(alloc); + const storage = &t.screens.active.kitty_images; + + { + const cmd = try command.Parser.parseString( + alloc, + "a=t,f=24,s=2,v=1,i=1;////////", + ); + defer cmd.deinit(alloc); + try testing.expect(execute(io, alloc, &t, &cmd).?.ok()); + } + + // The spec requires chunks to repeat a=f, but a bare continuation + // (which parses as a transmit action) must still continue the + // frame load rather than turn into an image transmission. + { + const cmd = try command.Parser.parseString( + alloc, + "a=f,i=1,f=24,s=2,v=1,m=1;/wAA", + ); + defer cmd.deinit(alloc); + try testing.expect(execute(io, alloc, &t, &cmd) == null); + } + { + const cmd = try command.Parser.parseString(alloc, "m=0;AAD/"); + defer cmd.deinit(alloc); + const resp = execute(io, alloc, &t, &cmd).?; + try testing.expect(resp.ok()); + try testing.expectEqual(@as(u32, 2), resp.frame); + } + + const anim = storage.imagePtrByIdOrNumber(1, 0).?.animation.?; + try testing.expectEqual(@as(usize, 1), anim.frames.items.len); +} + +test "kittygfx animation: control command sets gap current state and loops" { + const testing = std.testing; + const alloc = testing.allocator; + const io = testing.io; + + var t = try Terminal.init(io, alloc, .{ .rows = 5, .cols = 5 }); + defer t.deinit(alloc); + const storage = &t.screens.active.kitty_images; + { + const cmd = try command.Parser.parseString( + alloc, + "a=t,f=24,s=1,v=1,i=1;/wAA", + ); + defer cmd.deinit(alloc); + try testing.expect(execute(io, alloc, &t, &cmd).?.ok()); + } + { + const cmd = try command.Parser.parseString( + alloc, + "a=f,i=1,f=24,s=1,v=1;AAD/", + ); + defer cmd.deinit(alloc); + try testing.expect(execute(io, alloc, &t, &cmd).?.ok()); + } + const img = storage.imagePtrByIdOrNumber(1, 0).?; + const gen1 = img.generation; + + // One command can set several things: the root frame's gap + // (r=1,z=50), the current frame (c=2), the state (s=3, running), + // and the loop count (v=3, meaning two loops). Successful a=a + // commands never respond. + const cmd = try command.Parser.parseString( + alloc, + "a=a,i=1,r=1,z=50,c=2,s=3,v=3", + ); + defer cmd.deinit(alloc); + try testing.expect(execute(io, alloc, &t, &cmd) == null); + + const anim = img.animation.?; + try testing.expectEqual(@as(u32, 50), anim.root_gap_ms); + try testing.expectEqual(@as(u32, 1), anim.current_index); + try testing.expectEqual(animation.Animation.State.running, anim.state); + try testing.expectEqual(@as(u32, 2), anim.max_loops); + + // Changing the current frame changes the displayed content. + try testing.expect(img.generation > gen1); + try testing.expectEqualSlices(u8, &.{ 0, 0, 255, 255 }, img.renderData().bytes().?); +} + +test "kittygfx animation: control command ignores invalid values" { + const testing = std.testing; + const alloc = testing.allocator; + const io = testing.io; + + var t = try Terminal.init(io, alloc, .{ .rows = 5, .cols = 5 }); + defer t.deinit(alloc); + const storage = &t.screens.active.kitty_images; + { + const cmd = try command.Parser.parseString( + alloc, + "a=t,f=24,s=1,v=1,i=1;/wAA", + ); + defer cmd.deinit(alloc); + try testing.expect(execute(io, alloc, &t, &cmd).?.ok()); + } + + // Out-of-range frame numbers and s values are silently ignored. + const cmd = try command.Parser.parseString( + alloc, + "a=a,i=1,r=9,z=50,c=9,s=7", + ); + defer cmd.deinit(alloc); + try testing.expect(execute(io, alloc, &t, &cmd) == null); + + const anim = storage.imagePtrByIdOrNumber(1, 0).?.animation.?; + try testing.expectEqual(@as(u32, 0), anim.root_gap_ms); + try testing.expectEqual(@as(u32, 0), anim.current_index); + try testing.expectEqual(animation.Animation.State.stopped, anim.state); +} + +test "kittygfx animation: control command missing image responds ENOENT" { + const testing = std.testing; + const alloc = testing.allocator; + const io = testing.io; + + var t = try Terminal.init(io, alloc, .{ .rows = 5, .cols = 5 }); + defer t.deinit(alloc); + + const cmd = try command.Parser.parseString(alloc, "a=a,i=9,s=3"); + defer cmd.deinit(alloc); + const resp = execute(io, alloc, &t, &cmd).?; + try testing.expect(!resp.ok()); + try testing.expectEqualStrings("ENOENT: image not found", resp.message); +} + +test "kittygfx animation: compose frames" { + const testing = std.testing; + const alloc = testing.allocator; + const io = testing.io; + + var t = try Terminal.init(io, alloc, .{ .rows = 5, .cols = 5 }); + defer t.deinit(alloc); + const storage = &t.screens.active.kitty_images; + + // 2x1 RGB image: red then blue ("/wAAAAD/" is FF0000 0000FF). + { + const cmd = try command.Parser.parseString( + alloc, + "a=t,f=24,s=2,v=1,i=1;/wAAAAD/", + ); + defer cmd.deinit(alloc); + try testing.expect(execute(io, alloc, &t, &cmd).?.ok()); + } + // Frame 2 starts as a transparent canvas with a white pixel at + // x=0 ("////" is FFFFFF). + { + const cmd = try command.Parser.parseString( + alloc, + "a=f,i=1,f=24,s=1,v=1;////", + ); + defer cmd.deinit(alloc); + try testing.expect(execute(io, alloc, &t, &cmd).?.ok()); + } + + // Compose the root's pixel at (1,0) onto frame 2 at (0,0): + // r=1 is the source frame, c=2 the destination, X/Y the source + // offsets, x/y the destination offsets. + { + const cmd = try command.Parser.parseString( + alloc, + "a=c,i=1,r=1,c=2,w=1,h=1,X=1,x=0", + ); + defer cmd.deinit(alloc); + const resp = execute(io, alloc, &t, &cmd).?; + try testing.expect(resp.ok()); + try testing.expectEqual(@as(u32, 0), resp.frame); + } + + const anim = storage.imagePtrByIdOrNumber(1, 0).?.animation.?; + try testing.expectEqualSlices( + u8, + &.{ 0, 0, 255, 255, 0, 0, 0, 0 }, + anim.frames.items[0].data, + ); +} + +test "kittygfx animation: compose current frame bumps generation" { + const testing = std.testing; + const alloc = testing.allocator; + const io = testing.io; + + var t = try Terminal.init(io, alloc, .{ .rows = 5, .cols = 5 }); + defer t.deinit(alloc); + const storage = &t.screens.active.kitty_images; + + // 2x1 RGB white image; the root frame is the displayed frame. + { + const cmd = try command.Parser.parseString( + alloc, + "a=t,f=24,s=2,v=1,i=1;////////", + ); + defer cmd.deinit(alloc); + try testing.expect(execute(io, alloc, &t, &cmd).?.ok()); + } + const img = storage.imagePtrByIdOrNumber(1, 0).?; + const gen1 = img.generation; + + // a=c works on images without animation state: both frames are + // the root. Copy pixel 0 onto pixel 1. + const cmd = try command.Parser.parseString( + alloc, + "a=c,i=1,r=1,c=1,w=1,h=1,x=1", + ); + defer cmd.deinit(alloc); + try testing.expect(execute(io, alloc, &t, &cmd).?.ok()); + try testing.expect(img.generation > gen1); +} + +test "kittygfx animation: compose errors" { + const testing = std.testing; + const alloc = testing.allocator; + const io = testing.io; + + var t = try Terminal.init(io, alloc, .{ .rows = 5, .cols = 5 }); + defer t.deinit(alloc); + { + const cmd = try command.Parser.parseString( + alloc, + "a=t,f=24,s=1,v=1,i=1;/wAA", + ); + defer cmd.deinit(alloc); + try testing.expect(execute(io, alloc, &t, &cmd).?.ok()); + } + + const cases = [_]struct { + input: []const u8, + message: []const u8, + }{ + .{ + .input = "a=c,i=9,r=1,c=1", + .message = "ENOENT: image not found", + }, + .{ + .input = "a=c,i=1,r=2,c=1", + .message = "ENOENT: source frame not found", + }, + .{ + .input = "a=c,i=1,r=1,c=2", + .message = "ENOENT: destination frame not found", + }, + // 1x1 image: any offset pushes the rect out of bounds. + .{ + .input = "a=c,i=1,r=1,c=1,x=1", + .message = "EINVAL: destination rectangle out of bounds", + }, + .{ + .input = "a=c,i=1,r=1,c=1,X=1", + .message = "EINVAL: source rectangle out of bounds", + }, + // Same frame, same (whole-image) rect: overlap. + .{ + .input = "a=c,i=1,r=1,c=1", + .message = "EINVAL: source and destination rectangles overlap", + }, + }; + for (cases) |case| { + const cmd = try command.Parser.parseString(alloc, case.input); + defer cmd.deinit(alloc); + const resp = execute(io, alloc, &t, &cmd).?; + try testing.expect(!resp.ok()); + try testing.expectEqualStrings(case.message, resp.message); + } +} + +test "kittygfx animation: delete frame promotes root and fixes current" { + const testing = std.testing; + const alloc = testing.allocator; + const io = testing.io; + + var t = try Terminal.init(io, alloc, .{ .rows = 5, .cols = 5 }); + defer t.deinit(alloc); + const storage = &t.screens.active.kitty_images; + { + const cmd = try command.Parser.parseString( + alloc, + "a=t,f=24,s=1,v=1,i=1;/wAA", + ); + defer cmd.deinit(alloc); + try testing.expect(execute(io, alloc, &t, &cmd).?.ok()); + } + + // Frames 2 and 3: blue and white. + for ([_][]const u8{ + "a=f,i=1,f=24,s=1,v=1,z=10;AAD/", + "a=f,i=1,f=24,s=1,v=1,z=20;////", + }) |input| { + const cmd = try command.Parser.parseString(alloc, input); + defer cmd.deinit(alloc); + try testing.expect(execute(io, alloc, &t, &cmd).?.ok()); + } + const total_before = storage.total_bytes; + + // Delete the root frame (r omitted selects it): frame 2 becomes + // the new root, including its gap. Deletes never respond. + { + const cmd = try command.Parser.parseString(alloc, "a=d,d=f,i=1"); + defer cmd.deinit(alloc); + try testing.expect(execute(io, alloc, &t, &cmd) == null); + } + const img = storage.imagePtrByIdOrNumber(1, 0).?; + const anim = img.animation.?; + try testing.expectEqual(@as(u32, 2), anim.frameCount()); + try testing.expectEqual(@as(u32, 10), anim.root_gap_ms); + try testing.expectEqualSlices(u8, &.{ 0, 0, 255, 255 }, img.data.bytes().?); + try testing.expectEqual(total_before - 4, storage.total_bytes); + + // Delete a frame number past the end: clamps to the last frame. + { + const cmd = try command.Parser.parseString(alloc, "a=d,d=f,i=1,r=9"); + defer cmd.deinit(alloc); + try testing.expect(execute(io, alloc, &t, &cmd) == null); + } + try testing.expectEqual(@as(u32, 1), anim.frameCount()); + try testing.expectEqual(total_before - 8, storage.total_bytes); + + // Down to one frame: d=f is now a no-op and the image survives. + { + const cmd = try command.Parser.parseString(alloc, "a=d,d=f,i=1"); + defer cmd.deinit(alloc); + try testing.expect(execute(io, alloc, &t, &cmd) == null); + } + try testing.expect(storage.imageById(1) != null); +} + +test "kittygfx animation: uppercase frame delete removes non-animated image" { + const testing = std.testing; + const alloc = testing.allocator; + const io = testing.io; + + var t = try Terminal.init(io, alloc, .{ .rows = 5, .cols = 5 }); + defer t.deinit(alloc); + const storage = &t.screens.active.kitty_images; + + // Transmit and place: unlike d=I, an uppercase frame delete on a + // non-animated image removes it even though it is placed. + { + const cmd = try command.Parser.parseString( + alloc, + "a=T,f=24,s=1,v=1,i=1,C=1;/wAA", + ); + defer cmd.deinit(alloc); + try testing.expect(execute(io, alloc, &t, &cmd).?.ok()); + } + try testing.expectEqual(@as(usize, 1), storage.placements.count()); + + { + const cmd = try command.Parser.parseString(alloc, "a=d,d=F,i=1"); + defer cmd.deinit(alloc); + try testing.expect(execute(io, alloc, &t, &cmd) == null); + } + try testing.expect(storage.imageById(1) == null); + try testing.expectEqual(@as(usize, 0), storage.placements.count()); + try testing.expectEqual(@as(usize, 0), storage.total_bytes); +} + +test "kittygfx animation: deleting current frame refreshes display" { + const testing = std.testing; + const alloc = testing.allocator; + const io = testing.io; + + var t = try Terminal.init(io, alloc, .{ .rows = 5, .cols = 5 }); + defer t.deinit(alloc); + const storage = &t.screens.active.kitty_images; + { + const cmd = try command.Parser.parseString( + alloc, + "a=t,f=24,s=1,v=1,i=1;/wAA", + ); + defer cmd.deinit(alloc); + try testing.expect(execute(io, alloc, &t, &cmd).?.ok()); + } + for ([_][]const u8{ + "a=f,i=1,f=24,s=1,v=1;AAD/", + "a=f,i=1,f=24,s=1,v=1;////", + "a=a,i=1,c=3", + }) |input| { + const cmd = try command.Parser.parseString(alloc, input); + defer cmd.deinit(alloc); + _ = execute(io, alloc, &t, &cmd); + } + + const img = storage.imagePtrByIdOrNumber(1, 0).?; + const anim = img.animation.?; + try testing.expectEqual(@as(u32, 2), anim.current_index); + const gen1 = img.generation; + + // Deleting the last frame (the current one) clamps the index and + // refreshes the display. + { + const cmd = try command.Parser.parseString(alloc, "a=d,d=f,i=1,r=3"); + defer cmd.deinit(alloc); + try testing.expect(execute(io, alloc, &t, &cmd) == null); + } + try testing.expectEqual(@as(u32, 1), anim.current_index); + try testing.expect(img.generation > gen1); + try testing.expectEqualSlices(u8, &.{ 0, 0, 255, 255 }, img.renderData().bytes().?); +} + +test "kittygfx animation: retransmitting base image resets animation" { + const testing = std.testing; + const alloc = testing.allocator; + const io = testing.io; + + var t = try Terminal.init(io, alloc, .{ .rows = 5, .cols = 5 }); + defer t.deinit(alloc); + const storage = &t.screens.active.kitty_images; + { + const cmd = try command.Parser.parseString( + alloc, + "a=t,f=24,s=1,v=1,i=1;/wAA", + ); + defer cmd.deinit(alloc); + try testing.expect(execute(io, alloc, &t, &cmd).?.ok()); + } + { + const cmd = try command.Parser.parseString( + alloc, + "a=f,i=1,f=24,s=1,v=1;AAD/", + ); + defer cmd.deinit(alloc); + try testing.expect(execute(io, alloc, &t, &cmd).?.ok()); + } + try testing.expectEqual(@as(usize, 8), storage.total_bytes); + + // Retransmit the base image: the animation is dropped and the + // frame bytes are credited back. + { + const cmd = try command.Parser.parseString( + alloc, + "a=t,f=24,s=1,v=1,i=1;/wAA", + ); + defer cmd.deinit(alloc); + try testing.expect(execute(io, alloc, &t, &cmd).?.ok()); + } + const img = storage.imagePtrByIdOrNumber(1, 0).?; + try testing.expect(img.animation == null); + try testing.expectEqual(@as(usize, 3), storage.total_bytes); +} + +test "kittygfx animation: control negative gap makes frame gapless" { + const testing = std.testing; + const alloc = testing.allocator; + const io = testing.io; + + var t = try Terminal.init(io, alloc, .{ .rows = 5, .cols = 5 }); + defer t.deinit(alloc); + const storage = &t.screens.active.kitty_images; + { + const cmd = try command.Parser.parseString( + alloc, + "a=t,f=24,s=1,v=1,i=1;/wAA", + ); + defer cmd.deinit(alloc); + try testing.expect(execute(io, alloc, &t, &cmd).?.ok()); + } + { + const cmd = try command.Parser.parseString( + alloc, + "a=f,i=1,f=24,s=1,v=1;AAD/", + ); + defer cmd.deinit(alloc); + try testing.expect(execute(io, alloc, &t, &cmd).?.ok()); + } + + const cmd = try command.Parser.parseString(alloc, "a=a,i=1,r=2,z=-1"); + defer cmd.deinit(alloc); + try testing.expect(execute(io, alloc, &t, &cmd) == null); + + const anim = storage.imagePtrByIdOrNumber(1, 0).?.animation.?; + try testing.expectEqual(@as(u32, 0), anim.frames.items[0].gap_ms); } diff --git a/vendor/libghostty-vt/src/terminal/kitty/graphics_image.zig b/vendor/libghostty-vt/src/terminal/kitty/graphics_image.zig index 5841956..945a127 100644 --- a/vendor/libghostty-vt/src/terminal/kitty/graphics_image.zig +++ b/vendor/libghostty-vt/src/terminal/kitty/graphics_image.zig @@ -6,6 +6,7 @@ const ArenaAllocator = std.heap.ArenaAllocator; const posix = std.posix; const fastmem = @import("../../fastmem.zig"); +const animation = @import("graphics_animation.zig"); const command = @import("graphics_command.zig"); const PageList = @import("../PageList.zig"); const sys = @import("../sys.zig"); @@ -35,14 +36,38 @@ pub const LoadingImage = struct { /// so that we display the image after it is fully loaded. display: ?command.Display = null, + /// This is non-null when this load is an animation frame + /// transmission (a=f) rather than a new image. On completion the + /// data is composed into the target image's animation instead of + /// being stored as an image. + frame: ?FrameContext = null, + /// Quiet is the quiet settings for the initial load command. This is /// used if q isn't set on subsequent chunks. quiet: command.Command.Quiet, + /// Response identifiers from the initial load command. Subsequent chunks + /// omit these, so completion responses must use the saved values. + response: command.Response = .{}, + /// The temporary directory for file transmission (null means that /// temporary directory transmission is disabled). temporary_directory: ?[]const u8, + pub const FrameContext = struct { + /// The frame parameters from the initial a=f command. Chunked + /// continuations only contribute payload bytes; all parameters + /// come from the command that started the load, matching the + /// protocol's requirement that chunks repeat a=f. + cmd: command.AnimationFrameLoading, + + /// The generation of the target image when the load began. + /// A different generation at completion means the image was + /// replaced or evicted mid-transmission and the frame must be + /// discarded rather than composed onto the wrong image. + image_generation: u64, + }; + /// The limits of the Kitty Graphics protocol we should allow. /// /// This can be used to restrict the type of images and other @@ -91,6 +116,10 @@ pub const LoadingImage = struct { // These can be overwritten by the data loading process. For example, // PNG loading sets the width/height from the data. const t = cmd.transmission().?; + + // Validated here rather than while parsing so the response can + // carry the image id, matching Kitty's initialize_load_data. + if (t.format_unknown) return error.UnsupportedFormat; var result: LoadingImage = .{ .image = .{ .id = t.image_id, @@ -104,6 +133,11 @@ pub const LoadingImage = struct { .display = cmd.display(), .quiet = cmd.quiet, + .response = .{ + .id = t.image_id, + .image_number = t.image_number, + .placement_id = t.placement_id, + }, .temporary_directory = switch (limits.temporary_file) { .enabled => |d| d.directory, .disabled => null, @@ -176,6 +210,15 @@ pub const LoadingImage = struct { return error.UnsupportedMedium; } + // POSIX shared memory names must begin with a slash, contain at + // least one character after it, contain no other slashes, and fit + // within NAME_MAX. Some shm_open implementations accept names + // without the leading slash, but the Kitty protocol does not. + if (!validSharedMemoryName(path, posix.NAME_MAX)) { + log.warn("invalid shared memory name", .{}); + return error.InvalidData; + } + // Since we're only supporting posix then max_path_bytes should // be enough to stack allocate the path. var buf: [std.fs.max_path_bytes]u8 = undefined; @@ -377,11 +420,28 @@ pub const LoadingImage = struct { // Read the file var managed: std.ArrayList(u8) = .empty; errdefer managed.deinit(alloc); - const size: usize = if (t.size > 0) @min(t.size, max_size) else max_size; - reader.appendRemaining(alloc, &managed, .limited(size)) catch { - log.warn("failed to read temporary file: {?}", .{buf_reader.err}); - return error.InvalidData; - }; + if (t.size > 0) { + // S is the exact number of bytes to read, not a maximum: + // https://sw.kovidgoyal.net/kitty/graphics-protocol/#local-client + const size = std.math.cast(usize, t.size) orelse + return error.InvalidData; + if (size > max_size) return error.InvalidData; + + // Read exact size + const data = reader.readAlloc(alloc, size) catch |err| { + log.warn("failed to read image file: {}", .{err}); + return switch (err) { + error.OutOfMemory => error.OutOfMemory, + else => error.InvalidData, + }; + }; + managed = .{ .items = data, .capacity = data.len }; + } else { + reader.appendRemaining(alloc, &managed, .limited(max_size)) catch { + log.warn("failed to read image file: {?}", .{buf_reader.err}); + return error.InvalidData; + }; + } // Set our data assert(self.data.items.len == 0); @@ -471,11 +531,23 @@ pub const LoadingImage = struct { if (img.width == 0 or img.height == 0) return error.DimensionsRequired; if (img.width > max_dimension or img.height > max_dimension) return error.DimensionsTooLarge; - // Data length must be what we expect + // Data length must be what we expect. const bpp = command.Transmission.formatBpp(img.format); const expected_len = img.width * img.height * bpp; const actual_len = self.data.items.len; - if (actual_len != expected_len) { + if (self.frame != null) { + // Kitty allows animation frames to exceed their expected length + // and just truncates it. Not sure if thats expected but lets + // allow it too. + if (actual_len < expected_len) { + std.log.warn( + "insufficient frame data image id={} expected_len={} actual_len={}", + .{ img.id, expected_len, actual_len }, + ); + return error.InsufficientData; + } + self.data.items.len = expected_len; + } else if (actual_len != expected_len) { std.log.warn( "unexpected length image id={} width={} height={} bpp={} expected_len={} actual_len={}", .{ img.id, img.width, img.height, bpp, expected_len, actual_len }, @@ -605,8 +677,12 @@ pub const Image = struct { transient: bool = false, /// Set this if the image was loaded without an ID or number. Such - /// images must not receive responses even though they currently get - /// IDs in the public range (which is bad!). + /// images must not receive responses. Kitty gives these client ID + /// 0 (unaddressable); our storage keys everything by one public + /// u32 ID, so they get an ID from the upper half of the range + /// that is guaranteed unused at assignment time, but a client + /// that explicitly transmits that ID later can still replace + /// them. implicit_id: bool = false, /// Number of placements referencing this image. @@ -619,9 +695,25 @@ pub const Image = struct { /// have changed, even if the dimensions and byte length are the /// same (e.g. a retransmission of the same ID). Stamps order by /// transmission time. Zero means "never stored". + /// + /// For animated images this also changes whenever the frame that + /// should be displayed changes (advance, edit, or delete of the + /// current frame), since consumers key texture caches off it. generation: u64 = 0, + /// Animation state, non-null once any animation command (a=f, + /// a=a) has attached animation state to this image. Owned by the + /// image; replaced/retransmitted images drop it, which implements + /// the protocol's "retransmission resets the animation" rule. + /// + /// This is only ever attached to images stored in an ImageStorage + /// and must only be mutated through the storage's own pointer + /// (Image values are copied around freely; copies share this + /// pointer and never own it). + animation: ?*animation.Animation = null, + pub const Error = error{ + InsufficientData, InvalidData, DecompressionFailed, DimensionsRequired, @@ -671,6 +763,54 @@ pub const Image = struct { pub fn deinit(self: *Image, alloc: Allocator) void { self.data.deinit(alloc); + if (self.animation) |anim| { + anim.deinit(alloc); + alloc.destroy(anim); + self.animation = null; + } + } + + /// The pixel data that should be displayed for this image. For an + /// animated image this is the current animation frame; otherwise + /// (and for the root frame) it is the image's own data. + pub fn renderData(self: *const Image) Data { + if (self.animation) |anim| { + if (anim.current_index > 0) { + return .{ .complete = anim.frames.items[anim.current_index - 1].data }; + } + } + + return self.data; + } + + /// The pixel data of the given 1-based animation frame number, or + /// null if the frame doesn't exist. Frame 1 (the root frame) + /// always exists as long as the image data is complete, even for + /// images without animation state. + /// + /// The returned slice is owned by the image (or its animation) + /// and remains valid until the image or frame is mutated. + pub fn frameData(self: *const Image, number: u32) ?[]const u8 { + switch (number) { + 0 => return null, + 1 => return self.data.bytes(), + else => { + const anim = self.animation orelse return null; + // Minus 2 because frame is 1-based and frame 1 is the + // image base data, so the animation frames start at frame 2. + const idx = number - 2; + if (idx >= anim.frames.items.len) return null; + return anim.frames.items[idx].data; + }, + } + } + + /// Total bytes of pixel data reserved against the storage limit + /// for this image: the base data plus any animation frames. + pub fn storageSize(self: *const Image) usize { + var total: usize = self.data.len(); + if (self.animation) |anim| total += anim.frameBytes(); + return total; } /// Mostly for logging @@ -700,6 +840,16 @@ pub const Rect = struct { } }; +/// Returns whether a name follows the POSIX shared memory name format. +fn validSharedMemoryName(name: []const u8, name_max: usize) bool { + if (name.len < 2 or name.len > name_max or name[0] != '/') return false; + for (name[1..]) |c| { + if (c == '/' or c == 0) return false; + } + + return true; +} + /// Returns true if `path` is `dir` or is contained within it, requiring a /// path-separator boundary so similarly prefixed directories do not match. fn isPathInDir(dir: []const u8, path: []const u8) bool { @@ -721,6 +871,53 @@ test "temporary file path must be inside directory" { try testing.expect(!isPathInDir("/custom/tmp", "/custom/tmp-suffix/tty-graphics-protocol-image.data")); } +test "shared memory names follow POSIX rules" { + const testing = std.testing; + + try testing.expect(validSharedMemoryName("/kitty", 8)); + try testing.expect(validSharedMemoryName("/1234567", 8)); + + try testing.expect(!validSharedMemoryName("", 8)); + try testing.expect(!validSharedMemoryName("/", 8)); + try testing.expect(!validSharedMemoryName("kitty", 8)); + try testing.expect(!validSharedMemoryName("/kitty/image", 16)); + try testing.expect(!validSharedMemoryName("/kitty\x00image", 16)); + try testing.expect(!validSharedMemoryName("/12345678", 8)); +} + +test "image load rejects invalid POSIX shared memory names" { + if (comptime builtin.abi.isAndroid() or + builtin.target.os.tag == .windows or + !builtin.link_libc) + { + return error.SkipZigTest; + } + + const testing = std.testing; + const alloc = testing.allocator; + + var cmd: command.Command = .{ + .control = .{ .transmit = .{ + .format = .rgb, + .medium = .shared_memory, + .width = 1, + .height = 1, + .image_id = 31, + } }, + .data = try alloc.dupe(u8, "kitty-without-leading-slash"), + }; + defer cmd.deinit(alloc); + + try testing.expectError( + error.InvalidData, + LoadingImage.init(testing.io, alloc, &cmd, .{ + .file = false, + .temporary_file = .disabled, + .shared_memory = true, + }), + ); +} + test "shared memory range with offset and size" { const testing = std.testing; @@ -1151,6 +1348,101 @@ test "image load: rgb, not compressed, regular file" { try tmp_dir.dir.access(testing.io, path, .{}); } +test "image load: regular file size reads exactly requested bytes" { + const testing = std.testing; + const alloc = testing.allocator; + const io = testing.io; + + var tmp_dir = testing.tmpDir(.{}); + defer tmp_dir.cleanup(); + try tmp_dir.dir.writeFile(io, .{ + .sub_path = "image.data", + .data = &.{ 1, 2, 3, 4, 5, 6 }, + }); + + var path_buf: [std.fs.max_path_bytes]u8 = undefined; + const path = path_buf[0..try tmp_dir.dir.realPathFile( + io, + "image.data", + &path_buf, + )]; + + const cases = [_]struct { + offset: u32, + expected: [3]u8, + }{ + .{ .offset = 0, .expected = .{ 1, 2, 3 } }, + .{ .offset = 3, .expected = .{ 4, 5, 6 } }, + }; + for (cases) |case| { + var cmd: command.Command = .{ + .control = .{ .transmit = .{ + .format = .rgb, + .medium = .file, + .width = 1, + .height = 1, + .size = 3, + .offset = case.offset, + .image_id = 31, + } }, + .data = try alloc.dupe(u8, path), + }; + defer cmd.deinit(alloc); + + var loading = try LoadingImage.init(io, alloc, &cmd, .{ + .file = true, + .temporary_file = .disabled, + .shared_memory = false, + }); + defer loading.deinit(alloc); + var img = try loading.complete(alloc); + defer img.deinit(alloc); + + try testing.expectEqualSlices(u8, &case.expected, img.data.complete); + } +} + +test "image load: regular file size rejects short data" { + const testing = std.testing; + const alloc = testing.allocator; + const io = testing.io; + + var tmp_dir = testing.tmpDir(.{}); + defer tmp_dir.cleanup(); + try tmp_dir.dir.writeFile(io, .{ + .sub_path = "image.data", + .data = &.{ 1, 2 }, + }); + + var path_buf: [std.fs.max_path_bytes]u8 = undefined; + const path = path_buf[0..try tmp_dir.dir.realPathFile( + io, + "image.data", + &path_buf, + )]; + var cmd: command.Command = .{ + .control = .{ .transmit = .{ + .format = .rgb, + .medium = .file, + .width = 1, + .height = 1, + .size = 3, + .image_id = 31, + } }, + .data = try alloc.dupe(u8, path), + }; + defer cmd.deinit(alloc); + + try testing.expectError( + error.InvalidData, + LoadingImage.init(io, alloc, &cmd, .{ + .file = true, + .temporary_file = .disabled, + .shared_memory = false, + }), + ); +} + test "image load: rgb, not compressed, relative regular file" { const testing = std.testing; const alloc = testing.allocator; diff --git a/vendor/libghostty-vt/src/terminal/kitty/graphics_pixel.zig b/vendor/libghostty-vt/src/terminal/kitty/graphics_pixel.zig new file mode 100644 index 0000000..1f44687 --- /dev/null +++ b/vendor/libghostty-vt/src/terminal/kitty/graphics_pixel.zig @@ -0,0 +1,235 @@ +//! Pixel-buffer operations for the Kitty graphics protocol: format +//! conversion to RGBA, background fill, and rectangle composition. +//! These are the primitives behind animation frame loading (a=f) and +//! frame composition (a=c). See graphics_animation.zig for the +//! animation model built on top of them. + +const std = @import("std"); +const Allocator = std.mem.Allocator; +const wuffs = @import("wuffs"); +const command = @import("graphics_command.zig"); + +/// Convert pixel data in the given format to a freshly allocated RGBA +/// buffer. The caller owns the result; the input is not freed. +/// +/// The input length must be a multiple of the format's bytes per +/// pixel; image loading validates data length against the image +/// dimensions before storing it. +pub fn rgbaFromFormat( + alloc: Allocator, + format: command.Transmission.Format, + data: []const u8, +) Allocator.Error![]u8 { + const result = switch (format) { + .rgba => return try alloc.dupe(u8, data), + .rgb => wuffs.swizzle.rgbToRgba(alloc, data), + .gray => wuffs.swizzle.gToRgba(alloc, data), + .gray_alpha => wuffs.swizzle.gaToRgba(alloc, data), + + // PNG is decoded to RGBA during image loading. + .png => unreachable, + }; + + return result catch |err| switch (err) { + error.OutOfMemory => error.OutOfMemory, + // These are fixed, supported swizzles; wuffs cannot fail to + // prepare them and nothing else in the conversion errors. + error.WuffsError, error.Overflow => unreachable, + }; +} + +/// Fill an RGBA buffer with the given background color. +pub fn fillBackground( + data: []u8, + bg: command.AnimationFrameLoading.Background, +) void { + const raw: u32 = @bitCast(bg); + if (raw == 0) { + @memset(data, 0); + return; + } + var i: usize = 0; + while (i + 4 <= data.len) : (i += 4) { + data[i + 0] = bg.r; + data[i + 1] = bg.g; + data[i + 2] = bg.b; + data[i + 3] = bg.a; + } +} + +/// Compose the `src` rectangle (src_width x src_height RGBA pixels) +/// onto the `dst` canvas (dst_width x dst_height RGBA pixels) with +/// its top-left corner at (x, y). Portions of the rectangle outside +/// the canvas are silently clipped, matching Kitty's a=f behavior. +pub fn composeRect( + dst: []u8, + dst_width: u32, + dst_height: u32, + src: []const u8, + src_width: u32, + src_height: u32, + x: u32, + y: u32, + mode: command.CompositionMode, +) void { + if (x >= dst_width or y >= dst_height) return; + const width: usize = @min(src_width, dst_width - x); + const height: usize = @min(src_height, dst_height - y); + for (0..height) |row| { + const dst_off = ((y + row) * dst_width + x) * 4; + const src_off = row * @as(usize, src_width) * 4; + composeRow( + dst[dst_off..][0 .. width * 4], + src[src_off..][0 .. width * 4], + mode, + ); + } +} + +/// Compose a width x height rectangle between two full-size RGBA +/// canvases sharing the same `canvas_width` stride, reading from +/// (src_x, src_y) in `src` and writing at (dst_x, dst_y) in `dst`. +/// The caller must have validated that both rectangles are within +/// bounds; a=c reports out-of-bounds rectangles as errors rather +/// than clipping. +pub fn composeCanvasRect( + dst: []u8, + src: []const u8, + canvas_width: u32, + width: u32, + height: u32, + src_x: u32, + src_y: u32, + dst_x: u32, + dst_y: u32, + mode: command.CompositionMode, +) void { + for (0..height) |row| { + const dst_off = ((dst_y + row) * @as(usize, canvas_width) + dst_x) * 4; + const src_off = ((src_y + row) * @as(usize, canvas_width) + src_x) * 4; + composeRow( + dst[dst_off..][0 .. @as(usize, width) * 4], + src[src_off..][0 .. @as(usize, width) * 4], + mode, + ); + } +} + +fn composeRow(dst: []u8, src: []const u8, mode: command.CompositionMode) void { + switch (mode) { + .overwrite => @memcpy(dst, src), + .alpha_blend => wuffs.swizzle.rgbaSrcOver(dst, src), + } +} + +test "rgba conversion" { + const testing = std.testing; + const alloc = testing.allocator; + + { + const rgb = [_]u8{ 1, 2, 3, 4, 5, 6 }; + const result = try rgbaFromFormat(alloc, .rgb, &rgb); + defer alloc.free(result); + try testing.expectEqualSlices(u8, &.{ 1, 2, 3, 255, 4, 5, 6, 255 }, result); + } + + { + const gray = [_]u8{ 7, 8 }; + const result = try rgbaFromFormat(alloc, .gray, &gray); + defer alloc.free(result); + try testing.expectEqualSlices(u8, &.{ 7, 7, 7, 255, 8, 8, 8, 255 }, result); + } + + { + const ga = [_]u8{ 7, 100, 8, 200 }; + const result = try rgbaFromFormat(alloc, .gray_alpha, &ga); + defer alloc.free(result); + try testing.expectEqualSlices(u8, &.{ 7, 7, 7, 100, 8, 8, 8, 200 }, result); + } + + { + const rgba = [_]u8{ 1, 2, 3, 4 }; + const result = try rgbaFromFormat(alloc, .rgba, &rgba); + defer alloc.free(result); + try testing.expectEqualSlices(u8, &rgba, result); + } +} + +test "fill background" { + var buf: [8]u8 = undefined; + fillBackground(&buf, .{ .r = 1, .g = 2, .b = 3, .a = 4 }); + try std.testing.expectEqualSlices(u8, &.{ 1, 2, 3, 4, 1, 2, 3, 4 }, &buf); + + fillBackground(&buf, .{}); + try std.testing.expectEqualSlices(u8, &(.{0} ** 8), &buf); +} + +test "compose rect overwrite with clipping" { + // 2x2 canvas, compose a 2x1 rect at (1, 1): only the first pixel + // of the rect fits, the rest clips off the right edge. + var dst = [_]u8{0} ** 16; + const src = [_]u8{ 1, 2, 3, 4, 5, 6, 7, 8 }; + composeRect(&dst, 2, 2, &src, 2, 1, 1, 1, .overwrite); + + var expect = [_]u8{0} ** 16; + expect[12] = 1; + expect[13] = 2; + expect[14] = 3; + expect[15] = 4; + try std.testing.expectEqualSlices(u8, &expect, &dst); +} + +test "compose rect entirely out of bounds" { + var dst = [_]u8{9} ** 16; + const src = [_]u8{1} ** 4; + composeRect(&dst, 2, 2, &src, 1, 1, 2, 0, .overwrite); + composeRect(&dst, 2, 2, &src, 1, 1, 0, 2, .overwrite); + try std.testing.expectEqualSlices(u8, &(.{9} ** 16), &dst); +} + +test "alpha blend source-over semantics" { + const testing = std.testing; + + // Opaque source overwrites exactly. + { + var dst = [4]u8{ 10, 20, 30, 40 }; + composeRect(&dst, 1, 1, &.{ 100, 110, 120, 255 }, 1, 1, 0, 0, .alpha_blend); + try testing.expectEqualSlices(u8, &.{ 100, 110, 120, 255 }, &dst); + } + + // Fully transparent source leaves the destination untouched + // (like Kitty; see the module doc on rounding). + { + var dst = [4]u8{ 10, 20, 30, 40 }; + composeRect(&dst, 1, 1, &.{ 100, 110, 120, 0 }, 1, 1, 0, 0, .alpha_blend); + try testing.expectEqualSlices(u8, &.{ 10, 20, 30, 40 }, &dst); + } + + // 50% source over opaque destination. + { + var dst = [4]u8{ 0, 0, 0, 255 }; + composeRect(&dst, 1, 1, &.{ 255, 255, 255, 128 }, 1, 1, 0, 0, .alpha_blend); + try testing.expectEqual(@as(u8, 255), dst[3]); + try testing.expectEqual(@as(u8, 128), dst[0]); + } + + // Blending over a transparent destination yields the source + // exactly, matching Kitty: wuffs passes the source through + // untouched when the destination is transparent. + { + var dst = [4]u8{ 0, 0, 0, 0 }; + composeRect(&dst, 1, 1, &.{ 200, 100, 50, 128 }, 1, 1, 0, 0, .alpha_blend); + try testing.expectEqualSlices(u8, &.{ 200, 100, 50, 128 }, &dst); + } +} + +test "compose canvas rect" { + // 3x1 canvas: copy pixel at x=0 onto x=2. + var canvas = [_]u8{ 1, 2, 3, 4, 0, 0, 0, 0, 9, 9, 9, 9 }; + composeCanvasRect(&canvas, &canvas, 3, 1, 1, 0, 0, 2, 0, .overwrite); + try std.testing.expectEqualSlices( + u8, + &.{ 1, 2, 3, 4, 0, 0, 0, 0, 1, 2, 3, 4 }, + &canvas, + ); +} diff --git a/vendor/libghostty-vt/src/terminal/kitty/graphics_storage.zig b/vendor/libghostty-vt/src/terminal/kitty/graphics_storage.zig index fe97b15..ffd9171 100644 --- a/vendor/libghostty-vt/src/terminal/kitty/graphics_storage.zig +++ b/vendor/libghostty-vt/src/terminal/kitty/graphics_storage.zig @@ -6,6 +6,8 @@ const ArenaAllocator = std.heap.ArenaAllocator; const terminal = @import("../main.zig"); const point = @import("../point.zig"); const size = @import("../size.zig"); +const animation = @import("graphics_animation.zig"); +const pixel = @import("graphics_pixel.zig"); const command = @import("graphics_command.zig"); const PageList = @import("../PageList.zig"); const Screen = @import("../Screen.zig"); @@ -101,11 +103,10 @@ pub const ImageStorage = struct { /// This field must only be written via markMutated. generation: u64 = 0, - /// This is the next automatically assigned image ID. We start mid-way - /// through the u32 range to avoid collisions with buggy programs. - /// TODO: This isn't good enough, it's perfectly legal for programs - /// to use IDs in the latter half of the range and collisions - /// are not gracefully handled. + /// This is the next automatically assigned image ID for images + /// transmitted without an ID or number. We start mid-way through + /// the u32 range to stay clear of the low IDs client programs + /// typically pick. See nextImageId. next_image_id: u32 = 2147483647, /// This is the next automatically assigned placement ID. This is never @@ -186,13 +187,14 @@ pub const ImageStorage = struct { /// Record a content mutation: marks the storage dirty and assigns a /// fresh generation stamp. Must be called by anything that changes - /// the set of images or placements (or image contents). + /// the set of images or placements (or image contents), including + /// the animation command handlers in graphics_exec.zig. /// /// Do NOT call this for geometry-only events (scrolling, resizing, /// screen switches); those must set only the dirty flag directly. /// Bumping the generation for geometry changes would break the /// contract that an unchanged generation means unchanged contents. - fn markMutated(self: *ImageStorage, io: std.Io) void { + pub fn markMutated(self: *ImageStorage, io: std.Io) void { self.dirty = true; self.generation = nextGeneration(io); } @@ -228,6 +230,40 @@ pub const ImageStorage = struct { self.total_limit = limit; } + /// Returns the next ID to automatically assign to an image that + /// was transmitted without an explicit ID (i=). The result is + /// never zero (zero means "no ID") and never an ID currently in + /// use, so an automatic ID never replaces an existing image. + pub fn nextImageId( + self: *ImageStorage, + mode: enum { explicit, implicit }, + ) u32 { + // Starting ID depends on mode. + var id: u32 = switch (mode) { + // Yeah, starting from 1 is wasteful. This matches what Kitty + // does. In the future we can probably cache a low-water-mark + // based on delete behavior. + .explicit => 1, + .implicit => self.next_image_id, + }; + + // Go through all possible images. We have +2 because we can + // exceed the total by 1 (new ID). + const count: usize = self.images.count(); + for (0..count + 2) |_| { + if (id != 0 and !self.images.contains(id)) break; + id +%= 1; + } + + // Zero is never allowed. + if (id == 0) id = 1; + + // Keep track of our next image id for implicit to speed that up. + if (mode == .implicit) self.next_image_id = id +% 1; + + return id; + } + /// Add an image to the storage. This will automatically free any existing /// image with the same ID. Prefer addPendingImage for pending data so the /// caller receives a completion token. @@ -248,7 +284,7 @@ pub const ImageStorage = struct { // replacing pending snapshot metadata must be able to reuse the // reservation without evicting its own ID. const old_len = if (self.images.get(img.id)) |old| - old.data.len() + old.storageSize() else 0; assert(old_len <= self.total_bytes); @@ -274,11 +310,18 @@ pub const ImageStorage = struct { log.debug("addImage image={}", .{img.withoutData()}); - // Retransmitting a specific image ID replaces the old image and all - // of its placements, as required by the Kitty graphics protocol. if (gop.found_existing) { + // Retransmitting a specific image ID replaces the old image and all + // of its placements, as required by the Kitty graphics protocol. self.removePlacementsByImageId(s, img.id); - self.total_bytes -= gop.value_ptr.data.len(); + + // Relative placements parented to the removed placements go too. + _ = self.removeOrphans(s, null); + + // Replacing an image drops its animation frames with it, + // implementing the protocol rule that retransmitting the + // base image resets the animation. + self.total_bytes -= gop.value_ptr.storageSize(); gop.value_ptr.deinit(alloc); } @@ -329,13 +372,11 @@ pub const ImageStorage = struct { p, }); - // Tracked pins are marked garbage when their underlying history is - // pruned. Kitty removes placements once they scroll out of retained - // history, so reclaim those placements before growing the map for a - // new one. If allocation below fails, the sweep is still a content - // mutation and must be visible to consumers. - const removed_garbage = self.removeGarbagePlacements(s); - errdefer if (removed_garbage) self.markMutated(io); + // When we add a placement, take this opportunity to reap garbage. + // Garbage are placements that disappeared off scrollback (were + // pruned). We don't clear Kitty state in the hot path so we do + // this opportunistically here. + self.reapGarbagePlacements(io, s); // The important piece here is that the placement ID needs to // be marked internal if it is zero. This allows multiple placements @@ -372,30 +413,212 @@ pub const ImageStorage = struct { } gop.value_ptr.* = p; + // This always mutates self.markMutated(io); } - /// Remove pin-backed placements whose tracked content has been pruned. - /// Virtual placements have no tracked screen location and are retained. - fn removeGarbagePlacements( + /// How the row operation behind a scrollMarginsBegin/end pair moves + /// tracked pins, which determines how much repositioning work the + /// placements need. + pub const ScrollOp = enum { + /// The operation shifts the entire active window down (it + /// creates scrollback), so every placement anchored in the + /// active area changes active position and must be restored. + window_shift, + + /// The operation moves rows in place within the scrolling + /// region: pins anchored outside the region rows keep their + /// active position without help, so only placements anchored + /// inside the region rows need handling. This is the common + /// case (e.g. a status bar image beside a scrolling pane + /// costs almost nothing per scroll). + in_place, + }; + + /// Adjust placements for a scroll of the terminal's scrolling region + /// by `delta` rows (negative moves content up). This implements the + /// Kitty graphics protocol behavior for scrolls bounded by margins: + /// placements entirely inside the scrolling region move with the content + /// and are clipped when the move would extend them past a margin (deleted + /// when fully clipped), while every other placement (straddling a margin, + /// outside the region, or in the scrollback) must not move at all. + /// + /// This is split into two phases around the row operation because + /// the various scroll implementations move tracked pins in + /// inconsistent ways (some shift the active area so every pin moves, + /// some rotate rows in place and adjust only pins inside the + /// region). We record the desired final position of every placement + /// up front in the returned state, whose end() repositions the pins + /// once the rows have settled, making the result independent of the + /// path taken. Every call must be paired with exactly one end(). + /// + /// Callers must skip this entirely when the scrolling region is the + /// full screen: a full-screen scroll moves placements with their + /// anchored rows (possibly into the scrollback) via pin tracking, + /// which already matches kitty's marginless scroll behavior. + pub fn scrollMarginsBegin( + self: *ImageStorage, + io: std.Io, + t: *terminal.Terminal, + delta: isize, + op: ScrollOp, + ) ScrollMargins { + const s: *terminal.Screen = t.screens.active; + assert(self == &s.kitty_images); + var result: ScrollMargins = .{ .screen = s }; + + const top: i64 = t.scrolling_region.top; + const bottom: i64 = t.scrolling_region.bottom; + + var mutated = false; + var it = self.placements.iterator(); + while (it.next()) |entry| { + const p: *Placement = entry.value_ptr; + + // Virtual placements follow their placeholder cells and + // relative placements follow their parents, so neither is + // ever adjusted by scrolls, matching kitty. + const pin: *PageList.Pin = switch (p.location) { + .pin => |pin| pin, + .virtual, .relative => continue, + }; + + // Pruned placements are reaped by reapGarbagePlacements. + if (pin.garbage) continue; + + // Placements anchored outside the active area (scrollback) + // are never touched by a scroll within the margins. + const coord = (s.pages.pointFromPin( + .active, + pin.*, + ) orelse continue).coord(); + const y: i64 = coord.y; + + // An in-place operation doesn't touch pins anchored outside + // the region rows, and a placement anchored there can never + // be entirely inside the region, so it needs no work at all. + if (op == .in_place and (y < top or y > bottom)) continue; + + // The final position defaults to unmoved; restoring the pin + // to this exact spot afterwards is what stops the row + // operations from dragging the placement around. + var final_y: i64 = y; + + inside: { + const image = self.images.get(entry.key_ptr.image_id) orelse break :inside; + const grid = p.gridSize(image, t); + + // Unknown geometry (e.g. pixel sizes not yet reported) + // means we can't know the extent, so we can't consider + // the placement contained. + if (grid.rows == 0 or grid.cols == 0) break :inside; + + // Only placements entirely within the scrolling region + // move. Kitty only has top/bottom margins so it only + // checks rows; the column check is our generalization + // for left/right margins and is trivially true for + // full-width regions. + if (y < top or y + grid.rows - 1 > bottom) break :inside; + const x: i64 = coord.x; + if (x < t.scrolling_region.left or + @min(x + grid.cols - 1, @as(i64, t.cols) - 1) > t.scrolling_region.right) break :inside; + + const new_y: i64 = y + delta; + const rows: i64 = grid.rows; + const top_clip: i64 = @max(0, top - new_y); + const bottom_clip: i64 = @max(0, new_y + rows - 1 - bottom); + const visible: bool = clip: { + if (top_clip >= rows or bottom_clip >= rows) break :clip false; + + if (top_clip > 0) { + if (!p.clipTop( + image, + t, + @intCast(top_clip), + grid.rows, + )) break :clip false; + mutated = true; + final_y = top; + break :clip true; + } + + if (bottom_clip > 0) { + if (!p.clipBottom( + image, + t, + @intCast(bottom_clip), + grid.rows, + )) break :clip false; + mutated = true; + } + + final_y = new_y; + break :clip true; + }; + + // Scrolled or clipped entirely out of the region: the + // placement is deleted, like kitty. The image itself is + // retained for future placements. + if (!visible) { + self.removePlacement(s, entry); + mutated = true; + continue; + } + } + + result.restores.append(s.alloc, .{ + .pin = pin, + .x = pin.x, + .y = @intCast(final_y), + }) catch { + // OOM: this placement is left to the whims of the + // generic pin tracking. Degraded, but safe. + log.warn("OOM adjusting image placement for scroll", .{}); + continue; + }; + } + + if (mutated) { + // Placements deleted by clipping may orphan relative placements. + // Orphans have no pins so this never touches the restores. + _ = self.removeOrphans(s, null); + self.markMutated(io); + } + + return result; + } + + /// Reap pin-backed placements whose tracked content has been pruned + /// from history, along with any relative placements orphaned by + /// that, marking the content mutation. + /// + /// Virtual and relative placements have no tracked screen location and + /// are never pruned themselves. Kitty removes placements once they + /// scroll out of retained history. + fn reapGarbagePlacements( self: *ImageStorage, + io: std.Io, s: *terminal.Screen, - ) bool { + ) void { var removed = false; var it = self.placements.iterator(); while (it.next()) |entry| { const pin = switch (entry.value_ptr.location) { .pin => |pin| pin, - .virtual => continue, + .virtual, .relative => continue, }; if (!pin.garbage) continue; - entry.value_ptr.deinit(s); - self.removePlacementByPtr(entry.key_ptr); + self.removePlacement(s, entry); removed = true; } + if (!removed) return; - return removed; + // If we removed a placement, then also remove any orphan + // children if this was a parent. + _ = self.removeOrphans(s, null); + + self.markMutated(io); } fn clearPlacements(self: *ImageStorage, s: *terminal.Screen) void { @@ -412,16 +635,280 @@ pub const ImageStorage = struct { var it = self.placements.iterator(); while (it.next()) |entry| { if (entry.key_ptr.image_id != image_id) continue; - entry.value_ptr.deinit(s); - self.removePlacementByPtr(entry.key_ptr); + self.removePlacement(s, entry); } } - fn removePlacementByPtr(self: *ImageStorage, key: *PlacementKey) void { - const img = self.images.getPtr(key.image_id).?; + /// Remove a placement by its map entry, releasing any tracked pin + /// and keeping the image's placement count in sync. The entry must + /// point into the placements map (via iteration or getEntry). + fn removePlacement( + self: *ImageStorage, + s: *terminal.Screen, + entry: PlacementMap.Entry, + ) void { + entry.value_ptr.deinit(s); + const img = self.images.getPtr(entry.key_ptr.image_id).?; assert(img.metadata.placement_count > 0); img.metadata.placement_count -= 1; - self.placements.removeByPtr(key); + self.placements.removeByPtr(entry.key_ptr); + } + + /// Remove relative placements whose parent placement no longer + /// exists, keeping a relative placement's lifetime tied to its + /// parent chain. Chains can be several links deep, so this loops + /// until a pass removes nothing to take out entire orphaned + /// subtrees. Returns true if anything was removed. + /// + /// If `delete_unused` is non-null, an image left without placements + /// by the reap is freed using it. This matches uppercase delete + /// semantics; every other removal path retains image data and + /// passes null. + /// + /// This must be called, outside of any placements iteration, after + /// every operation that removes placements. Kitty gets the same + /// effect lazily by dropping unresolvable placements while drawing; + /// we do it eagerly because our renderer never mutates terminal + /// state. + fn removeOrphans( + self: *ImageStorage, + s: *terminal.Screen, + delete_unused: ?Allocator, + ) bool { + var removed_any = false; + var removed = true; + while (removed) { + removed = false; + var it = self.placements.iterator(); + while (it.next()) |entry| { + const rel = switch (entry.value_ptr.location) { + .relative => |rel| rel, + .pin, .virtual => continue, + }; + if (self.placements.contains(rel.parent)) continue; + + // Parent is gone, remove this placement. + const image_id = entry.key_ptr.image_id; + self.removePlacement(s, entry); + removed = true; + removed_any = true; + + if (delete_unused) |alloc| self.deleteIfUnused( + alloc, + image_id, + ); + } + } + + return removed_any; + } + + /// Maximum number of parent links in a relative placement chain, + /// matching the minimum specified in the spec and the actual + /// limit defined by Kitty at the time of authoring. + pub const parent_chain_limit = 8; + + pub const ParentError = error{ + /// The parent image (P=) does not exist. + ParentImageNotFound, + /// The parent image exists but the requested placement (Q=, or + /// any placement when Q is omitted) does not. + ParentPlacementNotFound, + /// The placement refers to itself as its own parent. + SelfParent, + /// The parent chain loops back to the placement being created. + Cycle, + /// The parent chain exceeds parent_chain_limit links. + TooDeep, + /// An ancestor in the chain no longer exists. This should not + /// happen since removeOrphans keeps chains intact, but we + /// check anyway rather than trusting the invariant. + AncestorNotFound, + }; + + /// Resolve and validate the parent reference (P=/Q=) of a relative + /// placement, returning the concrete key of the parent placement. + /// `child` is the key of the placement being created when it is + /// addressable (an explicit placement ID); null for placements that + /// will receive a fresh internal ID, which nothing can refer to yet. + /// + /// Checks: the parent must exist, the placement must not + /// parent itself, and the resulting ancestor chain must be acyclic + /// and within parent_chain_limit. + pub fn resolveParent( + self: *ImageStorage, + io: std.Io, + s: *terminal.Screen, + child: ?PlacementKey, + parent_image_id: u32, + parent_placement_id: u32, + ) ParentError!PlacementKey { + // Reap placements whose content has been pruned from history + // before resolving: a pruned parent must be reported as missing, + // not accepted and then immediately reaped by addPlacement's own + // sweep, which would store an orphan. + self.reapGarbagePlacements(io, s); + + // If the parent doesn't exist we're already failed. + if (!self.images.contains(parent_image_id)) { + return error.ParentImageNotFound; + } + + // Find the parent + const parent: PlacementKey = parent: { + // An explicit parent placement ID (Q=) selects exactly that + // placement. + if (parent_placement_id > 0) { + const key: PlacementKey = .{ + .image_id = parent_image_id, + .placement_id = .{ + .tag = .external, + .id = parent_placement_id, + }, + }; + if (!self.placements.contains(key)) { + return error.ParentPlacementNotFound; + } + break :parent key; + } + + // No Q: pick a placement of the parent image. Kitty picks + // the oldest surviving placement; our placement map doesn't + // track creation order so we pick by PlacementId.preferredOver + // instead. This is unspecified so any behavior here is fine. + // In practice the parent image has a single placement, and + // clients use Q when it doesn't. + const best: PlacementId = best: { + var best: ?PlacementId = null; + var it = self.placements.keyIterator(); + while (it.next()) |key| { + if (key.image_id != parent_image_id) continue; + const id = key.placement_id; + const b = best orelse { + best = id; + continue; + }; + if (id.preferredOver(b)) best = id; + } + break :best best orelse return error.ParentPlacementNotFound; + }; + + break :parent .{ + .image_id = parent_image_id, + .placement_id = best, + }; + }; + + // A placement cannot be its own parent. This must be checked + // before the chain walk so it reports EINVAL, not ECYCLE. + if (child) |c| if (parent.eql(c)) return error.SelfParent; + + // Walk the would-be ancestor chain. `depth` counts parent links, + // starting at one for the link we are about to create. + var depth: usize = 1; + var key = parent; + while (true) { + if (child) |c| if (key.eql(c)) return error.Cycle; + const p = self.placements.get(key) orelse return error.AncestorNotFound; + const rel = switch (p.location) { + .relative => |rel| rel, + // A pin or virtual placement is the chain root. + .pin, .virtual => break, + }; + if (depth >= parent_chain_limit) return error.TooDeep; + depth += 1; + key = rel.parent; + } + + return parent; + } + + /// The result of resolving a relative placement's parent chain: + /// the chain's root placement (always pin or virtual, never + /// relative) and the total cell offset from the root's origin. + pub const ResolvedChain = struct { + root_key: PlacementKey, + root: Placement, + horizontal_offset: i32, + vertical_offset: i32, + }; + + /// Resolve a relative placement's parent chain to its root, + /// accumulating the cell offsets (H=/V=) of every link on the way. + /// The placement's position is the root's origin plus the accumulated + /// offsets. + /// + /// Returns null when the chain is broken or too deep. Neither can + /// normally happen for stored placements (resolveParent validates + /// chains and removeOrphans removes broken ones), with one kitty + /// quirk: replacing an ancestor placement can deepen the chains + /// below it beyond parent_chain_limit after the fact. + pub fn resolveChain( + self: *const ImageStorage, + rel: Placement.Relative, + ) ?ResolvedChain { + var horizontal = rel.horizontal_offset; + var vertical = rel.vertical_offset; + var key = rel.parent; + var depth: usize = 1; + while (true) { + const p = self.placements.get(key) orelse return null; + switch (p.location) { + .relative => |parent_rel| { + if (depth >= parent_chain_limit) return null; + depth += 1; + horizontal +|= parent_rel.horizontal_offset; + vertical +|= parent_rel.vertical_offset; + key = parent_rel.parent; + }, + + .pin, .virtual => return .{ + .root_key = key, + .root = p, + .horizontal_offset = horizontal, + .vertical_offset = vertical, + }, + } + } + } + + /// Returns the placement that a unicode placeholder cell referencing + /// this image/placement ID pair targets, or null if there is none. A + /// zero placement ID targets one of the image's virtual placements, + /// chosen by PlacementId.preferredOver so the choice is stable (an + /// image rarely has more than one). This is the shared lookup used + /// both for sizing placeholder runs and for positioning relative + /// placements whose chain roots at a virtual placement. + pub fn placeholderTarget( + self: *const ImageStorage, + image_id: u32, + placement_id: u32, + ) ?struct { key: PlacementKey, placement: Placement } { + if (placement_id > 0) { + const key: PlacementKey = .{ + .image_id = image_id, + .placement_id = .{ .tag = .external, .id = placement_id }, + }; + const p = self.placements.get(key) orelse return null; + return .{ .key = key, .placement = p }; + } + + var best: ?PlacementKey = null; + var it = self.placements.iterator(); + while (it.next()) |entry| { + if (entry.key_ptr.image_id != image_id) continue; + if (entry.value_ptr.location != .virtual) continue; + const b = best orelse { + best = entry.key_ptr.*; + continue; + }; + if (entry.key_ptr.placement_id.preferredOver(b.placement_id)) { + best = entry.key_ptr.*; + } + } + + const key = best orelse return null; + return .{ .key = key, .placement = self.placements.get(key).? }; } /// Get an image by its ID. If the image doesn't exist, null is returned. @@ -447,47 +934,285 @@ pub const ImageStorage = struct { return newest; } + /// Get a mutable pointer to a stored image, by ID or (newest by) + /// number, following the protocol's id/number addressing. Used by + /// the animation commands, which mutate images in place. The + /// pointer is invalidated by any operation that adds or removes + /// images. + pub fn imagePtrByIdOrNumber( + self: *const ImageStorage, + image_id: u32, + image_number: u32, + ) ?*Image { + if (image_id != 0) return self.images.getPtr(image_id); + + var newest: ?*Image = null; + var it = self.images.iterator(); + while (it.next()) |kv| { + if (kv.value_ptr.number != image_number) continue; + if (newest == null or + kv.value_ptr.generation > newest.?.generation) + { + newest = kv.value_ptr; + } + } + + return newest; + } + + /// Record that the displayed content of an image changed without + /// the image being re-added: marks the storage mutated and stamps + /// the image with the fresh generation so consumers (e.g. the + /// renderer's texture cache) replace what they hold. Used when an + /// animation changes which frame is current or edits the current + /// frame's pixels. + pub fn markImageContentChanged( + self: *ImageStorage, + io: std.Io, + img: *Image, + ) void { + self.markMutated(io); + img.generation = self.generation; + } + + /// Convert a stored image's base data to RGBA in place, adjusting + /// byte accounting. All animation composition happens in RGBA; + /// this is called before the first composition into an image. + /// + /// The pixels are unchanged visually but the stored representation + /// changed, so the image is stamped with a fresh generation. + pub fn convertImageToRgba( + self: *ImageStorage, + io: std.Io, + alloc: Allocator, + img: *Image, + ) Allocator.Error!void { + if (img.format == .rgba) return; + const old = img.data.bytes() orelse return; + + const rgba = try pixel.rgbaFromFormat(alloc, img.format, old); + self.total_bytes -= old.len; + self.total_bytes += rgba.len; + img.data.deinit(alloc); + img.data = .{ .complete = rgba }; + img.format = .rgba; + self.markImageContentChanged(io, img); + } + + /// Reserve `bytes` of storage for animation frame data belonging + /// to `image_id`, evicting other images if needed, mirroring how + /// image transmission reserves space. + /// + /// Errors if the space cannot be made available. On success the caller + /// owns the reservation and must either attach the frame data to the + /// image or call releaseAnimationBytes. + pub fn reserveAnimationBytes( + self: *ImageStorage, + io: std.Io, + alloc: Allocator, + s: *terminal.Screen, + image_id: u32, + bytes: usize, + ) Allocator.Error!void { + if (bytes > self.total_limit) return error.OutOfMemory; + + const total_bytes = self.total_bytes + bytes; + if (total_bytes > self.total_limit) { + const req_bytes = total_bytes - self.total_limit; + // Excess this large cannot be recovered by evicting other + // images (evictImageExcept also requires it). + if (req_bytes > self.total_limit) return error.OutOfMemory; + log.info("evicting images for animation frame, evicting={}", .{req_bytes}); + if (!self.evictImageExcept( + io, + alloc, + s, + req_bytes, + image_id, + )) { + log.warn("failed to evict enough images for animation frame", .{}); + return error.OutOfMemory; + } + } + + self.total_bytes += bytes; + } + + /// Release a reservation made by reserveAnimationBytes, or credit + /// bytes freed by deleting animation frame data. + pub fn releaseAnimationBytes(self: *ImageStorage, bytes: usize) void { + assert(bytes <= self.total_bytes); + self.total_bytes -= bytes; + } + + /// Advance every running animation to the frame that should be + /// displayed at `now_ms` and report when the next frame change is + /// due, as a delay in milliseconds relative to `now_ms`. Null + /// means no running animation needs a future tick. + /// + /// `now_ms` is a monotonic timestamp on a clock of the caller's + /// choosing. The same clock must be used for every call. The + /// caller is expected to be the renderer, ticking once per frame + /// build and scheduling a wakeup for the returned delay. + pub fn animationTick(self: *ImageStorage, io: std.Io, now_ms: u64) ?u64 { + var min_delay: ?u64 = null; + + var it = self.images.iterator(); + while (it.next()) |entry| { + const img: *Image = entry.value_ptr; + + // The gates below mirror Kitty's image_is_animatable. + + // No animation state was ever attached (plain image). + const anim = img.animation orelse continue; + + // Stopped is the initial state of every animation: frames + // then only change client-driven (a=a c=N), never by time. + if (anim.state == .stopped) continue; + + // Only the root frame exists; there is nothing to advance + // to yet even in the running state. + if (anim.frames.items.len == 0) continue; + + // Unplaced images don't animate. This is our simpler + // approximation of Kitty's "is actually drawn" visibility + // gate; it is what stops an image that was transmitted but + // never placed from waking the renderer forever. + if (img.metadata.placement_count == 0) continue; + + // The base pixel data hasn't arrived yet (e.g. an image + // restored from a snapshot); nothing can be displayed. + if (img.data.isPending()) continue; + + // A zero total duration means every frame is gapless and + // no frame can ever be displayed, so the animation can + // never advance. This also guards the gapless-skip loop + // below from never terminating. + if (anim.durationMs() == 0) continue; + + // A finite loop budget (a=a v=N) that ran out on an + // earlier tick froze playback on the last frame for good. + if (anim.max_loops > 0 and anim.current_loop >= anim.max_loops) continue; + + const shown_at: u64 = shown_at: { + // First tick since playback started (or since the + // current frame changed through another path, e.g. + // a=a c=N): the frame is considered shown as of now, + // and its gap starts counting from here. + const at = anim.frame_shown_at_ms orelse break :shown_at now_ms; + + // A timestamp from the future means the caller's clock + // restarted; re-anchor rather than stalling until the + // old timestamp comes around again. + if (at > now_ms) break :shown_at now_ms; + + break :shown_at at; + }; + anim.frame_shown_at_ms = shown_at; + + // The current frame is replaced once its gap has elapsed. + // We advance at most one displayed frame per tick with no + // catch-up, exactly like Kitty: if ticks lag behind the + // gaps, the animation slows down rather than skipping. + var next_at: u64 = shown_at +| anim.gapAt(anim.current_index); + if (now_ms >= next_at) advance: { + // Walk forward to the next displayable frame. This is + // a loop only because gapless (gap=0) frames are never + // displayed and are stepped over; the durationMs gate + // above guarantees a displayable frame exists. + const count: u32 = anim.frameCount(); + var idx = anim.current_index; + while (true) { + const next = (idx + 1) % count; + if (next == 0) { + // Wrapping past the last frame back to the + // root. A loading-state (a=a s=2) animation + // refuses the wrap: it parks on the last frame + // awaiting more frames from the client. + if (anim.state == .loading) break :advance; + + // Each wrap completes a loop; a finite budget + // that just ran out parks on the last frame. + anim.current_loop += 1; + if (anim.max_loops > 0 and + anim.current_loop >= anim.max_loops) break :advance; + } + idx = next; + if (anim.gapAt(idx) != 0) break; + } + + // Show the chosen frame: restart its gap timer and + // stamp a fresh generation so consumers (the renderer + // texture cache, the C API) pick up the new pixels. + anim.current_index = idx; + anim.frame_shown_at_ms = now_ms; + self.markImageContentChanged(io, img); + next_at = now_ms +| anim.gapAt(idx); + } + + // Schedule the next tick. A parked animation left next_at + // in the past and so never schedules one; it is woken by + // its trigger instead (a new frame arriving, or an a=a + // command changing the state). + if (next_at > now_ms) { + const delay = next_at - now_ms; + min_delay = if (min_delay) |m| @min(m, delay) else delay; + } + } + + return min_delay; + } + + /// Clear placements intersecting the active screen. slosh patch 0001: + /// upstream also reclaims every image with no remaining placement here, + /// but the kitty protocol separates transmitting (a=t, once) from + /// placing (a=p, per frame) precisely so a client can upload up front -- + /// freeing the data on a screen clear makes every later placement of + /// that id draw nothing. Clearing the screen should clear what is *on* + /// it; the storage limit and LRU eviction are what bound retained data, + /// which is exactly the mechanism that makes keeping it safe. + pub fn clearScreen( + self: *ImageStorage, + io: std.Io, + alloc: Allocator, + t: *terminal.Terminal, + ) void { + const placements_before = self.placements.count(); + const images_before = self.images.count(); + + // Delete visible placements and reap orphaned relative placements; + // the image data itself stays (see above). + self.deleteVisiblePlacements(alloc, t, true); + _ = self.removeOrphans(t.screens.active, null); + + // Mark mutated only if things changed. + if (self.placements.count() != placements_before or + self.images.count() != images_before) self.markMutated(io); + } + /// Delete placements, images. pub fn delete( self: *ImageStorage, io: std.Io, alloc: Allocator, t: *terminal.Terminal, - cmd: command.Delete, + cmd: command.Delete.Action, ) void { // Deletes only ever remove placements/images, so comparing counts // before and after tells us whether anything actually changed. - // Only then do we mark a mutation. This matters because a - // delete-all runs on every screen clear (e.g. `ESC [ 2 J`), and - // we don't want empty clears to dirty the image state or bump - // the generation. + // Only then do we mark a mutation, so a delete that matches nothing + // doesn't dirty the image state or bump the generation. const placements_before = self.placements.count(); const images_before = self.images.count(); defer if (self.placements.count() != placements_before or self.images.count() != images_before) self.markMutated(io); switch (cmd) { - .all => |delete_images| { - var it = self.placements.iterator(); - while (it.next()) |entry| { - // Skip virtual placements - switch (entry.value_ptr.location) { - .pin => {}, - .virtual => continue, - } - - // Deinit the placement and remove it - const image_id = entry.key_ptr.image_id; - entry.value_ptr.deinit(t.screens.active); - self.removePlacementByPtr(entry.key_ptr); - if (delete_images) self.deleteIfUnused(alloc, image_id); - } - - if (delete_images) { - var image_it = self.images.iterator(); - while (image_it.next()) |kv| self.deleteIfUnused(alloc, kv.key_ptr.*); - } - }, + .all => |delete_images| self.deleteVisiblePlacements( + alloc, + t, + delete_images, + ), .id => |v| self.deleteById( alloc, @@ -576,8 +1301,7 @@ pub const ImageStorage = struct { const img = self.imageById(entry.key_ptr.image_id) orelse continue; const rect = entry.value_ptr.rect(img, t) orelse continue; if (rect.top_left.x <= x and rect.bottom_right.x >= x) { - entry.value_ptr.deinit(t.screens.active); - self.removePlacementByPtr(entry.key_ptr); + self.removePlacement(t.screens.active, entry); if (v.delete) self.deleteIfUnused(alloc, img.id); } } @@ -605,8 +1329,7 @@ pub const ImageStorage = struct { var target_pin_copy = target_pin; target_pin_copy.x = rect.top_left.x; if (target_pin_copy.isBetween(rect.top_left, rect.bottom_right)) { - entry.value_ptr.deinit(t.screens.active); - self.removePlacementByPtr(entry.key_ptr); + self.removePlacement(t.screens.active, entry); if (v.delete) self.deleteIfUnused(alloc, img.id); } } @@ -616,7 +1339,9 @@ pub const ImageStorage = struct { var it = self.placements.iterator(); while (it.next()) |entry| { switch (entry.value_ptr.location) { - .pin => {}, + // Relative placements carry their own z value and + // are matched by z deletes just like kitty does. + .pin, .relative => {}, // Virtual placeholders cannot delete by z according // to the spec. @@ -625,37 +1350,99 @@ pub const ImageStorage = struct { if (entry.value_ptr.z == v.z) { const image_id = entry.key_ptr.image_id; - entry.value_ptr.deinit(t.screens.active); - self.removePlacementByPtr(entry.key_ptr); + self.removePlacement(t.screens.active, entry); if (v.delete) self.deleteIfUnused(alloc, image_id); } } }, .range => |v| range: { - if (v.first <= 0 or v.last <= 0) { - log.warn("delete range values must be greater than zero", .{}); - break :range; - } - if (v.first > v.last) { - log.warn("delete range 'x' ({}) must be less than or equal to 'y' ({})", .{ v.first, v.last }); - break :range; + // Both bounds default to zero when omitted. An inverted range + // or a zero upper bound is not an error, it just selects + // nothing: image IDs are always non-zero. + if (v.last == 0 or v.first > v.last) break :range; + + // Remove matching placements in one pass. + var placement_it = self.placements.iterator(); + while (placement_it.next()) |entry| { + if (entry.key_ptr.image_id < v.first or + entry.key_ptr.image_id > v.last) continue; + + self.removePlacement(t.screens.active, entry); } - var it = self.placements.iterator(); - while (it.next()) |entry| { - if (entry.key_ptr.image_id >= v.first and entry.key_ptr.image_id <= v.last) { - const image_id = entry.key_ptr.image_id; - entry.value_ptr.deinit(t.screens.active); - self.removePlacementByPtr(entry.key_ptr); - if (v.delete) self.deleteIfUnused(alloc, image_id); + // Uppercase deletion also frees matching images that are now + // unused, including images that had no placements initially. + if (!v.delete) break :range; + var image_it = self.images.iterator(); + while (image_it.next()) |entry| { + const image_id = entry.key_ptr.*; + if (image_id >= v.first and image_id <= v.last) { + self.deleteIfUnused(alloc, image_id); } } }, - // We don't support animation frames yet so they are successfully - // deleted! - .animation_frames => {}, + .animation_frames => |v| self.deleteAnimationFrame( + io, + alloc, + t.screens.active, + v, + ), + } + + // Deleting placements orphans any relative placements parented + // to them (transitively). Their lifetime is tied to the parent + // so they are removed as well, and an uppercase delete also + // frees any image the cascade leaves without placements (the + // per-branch deleteIfUnused calls above ran while the orphans + // still counted as placements). + const delete_unused: bool = switch (cmd) { + .all, .intersect_cursor => |v| v, + inline else => |v| v.delete, + }; + _ = self.removeOrphans( + t.screens.active, + if (delete_unused) alloc else null, + ); + } + + /// Delete only non-virtual placements that intersect the active screen. + /// The protocol defines d=a/A in terms of visible placements, and an + /// uppercase delete only frees data for images selected by those + /// placements. + fn deleteVisiblePlacements( + self: *ImageStorage, + alloc: Allocator, + t: *terminal.Terminal, + delete_unused: bool, + ) void { + var it = self.placements.iterator(); + while (it.next()) |entry| { + const pin = switch (entry.value_ptr.location) { + .pin => |pin| pin, + // Virtual placements are never selected by visible + // deletes per the protocol. Relative placements are + // removed with their parents instead (removeOrphans). + .virtual, .relative => continue, + }; + if (pin.garbage) continue; + + // Placements anchored in the active area are necessarily visible. + // Only compute their extent when the anchor is already in history + // and the bottom edge may still intersect the active area. + if (t.screens.active.pages.pointFromPin( + .active, + pin.*, + ) == null) { + const image = self.imageById(entry.key_ptr.image_id) orelse continue; + const rect = entry.value_ptr.rect(image, t) orelse continue; + if (t.screens.active.pages.pointFromPin(.active, rect.bottom_right) == null) continue; + } + + const image_id = entry.key_ptr.image_id; + self.removePlacement(t.screens.active, entry); + if (delete_unused) self.deleteIfUnused(alloc, image_id); } } @@ -667,28 +1454,112 @@ pub const ImageStorage = struct { placement_id: u32, delete_unused: bool, ) void { + var matched = placement_id == 0; + // If no placement, we delete all placements with the ID - if (placement_id == 0) { - var it = self.placements.iterator(); - while (it.next()) |entry| { - if (entry.key_ptr.image_id == image_id) { - entry.value_ptr.deinit(s); - self.removePlacementByPtr(entry.key_ptr); - } + if (placement_id == 0) self.removePlacementsByImageId( + s, + image_id, + ) else if (self.placements.getEntry(.{ + .image_id = image_id, + .placement_id = .{ + .tag = .external, + .id = placement_id, + }, + })) |entry| { + self.removePlacement(s, entry); + matched = true; + } + + // A placement ID narrows the selection to that exact placement, so an + // unmatched selector must not free otherwise-unreferenced image data. + // https://sw.kovidgoyal.net/kitty/graphics-protocol/#deleting-images + if (delete_unused and matched) self.deleteIfUnused(alloc, image_id); + } + + /// Delete an animation frame (d=f/F). Deletes never produce + /// responses, so all failures are only logged. Kitty behaviors + /// implemented here: on an image without extra frames a lowercase + /// delete is a no-op while an uppercase delete removes the entire + /// image, placements included; the frame number is clamped to the + /// last frame and zero selects the root frame; deleting the root + /// frame promotes frame 2 to be the new root. + fn deleteAnimationFrame( + self: *ImageStorage, + io: std.Io, + alloc: Allocator, + s: *terminal.Screen, + v: command.Delete.Action.AnimationFrames, + ) void { + if (v.image_id == 0 and v.image_number == 0) { + log.warn("delete animation frames requires image id or number", .{}); + return; + } + const img = self.imagePtrByIdOrNumber( + v.image_id, + v.image_number, + ) orelse { + log.warn( + "delete animation frames for unknown image id={} number={}", + .{ v.image_id, v.image_number }, + ); + return; + }; + + const anim: *animation.Animation = anim: { + if (img.animation) |anim| { + if (anim.frames.items.len > 0) break :anim anim; } + + // The image is not (or no longer) an animation. The + // uppercase delete removes the entire image, even when it + // still has placements. + if (!v.delete) return; + self.removePlacementsByImageId(s, img.id); + const entry = self.images.getEntry(img.id).?; + self.total_bytes -= entry.value_ptr.storageSize(); + entry.value_ptr.deinit(alloc); + self.images.removeByPtr(entry.key_ptr); + return; + }; + + // Clamp the frame number: zero selects the root frame and + // values past the end select the last frame. + const count: u32 = anim.frameCount(); + var number: u32 = @min(v.frame, count); + if (number == 0) number = 1; + + if (number == 1) { + // Deleting the root frame promotes frame 2 to root. The + // promoted frame's bytes stay reserved; only the old root + // data is freed. + self.releaseAnimationBytes(img.data.len()); + img.data.deinit(alloc); + const promoted = anim.frames.orderedRemove(0); + img.data = .{ .complete = promoted.data }; + anim.root_gap_ms = promoted.gap_ms; } else { - if (self.placements.getEntry(.{ - .image_id = image_id, - .placement_id = .{ .tag = .external, .id = placement_id }, - })) |entry| { - entry.value_ptr.deinit(s); - self.removePlacementByPtr(entry.key_ptr); - } + const removed = anim.frames.orderedRemove(number - 2); + self.releaseAnimationBytes(removed.data.len); + alloc.free(removed.data); } - // If this is specified, then we also delete the image - // if it is no longer in use. - if (delete_unused) self.deleteIfUnused(alloc, image_id); + // Fix up the current frame. + const removed_idx: u32 = if (number == 1) 0 else number - 2; + const remaining: u32 = @intCast(anim.frames.items.len); + if (anim.current_index > remaining) { + anim.current_index = remaining; + anim.frame_shown_at_ms = null; + self.markImageContentChanged(io, img); + return; + } + if (removed_idx == anim.current_index) { + anim.frame_shown_at_ms = null; + self.markImageContentChanged(io, img); + } else { + if (removed_idx < anim.current_index) anim.current_index -= 1; + self.markMutated(io); + } } /// Delete an image if it is unused. @@ -696,7 +1567,7 @@ pub const ImageStorage = struct { const entry = self.images.getEntry(image_id) orelse return; if (entry.value_ptr.metadata.placement_count > 0) return; - self.total_bytes -= entry.value_ptr.data.len(); + self.total_bytes -= entry.value_ptr.storageSize(); entry.value_ptr.deinit(alloc); self.images.removeByPtr(entry.key_ptr); } @@ -720,8 +1591,7 @@ pub const ImageStorage = struct { const rect = entry.value_ptr.rect(img, t) orelse continue; if (rect.contains(target_pin)) { if (filter) |f| if (!f(filter_ctx, entry.value_ptr.*)) continue; - entry.value_ptr.deinit(t.screens.active); - self.removePlacementByPtr(entry.key_ptr); + self.removePlacement(t.screens.active, entry); if (delete_unused) self.deleteIfUnused(alloc, img.id); } } @@ -786,8 +1656,13 @@ pub const ImageStorage = struct { // Evicting anything is a content mutation. This matters for the // setLimit path in particular, which doesn't otherwise mark it. + // Evicted placements can also orphan relative placements of + // other images, which must be reaped along with them. const images_before = self.images.count(); - defer if (self.images.count() != images_before) self.markMutated(io); + defer if (self.images.count() != images_before) { + _ = self.removeOrphans(s, null); + self.markMutated(io); + }; var evicted: usize = 0; while (evicted < req) { @@ -807,13 +1682,12 @@ pub const ImageStorage = struct { var p_it = self.placements.iterator(); while (p_it.next()) |entry| { if (entry.key_ptr.image_id == c.id) { - entry.value_ptr.deinit(s); - self.removePlacementByPtr(entry.key_ptr); + self.removePlacement(s, entry); } } const entry = self.images.getEntry(c.id).?; - const image_len = entry.value_ptr.data.len(); + const image_len = entry.value_ptr.storageSize(); log.info("evicting image id={} bytes={}", .{ c.id, image_len }); evicted += image_len; @@ -826,15 +1700,62 @@ pub const ImageStorage = struct { return true; } + /// State returned by scrollMarginsBegin: the final active-area + /// position every surviving placement pin must be restored to once + /// the scrolled rows have settled. Every begin must be paired with + /// exactly one end() call after the scroll's row operations + /// complete. + pub const ScrollMargins = struct { + screen: *terminal.Screen, + restores: std.ArrayListUnmanaged(Restore) = .empty, + + const Restore = struct { + pin: *PageList.Pin, + x: size.CellCountInt, + y: u32, + }; + + /// Reposition every recorded placement pin to its post-scroll + /// position and release the state. Must be called exactly + /// once, after the scroll's row operations complete. + pub fn end(self: *ScrollMargins) void { + for (self.restores.items) |restore| { + restore.pin.* = self.screen.pages.pin(.{ .active = .{ + .x = restore.x, + .y = restore.y, + } }) orelse continue; + } + self.restores.deinit(self.screen.alloc); + } + }; + /// Every placement is uniquely identified by the image ID and the /// placement ID. If an image ID isn't specified it is assumed to be 0. /// Likewise, if a placement ID isn't specified it is assumed to be 0. pub const PlacementKey = struct { image_id: u32, - placement_id: packed struct { - tag: enum(u1) { internal, external }, - id: u32, - }, + placement_id: PlacementId, + + pub fn eql(self: PlacementKey, other: PlacementKey) bool { + return std.meta.eql(self, other); + } + }; + + /// Internal placement IDs are assigned by us for placements created + /// without an explicit ID (p=0); external IDs are client-specified. + /// The two are separate namespaces, hence the tag. + pub const PlacementId = packed struct { + tag: enum(u1) { internal, external }, + id: u32, + + /// Deterministic preference order used when an operation must + /// pick a single placement out of several (kitty uses creation + /// order, which our map doesn't track): external IDs win over + /// internal ones, and lower IDs win within a tag. + pub fn preferredOver(self: PlacementId, other: PlacementId) bool { + if (self.tag != other.tag) return self.tag == .external; + return self.id < other.id; + } }; pub const Placement = struct { @@ -863,7 +1784,27 @@ pub const ImageStorage = struct { pin: *PageList.Pin, /// Virtual placement (U=1) for unicode placeholders. - virtual: void, + virtual, + + /// Placed relative to a parent placement (P=/Q=). The + /// placement has no screen position of its own; it is + /// positioned at render time by resolving the parent chain + /// to its root (see resolveChain) and therefore follows the + /// parent through scrolls automatically. Its lifetime is + /// tied to the parent: removing any ancestor removes it + /// too (see removeOrphans). + relative: Relative, + }; + + pub const Relative = struct { + /// The parent placement. Resolved to a concrete key when + /// the placement is created, so the parent is guaranteed + /// to exist at that point. + parent: PlacementKey, + + /// Cell offsets from the parent's origin (H=/V=). + horizontal_offset: i32 = 0, + vertical_offset: i32 = 0, }; pub fn deinit( @@ -872,7 +1813,7 @@ pub const ImageStorage = struct { ) void { switch (self.location) { .pin => |p| s.pages.untrackPin(p), - .virtual => {}, + .virtual, .relative => {}, } } @@ -894,6 +1835,58 @@ pub const ImageStorage = struct { return std.math.cast(u32, rounded) orelse std.math.maxInt(u32); } + pub const SourceRect = struct { + x: u32, + y: u32, + width: u32, + height: u32, + }; + + /// Returns the requested source rectangle intersected with the image. + /// A zero width or height requests the full corresponding image + /// dimension before intersection, as defined by the Kitty protocol. + pub fn sourceRect(self: Placement, image: Image) SourceRect { + const x = @min(self.source_x, image.width); + const y = @min(self.source_y, image.height); + return .{ + .x = x, + .y = y, + .width = @min( + if (self.source_width > 0) self.source_width else image.width, + image.width - x, + ), + .height = @min( + if (self.source_height > 0) self.source_height else image.height, + image.height - y, + ), + }; + } + + /// Returns the placement offset clamped within the first cell. Pixel + /// geometry may temporarily be unavailable, in which case there is no + /// valid offset within the cell. + pub fn cellOffset( + self: Placement, + t: *const terminal.Terminal, + ) struct { + x: u32, + y: u32, + } { + const cell_width: u32 = t.width_px / t.cols; + const cell_height: u32 = t.height_px / t.rows; + + return .{ + .x = if (cell_width > 0) + @min(self.x_offset, cell_width - 1) + else + 0, + .y = if (cell_height > 0) + @min(self.y_offset, cell_height - 1) + else + 0, + }; + } + /// Returns the size of this placement's image in pixels, /// taking into account the source rectangle, specified /// rows/columns, and aspect ratio. @@ -905,9 +1898,9 @@ pub const ImageStorage = struct { width: u32, height: u32, } { - // Height / width of the image in px. - const width = if (self.source_width > 0) self.source_width else image.width; - const height = if (self.source_height > 0) self.source_height else image.height; + const source = self.sourceRect(image); + const width = source.width; + const height = source.height; // If we don't have any specified cols or rows then the placement // should be the native size of the image, and doesn't need to be @@ -924,6 +1917,7 @@ pub const ImageStorage = struct { // count and the height by the row count, because it should be. const cell_width: u32 = t.width_px / t.cols; const cell_height: u32 = t.height_px / t.rows; + const cell_offset = self.cellOffset(t); // If we have a specified cols AND rows then we calculate // the width and height from them directly, we don't need @@ -933,8 +1927,8 @@ pub const ImageStorage = struct { const calc_height = saturatingMul(cell_height, self.rows); return .{ - .width = calc_width, - .height = calc_height, + .width = calc_width -| cell_offset.x, + .height = calc_height -| cell_offset.y, }; } @@ -944,7 +1938,10 @@ pub const ImageStorage = struct { // If only the columns were specified, we determine // the height of the image based on the aspect ratio. if (self.columns > 0) { - const calc_width = saturatingMul(cell_width, self.columns); + const calc_width = saturatingMul( + cell_width, + self.columns, + ) -| cell_offset.x; const calc_height = scaleDimension(calc_width, height, width); return .{ @@ -956,7 +1953,10 @@ pub const ImageStorage = struct { // Otherwise, only the rows were specified, so we // determine the width based on the aspect ratio. { - const calc_height = saturatingMul(cell_height, self.rows); + const calc_height = saturatingMul( + cell_height, + self.rows, + ) -| cell_offset.y; const calc_width = scaleDimension(calc_height, width, height); return .{ @@ -984,15 +1984,16 @@ pub const ImageStorage = struct { // Otherwise we calculate the pixel size, divide by // cell size, and round up to the nearest integer. const calc_size = self.pixelSize(image, t); + const cell_offset = self.cellOffset(t); return .{ .cols = std.math.divCeil( u32, - calc_size.width +| self.x_offset, + calc_size.width +| cell_offset.x, t.width_px / t.cols, ) catch 0, .rows = std.math.divCeil( u32, - calc_size.height +| self.y_offset, + calc_size.height +| cell_offset.y, t.height_px / t.rows, ) catch 0, }; @@ -1000,9 +2001,82 @@ pub const ImageStorage = struct { // in such a case it seems safe to return 0 for this. } + /// Permanently clip `count` rows off the top of this placement, + /// which currently spans `span` grid rows, by shrinking the + /// source rectangle (the requested source rect is materialized + /// into explicit values in the process). Used when a scroll + /// within margins pushes the placement past the top margin. + /// Requires 0 < count < span. Returns false if no visible + /// source pixels would remain, in which case the placement is + /// unmodified and should be deleted. + fn clipTop( + self: *Placement, + image: Image, + t: *const terminal.Terminal, + count: u32, + span: u32, + ) bool { + assert(count > 0 and count < span); + const src = self.sourceRect(image); + const crop: u32 = if (self.rows > 0) + // Scaled placement: each grid row shows an equal + // fraction of the source. + @intCast(@as(u64, src.height) * count / span) + else + // Native size: each grid row shows one cell height of + // source pixels (kitty does the same). + saturatingMul(t.height_px / t.rows, count); + if (crop >= src.height) return false; + self.source_x = src.x; + self.source_y = src.y + crop; + self.source_width = src.width; + self.source_height = src.height - crop; + if (self.rows > 0) self.rows -= count; + return true; + } + + /// clipTop, but clipping `count` rows off the bottom. + fn clipBottom( + self: *Placement, + image: Image, + t: *const terminal.Terminal, + count: u32, + span: u32, + ) bool { + assert(count > 0 and count < span); + const src = self.sourceRect(image); + // An empty source can never be clipped smaller. This also + // guarantees we never store an explicit zero source height, + // which would be reinterpreted as "full image". + if (src.height == 0) return false; + if (self.rows > 0) { + const crop: u32 = @intCast(@as(u64, src.height) * count / span); + if (crop >= src.height) return false; + self.source_height = src.height - crop; + self.rows -= count; + } else { + // The remaining rows can show this many source pixels, + // with the first cell losing cellOffset pixels to the + // placement's y offset. + const visible = saturatingMul(t.height_px / t.rows, span - count); + const offset = self.cellOffset(t).y; + if (visible <= offset) return false; + self.source_height = @min(src.height, visible - offset); + } + self.source_x = src.x; + self.source_y = src.y; + self.source_width = src.width; + return true; + } + /// Returns a selection of the entire rectangle this placement /// occupies within the screen. This can return null for a virtual /// placement or when unavailable pixel geometry makes it empty. + /// + /// Relative placements also return null: they have no screen + /// position of their own, so geometric queries (delete by + /// intersection, row, column, etc.) never match them directly. + /// They are instead removed when their parent chain is removed. pub fn rect( self: Placement, image: Image, @@ -1011,7 +2085,7 @@ pub const ImageStorage = struct { const grid_size = self.gridSize(image, t); const pin = switch (self.location) { .pin => |p| p, - .virtual => return null, + .virtual, .relative => return null, }; if (pin.garbage) return null; @@ -1176,7 +2250,7 @@ test "storage: placement count limit permits replacement" { try testing.expectEqual(@as(usize, 1), s.placements.count()); } -test "storage: delete all placements and images" { +test "storage: delete all visible placements and matching images" { const testing = std.testing; const alloc = testing.allocator; const io = testing.io; @@ -1195,7 +2269,8 @@ test "storage: delete all placements and images" { s.dirty = false; s.delete(io, alloc, &t, .{ .all = true }); try testing.expect(s.dirty); - try testing.expectEqual(@as(usize, 0), s.images.count()); + try testing.expectEqual(@as(usize, 1), s.images.count()); + try testing.expect(s.images.contains(3)); try testing.expectEqual(@as(usize, 0), s.placements.count()); try testing.expectEqual(tracked, t.screens.active.pages.countTrackedPins()); } @@ -1220,12 +2295,98 @@ test "storage: delete all placements and images preserves limit" { s.dirty = false; s.delete(io, alloc, &t, .{ .all = true }); try testing.expect(s.dirty); - try testing.expectEqual(@as(usize, 0), s.images.count()); + try testing.expectEqual(@as(usize, 1), s.images.count()); + try testing.expect(s.images.contains(3)); try testing.expectEqual(@as(usize, 0), s.placements.count()); try testing.expectEqual(@as(usize, 5000), s.total_limit); try testing.expectEqual(tracked, t.screens.active.pages.countTrackedPins()); } +test "storage: delete all visible placements preserves scrollback" { + const testing = std.testing; + const alloc = testing.allocator; + const io = testing.io; + var t = try terminal.Terminal.init(io, alloc, .{ + .rows = 2, + .cols = 2, + .max_scrollback_bytes = 4096, + }); + defer t.deinit(alloc); + t.width_px = 2; + t.height_px = 2; + + var s: ImageStorage = .{}; + defer s.deinit(alloc, t.screens.active); + try s.addImage(io, alloc, t.screens.active, .{ .id = 1, .width = 1, .height = 1 }); + try s.addPlacement(io, alloc, t.screens.active, 1, 1, .{ + .location = .{ .pin = try trackPin(&t, .{ .y = 0 }) }, + }); + try t.scrollUp(1); + + const history_key: ImageStorage.PlacementKey = .{ + .image_id = 1, + .placement_id = .{ .tag = .external, .id = 1 }, + }; + try testing.expect(t.screens.active.pages.pointFromPin( + .active, + s.placements.get(history_key).?.location.pin.*, + ) == null); + + try s.addPlacement(io, alloc, t.screens.active, 1, 2, .{ + .location = .{ .pin = try trackPin(&t, .{ .y = 0 }) }, + }); + + s.delete(io, alloc, &t, .{ .all = true }); + try testing.expectEqual(@as(usize, 1), s.placements.count()); + try testing.expect(s.placements.contains(history_key)); + try testing.expectEqual(@as(usize, 1), s.images.count()); + try testing.expect(s.images.contains(1)); +} + +test "storage: delete all includes placements spanning into active area" { + const testing = std.testing; + const alloc = testing.allocator; + const io = testing.io; + var t = try terminal.Terminal.init(io, alloc, .{ + .rows = 2, + .cols = 2, + .max_scrollback_bytes = 4096, + }); + defer t.deinit(alloc); + t.width_px = 2; + t.height_px = 2; + + var s: ImageStorage = .{}; + defer s.deinit(alloc, t.screens.active); + try s.addImage(io, alloc, t.screens.active, .{ + .id = 1, + .width = 1, + .height = 2, + }); + try s.addPlacement(io, alloc, t.screens.active, 1, 1, .{ + .location = .{ .pin = try trackPin(&t, .{ .y = 0 }) }, + }); + try t.scrollUp(1); + + const placement = s.placements.get(.{ + .image_id = 1, + .placement_id = .{ .tag = .external, .id = 1 }, + }).?; + try testing.expect(t.screens.active.pages.pointFromPin( + .active, + placement.location.pin.*, + ) == null); + const rect = placement.rect(s.imageById(1).?, &t).?; + try testing.expect(t.screens.active.pages.pointFromPin( + .active, + rect.bottom_right, + ) != null); + + s.delete(io, alloc, &t, .{ .all = true }); + try testing.expectEqual(@as(usize, 0), s.placements.count()); + try testing.expectEqual(@as(usize, 0), s.images.count()); +} + test "storage: delete all placements" { const testing = std.testing; const alloc = testing.allocator; @@ -1327,6 +2488,79 @@ test "storage: delete placement by specific id" { try testing.expectEqual(tracked + 2, t.screens.active.pages.countTrackedPins()); } +test "storage: uppercase id delete preserves image when placement does not match" { + const testing = std.testing; + const alloc = testing.allocator; + const io = testing.io; + var t = try terminal.Terminal.init(io, alloc, .{ .rows = 3, .cols = 3 }); + defer t.deinit(alloc); + + var s: ImageStorage = .{}; + defer s.deinit(alloc, t.screens.active); + try s.addImage(io, alloc, t.screens.active, .{ .id = 1 }); + + s.dirty = false; + const generation = s.generation; + s.delete(io, alloc, &t, .{ .id = .{ + .delete = true, + .image_id = 1, + .placement_id = 7, + } }); + + try testing.expect(s.imageById(1) != null); + try testing.expect(!s.dirty); + try testing.expectEqual(generation, s.generation); +} + +test "storage: uppercase id delete frees image after placement matches" { + const testing = std.testing; + const alloc = testing.allocator; + const io = testing.io; + var t = try terminal.Terminal.init(io, alloc, .{ .rows = 3, .cols = 3 }); + defer t.deinit(alloc); + const tracked = t.screens.active.pages.countTrackedPins(); + + var s: ImageStorage = .{}; + defer s.deinit(alloc, t.screens.active); + try s.addImage(io, alloc, t.screens.active, .{ .id = 1 }); + try s.addPlacement(io, alloc, t.screens.active, 1, 9, .{ + .location = .{ .pin = try trackPin(&t, .{ .x = 1, .y = 1 }) }, + }); + + s.dirty = false; + s.delete(io, alloc, &t, .{ .id = .{ + .delete = true, + .image_id = 1, + .placement_id = 9, + } }); + + try testing.expectEqual(@as(usize, 0), s.placements.count()); + try testing.expectEqual(@as(usize, 0), s.images.count()); + try testing.expect(s.dirty); + try testing.expectEqual(tracked, t.screens.active.pages.countTrackedPins()); +} + +test "storage: uppercase id delete without placement frees unplaced image" { + const testing = std.testing; + const alloc = testing.allocator; + const io = testing.io; + var t = try terminal.Terminal.init(io, alloc, .{ .rows = 3, .cols = 3 }); + defer t.deinit(alloc); + + var s: ImageStorage = .{}; + defer s.deinit(alloc, t.screens.active); + try s.addImage(io, alloc, t.screens.active, .{ .id = 1 }); + + s.dirty = false; + s.delete(io, alloc, &t, .{ .id = .{ + .delete = true, + .image_id = 1, + } }); + + try testing.expectEqual(@as(usize, 0), s.images.count()); + try testing.expect(s.dirty); +} + test "storage: delete intersecting cursor" { const testing = std.testing; const alloc = testing.allocator; @@ -1733,6 +2967,164 @@ test "storage: delete images by range 4" { try testing.expectEqual(tracked + 2, t.screens.active.pages.countTrackedPins()); } +test "storage: uppercase range deletes unplaced image data" { + const testing = std.testing; + const alloc = testing.allocator; + const io = testing.io; + var t = try terminal.Terminal.init(io, alloc, .{ .rows = 3, .cols = 3 }); + defer t.deinit(alloc); + + var s: ImageStorage = .{}; + defer s.deinit(alloc, t.screens.active); + try s.addImage(io, alloc, t.screens.active, .{ .id = 1 }); + try s.addImage(io, alloc, t.screens.active, .{ .id = 2 }); + try s.addImage(io, alloc, t.screens.active, .{ .id = 3 }); + + s.delete(io, alloc, &t, .{ + .range = .{ + .delete = true, + // Zero is the default lower bound when x is omitted. + .first = 0, + .last = 2, + }, + }); + try testing.expectEqual(@as(usize, 1), s.images.count()); + try testing.expect(s.images.contains(3)); +} + +test "storage: delete images by empty range" { + // Ranges that select nothing are a silent no-op, matching kitty, which + // performs no validation and filters with `x <= image_id <= y`. + const testing = std.testing; + const alloc = testing.allocator; + const io = testing.io; + var t = try terminal.Terminal.init(io, alloc, .{ .rows = 3, .cols = 3 }); + defer t.deinit(alloc); + const tracked = t.screens.active.pages.countTrackedPins(); + + var s: ImageStorage = .{}; + defer s.deinit(alloc, t.screens.active); + try s.addImage(io, alloc, t.screens.active, .{ .id = 1 }); + try s.addImage(io, alloc, t.screens.active, .{ .id = 2 }); + try s.addPlacement(io, alloc, t.screens.active, 1, 1, .{ .location = .{ .pin = try trackPin(&t, .{ .x = 1, .y = 1 }) } }); + try s.addPlacement(io, alloc, t.screens.active, 2, 1, .{ .location = .{ .pin = try trackPin(&t, .{ .x = 1, .y = 1 }) } }); + + // Inverted range. + s.delete(io, alloc, &t, .{ .range = .{ .delete = true, .first = 5, .last = 4 } }); + try testing.expectEqual(@as(usize, 2), s.images.count()); + try testing.expectEqual(@as(usize, 2), s.placements.count()); + + // Upper bound omitted, so it defaults to zero. + s.delete(io, alloc, &t, .{ .range = .{ .delete = true, .first = 5, .last = 0 } }); + try testing.expectEqual(@as(usize, 2), s.images.count()); + try testing.expectEqual(@as(usize, 2), s.placements.count()); + + // Both bounds omitted. Image IDs are never zero so this matches nothing. + s.delete(io, alloc, &t, .{ .range = .{ .delete = true, .first = 0, .last = 0 } }); + try testing.expectEqual(@as(usize, 2), s.images.count()); + try testing.expectEqual(@as(usize, 2), s.placements.count()); + + // Both placements survive, so both of their pins are still tracked. + try testing.expectEqual(tracked + 2, t.screens.active.pages.countTrackedPins()); +} + +test "storage: erase display preserves scrollback and reclaims unplaced images" { + const testing = std.testing; + const alloc = testing.allocator; + const io = testing.io; + var t = try terminal.Terminal.init(io, alloc, .{ + .rows = 2, + .cols = 2, + .max_scrollback_bytes = 4096, + }); + defer t.deinit(alloc); + t.width_px = 2; + t.height_px = 2; + + const s = &t.screens.active.kitty_images; + try s.addImage(io, alloc, t.screens.active, .{ .id = 1, .width = 1, .height = 1 }); + try s.addPlacement(io, alloc, t.screens.active, 1, 1, .{ + .location = .{ .pin = try trackPin(&t, .{ .y = 0 }) }, + }); + try t.scrollUp(1); + + const history_key: ImageStorage.PlacementKey = .{ + .image_id = 1, + .placement_id = .{ .tag = .external, .id = 1 }, + }; + try testing.expect(t.screens.active.pages.pointFromPin( + .active, + s.placements.get(history_key).?.location.pin.*, + ) == null); + + try s.addImage(io, alloc, t.screens.active, .{ .id = 2, .width = 1, .height = 1 }); + try s.addPlacement(io, alloc, t.screens.active, 2, 1, .{ + .location = .{ .pin = try trackPin(&t, .{ .y = 0 }) }, + }); + try s.addImage(io, alloc, t.screens.active, .{ .id = 3, .width = 1, .height = 1 }); + + t.eraseDisplay(.complete, false); + try testing.expectEqual(@as(usize, 1), s.placements.count()); + try testing.expect(s.placements.contains(history_key)); + try testing.expectEqual(@as(usize, 1), s.images.count()); + try testing.expect(s.images.contains(1)); + try testing.expect(!s.images.contains(2)); + try testing.expect(!s.images.contains(3)); +} + +test "storage: cell offsets stay within explicit destination rectangle" { + const testing = std.testing; + const alloc = testing.allocator; + const io = testing.io; + + var t = try terminal.Terminal.init(io, alloc, .{ .cols = 5, .rows = 5 }); + defer t.deinit(alloc); + t.width_px = 50; // 10 px per col + t.height_px = 100; // 20 px per row + + // Explicit columns and rows describe the far cell boundaries. Offsets + // move the near edge inward without moving those far edges. + const placement: ImageStorage.Placement = .{ + .location = .{ .virtual = {} }, + .x_offset = 3, + .y_offset = 4, + .columns = 2, + .rows = 1, + }; + const actual = placement.pixelSize(.{ .width = 4, .height = 3 }, &t); + try testing.expectEqual(@as(u32, 17), actual.width); + try testing.expectEqual(@as(u32, 16), actual.height); + try testing.expectEqual(@as(u32, 2), placement.gridSize(.{ .width = 4, .height = 3 }, &t).cols); + try testing.expectEqual(@as(u32, 1), placement.gridSize(.{ .width = 4, .height = 3 }, &t).rows); +} + +test "storage: cell offsets clamp to cell bounds" { + const testing = std.testing; + const alloc = testing.allocator; + const io = testing.io; + + var t = try terminal.Terminal.init(io, alloc, .{ .cols = 5, .rows = 5 }); + defer t.deinit(alloc); + t.width_px = 50; // 10 px per col + t.height_px = 100; // 20 px per row + + const placement: ImageStorage.Placement = .{ + .location = .{ .virtual = {} }, + .x_offset = std.math.maxInt(u32), + .y_offset = std.math.maxInt(u32), + .columns = 1, + .rows = 1, + }; + const offset = placement.cellOffset(&t); + try testing.expectEqual(@as(u32, 9), offset.x); + try testing.expectEqual(@as(u32, 19), offset.y); + + // Even hostile offsets leave one pixel inside the requested cell. + const actual = placement.pixelSize(.{ .width = 1, .height = 1 }, &t); + try testing.expectEqual(@as(u32, 1), actual.width); + try testing.expectEqual(@as(u32, 1), actual.height); +} + test "storage: aspect ratio calculation when only columns or rows specified" { const testing = std.testing; const alloc = testing.allocator; @@ -1780,6 +3172,50 @@ test "storage: aspect ratio calculation when only columns or rows specified" { } } +test "storage: default source rectangle is intersected before sizing" { + const testing = std.testing; + const alloc = testing.allocator; + const io = testing.io; + + var t = try terminal.Terminal.init(io, alloc, .{ .cols = 5, .rows = 5 }); + defer t.deinit(alloc); + + const placement: ImageStorage.Placement = .{ + .location = .{ .virtual = {} }, + .source_x = 3, + .source_y = 1, + }; + const actual = placement.pixelSize(.{ .width = 4, .height = 3 }, &t); + try testing.expectEqual(@as(u32, 1), actual.width); + try testing.expectEqual(@as(u32, 2), actual.height); +} + +test "storage: explicit source rectangle is intersected before sizing" { + const testing = std.testing; + const alloc = testing.allocator; + const io = testing.io; + + var t = try terminal.Terminal.init(io, alloc, .{ .cols = 10, .rows = 10 }); + defer t.deinit(alloc); + t.width_px = 100; + t.height_px = 100; + + // The requested 8x8 rectangle intersects this 10x10 image as 2x3. + // With a two-column destination, the clipped 2:3 aspect ratio produces + // a 20x30 pixel destination. + const placement: ImageStorage.Placement = .{ + .location = .{ .virtual = {} }, + .source_x = 8, + .source_y = 7, + .source_width = 8, + .source_height = 8, + .columns = 2, + }; + const actual = placement.pixelSize(.{ .width = 10, .height = 10 }, &t); + try testing.expectEqual(@as(u32, 20), actual.width); + try testing.expectEqual(@as(u32, 30), actual.height); +} + test "storage: placement geometry handles untrusted dimensions" { const testing = std.testing; const alloc = testing.allocator; @@ -1827,8 +3263,8 @@ test "storage: placement geometry handles untrusted dimensions" { try testing.expectEqual(max, actual.height); } - // Pixel offsets are protocol-controlled too. Include them without - // allowing the grid-size numerator to wrap. + // Pixel offsets are protocol-controlled too. Clamp them to the cell + // before including them in grid geometry. t.width_px = 2; t.height_px = 2; { @@ -1838,8 +3274,8 @@ test "storage: placement geometry handles untrusted dimensions" { .y_offset = max, }; const actual = placement.gridSize(.{ .width = 1, .height = 1 }, &t); - try testing.expectEqual(max, actual.cols); - try testing.expectEqual(max, actual.rows); + try testing.expectEqual(@as(u32, 1), actual.cols); + try testing.expectEqual(@as(u32, 1), actual.rows); } const pin = try trackPin(&t, .{ .x = 0, .y = 0 }); @@ -2330,3 +3766,899 @@ test "storage: pending images share exact eviction ordering" { try testing.expect(!s.images.contains(3)); try testing.expectEqual(@as(usize, 128), s.total_bytes); } + +test "storage: nextImageId number matches Kitty get_free_client_id" { + const testing = std.testing; + const io = testing.io; + const alloc = testing.allocator; + var t = try terminal.Terminal.init(io, alloc, .{ .rows = 3, .cols = 3 }); + defer t.deinit(alloc); + + var s: ImageStorage = .{}; + defer s.deinit(alloc, t.screens.active); + + // Empty storage returns 1, as does Kitty. + try testing.expectEqual(@as(u32, 1), s.nextImageId(.explicit)); + + // Contiguous IDs from 1: the next ID after them. + try s.addImage(io, alloc, t.screens.active, .{ .id = 1 }); + try s.addImage(io, alloc, t.screens.active, .{ .id = 2 }); + try s.addImage(io, alloc, t.screens.active, .{ .id = 3 }); + try testing.expectEqual(@as(u32, 4), s.nextImageId(.explicit)); + + // The first gap is filled, even when higher IDs exist. + try s.addImage(io, alloc, t.screens.active, .{ .id = 100 }); + s.delete(io, alloc, &t, .{ .id = .{ .image_id = 2, .delete = true } }); + try testing.expectEqual(@as(u32, 2), s.nextImageId(.explicit)); + + // 1 is reused as soon as it is free. + s.delete(io, alloc, &t, .{ .id = .{ .image_id = 1, .delete = true } }); + try testing.expectEqual(@as(u32, 1), s.nextImageId(.explicit)); +} + +test "storage: nextImageId implicit skips in-use ids and zero" { + const testing = std.testing; + const io = testing.io; + const alloc = testing.allocator; + var t = try terminal.Terminal.init(io, alloc, .{ .rows = 3, .cols = 3 }); + defer t.deinit(alloc); + + var s: ImageStorage = .{}; + defer s.deinit(alloc, t.screens.active); + + // A client image already sits on the counter's first two values. + try s.addImage(io, alloc, t.screens.active, .{ .id = 2147483647 }); + try s.addImage(io, alloc, t.screens.active, .{ .id = 2147483648 }); + try testing.expectEqual(@as(u32, 2147483649), s.nextImageId(.implicit)); + try testing.expectEqual(@as(u32, 2147483650), s.nextImageId(.implicit)); + + // Wrapping skips zero, which means "no ID" protocol-wide. + s.next_image_id = std.math.maxInt(u32); + try testing.expectEqual(std.math.maxInt(u32), s.nextImageId(.implicit)); + try testing.expectEqual(@as(u32, 1), s.nextImageId(.implicit)); +} + +test "storage: scroll margins placement inside region scrolls and clips at top" { + const testing = std.testing; + const alloc = testing.allocator; + const io = testing.io; + var t = try terminal.Terminal.init(io, alloc, .{ .cols = 10, .rows = 6 }); + defer t.deinit(alloc); + t.width_px = 100; + t.height_px = 60; + + // 2-row image (10x10px cells) inside the region (rows 1-4). + const storage: *ImageStorage = &t.screens.active.kitty_images; + try storage.addImage(io, alloc, t.screens.active, .{ + .id = 1, + .width = 10, + .height = 20, + }); + try storage.addPlacement(io, alloc, t.screens.active, 1, 1, .{ + .location = .{ .pin = try trackPin(&t, .{ .x = 0, .y = 2 }) }, + }); + t.setTopAndBottomMargin(2, 5); + t.setCursorPos(5, 1); + + // Entirely inside: moves up with the content. + try t.index(); + { + const p = storage.placements.get(.{ + .image_id = 1, + .placement_id = .{ .tag = .external, .id = 1 }, + }).?; + const pt = t.screens.active.pages.pointFromPin(.active, p.location.pin.*).?; + try testing.expectEqual(point.Coordinate{ .x = 0, .y = 1 }, pt.coord()); + } + + // Would extend above the top margin: anchored at the top margin + // with the top cell row clipped off the source. + try t.index(); + { + const p = storage.placements.get(.{ + .image_id = 1, + .placement_id = .{ .tag = .external, .id = 1 }, + }).?; + const pt = t.screens.active.pages.pointFromPin(.active, p.location.pin.*).?; + try testing.expectEqual(point.Coordinate{ .x = 0, .y = 1 }, pt.coord()); + try testing.expectEqual(@as(u32, 10), p.source_y); + try testing.expectEqual(@as(u32, 10), p.source_height); + } + + // Fully clipped: deleted. + try t.index(); + try testing.expectEqual(@as(usize, 0), storage.placements.count()); + // The image itself is retained. + try testing.expectEqual(@as(usize, 1), storage.images.count()); +} + +test "storage: scroll margins placement straddling region does not move" { + const testing = std.testing; + const alloc = testing.allocator; + const io = testing.io; + var t = try terminal.Terminal.init(io, alloc, .{ .cols = 10, .rows = 6 }); + defer t.deinit(alloc); + t.width_px = 100; + t.height_px = 60; + + // 3-row image anchored at the top of a 2-row region: not entirely + // within the region, so region scrolls must not move it. This + // exercises the scrollback-creating path (region top at row 0). + const storage: *ImageStorage = &t.screens.active.kitty_images; + try storage.addImage(io, alloc, t.screens.active, .{ + .id = 1, + .width = 10, + .height = 30, + }); + try storage.addPlacement(io, alloc, t.screens.active, 1, 1, .{ + .location = .{ .pin = try trackPin(&t, .{ .x = 0, .y = 0 }) }, + }); + t.setTopAndBottomMargin(1, 2); + t.setCursorPos(2, 1); + + try t.index(); + const p = storage.placements.get(.{ + .image_id = 1, + .placement_id = .{ .tag = .external, .id = 1 }, + }).?; + const pt = t.screens.active.pages.pointFromPin(.active, p.location.pin.*).?; + try testing.expectEqual(point.Coordinate{ .x = 0, .y = 0 }, pt.coord()); + try testing.expectEqual(@as(u32, 0), p.source_y); + try testing.expectEqual(@as(u32, 0), p.source_height); +} + +test "storage: scroll margins placement below region does not move" { + const testing = std.testing; + const alloc = testing.allocator; + const io = testing.io; + var t = try terminal.Terminal.init(io, alloc, .{ .cols = 10, .rows = 6 }); + defer t.deinit(alloc); + t.width_px = 100; + t.height_px = 60; + + // A "status bar" image below the bottom margin must stay put while + // the region above it scrolls (scrollback-creating path). + const storage: *ImageStorage = &t.screens.active.kitty_images; + try storage.addImage(io, alloc, t.screens.active, .{ + .id = 1, + .width = 10, + .height = 10, + }); + try storage.addPlacement(io, alloc, t.screens.active, 1, 1, .{ + .location = .{ .pin = try trackPin(&t, .{ .x = 0, .y = 5 }) }, + }); + t.setTopAndBottomMargin(1, 4); + t.setCursorPos(4, 1); + + try t.index(); + try t.index(); + const p = storage.placements.get(.{ + .image_id = 1, + .placement_id = .{ .tag = .external, .id = 1 }, + }).?; + const pt = t.screens.active.pages.pointFromPin(.active, p.location.pin.*).?; + try testing.expectEqual(point.Coordinate{ .x = 0, .y = 5 }, pt.coord()); +} + +test "storage: scroll margins reverse index clips at bottom" { + const testing = std.testing; + const alloc = testing.allocator; + const io = testing.io; + var t = try terminal.Terminal.init(io, alloc, .{ .cols = 10, .rows = 6 }); + defer t.deinit(alloc); + t.width_px = 100; + t.height_px = 60; + + // 2-row image at the bottom of the region (rows 1-4). + const storage: *ImageStorage = &t.screens.active.kitty_images; + try storage.addImage(io, alloc, t.screens.active, .{ + .id = 1, + .width = 10, + .height = 20, + }); + try storage.addPlacement(io, alloc, t.screens.active, 1, 1, .{ + .location = .{ .pin = try trackPin(&t, .{ .x = 0, .y = 3 }) }, + }); + t.setTopAndBottomMargin(2, 5); + t.setCursorPos(2, 1); + + // Moves down, with the bottom cell row clipped off the source. + t.reverseIndex(); + { + const p = storage.placements.get(.{ + .image_id = 1, + .placement_id = .{ .tag = .external, .id = 1 }, + }).?; + const pt = t.screens.active.pages.pointFromPin(.active, p.location.pin.*).?; + try testing.expectEqual(point.Coordinate{ .x = 0, .y = 4 }, pt.coord()); + try testing.expectEqual(@as(u32, 0), p.source_y); + try testing.expectEqual(@as(u32, 10), p.source_height); + } + + // Fully clipped: deleted. + t.reverseIndex(); + try testing.expectEqual(@as(usize, 0), storage.placements.count()); +} + +test "storage: scroll margins scaled placement clips proportionally" { + const testing = std.testing; + const alloc = testing.allocator; + const io = testing.io; + var t = try terminal.Terminal.init(io, alloc, .{ .cols = 10, .rows = 6 }); + defer t.deinit(alloc); + t.width_px = 100; + t.height_px = 60; + + // A 40px-tall image scaled into 2 rows, inside a 2-row region. + const storage: *ImageStorage = &t.screens.active.kitty_images; + try storage.addImage(io, alloc, t.screens.active, .{ + .id = 1, + .width = 10, + .height = 40, + }); + try storage.addPlacement(io, alloc, t.screens.active, 1, 1, .{ + .location = .{ .pin = try trackPin(&t, .{ .x = 0, .y = 1 }) }, + .columns = 1, + .rows = 2, + }); + t.setTopAndBottomMargin(2, 3); + t.setCursorPos(3, 1); + + try t.index(); + const p = storage.placements.get(.{ + .image_id = 1, + .placement_id = .{ .tag = .external, .id = 1 }, + }).?; + const pt = t.screens.active.pages.pointFromPin(.active, p.location.pin.*).?; + try testing.expectEqual(point.Coordinate{ .x = 0, .y = 1 }, pt.coord()); + try testing.expectEqual(@as(u32, 20), p.source_y); + try testing.expectEqual(@as(u32, 20), p.source_height); + try testing.expectEqual(@as(u32, 1), p.rows); + try testing.expectEqual(@as(u32, 1), p.columns); +} + +test "storage: scroll margins left/right margins respect columns" { + const testing = std.testing; + const alloc = testing.allocator; + const io = testing.io; + var t = try terminal.Terminal.init(io, alloc, .{ .cols = 10, .rows = 6 }); + defer t.deinit(alloc); + t.width_px = 100; + t.height_px = 60; + + // One placement outside the left/right margin columns, one inside. + const storage: *ImageStorage = &t.screens.active.kitty_images; + try storage.addImage(io, alloc, t.screens.active, .{ + .id = 1, + .width = 10, + .height = 10, + }); + try storage.addPlacement(io, alloc, t.screens.active, 1, 1, .{ + .location = .{ .pin = try trackPin(&t, .{ .x = 0, .y = 2 }) }, + }); + try storage.addPlacement(io, alloc, t.screens.active, 1, 2, .{ + .location = .{ .pin = try trackPin(&t, .{ .x = 2, .y = 2 }) }, + }); + + t.modes.set(.enable_left_and_right_margin, true); + t.setLeftAndRightMargin(2, 8); + + try t.scrollUp(1); + + // Outside the margin columns: stays. + { + const p = storage.placements.get(.{ + .image_id = 1, + .placement_id = .{ .tag = .external, .id = 1 }, + }).?; + const pt = t.screens.active.pages.pointFromPin(.active, p.location.pin.*).?; + try testing.expectEqual(point.Coordinate{ .x = 0, .y = 2 }, pt.coord()); + } + + // Inside: moves up. + { + const p = storage.placements.get(.{ + .image_id = 1, + .placement_id = .{ .tag = .external, .id = 2 }, + }).?; + const pt = t.screens.active.pages.pointFromPin(.active, p.location.pin.*).?; + try testing.expectEqual(point.Coordinate{ .x = 2, .y = 1 }, pt.coord()); + } +} + +test "storage: scroll margins insert/delete lines do not move placements" { + const testing = std.testing; + const alloc = testing.allocator; + const io = testing.io; + var t = try terminal.Terminal.init(io, alloc, .{ .cols = 10, .rows = 6 }); + defer t.deinit(alloc); + t.width_px = 100; + t.height_px = 60; + + // Kitty does not scroll images for IL/DL, only for IND/RI/SU/SD. + const storage: *ImageStorage = &t.screens.active.kitty_images; + try storage.addImage(io, alloc, t.screens.active, .{ + .id = 1, + .width = 10, + .height = 10, + }); + try storage.addPlacement(io, alloc, t.screens.active, 1, 1, .{ + .location = .{ .pin = try trackPin(&t, .{ .x = 0, .y = 2 }) }, + }); + t.setTopAndBottomMargin(2, 5); + t.setCursorPos(2, 1); + + t.deleteLines(1); + { + const p = storage.placements.get(.{ + .image_id = 1, + .placement_id = .{ .tag = .external, .id = 1 }, + }).?; + const pt = t.screens.active.pages.pointFromPin(.active, p.location.pin.*).?; + try testing.expectEqual(point.Coordinate{ .x = 0, .y = 2 }, pt.coord()); + } + + t.insertLines(1); + { + const p = storage.placements.get(.{ + .image_id = 1, + .placement_id = .{ .tag = .external, .id = 1 }, + }).?; + const pt = t.screens.active.pages.pointFromPin(.active, p.location.pin.*).?; + try testing.expectEqual(point.Coordinate{ .x = 0, .y = 2 }, pt.coord()); + } +} + +test "storage: scroll without margins moves placement into scrollback" { + const testing = std.testing; + const alloc = testing.allocator; + const io = testing.io; + var t = try terminal.Terminal.init(io, alloc, .{ .cols = 10, .rows = 6 }); + defer t.deinit(alloc); + t.width_px = 100; + t.height_px = 60; + + // No margins: the placement follows its anchored row into the + // scrollback via pin tracking (kitty's marginless behavior). + const storage: *ImageStorage = &t.screens.active.kitty_images; + try storage.addImage(io, alloc, t.screens.active, .{ + .id = 1, + .width = 10, + .height = 20, + }); + try storage.addPlacement(io, alloc, t.screens.active, 1, 1, .{ + .location = .{ .pin = try trackPin(&t, .{ .x = 0, .y = 0 }) }, + }); + t.setCursorPos(6, 1); + + try t.index(); + const p = storage.placements.get(.{ + .image_id = 1, + .placement_id = .{ .tag = .external, .id = 1 }, + }).?; + try testing.expect(t.screens.active.pages.pointFromPin( + .active, + p.location.pin.*, + ) == null); + const pt = t.screens.active.pages.pointFromPin(.screen, p.location.pin.*).?; + try testing.expectEqual(point.Coordinate{ .x = 0, .y = 0 }, pt.coord()); +} + +test "storage: scroll margins large scroll deletes inside placement" { + const testing = std.testing; + const alloc = testing.allocator; + const io = testing.io; + var t = try terminal.Terminal.init(io, alloc, .{ .cols = 10, .rows = 6 }); + defer t.deinit(alloc); + t.width_px = 100; + t.height_px = 60; + + const storage: *ImageStorage = &t.screens.active.kitty_images; + try storage.addImage(io, alloc, t.screens.active, .{ + .id = 1, + .width = 10, + .height = 10, + }); + try storage.addPlacement(io, alloc, t.screens.active, 1, 1, .{ + .location = .{ .pin = try trackPin(&t, .{ .x = 0, .y = 2 }) }, + }); + t.setTopAndBottomMargin(2, 5); + + try t.scrollUp(10); + try testing.expectEqual(@as(usize, 0), storage.placements.count()); +} + +test "storage: scroll margins straddling placement pin inside region restored" { + const testing = std.testing; + const alloc = testing.allocator; + const io = testing.io; + var t = try terminal.Terminal.init(io, alloc, .{ .cols = 10, .rows = 6 }); + defer t.deinit(alloc); + t.width_px = 100; + t.height_px = 60; + + // 2-row image anchored on the bottom margin row, extending below + // the region: not entirely inside, so it must not move even though + // the region scroll moves every tracked pin within the region rows. + const storage: *ImageStorage = &t.screens.active.kitty_images; + try storage.addImage(io, alloc, t.screens.active, .{ + .id = 1, + .width = 10, + .height = 20, + }); + try storage.addPlacement(io, alloc, t.screens.active, 1, 1, .{ + .location = .{ .pin = try trackPin(&t, .{ .x = 0, .y = 4 }) }, + }); + t.setTopAndBottomMargin(2, 5); + t.setCursorPos(5, 1); + + try t.index(); + const p = storage.placements.get(.{ + .image_id = 1, + .placement_id = .{ .tag = .external, .id = 1 }, + }).?; + const pt = t.screens.active.pages.pointFromPin(.active, p.location.pin.*).?; + try testing.expectEqual(point.Coordinate{ .x = 0, .y = 4 }, pt.coord()); + try testing.expectEqual(@as(u32, 0), p.source_y); + try testing.expectEqual(@as(u32, 0), p.source_height); +} + +test "storage: scroll margins multi-line scroll up with scrollback" { + const testing = std.testing; + const alloc = testing.allocator; + const io = testing.io; + var t = try terminal.Terminal.init(io, alloc, .{ .cols = 10, .rows = 6 }); + defer t.deinit(alloc); + t.width_px = 100; + t.height_px = 60; + + // 1-row image inside a region whose top is the screen top, so the + // scroll pushes text into the scrollback (window shift) while the + // image moves within the region. + const storage: *ImageStorage = &t.screens.active.kitty_images; + try storage.addImage(io, alloc, t.screens.active, .{ + .id = 1, + .width = 10, + .height = 10, + }); + try storage.addPlacement(io, alloc, t.screens.active, 1, 1, .{ + .location = .{ .pin = try trackPin(&t, .{ .x = 0, .y = 2 }) }, + }); + t.setTopAndBottomMargin(1, 4); + + try t.scrollUp(2); + { + const p = storage.placements.get(.{ + .image_id = 1, + .placement_id = .{ .tag = .external, .id = 1 }, + }).?; + const pt = t.screens.active.pages.pointFromPin(.active, p.location.pin.*).?; + try testing.expectEqual(point.Coordinate{ .x = 0, .y = 0 }, pt.coord()); + } + + // Another two rows scrolls it out of the region: deleted. + try t.scrollUp(2); + try testing.expectEqual(@as(usize, 0), storage.placements.count()); +} + +test "storage: resolveChain accumulates offsets to the pin root" { + const testing = std.testing; + const alloc = testing.allocator; + const io = testing.io; + var t = try terminal.Terminal.init(io, alloc, .{ .rows = 5, .cols = 5 }); + defer t.deinit(alloc); + + var s: ImageStorage = .{}; + defer s.deinit(alloc, t.screens.active); + try s.addImage(io, alloc, t.screens.active, .{ .id = 1 }); + try s.addPlacement(io, alloc, t.screens.active, 1, 1, .{ + .location = .{ .pin = try trackPin(&t, .{ .x = 1, .y = 1 }) }, + }); + try s.addPlacement(io, alloc, t.screens.active, 1, 2, .{ + .location = .{ .relative = .{ + .parent = .{ + .image_id = 1, + .placement_id = .{ .tag = .external, .id = 1 }, + }, + .horizontal_offset = 2, + .vertical_offset = 1, + } }, + }); + try s.addPlacement(io, alloc, t.screens.active, 1, 3, .{ + .location = .{ .relative = .{ + .parent = .{ + .image_id = 1, + .placement_id = .{ .tag = .external, .id = 2 }, + }, + .horizontal_offset = -1, + .vertical_offset = 4, + } }, + }); + + const grandchild = s.placements.get(.{ + .image_id = 1, + .placement_id = .{ .tag = .external, .id = 3 }, + }).?; + const chain = s.resolveChain(grandchild.location.relative).?; + try testing.expect(chain.root_key.eql(.{ + .image_id = 1, + .placement_id = .{ .tag = .external, .id = 1 }, + })); + try testing.expect(chain.root.location == .pin); + try testing.expectEqual(@as(i32, 1), chain.horizontal_offset); + try testing.expectEqual(@as(i32, 5), chain.vertical_offset); +} + +test "storage: resolveChain finds virtual roots" { + const testing = std.testing; + const alloc = testing.allocator; + const io = testing.io; + var t = try terminal.Terminal.init(io, alloc, .{ .rows = 5, .cols = 5 }); + defer t.deinit(alloc); + + var s: ImageStorage = .{}; + defer s.deinit(alloc, t.screens.active); + try s.addImage(io, alloc, t.screens.active, .{ .id = 1 }); + try s.addPlacement(io, alloc, t.screens.active, 1, 1, .{ + .location = .{ .virtual = {} }, + }); + try s.addPlacement(io, alloc, t.screens.active, 1, 2, .{ + .location = .{ .relative = .{ + .parent = .{ + .image_id = 1, + .placement_id = .{ .tag = .external, .id = 1 }, + }, + .horizontal_offset = 1, + } }, + }); + + const child = s.placements.get(.{ + .image_id = 1, + .placement_id = .{ .tag = .external, .id = 2 }, + }).?; + const chain = s.resolveChain(child.location.relative).?; + try testing.expect(chain.root.location == .virtual); + try testing.expect(chain.root_key.eql(.{ + .image_id = 1, + .placement_id = .{ .tag = .external, .id = 1 }, + })); + try testing.expectEqual(@as(i32, 1), chain.horizontal_offset); + try testing.expectEqual(@as(i32, 0), chain.vertical_offset); +} + +test "storage: eviction removes orphaned relative placements" { + const testing = std.testing; + const alloc = testing.allocator; + const io = testing.io; + var t = try terminal.Terminal.init(io, alloc, .{ .rows = 5, .cols = 5 }); + defer t.deinit(alloc); + + var s: ImageStorage = .{}; + defer s.deinit(alloc, t.screens.active); + s.total_limit = 8; + + // Image 1 holds most of the byte budget and has a pin placement. + try s.addImage(io, alloc, t.screens.active, .{ + .id = 1, + .width = 2, + .height = 1, + .data = .{ .complete = try alloc.dupe(u8, &.{ 0, 0, 0, 0, 0, 0 }) }, + }); + try s.addPlacement(io, alloc, t.screens.active, 1, 1, .{ + .location = .{ .pin = try trackPin(&t, .{ .x = 1, .y = 1 }) }, + }); + + // Image 2's placement is relative to image 1's placement. + try s.addImage(io, alloc, t.screens.active, .{ .id = 2 }); + try s.addPlacement(io, alloc, t.screens.active, 2, 1, .{ + .location = .{ .relative = .{ .parent = .{ + .image_id = 1, + .placement_id = .{ .tag = .external, .id = 1 }, + } } }, + }); + + // Adding image 3 exceeds the limit and evicts image 1 (oldest), + // removing its placement, which orphans image 2's placement. + try s.addImage(io, alloc, t.screens.active, .{ + .id = 3, + .width = 2, + .height = 1, + .data = .{ .complete = try alloc.dupe(u8, &.{ 0, 0, 0, 0, 0, 0 }) }, + }); + + try testing.expect(s.imageById(1) == null); + try testing.expectEqual(@as(usize, 0), s.placements.count()); + try testing.expect(s.imageById(2) != null); +} + +test "storage: placeholderTarget lookup" { + const testing = std.testing; + const alloc = testing.allocator; + const io = testing.io; + var t = try terminal.Terminal.init(io, alloc, .{ .rows = 5, .cols = 5 }); + defer t.deinit(alloc); + + var s: ImageStorage = .{}; + defer s.deinit(alloc, t.screens.active); + try s.addImage(io, alloc, t.screens.active, .{ .id = 1 }); + try s.addPlacement(io, alloc, t.screens.active, 1, 5, .{ + .location = .{ .virtual = {} }, + }); + + // Exact external ID match. + { + const target = s.placeholderTarget(1, 5).?; + const expected: ImageStorage.PlacementKey = .{ + .image_id = 1, + .placement_id = .{ .tag = .external, .id = 5 }, + }; + try testing.expectEqual(expected, target.key); + try testing.expect(target.placement.location == .virtual); + } + + // Zero placement ID falls back to the image's virtual placement. + { + const target = s.placeholderTarget(1, 0).?; + const expected: ImageStorage.PlacementKey = .{ + .image_id = 1, + .placement_id = .{ .tag = .external, .id = 5 }, + }; + try testing.expectEqual(expected, target.key); + } + + try testing.expect(s.placeholderTarget(1, 9) == null); + try testing.expect(s.placeholderTarget(2, 0) == null); + + // With multiple virtual placements, the zero-ID fallback picks + // deterministically by PlacementId.preferredOver: lowest external. + try s.addPlacement(io, alloc, t.screens.active, 1, 3, .{ + .location = .{ .virtual = {} }, + }); + { + const expected: ImageStorage.PlacementKey = .{ + .image_id = 1, + .placement_id = .{ .tag = .external, .id = 3 }, + }; + try testing.expectEqual(expected, s.placeholderTarget(1, 0).?.key); + } +} + +test "storage: animation tick advances and schedules" { + const testing = std.testing; + const alloc = testing.allocator; + const io = testing.io; + var t = try terminal.Terminal.init(io, alloc, .{ .cols = 10, .rows = 10 }); + defer t.deinit(alloc); + t.width_px = 100; + t.height_px = 100; + + var s: ImageStorage = .{}; + defer s.deinit(alloc, t.screens.active); + + // A running 1x1 RGBA image whose animation has one extra frame. + try s.addImage(io, alloc, t.screens.active, .{ + .id = 1, + .width = 1, + .height = 1, + .format = .rgba, + .data = .{ .complete = try alloc.dupe(u8, &.{ 255, 0, 0, 255 }) }, + }); + const img = s.images.getPtr(1).?; + const anim = try alloc.create(animation.Animation); + anim.* = .{ .state = .running }; + img.animation = anim; + try anim.frames.append(alloc, .{ + .data = try alloc.dupe(u8, &.{ 0, 0, 255, 255 }), + .gap_ms = 40, + }); + + // Without a placement the animation doesn't advance (our + // approximation of Kitty's visibility gate). + try testing.expect(s.animationTick(io, 0) == null); + try testing.expectEqual(@as(u32, 0), anim.current_index); + + try s.addPlacement(io, alloc, t.screens.active, 1, 0, .{ + .location = .{ .pin = try trackPin(&t, .{ .x = 0, .y = 0 }) }, + }); + const gen1 = img.generation; + s.dirty = false; + + // First tick: the gapless root frame is due immediately and is + // skipped over to frame 2, which is due again in its 40ms gap. + try testing.expectEqual(@as(?u64, 40), s.animationTick(io, 0)); + try testing.expectEqual(@as(u32, 1), anim.current_index); + try testing.expect(img.generation > gen1); + try testing.expect(s.dirty); + + // Nothing due yet: no advance, and the delay counts down. + const gen2 = img.generation; + try testing.expectEqual(@as(?u64, 30), s.animationTick(io, 10)); + try testing.expectEqual(gen2, img.generation); + + // Wrapping is fine with an infinite loop budget: the gapless + // root is skipped and frame 2 is shown again. + try testing.expectEqual(@as(?u64, 40), s.animationTick(io, 40)); + try testing.expectEqual(@as(u32, 1), anim.current_index); + try testing.expectEqual(@as(u32, 1), anim.current_loop); +} + +test "storage: animation tick loading state parks on last frame" { + const testing = std.testing; + const alloc = testing.allocator; + const io = testing.io; + var t = try terminal.Terminal.init(io, alloc, .{ .cols = 10, .rows = 10 }); + defer t.deinit(alloc); + t.width_px = 100; + t.height_px = 100; + + var s: ImageStorage = .{}; + defer s.deinit(alloc, t.screens.active); + + // A placed 1x1 RGBA image in the loading state (a=a s=2) whose + // animation has one extra frame. + try s.addImage(io, alloc, t.screens.active, .{ + .id = 1, + .width = 1, + .height = 1, + .format = .rgba, + .data = .{ .complete = try alloc.dupe(u8, &.{ 255, 0, 0, 255 }) }, + }); + const anim = try alloc.create(animation.Animation); + anim.* = .{ .state = .loading }; + s.images.getPtr(1).?.animation = anim; + try anim.frames.append(alloc, .{ + .data = try alloc.dupe(u8, &.{ 0, 0, 255, 255 }), + .gap_ms = 40, + }); + try s.addPlacement(io, alloc, t.screens.active, 1, 0, .{ + .location = .{ .pin = try trackPin(&t, .{ .x = 0, .y = 0 }) }, + }); + + // Reach the last frame, then park: no wakeup is scheduled while + // waiting for more frames and the loop counter stays untouched. + try testing.expectEqual(@as(?u64, 40), s.animationTick(io, 0)); + try testing.expectEqual(@as(u32, 1), anim.current_index); + try testing.expect(s.animationTick(io, 100) == null); + try testing.expectEqual(@as(u32, 1), anim.current_index); + try testing.expectEqual(@as(u32, 0), anim.current_loop); + + // A new frame arriving un-parks playback. + try anim.frames.append(alloc, .{ + .data = try alloc.dupe(u8, &.{ 0, 255, 0, 255 }), + .gap_ms = 25, + }); + try testing.expectEqual(@as(?u64, 25), s.animationTick(io, 150)); + try testing.expectEqual(@as(u32, 2), anim.current_index); +} + +test "storage: animation tick exhausts loop budget" { + const testing = std.testing; + const alloc = testing.allocator; + const io = testing.io; + var t = try terminal.Terminal.init(io, alloc, .{ .cols = 10, .rows = 10 }); + defer t.deinit(alloc); + t.width_px = 100; + t.height_px = 100; + + var s: ImageStorage = .{}; + defer s.deinit(alloc, t.screens.active); + + // A placed, running 1x1 RGBA image with a gapped root frame, one + // extra frame, and a one-loop budget (a=a v=2). + try s.addImage(io, alloc, t.screens.active, .{ + .id = 1, + .width = 1, + .height = 1, + .format = .rgba, + .data = .{ .complete = try alloc.dupe(u8, &.{ 255, 0, 0, 255 }) }, + }); + const anim = try alloc.create(animation.Animation); + anim.* = .{ + .state = .running, + .root_gap_ms = 10, + .max_loops = 1, + }; + s.images.getPtr(1).?.animation = anim; + try anim.frames.append(alloc, .{ + .data = try alloc.dupe(u8, &.{ 0, 0, 255, 255 }), + .gap_ms = 40, + }); + try s.addPlacement(io, alloc, t.screens.active, 1, 0, .{ + .location = .{ .pin = try trackPin(&t, .{ .x = 0, .y = 0 }) }, + }); + + // Root shows for 10ms, frame 2 for 40ms, then the wrap exhausts + // the budget and playback freezes on the last frame for good. + try testing.expectEqual(@as(?u64, 10), s.animationTick(io, 0)); + try testing.expectEqual(@as(u32, 0), anim.current_index); + try testing.expectEqual(@as(?u64, 40), s.animationTick(io, 10)); + try testing.expectEqual(@as(u32, 1), anim.current_index); + try testing.expect(s.animationTick(io, 50) == null); + try testing.expectEqual(@as(u32, 1), anim.current_index); + try testing.expect(s.animationTick(io, 500) == null); +} + +test "storage: animation tick ignores ineligible animations" { + const testing = std.testing; + const alloc = testing.allocator; + const io = testing.io; + var t = try terminal.Terminal.init(io, alloc, .{ .cols = 10, .rows = 10 }); + defer t.deinit(alloc); + t.width_px = 100; + t.height_px = 100; + + var s: ImageStorage = .{}; + defer s.deinit(alloc, t.screens.active); + + // A placed 1x1 RGBA image with one extra frame, in the default + // stopped state. + try s.addImage(io, alloc, t.screens.active, .{ + .id = 1, + .width = 1, + .height = 1, + .format = .rgba, + .data = .{ .complete = try alloc.dupe(u8, &.{ 255, 0, 0, 255 }) }, + }); + const anim = try alloc.create(animation.Animation); + anim.* = .{}; + s.images.getPtr(1).?.animation = anim; + try anim.frames.append(alloc, .{ + .data = try alloc.dupe(u8, &.{ 0, 0, 255, 255 }), + .gap_ms = 40, + }); + try s.addPlacement(io, alloc, t.screens.active, 1, 0, .{ + .location = .{ .pin = try trackPin(&t, .{ .x = 0, .y = 0 }) }, + }); + + // Stopped (the default) never advances. + try testing.expect(s.animationTick(io, 0) == null); + + // An all-gapless animation can never advance either. + anim.state = .running; + anim.frames.items[0].gap_ms = 0; + try testing.expect(s.animationTick(io, 0) == null); + try testing.expectEqual(@as(u32, 0), anim.current_index); +} + +test "storage: animation tick re-anchors a restarted clock" { + const testing = std.testing; + const alloc = testing.allocator; + const io = testing.io; + var t = try terminal.Terminal.init(io, alloc, .{ .cols = 10, .rows = 10 }); + defer t.deinit(alloc); + t.width_px = 100; + t.height_px = 100; + + var s: ImageStorage = .{}; + defer s.deinit(alloc, t.screens.active); + + // A placed, running 1x1 RGBA image displaying its extra frame, + // with a shown-at timestamp far ahead of the tick clock. + try s.addImage(io, alloc, t.screens.active, .{ + .id = 1, + .width = 1, + .height = 1, + .format = .rgba, + .data = .{ .complete = try alloc.dupe(u8, &.{ 255, 0, 0, 255 }) }, + }); + const anim = try alloc.create(animation.Animation); + anim.* = .{ + .state = .running, + .current_index = 1, + .frame_shown_at_ms = 1000, + }; + s.images.getPtr(1).?.animation = anim; + try anim.frames.append(alloc, .{ + .data = try alloc.dupe(u8, &.{ 0, 0, 255, 255 }), + .gap_ms = 40, + }); + try s.addPlacement(io, alloc, t.screens.active, 1, 0, .{ + .location = .{ .pin = try trackPin(&t, .{ .x = 0, .y = 0 }) }, + }); + + // A timestamp in the future relative to now means the caller's + // clock restarted; the animation must not stall until the old + // timestamp comes around again. + try testing.expectEqual(@as(?u64, 40), s.animationTick(io, 5)); + try testing.expectEqual(@as(?u64, 5), anim.frame_shown_at_ms); +} diff --git a/vendor/libghostty-vt/src/terminal/kitty/graphics_unicode.zig b/vendor/libghostty-vt/src/terminal/kitty/graphics_unicode.zig index 4855025..b62f817 100644 --- a/vendor/libghostty-vt/src/terminal/kitty/graphics_unicode.zig +++ b/vendor/libghostty-vt/src/terminal/kitty/graphics_unicode.zig @@ -363,29 +363,17 @@ pub const Placement = struct { image: *const Image, cell_width: u32, cell_height: u32, - ) !struct { + ) Error!struct { rows: u32, columns: u32, } { // Get the placement. If an ID is specified we look for the exact one. // If no ID, then we find the first virtual placement for this image. - const placement = if (self.placement_id > 0) storage.placements.get(.{ - .image_id = self.image_id, - .placement_id = .{ .tag = .external, .id = self.placement_id }, - }) orelse { - return Error.PlacementMissingPlacement; - } else placement: { - var it = storage.placements.iterator(); - while (it.next()) |entry| { - if (entry.key_ptr.image_id == self.image_id and - entry.value_ptr.location == .virtual) - { - break :placement entry.value_ptr.*; - } - } - - return Error.PlacementMissingPlacement; - }; + const target = storage.placeholderTarget( + self.image_id, + self.placement_id, + ) orelse return Error.PlacementMissingPlacement; + const placement = target.placement; // Use requested rows/columns if specified // For unspecified rows/columns, calculate based on the image size. diff --git a/vendor/libghostty-vt/src/terminal/main.zig b/vendor/libghostty-vt/src/terminal/main.zig index fb846ae..5d7c738 100644 --- a/vendor/libghostty-vt/src/terminal/main.zig +++ b/vendor/libghostty-vt/src/terminal/main.zig @@ -20,6 +20,7 @@ pub const kitty = @import("kitty.zig"); pub const modes = @import("modes.zig"); pub const page = @import("page.zig"); pub const parse_table = @import("parse_table.zig"); +pub const paste = @import("paste.zig"); pub const search = @import("search.zig"); pub const snapshot = @import("snapshot/main.zig"); pub const sgr = @import("sgr.zig"); @@ -60,6 +61,11 @@ pub const TerminalStream = stream_terminal.Stream; pub const Stream = stream.Stream; pub const StreamAction = stream.Action; pub const UnknownSequence = stream_terminal.Handler.UnknownSequence; +pub const Paste = paste.Request; +pub const PasteContents = paste.Contents; +pub const PasteSource = paste.Source; +pub const MimeReader = clipboard.MimeReader; +pub const PasteError = stream_terminal.Handler.PasteError; pub const Cursor = Screen.Cursor; pub const CursorStyle = Screen.CursorStyle; pub const CursorStyleReq = ansi.CursorStyle; @@ -90,6 +96,7 @@ test { // Internals _ = @import("bitmap_allocator.zig"); _ = @import("compress.zig"); + _ = @import("fraction.zig"); _ = @import("hash_map.zig"); _ = @import("mem.zig"); _ = @import("ref_counted_set.zig"); diff --git a/vendor/libghostty-vt/src/terminal/modes.zig b/vendor/libghostty-vt/src/terminal/modes.zig index c545b33..ed5f1b7 100644 --- a/vendor/libghostty-vt/src/terminal/modes.zig +++ b/vendor/libghostty-vt/src/terminal/modes.zig @@ -8,6 +8,8 @@ //! to ensure all our various types and logic remain in sync. const std = @import("std"); +const builtin = @import("builtin"); +const build_options = @import("terminal_options"); const testing = std.testing; /// A struct that maintains the state of all the settable modes. @@ -327,6 +329,17 @@ const entries: []const ModeEntry = &.{ .{ .name = "report_color_scheme", .value = 2031 }, .{ .name = "report_visibility", .value = 2033, .default_configurable = false }, .{ .name = "in_band_size_reports", .value = 2048 }, + // Kitty clipboard protocol paste events. When set, a user-initiated + // paste sends an unsolicited OSC 5522 targets listing with a + // one-time password instead of pasting the text. + // See https://sw.kovidgoyal.net/kitty/clipboard/ + .{ + .name = "kitty_paste_events", + .value = 5522, + // The macOS app and libghostty-vt can both serve the follow-up + // Kitty clipboard read that a paste event grants. + .disabled = build_options.artifact != .lib and builtin.os.tag != .macos, + }, }; test { diff --git a/vendor/libghostty-vt/src/terminal/osc.zig b/vendor/libghostty-vt/src/terminal/osc.zig index 6187772..dbc3549 100644 --- a/vendor/libghostty-vt/src/terminal/osc.zig +++ b/vendor/libghostty-vt/src/terminal/osc.zig @@ -46,12 +46,13 @@ pub const Command = union(Key) { /// Semantic prompt command: https://gitlab.freedesktop.org/Per_Bothner/specifications/blob/master/proposals/semantic-prompts.md semantic_prompt: SemanticPrompt, - /// Set or get clipboard contents. If data is null, then the current - /// clipboard contents are sent to the pty. If data is set, this - /// contents is set on the clipboard. + /// Set or get clipboard contents. If data is "?", then the current + /// clipboard contents are sent to the pty. Otherwise, the contents + /// are set on the clipboard. clipboard_contents: struct { kind: u8, data: [:0]const u8, + terminator: Terminator = .st, }, /// OSC 7. Reports the current working directory of the shell. This is @@ -163,12 +164,17 @@ pub const Command = union(Key) { /// https://uapi-group.org/specifications/specs/osc_context/ context_signal: parsers.context_signal.Command, + /// Kitty desktop notifications (OSC 99) + kitty_desktop_notification: KittyDesktopNotification, + pub const SemanticPrompt = parsers.semantic_prompt.Command; pub const KittyClipboardProtocol = parsers.kitty_clipboard_protocol.OSC; pub const KittyDndProtocol = parsers.kitty_dnd_protocol.OSC; + pub const KittyDesktopNotification = parsers.kitty_desktop_notification.OSC; + pub const Key = LibEnum( lib.target, // NOTE: Order matters, see LibEnum documentation. @@ -199,6 +205,7 @@ pub const Command = union(Key) { "kitty_clipboard_protocol", "kitty_dnd_protocol", "context_signal", + "kitty_desktop_notification", }, ); @@ -364,6 +371,7 @@ pub const Parser = struct { @"66", @"72", @"77", + @"99", @"104", @"110", @"111", @@ -444,6 +452,7 @@ pub const Parser = struct { .kitty_text_sizing, .kitty_clipboard_protocol, .kitty_dnd_protocol, + .kitty_desktop_notification, .context_signal, => {}, } @@ -531,6 +540,39 @@ pub const Parser = struct { try self.writer.writeByte(byte); } + /// Append a slice without permitting the backing allocation to + /// grow beyond max_bytes. This matches the byte-at-a-time + /// semantics of writeByte: bytes are retained up to exactly + /// max_bytes and the first byte that doesn't fit fails the + /// write. + pub fn writeSlice( + self: *Capture, + bytes: []const u8, + ) error{WriteFailed}!void { + const avail = self.max_bytes - self.writer.buffered().len; + const n = @min(bytes.len, avail); + + switch (self.backing) { + .fixed => {}, + .allocating => |*w| { + const needed = w.writer.end + n; + if (needed > w.writer.buffer.len) { + const new_capacity = @min( + self.max_bytes, + @max(w.writer.buffer.len *| 2, needed), + ); + w.writer.buffer = w.allocator.realloc( + w.writer.buffer, + new_capacity, + ) catch return error.WriteFailed; + } + }, + } + + try self.writer.writeAll(bytes[0..n]); + if (n < bytes.len) return error.WriteFailed; + } + pub fn deinit(self: *Capture) void { switch (self.backing) { .fixed => {}, @@ -585,6 +627,33 @@ pub const Parser = struct { } } + /// Consume a slice of bytes, advancing the parser state. This is + /// equivalent to calling `next` for each byte in order, but is much + /// faster once a data capture is active because the remaining bytes + /// are appended to the capture in bulk. + pub fn nextSlice(self: *Parser, input: []const u8) void { + if (self.state == .invalid) return; + + // Run the state machine byte-at-a-time until a capture begins. + // The command prefix before a capture starts is only a handful + // of bytes so this loop is short in practice. + var offset: usize = 0; + while (self.capture == null) { + if (offset >= input.len) return; + self.next(input[offset]); + offset += 1; + if (self.state == .invalid) return; + } + + const rem = input[offset..]; + if (rem.len == 0) return; + self.capture.?.writeSlice(rem) catch |err| switch (err) { + // We have overflowed our buffer or had some other error, set + // the state to invalid so that we discard any further input. + error.WriteFailed => self.state = .invalid, + }; + } + /// Consume the next character c and advance the parser state. pub fn next(self: *Parser, c: u8) void { // If the state becomes invalid for any reason, just discard @@ -783,11 +852,24 @@ pub const Parser = struct { else => self.state = .invalid, }, + .@"9", + => switch (c) { + ';' => self.captureTrailing(.fixed), + '9' => self.state = .@"99", + else => self.state = .invalid, + }, + + .@"99", + => switch (c) { + // OSC 99 encoded payloads can exceed the fixed buffer. + ';' => self.captureTrailing(.allocating), + else => self.state = .invalid, + }, + .@"0", .@"22", .@"777", .@"8", - .@"9", => switch (c) { ';' => self.captureTrailing(.fixed), else => self.state = .invalid, @@ -868,6 +950,8 @@ pub const Parser = struct { .@"77" => null, + .@"99" => parsers.kitty_desktop_notification.parse(self, terminator_ch), + .@"133" => parsers.semantic_prompt.parse(self, terminator_ch), .@"552" => null, @@ -888,7 +972,7 @@ test { test "Parser allocating captures have a hard limit" { const testing = std.testing; - const prefixes = [_][]const u8{ "52;", "66;", "72;", "5522;" }; + const prefixes = [_][]const u8{ "52;", "66;", "72;", "99;", "5522;" }; const limit = Parser.MAX_BUF + 1; for (prefixes) |prefix| { @@ -910,6 +994,70 @@ test "Parser allocating captures have a hard limit" { } } +test "Parser nextSlice allocating captures have a hard limit" { + const testing = std.testing; + const limit = Parser.MAX_BUF + 1; + + var p: Parser = .init(testing.allocator); + defer p.deinit(); + p.max_allocating_bytes = limit; + + const data = try testing.allocator.alloc(u8, limit); + defer testing.allocator.free(data); + @memset(data, 'a'); + + // Exactly at the limit stays valid and bounded. + p.nextSlice("52;"); + p.nextSlice(data); + const cap = &p.capture.?; + try testing.expect(p.state != .invalid); + try testing.expectEqual(@as(usize, limit), cap.trailing().len); + try testing.expectEqual(@as(usize, limit), cap.writer.buffer.len); + + // One more byte overflows: the state becomes invalid and the + // retained bytes and allocation stay bounded. + p.nextSlice("a"); + try testing.expectEqual(Parser.State.invalid, p.state); + try testing.expectEqual(@as(usize, limit), cap.trailing().len); + try testing.expectEqual(@as(usize, limit), cap.writer.buffer.len); + try testing.expect(p.end(null) == null); +} + +test "Parser nextSlice overflowing slice is truncated at the limit" { + const testing = std.testing; + + var p: Parser = .init(testing.allocator); + defer p.deinit(); + p.max_allocating_bytes = 4; + + p.nextSlice("52;abcdef"); + try testing.expectEqual(Parser.State.invalid, p.state); + try testing.expect(p.end(null) == null); + + const cap = &p.capture.?; + try testing.expectEqualStrings("abcd", cap.trailing()); + try testing.expectEqual(@as(usize, 4), cap.writer.buffer.len); +} + +test "Parser nextSlice matches per-byte parsing" { + const testing = std.testing; + const input = "52;c;aGVsbG8="; + + // Every two-way split of the input must parse identically to + // the byte-at-a-time path. + for (0..input.len + 1) |split| { + var p: Parser = .init(testing.allocator); + defer p.deinit(); + p.nextSlice(input[0..split]); + p.nextSlice(input[split..]); + + const cmd = p.end(null).?.*; + try testing.expect(cmd == .clipboard_contents); + try testing.expectEqual(@as(u8, 'c'), cmd.clipboard_contents.kind); + try testing.expectEqualStrings("aGVsbG8=", cmd.clipboard_contents.data); + } +} + test "Parser allocating capture limit includes parser-added bytes" { const testing = std.testing; var p: Parser = .init(testing.allocator); diff --git a/vendor/libghostty-vt/src/terminal/osc/kitty_metadata.zig b/vendor/libghostty-vt/src/terminal/osc/kitty_metadata.zig new file mode 100644 index 0000000..469c97a --- /dev/null +++ b/vendor/libghostty-vt/src/terminal/osc/kitty_metadata.zig @@ -0,0 +1,94 @@ +//! Helpers for parsing metadata shared by Kitty OSC protocols. +//! +//! Kitty OSC 99 and OSC 5522 encode metadata as colon-separated `key=value` +//! fields. The iterator in this module lazily searches that metadata for one +//! key, preserving the order of repeated values without allocating. +//! +//! Parsing is intentionally tolerant. Whitespace around keys and values is +//! trimmed, while malformed fields, non-matching keys, and invalid values are +//! skipped. Returned values are slices of the original metadata and remain +//! valid only as long as that input remains valid. + +const std = @import("std"); + +/// Return an iterator over values whose key exactly matches `key`. +/// +/// If `valid_value_characters` is non-null, every byte in a returned value must +/// appear in that character set. Passing null disables value validation. Both +/// arguments are comptime-known so each protocol can specialize the iterator +/// for its metadata grammar without storing a key or validator at runtime. +pub fn ValueIterator( + comptime key: []const u8, + comptime valid_value_characters: ?[]const u8, +) type { + return struct { + const Self = @This(); + + metadata: []const u8, + pos: usize, + + /// Initialize an iterator borrowing `metadata`. + pub fn init(metadata: []const u8) Self { + return .{ + .metadata = metadata, + .pos = 0, + }; + } + + /// Return the next valid matching value, or null when none remain. + /// The returned slice borrows the metadata passed to `init`. + pub fn next(self: *Self) ?[]const u8 { + while (self.pos < self.metadata.len) { + const end = std.mem.indexOfScalarPos( + u8, + self.metadata, + self.pos, + ':', + ) orelse self.metadata.len; + const field = self.metadata[self.pos..end]; + self.pos = if (end < self.metadata.len) end + 1 else end; + + const equals = std.mem.indexOfScalar(u8, field, '=') orelse + continue; + const field_key = std.mem.trim( + u8, + field[0..equals], + &std.ascii.whitespace, + ); + if (!std.mem.eql(u8, field_key, key)) continue; + + const value = std.mem.trim( + u8, + field[equals + 1 ..], + &std.ascii.whitespace, + ); + if (valid_value_characters) |valid| { + if (std.mem.indexOfNone(u8, value, valid) != null) continue; + } + + return value; + } + + return null; + } + }; +} + +test "ValueIterator skips malformed and prefix-matching keys" { + const testing = std.testing; + var it: ValueIterator("id", null) = .init( + "id-extra=wrong:id: id = first :id=second", + ); + + try testing.expectEqualStrings("first", it.next().?); + try testing.expectEqualStrings("second", it.next().?); + try testing.expect(it.next() == null); +} + +test "ValueIterator skips values containing disallowed characters" { + const testing = std.testing; + var it: ValueIterator("i", "abc") = .init("i=a?:i=abc"); + + try testing.expectEqualStrings("abc", it.next().?); + try testing.expect(it.next() == null); +} diff --git a/vendor/libghostty-vt/src/terminal/osc/parsers.zig b/vendor/libghostty-vt/src/terminal/osc/parsers.zig index 8cb91fb..148bfa3 100644 --- a/vendor/libghostty-vt/src/terminal/osc/parsers.zig +++ b/vendor/libghostty-vt/src/terminal/osc/parsers.zig @@ -10,6 +10,7 @@ pub const iterm2 = @import("parsers/iterm2.zig"); pub const kitty_clipboard_protocol = @import("parsers/kitty_clipboard_protocol.zig"); pub const kitty_color = @import("parsers/kitty_color.zig"); pub const kitty_dnd_protocol = @import("parsers/kitty_dnd_protocol.zig"); +pub const kitty_desktop_notification = @import("parsers/kitty_desktop_notification.zig"); pub const kitty_text_sizing = @import("parsers/kitty_text_sizing.zig"); pub const mouse_shape = @import("parsers/mouse_shape.zig"); pub const osc9 = @import("parsers/osc9.zig"); diff --git a/vendor/libghostty-vt/src/terminal/osc/parsers/clipboard_operation.zig b/vendor/libghostty-vt/src/terminal/osc/parsers/clipboard_operation.zig index ec8836f..0158877 100644 --- a/vendor/libghostty-vt/src/terminal/osc/parsers/clipboard_operation.zig +++ b/vendor/libghostty-vt/src/terminal/osc/parsers/clipboard_operation.zig @@ -6,7 +6,7 @@ const Parser = @import("../../osc.zig").Parser; const Command = @import("../../osc.zig").Command; /// Parse OSC 52 -pub fn parse(parser: *Parser, _: ?u8) ?*Command { +pub fn parse(parser: *Parser, terminator_ch: ?u8) ?*Command { assert(parser.state == .@"52"); const cap = if (parser.capture) |*c| c else { parser.state = .invalid; @@ -26,6 +26,7 @@ pub fn parse(parser: *Parser, _: ?u8) ?*Command { .clipboard_contents = .{ .kind = 'c', .data = data[1 .. data.len - 1 :0], + .terminator = .init(terminator_ch), }, }; } else { @@ -41,6 +42,7 @@ pub fn parse(parser: *Parser, _: ?u8) ?*Command { .clipboard_contents = .{ .kind = data[0], .data = data[2 .. data.len - 1 :0], + .terminator = .init(terminator_ch), }, }; } @@ -59,6 +61,20 @@ test "OSC 52: get/set clipboard" { try testing.expect(cmd == .clipboard_contents); try testing.expect(cmd.clipboard_contents.kind == 's'); try testing.expectEqualStrings("?", cmd.clipboard_contents.data); + try testing.expectEqual(.st, cmd.clipboard_contents.terminator); +} + +test "OSC 52: get clipboard with BEL terminator" { + const testing = std.testing; + + var p: Parser = .init(null); + + const input = "52;c;?"; + for (input) |ch| p.next(ch); + + const cmd = p.end(0x07).?.*; + try testing.expect(cmd == .clipboard_contents); + try testing.expectEqual(.bel, cmd.clipboard_contents.terminator); } test "OSC 52: get/set clipboard (optional parameter)" { diff --git a/vendor/libghostty-vt/src/terminal/osc/parsers/kitty_clipboard_protocol.zig b/vendor/libghostty-vt/src/terminal/osc/parsers/kitty_clipboard_protocol.zig index 5bd0e25..1bcc7d3 100644 --- a/vendor/libghostty-vt/src/terminal/osc/parsers/kitty_clipboard_protocol.zig +++ b/vendor/libghostty-vt/src/terminal/osc/parsers/kitty_clipboard_protocol.zig @@ -10,6 +10,7 @@ const assert = @import("../../../quirks.zig").inlineAssert; const Parser = @import("../../osc.zig").Parser; const Command = @import("../../osc.zig").Command; const Terminator = @import("../../osc.zig").Terminator; +const kitty_metadata = @import("../kitty_metadata.zig"); const encoding = @import("../encoding.zig"); const log = std.log.scoped(.kitty_clipboard_protocol); @@ -22,6 +23,12 @@ pub const OSC = struct { /// The terminator that was used in case we need to send a response. terminator: Terminator, + pub const C = void; + + pub fn cval(_: OSC) C { + return {}; + } + /// Decode an option from the metadata. pub fn readOption(self: OSC, comptime key: Option) ?key.Type() { return key.read(self.metadata); @@ -51,6 +58,7 @@ pub const Status = enum { DATA, DONE, EBUSY, + EFBIG, EINVAL, EIO, ENOSYS, @@ -90,37 +98,8 @@ pub const Option = enum { comptime key: Option, metadata: []const u8, ) ?key.Type() { - const value: []const u8 = value: { - var pos: usize = 0; - while (pos < metadata.len) { - // skip any whitespace - while (pos < metadata.len and std.ascii.isWhitespace(metadata[pos])) pos += 1; - // bail if we are out of metadata - if (pos >= metadata.len) return null; - if (!std.mem.startsWith(u8, metadata[pos..], @tagName(key))) { - // this isn't the key we are looking for, skip to the next option, or bail if - // there is no next option - pos = std.mem.indexOfScalarPos(u8, metadata, pos, ':') orelse return null; - pos += 1; - continue; - } - // skip past the key - pos += @tagName(key).len; - // skip any whitespace - while (pos < metadata.len and std.ascii.isWhitespace(metadata[pos])) pos += 1; - // bail if we are out of metadata - if (pos >= metadata.len) return null; - // a valid option has an '=' - if (metadata[pos] != '=') return null; - // the end of the value is bounded by a ':' or the end of the metadata - const end = std.mem.indexOfScalarPos(u8, metadata, pos, ':') orelse metadata.len; - const start = pos + 1; - // strip any leading or trailing whitespace - break :value std.mem.trim(u8, metadata[start..end], &std.ascii.whitespace); - } - // the key was not found - return null; - }; + var it: kitty_metadata.ValueIterator(@tagName(key), null) = .init(metadata); + const value = it.next() orelse return null; // return the parsed value return switch (key) { diff --git a/vendor/libghostty-vt/src/terminal/osc/parsers/kitty_desktop_notification.zig b/vendor/libghostty-vt/src/terminal/osc/parsers/kitty_desktop_notification.zig new file mode 100644 index 0000000..27fc867 --- /dev/null +++ b/vendor/libghostty-vt/src/terminal/osc/parsers/kitty_desktop_notification.zig @@ -0,0 +1,1201 @@ +//! Kitty's desktop notification protocol (OSC 99) +//! Specification: https://sw.kovidgoyal.net/kitty/desktop-notifications/ + +const std = @import("std"); + +const assert = @import("../../../quirks.zig").inlineAssert; + +const Parser = @import("../../osc.zig").Parser; +const Command = @import("../../osc.zig").Command; +const Terminator = @import("../../osc.zig").Terminator; +const kitty_metadata = @import("../kitty_metadata.zig"); +const encoding = @import("../encoding.zig"); + +const log = std.log.scoped(.kitty_desktop_notification); + +pub const MAX_PLAIN_PAYLOAD_BYTES = 2048; +pub const MAX_ENCODED_PAYLOAD_BYTES = 4096; + +pub const OSC = struct { + /// The raw metadata that was received. It can be parsed by using the `readOption` method. + metadata: []const u8, + /// The raw payload. It may be Base64 encoded, check the `e` option. + payload: []const u8, + /// The terminator that was used in case we need to send a response. + terminator: Terminator, + + /// Decode an option from the metadata. + pub fn readOption(self: OSC, comptime key: Option) key.Type() { + return key.read(self.metadata); + } +}; + +pub const Action = packed struct { + focus: bool, + report: bool, + + pub const default: Action = .{ + .focus = true, + .report = false, + }; + + pub fn init(str: []const u8) Action { + return parsePackedStruct(Action, str); + } +}; + +pub const Occasion = enum { + always, + invisible, + unfocused, + + pub const default: Occasion = .always; + + pub fn init(str: []const u8) Occasion { + return std.meta.stringToEnum(Occasion, str) orelse .default; + } +}; + +pub const Payload = enum { + alive, + body, + buttons, + close, + icon, + query, + title, + /// This is a special value to indicate that an unknown payload value was + /// specified and it should be ignored. + unknown, + + pub const default: Payload = .title; + + pub fn init(str: []const u8) Payload { + if (str.len == 1 and str[0] == '?') return .query; + // The string `query` is not allowed, it should be a single question + // mark if you want a query. + if (std.mem.eql(u8, "query", str)) return .unknown; + return std.meta.stringToEnum(Payload, str) orelse .unknown; + } +}; + +pub const Urgency = enum { + low, + normal, + high, + + pub const default: Urgency = .normal; + + pub fn init(str: []const u8) Urgency { + if (str.len != 1) return .default; + return switch (str[0]) { + '0' => .low, + '1' => .normal, + '2' => .high, + else => .default, + }; + } +}; + +pub const Option = enum { + /// What action(s) should be taken when a notification is clicked. + a, + /// Should a notification be sent to the application when the notification + /// is closed? + c, + /// Are we done with the notification, and it is ready to be sent? + d, + /// Is the payload encoded with Base64? + e, + /// The name of the application that is sending the notification. + f, + /// Identifier for icon data. Only used when the payload is icon data. + g, + /// Identifier for the notification. + i, + /// Icon name. + n, + /// When to honor the notification request. + o, + /// Type of the payload. + p, + /// The sound name to play with the notification. + s, + /// The type of the notification. + t, + /// The urgency of the notification. + u, + /// When to auto-close the notification. + w, + + pub fn Type(comptime key: Option) type { + return switch (key) { + .a => Action, + .c => bool, + .d => bool, + .e => bool, + .f => ?[]const u8, + .g => ?[]const u8, + .i => ?[]const u8, + .n => kitty_metadata.ValueIterator( + @tagName(key), + valid_metadata_value_characters, + ), + .o => Occasion, + .p => Payload, + .s => []const u8, + .t => kitty_metadata.ValueIterator( + @tagName(key), + valid_metadata_value_characters, + ), + .u => Urgency, + .w => i32, + }; + } + + pub fn default(comptime key: Option) key.Type() { + return switch (key) { + .a => .default, + .c => false, + .d => true, + .e => false, + .f => null, + .g => null, + .i => null, + .n => unreachable, + .o => .default, + .p => .default, + .s => "system", + .t => unreachable, + .u => .default, + .w => -1, + }; + } + + /// Read the option value from the raw metadata string. + /// + /// Unknown and malformed values are ignored. Optional values return null; + /// all other values return the protocol default. + pub fn read( + comptime key: Option, + metadata: []const u8, + ) key.Type() { + var it: kitty_metadata.ValueIterator( + @tagName(key), + valid_metadata_value_characters, + ) = switch (key) { + .t, .n => return .init(metadata), + else => .init(metadata), + }; + + const value = it.next() orelse return key.default(); + + // return the parsed value, the iterator guarantees that it's a valid + // metadata value + return switch (key) { + .a => .init(value), + .c => parseBool(value) orelse key.default(), + .d => parseBool(value) orelse key.default(), + .e => parseBool(value) orelse key.default(), + .f => value, + .g => parseIdentifier(value), + .i => parseIdentifier(value), + .n => unreachable, + .o => .init(value), + .p => .init(value), + .s => value, + .t => unreachable, + .u => .init(value), + .w => value: { + // Zig's integer parser allows '_', we don't + if (std.mem.indexOfScalar(u8, value, '_')) |_| break :value key.default(); + const tmp = std.fmt.parseInt(i32, value, 10) catch break :value key.default(); + // negative values less than -1 are not allowed + if (tmp < -1) break :value key.default(); + break :value tmp; + }, + }; + } +}; + +/// Parse the protocol's booleans +fn parseBool(str: []const u8) ?bool { + if (str.len != 1) return null; + return switch (str[0]) { + '0' => false, + '1' => true, + else => null, + }; +} + +/// This is similar to the packed struct parser used in the configs. The +/// differences are that a literal `true` or `false` value does not turn on/off +/// all the values, and the negation prefix is `-` not `no-`. +fn parsePackedStruct(comptime T: type, str: []const u8) T { + const info = @typeInfo(T).@"struct"; + comptime assert(info.layout == .@"packed"); + + var result: T = .default; + + // We split each value by "," + var iter = std.mem.splitSequence(u8, str, ","); + loop: while (iter.next()) |raw| { + // Determine the field we're looking for and the value. If the + // field is prefixed with "-" then we set the value to false. + const part, const value = part: { + const negation_prefix = "-"; + const trimmed = std.mem.trim(u8, raw, &std.ascii.whitespace); + if (std.mem.startsWith(u8, trimmed, negation_prefix)) { + break :part .{ trimmed[negation_prefix.len..], false }; + } else { + break :part .{ trimmed, true }; + } + }; + + inline for (info.fields) |field| { + comptime assert(field.type == bool); + if (std.mem.eql(u8, field.name, part)) { + @field(result, field.name) = value; + continue :loop; + } + } + + // No field matched + return .default; + } + + return result; +} + +/// Characters that are valid in a metadata value. Including `=` is technically +/// against the spec but is needed since Base64 encoded values (with padding) +/// are valid for some options. Including `?` is technically against the spec +/// but is needed since it is a valid value for the `p` option. +const valid_metadata_value_characters: []const u8 = valid_identifier_characters ++ "/,(){}[]*&^%$#@!`~=?"; + +/// Characters that are valid in identifiers. +const valid_identifier_characters: []const u8 = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789-_+."; + +fn isValidIdentifier(str: []const u8) bool { + return std.mem.indexOfNone(u8, str, valid_identifier_characters) == null; +} + +fn parseIdentifier(str: []const u8) ?[]const u8 { + if (isValidIdentifier(str)) return str; + return null; +} + +pub fn parse(parser: *Parser, terminator_ch: ?u8) ?*Command { + assert(parser.state == .@"99"); + + const cap = if (parser.capture) |*c| c else { + parser.state = .invalid; + return null; + }; + + const data = cap.trailing(); + + const payload_start = std.mem.indexOfScalar(u8, data, ';') orelse { + log.warn("missing semicolon before payload", .{}); + parser.state = .invalid; + return null; + }; + + const metadata = data[0..payload_start]; + const payload = data[payload_start + 1 .. data.len]; + + const max_payload_bytes: usize = if (Option.e.read(metadata)) + MAX_ENCODED_PAYLOAD_BYTES + else + MAX_PLAIN_PAYLOAD_BYTES; + if (payload.len > max_payload_bytes) { + log.warn( + "payload is too large: size={d} max={d}", + .{ payload.len, max_payload_bytes }, + ); + parser.state = .invalid; + return null; + } + + // Payload has to be an escape-code-safe UTF-8 string. + if (!encoding.isSafeUtf8(payload)) { + log.warn("payload is not escape code safe UTF-8", .{}); + parser.state = .invalid; + return null; + } + + parser.command = .{ + .kitty_desktop_notification = .{ + .metadata = metadata, + .payload = payload, + .terminator = .init(terminator_ch), + }, + }; + + return &parser.command; +} + +test "OSC 99: empty metadata and payload" { + const testing = std.testing; + + var p: Parser = .init(null); + + const input = "99;;"; + for (input) |ch| p.next(ch); + + const cmd = p.end('\x1b').?.*; + try testing.expect(cmd == .kitty_desktop_notification); + try testing.expectEqualStrings("", cmd.kitty_desktop_notification.metadata); + try testing.expectEqualStrings("", cmd.kitty_desktop_notification.payload); + try testing.expectEqualDeep(Option.a.default(), cmd.kitty_desktop_notification.readOption(.a)); + try testing.expect(cmd.kitty_desktop_notification.readOption(.c) == false); + try testing.expect(cmd.kitty_desktop_notification.readOption(.d) == true); + try testing.expect(cmd.kitty_desktop_notification.readOption(.e) == false); + try testing.expect(cmd.kitty_desktop_notification.readOption(.f) == null); + try testing.expect(cmd.kitty_desktop_notification.readOption(.g) == null); + try testing.expect(cmd.kitty_desktop_notification.readOption(.i) == null); + { + var it = cmd.kitty_desktop_notification.readOption(.n); + try testing.expect(it.next() == null); + } + try testing.expectEqual(.always, cmd.kitty_desktop_notification.readOption(.o)); + try testing.expectEqual(.title, cmd.kitty_desktop_notification.readOption(.p)); + try testing.expectEqualStrings("system", cmd.kitty_desktop_notification.readOption(.s)); + { + var it = cmd.kitty_desktop_notification.readOption(.t); + try testing.expect(it.next() == null); + } + try testing.expectEqual(.normal, cmd.kitty_desktop_notification.readOption(.u)); + try testing.expectEqual(-1, cmd.kitty_desktop_notification.readOption(.w)); + try testing.expectEqual(.st, cmd.kitty_desktop_notification.terminator); +} + +test "OSC 99: empty metadata with payload" { + const testing = std.testing; + + var p: Parser = .init(null); + + const input = "99;;bobr"; + for (input) |ch| p.next(ch); + + const cmd = p.end('\x1b').?.*; + try testing.expect(cmd == .kitty_desktop_notification); + try testing.expectEqualStrings("", cmd.kitty_desktop_notification.metadata); + try testing.expectEqualStrings("bobr", cmd.kitty_desktop_notification.payload); + try testing.expectEqualDeep(Option.a.default(), cmd.kitty_desktop_notification.readOption(.a)); + try testing.expect(cmd.kitty_desktop_notification.readOption(.c) == false); + try testing.expect(cmd.kitty_desktop_notification.readOption(.d) == true); + try testing.expect(cmd.kitty_desktop_notification.readOption(.e) == false); + try testing.expect(cmd.kitty_desktop_notification.readOption(.f) == null); + try testing.expect(cmd.kitty_desktop_notification.readOption(.g) == null); + try testing.expect(cmd.kitty_desktop_notification.readOption(.i) == null); + { + var it = cmd.kitty_desktop_notification.readOption(.n); + try testing.expect(it.next() == null); + } + try testing.expectEqual(.always, cmd.kitty_desktop_notification.readOption(.o)); + try testing.expectEqual(.title, cmd.kitty_desktop_notification.readOption(.p)); + try testing.expectEqualStrings("system", cmd.kitty_desktop_notification.readOption(.s)); + { + var it = cmd.kitty_desktop_notification.readOption(.t); + try testing.expect(it.next() == null); + } + try testing.expectEqual(.normal, cmd.kitty_desktop_notification.readOption(.u)); + try testing.expectEqual(-1, cmd.kitty_desktop_notification.readOption(.w)); +} + +test "OSC 99: payload size limits" { + const testing = std.testing; + const cases = [_]struct { + metadata: []const u8, + payload_size: usize, + valid: bool, + }{ + .{ .metadata = "", .payload_size = MAX_PLAIN_PAYLOAD_BYTES, .valid = true }, + .{ .metadata = "", .payload_size = MAX_PLAIN_PAYLOAD_BYTES + 1, .valid = false }, + .{ .metadata = "e=1", .payload_size = MAX_ENCODED_PAYLOAD_BYTES, .valid = true }, + .{ .metadata = "e=1", .payload_size = MAX_ENCODED_PAYLOAD_BYTES + 1, .valid = false }, + }; + + for (cases) |case| { + var p: Parser = .init(testing.allocator); + defer p.deinit(); + + for ("99;") |ch| p.next(ch); + for (case.metadata) |ch| p.next(ch); + p.next(';'); + for (0..case.payload_size) |_| p.next('a'); + + try testing.expectEqual(case.valid, p.end('\x1b') != null); + } +} + +test "OSC 99: unknown prefix does not hide dotted identifier" { + const testing = std.testing; + + var p: Parser = .init(null); + const input = "99;invalid=wrong:i=org.ghostty;payload"; + for (input) |ch| p.next(ch); + + const cmd = p.end('\x1b').?.*; + try testing.expectEqualStrings( + "org.ghostty", + cmd.kitty_desktop_notification.readOption(.i).?, + ); +} + +test "OSC 99: single parameter i" { + const testing = std.testing; + + var p: Parser = .init(null); + + const input = "99;i=bobr;payload"; + for (input) |ch| p.next(ch); + + const cmd = p.end('\x1b').?.*; + try testing.expect(cmd == .kitty_desktop_notification); + try testing.expectEqualStrings("bobr", cmd.kitty_desktop_notification.readOption(.i).?); + try testing.expectEqualStrings("payload", cmd.kitty_desktop_notification.payload); +} + +test "OSC 99: repeated parameter i" { + const testing = std.testing; + + var p: Parser = .init(null); + + const input = "99;i=bobr:i=foobar;payload"; + for (input) |ch| p.next(ch); + + const cmd = p.end('\x1b').?.*; + try testing.expect(cmd == .kitty_desktop_notification); + try testing.expectEqualStrings("bobr", cmd.kitty_desktop_notification.readOption(.i).?); + try testing.expectEqualStrings("payload", cmd.kitty_desktop_notification.payload); +} + +test "OSC 99: multiple types" { + const testing = std.testing; + + var p: Parser = .init(null); + + const input = "99;t=mail: t = chat : t = alert ;notification"; + for (input) |ch| p.next(ch); + + const cmd = p.end('\x1b').?.*; + try testing.expect(cmd == .kitty_desktop_notification); + try testing.expectEqualStrings("notification", cmd.kitty_desktop_notification.payload); + var it = cmd.kitty_desktop_notification.readOption(.t); + try testing.expectEqualStrings("mail", it.next().?); + try testing.expectEqualStrings("chat", it.next().?); + try testing.expectEqualStrings("alert", it.next().?); + try testing.expect(it.next() == null); +} + +test "OSC 99: a 1" { + const testing = std.testing; + + var p: Parser = .init(null); + + const input = "99;a=report,focus;foobar"; + for (input) |ch| p.next(ch); + + const cmd = p.end('\x1b').?.*; + try testing.expect(cmd == .kitty_desktop_notification); + try testing.expectEqualStrings("foobar", cmd.kitty_desktop_notification.payload); + try testing.expectEqualDeep(Action{ .report = true, .focus = true }, cmd.kitty_desktop_notification.readOption(.a)); +} + +test "OSC 99: a 2" { + const testing = std.testing; + + var p: Parser = .init(null); + + const input = "99;a=report,-focus;foobar"; + for (input) |ch| p.next(ch); + + const cmd = p.end('\x1b').?.*; + try testing.expect(cmd == .kitty_desktop_notification); + try testing.expectEqualStrings("foobar", cmd.kitty_desktop_notification.payload); + try testing.expectEqualDeep(Action{ .report = true, .focus = false }, cmd.kitty_desktop_notification.readOption(.a)); +} + +test "OSC 99: a 3" { + const testing = std.testing; + + var p: Parser = .init(null); + + const input = "99;a=-report,focus;foobar"; + for (input) |ch| p.next(ch); + + const cmd = p.end('\x1b').?.*; + try testing.expect(cmd == .kitty_desktop_notification); + try testing.expectEqualStrings("foobar", cmd.kitty_desktop_notification.payload); + try testing.expectEqualDeep(Action{ .report = false, .focus = true }, cmd.kitty_desktop_notification.readOption(.a)); +} + +test "OSC 99: a 4" { + const testing = std.testing; + + var p: Parser = .init(null); + + const input = "99;a=-report,-focus;foobar"; + for (input) |ch| p.next(ch); + + const cmd = p.end('\x1b').?.*; + try testing.expect(cmd == .kitty_desktop_notification); + try testing.expectEqualStrings("foobar", cmd.kitty_desktop_notification.payload); + try testing.expectEqualDeep(Action{ .report = false, .focus = false }, cmd.kitty_desktop_notification.readOption(.a)); +} + +test "OSC 99: c 1" { + const testing = std.testing; + + var p: Parser = .init(null); + + const input = "99;c=0;foobar"; + for (input) |ch| p.next(ch); + + const cmd = p.end('\x1b').?.*; + try testing.expect(cmd == .kitty_desktop_notification); + try testing.expectEqualStrings("foobar", cmd.kitty_desktop_notification.payload); + try testing.expect(cmd.kitty_desktop_notification.readOption(.c) == false); +} + +test "OSC 99: c 2" { + const testing = std.testing; + + var p: Parser = .init(null); + + const input = "99;c=1;foobar"; + for (input) |ch| p.next(ch); + + const cmd = p.end('\x1b').?.*; + try testing.expect(cmd == .kitty_desktop_notification); + try testing.expectEqualStrings("foobar", cmd.kitty_desktop_notification.payload); + try testing.expect(cmd.kitty_desktop_notification.readOption(.c) == true); +} + +test "OSC 99: c 3" { + const testing = std.testing; + + var p: Parser = .init(null); + + const input = "99;c=bobr;foobar"; + for (input) |ch| p.next(ch); + + const cmd = p.end('\x1b').?.*; + try testing.expect(cmd == .kitty_desktop_notification); + try testing.expectEqualStrings("foobar", cmd.kitty_desktop_notification.payload); + try testing.expect(cmd.kitty_desktop_notification.readOption(.c) == false); +} + +test "OSC 99: d 1" { + const testing = std.testing; + + var p: Parser = .init(null); + + const input = "99;d=0;foobar"; + for (input) |ch| p.next(ch); + + const cmd = p.end('\x1b').?.*; + try testing.expect(cmd == .kitty_desktop_notification); + try testing.expectEqualStrings("foobar", cmd.kitty_desktop_notification.payload); + try testing.expect(cmd.kitty_desktop_notification.readOption(.d) == false); +} + +test "OSC 99: d 2" { + const testing = std.testing; + + var p: Parser = .init(null); + + const input = "99;d=1;foobar"; + for (input) |ch| p.next(ch); + + const cmd = p.end('\x1b').?.*; + try testing.expect(cmd == .kitty_desktop_notification); + try testing.expectEqualStrings("foobar", cmd.kitty_desktop_notification.payload); + try testing.expect(cmd.kitty_desktop_notification.readOption(.d) == true); +} + +test "OSC 99: d 3" { + const testing = std.testing; + + var p: Parser = .init(null); + + const input = "99;d=bobr;foobar"; + for (input) |ch| p.next(ch); + + const cmd = p.end('\x1b').?.*; + try testing.expect(cmd == .kitty_desktop_notification); + try testing.expectEqualStrings("foobar", cmd.kitty_desktop_notification.payload); + try testing.expect(cmd.kitty_desktop_notification.readOption(.d) == true); +} + +test "OSC 99: e 1" { + const testing = std.testing; + + var p: Parser = .init(null); + + const input = "99;e=0;foobar"; + for (input) |ch| p.next(ch); + + const cmd = p.end('\x1b').?.*; + try testing.expect(cmd == .kitty_desktop_notification); + try testing.expectEqualStrings("foobar", cmd.kitty_desktop_notification.payload); + try testing.expect(cmd.kitty_desktop_notification.readOption(.e) == false); +} + +test "OSC 99: e 2" { + const testing = std.testing; + + var p: Parser = .init(null); + + const input = "99;e=1;foobar"; + for (input) |ch| p.next(ch); + + const cmd = p.end('\x1b').?.*; + try testing.expect(cmd == .kitty_desktop_notification); + try testing.expectEqualStrings("foobar", cmd.kitty_desktop_notification.payload); + try testing.expect(cmd.kitty_desktop_notification.readOption(.e) == true); +} + +test "OSC 99: e 3" { + const testing = std.testing; + + var p: Parser = .init(null); + + const input = "99;e=bobr;foobar"; + for (input) |ch| p.next(ch); + + const cmd = p.end('\x1b').?.*; + try testing.expect(cmd == .kitty_desktop_notification); + try testing.expectEqualStrings("foobar", cmd.kitty_desktop_notification.payload); + try testing.expect(cmd.kitty_desktop_notification.readOption(.e) == false); +} + +test "OSC 99: f 1" { + const testing = std.testing; + + var p: Parser = .init(null); + + const input = "99;f=R2hvc3R0eQ==;foobar"; + for (input) |ch| p.next(ch); + + const cmd = p.end('\x1b').?.*; + try testing.expect(cmd == .kitty_desktop_notification); + try testing.expectEqualStrings("foobar", cmd.kitty_desktop_notification.payload); + try testing.expectEqualStrings("R2hvc3R0eQ==", cmd.kitty_desktop_notification.readOption(.f).?); +} + +test "OSC 99: f 2" { + const testing = std.testing; + + var p: Parser = .init(null); + + const input = "99;c=0:f= R2hvc3R0eQ== ;foobar"; + for (input) |ch| p.next(ch); + + const cmd = p.end('\x1b').?.*; + try testing.expect(cmd == .kitty_desktop_notification); + try testing.expectEqualStrings("foobar", cmd.kitty_desktop_notification.payload); + try testing.expectEqualStrings("R2hvc3R0eQ==", cmd.kitty_desktop_notification.readOption(.f).?); +} + +test "OSC 99: g 1" { + const testing = std.testing; + + var p: Parser = .init(null); + + const input = "99;c=0:g=7f8a9129-a35d-4e9f-8043-ce2700e15e2c;foobar"; + for (input) |ch| p.next(ch); + + const cmd = p.end('\x1b').?.*; + try testing.expect(cmd == .kitty_desktop_notification); + try testing.expectEqualStrings("foobar", cmd.kitty_desktop_notification.payload); + try testing.expectEqualStrings("7f8a9129-a35d-4e9f-8043-ce2700e15e2c", cmd.kitty_desktop_notification.readOption(.g).?); +} + +test "OSC 99: g 2" { + const testing = std.testing; + + var p: Parser = .init(null); + + const input = "99;c=0:g=aaa*bbb;foobar"; + for (input) |ch| p.next(ch); + + const cmd = p.end('\x1b').?.*; + try testing.expect(cmd == .kitty_desktop_notification); + try testing.expectEqualStrings("foobar", cmd.kitty_desktop_notification.payload); + try testing.expect(cmd.kitty_desktop_notification.readOption(.g) == null); +} + +test "OSC 99: i 1" { + const testing = std.testing; + + var p: Parser = .init(null); + + const input = "99;;foobar"; + for (input) |ch| p.next(ch); + + const cmd = p.end('\x1b').?.*; + try testing.expect(cmd == .kitty_desktop_notification); + try testing.expectEqualStrings("foobar", cmd.kitty_desktop_notification.payload); + try testing.expect(cmd.kitty_desktop_notification.readOption(.i) == null); +} + +test "OSC 99: i 2" { + const testing = std.testing; + + var p: Parser = .init(null); + + const input = "99;i=bobr;foobar"; + for (input) |ch| p.next(ch); + + const cmd = p.end('\x1b').?.*; + try testing.expect(cmd == .kitty_desktop_notification); + try testing.expectEqualStrings("foobar", cmd.kitty_desktop_notification.payload); + try testing.expectEqualStrings("bobr", cmd.kitty_desktop_notification.readOption(.i).?); +} + +test "OSC 99: i 3" { + const testing = std.testing; + + var p: Parser = .init(null); + + const input = "99;i=;foobar"; + for (input) |ch| p.next(ch); + + const cmd = p.end('\x1b').?.*; + try testing.expect(cmd == .kitty_desktop_notification); + try testing.expectEqualStrings("foobar", cmd.kitty_desktop_notification.payload); + try testing.expectEqualStrings("", cmd.kitty_desktop_notification.readOption(.i).?); +} + +test "OSC 99: i 4" { + const testing = std.testing; + + var p: Parser = .init(null); + + const input = "99;i= :;foobar"; + for (input) |ch| p.next(ch); + + const cmd = p.end('\x1b').?.*; + try testing.expect(cmd == .kitty_desktop_notification); + try testing.expectEqualStrings("foobar", cmd.kitty_desktop_notification.payload); + try testing.expectEqualStrings("", cmd.kitty_desktop_notification.readOption(.i).?); +} + +test "OSC 99: i 5" { + const testing = std.testing; + + var p: Parser = .init(null); + + const input = "99;i= bobr ;foobar"; + for (input) |ch| p.next(ch); + + const cmd = p.end('\x1b').?.*; + try testing.expect(cmd == .kitty_desktop_notification); + try testing.expectEqualStrings("foobar", cmd.kitty_desktop_notification.payload); + try testing.expectEqualStrings("bobr", cmd.kitty_desktop_notification.readOption(.i).?); +} + +test "OSC 99: i 6" { + const testing = std.testing; + + var p: Parser = .init(null); + + const input = "99;i= bobr : i=kurwa ;foobar"; + for (input) |ch| p.next(ch); + + const cmd = p.end('\x1b').?.*; + try testing.expect(cmd == .kitty_desktop_notification); + try testing.expectEqualStrings("foobar", cmd.kitty_desktop_notification.payload); + try testing.expectEqualStrings("bobr", cmd.kitty_desktop_notification.readOption(.i).?); +} + +test "OSC 99: n 1" { + const testing = std.testing; + + var p: Parser = .init(null); + + const input = "99;n=R2hvc3R0eQ==;foobar"; + for (input) |ch| p.next(ch); + + const cmd = p.end('\x1b').?.*; + try testing.expect(cmd == .kitty_desktop_notification); + try testing.expectEqualStrings("foobar", cmd.kitty_desktop_notification.payload); + var it = cmd.kitty_desktop_notification.readOption(.n); + try testing.expectEqualStrings("R2hvc3R0eQ==", it.next().?); + try testing.expect(it.next() == null); +} + +test "OSC 99: n 2" { + const testing = std.testing; + + var p: Parser = .init(null); + + const input = "99;n=R2hvc3R0eQ==:n=R2hvc3R0eQ==;foobar"; + for (input) |ch| p.next(ch); + + const cmd = p.end('\x1b').?.*; + try testing.expect(cmd == .kitty_desktop_notification); + try testing.expectEqualStrings("foobar", cmd.kitty_desktop_notification.payload); + var it = cmd.kitty_desktop_notification.readOption(.n); + try testing.expectEqualStrings("R2hvc3R0eQ==", it.next().?); + try testing.expectEqualStrings("R2hvc3R0eQ==", it.next().?); + try testing.expect(it.next() == null); +} + +test "OSC 99: o 1" { + const testing = std.testing; + + var p: Parser = .init(null); + + const input = "99;o= ;foobar"; + for (input) |ch| p.next(ch); + + const cmd = p.end('\x1b').?.*; + try testing.expect(cmd == .kitty_desktop_notification); + try testing.expectEqualStrings("foobar", cmd.kitty_desktop_notification.payload); + try testing.expectEqual(.always, cmd.kitty_desktop_notification.readOption(.o)); +} + +test "OSC 99: o 2" { + const testing = std.testing; + + var p: Parser = .init(null); + + const input = "99;o=always;foobar"; + for (input) |ch| p.next(ch); + + const cmd = p.end('\x1b').?.*; + try testing.expect(cmd == .kitty_desktop_notification); + try testing.expectEqualStrings("foobar", cmd.kitty_desktop_notification.payload); + try testing.expectEqual(.always, cmd.kitty_desktop_notification.readOption(.o)); +} + +test "OSC 99: o 3" { + const testing = std.testing; + + var p: Parser = .init(null); + + const input = "99;o=unfocused;foobar"; + for (input) |ch| p.next(ch); + + const cmd = p.end('\x1b').?.*; + try testing.expect(cmd == .kitty_desktop_notification); + try testing.expectEqualStrings("foobar", cmd.kitty_desktop_notification.payload); + try testing.expectEqual(.unfocused, cmd.kitty_desktop_notification.readOption(.o)); +} + +test "OSC 99: o 4" { + const testing = std.testing; + + var p: Parser = .init(null); + + const input = "99;o=invisible;foobar"; + for (input) |ch| p.next(ch); + + const cmd = p.end('\x1b').?.*; + try testing.expect(cmd == .kitty_desktop_notification); + try testing.expectEqualStrings("foobar", cmd.kitty_desktop_notification.payload); + try testing.expectEqual(.invisible, cmd.kitty_desktop_notification.readOption(.o)); +} + +test "OSC 99: o 5" { + const testing = std.testing; + + var p: Parser = .init(null); + + const input = "99;o=bobr;foobar"; + for (input) |ch| p.next(ch); + + const cmd = p.end('\x1b').?.*; + try testing.expect(cmd == .kitty_desktop_notification); + try testing.expectEqualStrings("foobar", cmd.kitty_desktop_notification.payload); + try testing.expectEqual(.always, cmd.kitty_desktop_notification.readOption(.o)); +} + +test "OSC 99: p 1" { + const testing = std.testing; + + var p: Parser = .init(null); + + const input = "99;p=alive;foobar"; + for (input) |ch| p.next(ch); + + const cmd = p.end('\x1b').?.*; + try testing.expect(cmd == .kitty_desktop_notification); + try testing.expectEqualStrings("foobar", cmd.kitty_desktop_notification.payload); + try testing.expectEqual(.alive, cmd.kitty_desktop_notification.readOption(.p)); +} + +test "OSC 99: p 2" { + const testing = std.testing; + + var p: Parser = .init(null); + + const input = "99;p=body;foobar"; + for (input) |ch| p.next(ch); + + const cmd = p.end('\x1b').?.*; + try testing.expect(cmd == .kitty_desktop_notification); + try testing.expectEqualStrings("foobar", cmd.kitty_desktop_notification.payload); + try testing.expectEqual(.body, cmd.kitty_desktop_notification.readOption(.p)); +} + +test "OSC 99: p 3" { + const testing = std.testing; + + var p: Parser = .init(null); + + const input = "99;p=buttons;foobar"; + for (input) |ch| p.next(ch); + + const cmd = p.end('\x1b').?.*; + try testing.expect(cmd == .kitty_desktop_notification); + try testing.expectEqualStrings("foobar", cmd.kitty_desktop_notification.payload); + try testing.expectEqual(.buttons, cmd.kitty_desktop_notification.readOption(.p)); +} + +test "OSC 99: p 4" { + const testing = std.testing; + + var p: Parser = .init(null); + + const input = "99;p=close;foobar"; + for (input) |ch| p.next(ch); + + const cmd = p.end('\x1b').?.*; + try testing.expect(cmd == .kitty_desktop_notification); + try testing.expectEqualStrings("foobar", cmd.kitty_desktop_notification.payload); + try testing.expectEqual(.close, cmd.kitty_desktop_notification.readOption(.p)); +} + +test "OSC 99: p 5" { + const testing = std.testing; + + var p: Parser = .init(null); + + const input = "99;p=icon;foobar"; + for (input) |ch| p.next(ch); + + const cmd = p.end('\x1b').?.*; + try testing.expect(cmd == .kitty_desktop_notification); + try testing.expectEqualStrings("foobar", cmd.kitty_desktop_notification.payload); + try testing.expectEqual(.icon, cmd.kitty_desktop_notification.readOption(.p)); +} + +test "OSC 99: p 6" { + const testing = std.testing; + + var p: Parser = .init(null); + + const input = "99;p=?;foobar"; + for (input) |ch| p.next(ch); + + const cmd = p.end('\x1b').?.*; + try testing.expect(cmd == .kitty_desktop_notification); + try testing.expectEqualStrings("foobar", cmd.kitty_desktop_notification.payload); + try testing.expectEqual(.query, cmd.kitty_desktop_notification.readOption(.p)); +} + +test "OSC 99: p 7" { + const testing = std.testing; + + var p: Parser = .init(null); + + const input = "99;p=title;foobar"; + for (input) |ch| p.next(ch); + + const cmd = p.end('\x1b').?.*; + try testing.expect(cmd == .kitty_desktop_notification); + try testing.expectEqualStrings("foobar", cmd.kitty_desktop_notification.payload); + try testing.expectEqual(.title, cmd.kitty_desktop_notification.readOption(.p)); +} + +test "OSC 99: p 8" { + const testing = std.testing; + + var p: Parser = .init(null); + + const input = "99;p=query;foobar"; + for (input) |ch| p.next(ch); + + const cmd = p.end('\x1b').?.*; + try testing.expect(cmd == .kitty_desktop_notification); + try testing.expectEqualStrings("foobar", cmd.kitty_desktop_notification.payload); + try testing.expectEqual(.unknown, cmd.kitty_desktop_notification.readOption(.p)); +} + +test "OSC 99: p 9" { + const testing = std.testing; + + var p: Parser = .init(null); + + const input = "99;p=bobr;foobar"; + for (input) |ch| p.next(ch); + + const cmd = p.end('\x1b').?.*; + try testing.expect(cmd == .kitty_desktop_notification); + try testing.expectEqualStrings("foobar", cmd.kitty_desktop_notification.payload); + try testing.expectEqual(.unknown, cmd.kitty_desktop_notification.readOption(.p)); +} + +test "OSC 99: s 1" { + const testing = std.testing; + + var p: Parser = .init(null); + + const input = "99;s=R2hvc3R0eQ==;foobar"; + for (input) |ch| p.next(ch); + + const cmd = p.end('\x1b').?.*; + try testing.expect(cmd == .kitty_desktop_notification); + try testing.expectEqualStrings("foobar", cmd.kitty_desktop_notification.payload); + try testing.expectEqualStrings("R2hvc3R0eQ==", cmd.kitty_desktop_notification.readOption(.s)); +} + +test "OSC 99: t 1" { + const testing = std.testing; + + var p: Parser = .init(null); + + const input = "99;t=R2hvc3R0eQ==;foobar"; + for (input) |ch| p.next(ch); + + const cmd = p.end('\x1b').?.*; + try testing.expect(cmd == .kitty_desktop_notification); + try testing.expectEqualStrings("foobar", cmd.kitty_desktop_notification.payload); + var it = cmd.kitty_desktop_notification.readOption(.t); + try testing.expectEqualStrings("R2hvc3R0eQ==", it.next().?); + try testing.expect(it.next() == null); +} + +test "OSC 99: t 2" { + const testing = std.testing; + + var p: Parser = .init(null); + + const input = "99;t=R2hvc3R0eQ==:t=R2hvc3R0eQ==;foobar"; + for (input) |ch| p.next(ch); + + const cmd = p.end('\x1b').?.*; + try testing.expect(cmd == .kitty_desktop_notification); + try testing.expectEqualStrings("foobar", cmd.kitty_desktop_notification.payload); + var it = cmd.kitty_desktop_notification.readOption(.t); + try testing.expectEqualStrings("R2hvc3R0eQ==", it.next().?); + try testing.expectEqualStrings("R2hvc3R0eQ==", it.next().?); + try testing.expect(it.next() == null); +} + +test "OSC 99: u 1" { + const testing = std.testing; + + var p: Parser = .init(null); + + const input = "99;u=0;foobar"; + for (input) |ch| p.next(ch); + + const cmd = p.end('\x1b').?.*; + try testing.expect(cmd == .kitty_desktop_notification); + try testing.expectEqualStrings("foobar", cmd.kitty_desktop_notification.payload); + try testing.expectEqual(.low, cmd.kitty_desktop_notification.readOption(.u)); +} + +test "OSC 99: u 2" { + const testing = std.testing; + + var p: Parser = .init(null); + + const input = "99;u=1;foobar"; + for (input) |ch| p.next(ch); + + const cmd = p.end('\x1b').?.*; + try testing.expect(cmd == .kitty_desktop_notification); + try testing.expectEqualStrings("foobar", cmd.kitty_desktop_notification.payload); + try testing.expectEqual(.normal, cmd.kitty_desktop_notification.readOption(.u)); +} + +test "OSC 99: u 3" { + const testing = std.testing; + + var p: Parser = .init(null); + + const input = "99;u=2;foobar"; + for (input) |ch| p.next(ch); + + const cmd = p.end('\x1b').?.*; + try testing.expect(cmd == .kitty_desktop_notification); + try testing.expectEqualStrings("foobar", cmd.kitty_desktop_notification.payload); + try testing.expectEqual(.high, cmd.kitty_desktop_notification.readOption(.u)); +} + +test "OSC 99: u 4" { + const testing = std.testing; + + var p: Parser = .init(null); + + const input = "99;u=bobr;foobar"; + for (input) |ch| p.next(ch); + + const cmd = p.end('\x1b').?.*; + try testing.expect(cmd == .kitty_desktop_notification); + try testing.expectEqualStrings("foobar", cmd.kitty_desktop_notification.payload); + try testing.expectEqual(.normal, cmd.kitty_desktop_notification.readOption(.u)); +} + +test "OSC 99: w 1" { + const testing = std.testing; + + var p: Parser = .init(null); + + const input = "99;w=0;foobar"; + for (input) |ch| p.next(ch); + + const cmd = p.end('\x1b').?.*; + try testing.expect(cmd == .kitty_desktop_notification); + try testing.expectEqualStrings("foobar", cmd.kitty_desktop_notification.payload); + try testing.expectEqual(0, cmd.kitty_desktop_notification.readOption(.w)); +} + +test "OSC 99: w 2" { + const testing = std.testing; + + var p: Parser = .init(null); + + const input = "99;w=-1;foobar"; + for (input) |ch| p.next(ch); + + const cmd = p.end('\x1b').?.*; + try testing.expect(cmd == .kitty_desktop_notification); + try testing.expectEqualStrings("foobar", cmd.kitty_desktop_notification.payload); + try testing.expectEqual(-1, cmd.kitty_desktop_notification.readOption(.w)); +} + +test "OSC 99: w 3" { + const testing = std.testing; + + var p: Parser = .init(null); + + const input = "99;w=-42;foobar"; + for (input) |ch| p.next(ch); + + const cmd = p.end('\x1b').?.*; + try testing.expect(cmd == .kitty_desktop_notification); + try testing.expectEqualStrings("foobar", cmd.kitty_desktop_notification.payload); + try testing.expectEqual(-1, cmd.kitty_desktop_notification.readOption(.w)); +} + +test "OSC 99: w 4" { + const testing = std.testing; + + var p: Parser = .init(null); + + const input = "99;w=4294967296;foobar"; + for (input) |ch| p.next(ch); + + const cmd = p.end('\x1b').?.*; + try testing.expect(cmd == .kitty_desktop_notification); + try testing.expectEqualStrings("foobar", cmd.kitty_desktop_notification.payload); + try testing.expectEqual(-1, cmd.kitty_desktop_notification.readOption(.w)); +} diff --git a/vendor/libghostty-vt/src/terminal/osc/parsers/kitty_dnd_protocol.zig b/vendor/libghostty-vt/src/terminal/osc/parsers/kitty_dnd_protocol.zig index 35d2390..839e396 100644 --- a/vendor/libghostty-vt/src/terminal/osc/parsers/kitty_dnd_protocol.zig +++ b/vendor/libghostty-vt/src/terminal/osc/parsers/kitty_dnd_protocol.zig @@ -1,5 +1,12 @@ //! Kitty's drag and drop protocol (OSC 72) -//! Specification: https://sw.kovidgoyal.net/kitty/drag-and-drop-protocol/ +//! +//! This only captures the raw metadata and payload for the OSC. The +//! actual protocol grammar (metadata keys, event types, chunking) is +//! implemented in `terminal/kitty/dnd.zig` and its submodules, since the +//! protocol requires stateful handling that doesn't belong in the +//! stateless OSC parser. +//! +//! Specification: https://sw.kovidgoyal.net/kitty/dnd-protocol/ const std = @import("std"); @@ -9,143 +16,52 @@ const Parser = @import("../../osc.zig").Parser; const Command = @import("../../osc.zig").Command; const Terminator = @import("../../osc.zig").Terminator; -const log = std.log.scoped(.kitty_dnd_protocol); - pub const OSC = struct { - /// The raw metadata that was received. Parse individual values with `readOption`. + /// The raw metadata that was received. Parse with + /// `kitty.dnd.Metadata.parse`. metadata: []const u8, - /// The raw payload. Its meaning and encoding depend on the event type (`t` key). + + /// The raw payload. Its meaning and encoding depend on the event + /// type (`t` metadata key). Null when the OSC had no `;` after the + /// metadata; an empty payload is distinct from no payload. payload: ?[]const u8, + /// The terminator used for this OSC, so any response can match it. terminator: Terminator, - pub fn readOption(self: OSC, comptime key: Option) ?key.Type() { - return key.read(self.metadata); - } -}; - -/// Values for the `t` (event type) metadata key. -pub const EventType = enum { - /// ('a') Terminal registers itself as willing to accept drops. - accept_drops, - /// ('A') Terminal unregisters itself; drops should no longer be forwarded. - stop_accepting_drops, - /// ('m') Pointer is moving over the terminal while a drag is in progress. - /// Carries `x`/`y` cursor position; -1 signals the drag left the window. - drop_move, - /// ('M') Items were dropped onto the terminal. - /// Carries `x`/`y` drop position and `i` (multiplexer session ID). - drop_dropped, - /// ('r') Terminal requests data for a specific MIME type from the drag source. - /// Carries `i` (multiplexer session ID) and `y` (1-based MIME type index). - request_data, - /// ('R') Error response to a `request_data` event. - request_error, - /// ('o') Terminal offers data for an outgoing drag (drag-out from terminal). - offer_drag, - /// ('p') Drag source presents the actual payload for a previously requested MIME type. - /// Carries `i` (multiplexer session ID), `o` (operation), and `m` (chunking flag). - present_data, - /// ('P') Replace the current drag image with a new one. - /// Payload is the image data; `X`/`Y` carry image dimensions in pixels. - change_drag_image, - /// ('e') Notification of an event on an outgoing drag offer (e.g., accepted or rejected). - drag_offer_event, - /// ('E') Error on an outgoing drag offer. - drag_offer_error, - /// ('k') URI list data delivered as part of a drag or clipboard transfer. - uri_list_data, - /// ('q') Query terminal capabilities related to the drag-and-drop protocol. - query, + /// We don't currently support encoding this to C in any way. + pub const C = void; - pub fn init(str: []const u8) ?EventType { - if (str.len != 1) return null; - return switch (str[0]) { - 'a' => .accept_drops, - 'A' => .stop_accepting_drops, - 'm' => .drop_move, - 'M' => .drop_dropped, - 'r' => .request_data, - 'R' => .request_error, - 'o' => .offer_drag, - 'p' => .present_data, - 'P' => .change_drag_image, - 'e' => .drag_offer_event, - 'E' => .drag_offer_error, - 'k' => .uri_list_data, - 'q' => .query, - else => null, - }; + pub fn cval(_: OSC) C { + return {}; } }; -/// Metadata keys defined by the protocol. Keys are case-sensitive: `x` and `X` are distinct. -pub const Option = enum { - /// Event type. Maps to `EventType`; present in every OSC 72 sequence. - t, - /// Chunking flag. `0` = this is the final (or only) chunk; `1` = more chunks follow. - m, - /// Multiplexer session ID. Echoed back in responses so a terminal multiplexer - /// (e.g. tmux) can route data to the correct pane. - i, - /// Drop operation. `0` = reject, `1` = copy, `2` = move, `3` = copy or move. - o, - /// Cursor column in cell units (zero-based). -1 signals the drag has left the window. - x, - /// Cursor row in cell units (zero-based). Also used as a 1-based MIME type index - /// in some events (e.g. `request_data`). -1 signals the drag has left the window. - y, - /// Pixel offset from the left edge of the cell; also used as image width - /// (with `change_drag_image`) or as a symlink/directory marker. - X, - /// Pixel offset from the top edge of the cell; also used as image height - /// (with `change_drag_image`) or as a parent directory handle. - Y, - - pub fn Type(comptime key: Option) type { - return switch (key) { - .t => EventType, - // The spec uses 32-bit signed or unsigned; we standardize on - // i32 because the location keys legitimately take -1 (drag - // leaves the window) and other keys never exceed i32 range. - .m, .i, .o, .x, .y, .X, .Y => i32, - }; - } - - pub fn read(comptime key: Option, metadata: []const u8) ?key.Type() { - const name = @tagName(key); +pub fn parse(parser: *Parser, terminator_ch: ?u8) ?*Command { + assert(parser.state == .@"72"); - const value: []const u8 = value: { - var pos: usize = 0; - while (pos < metadata.len) { - while (pos < metadata.len and std.ascii.isWhitespace(metadata[pos])) pos += 1; - if (pos >= metadata.len) return null; + const cap = if (parser.capture) |*c| c else { + parser.state = .invalid; + return null; + }; - // Case-sensitive match: x and X must not be confused. - if (!std.mem.startsWith(u8, metadata[pos..], name)) { - pos = std.mem.indexOfScalarPos(u8, metadata, pos, ':') orelse return null; - pos += 1; - continue; - } - pos += name.len; + const data = cap.trailing(); - while (pos < metadata.len and std.ascii.isWhitespace(metadata[pos])) pos += 1; - if (pos >= metadata.len) return null; - if (metadata[pos] != '=') return null; + const metadata: []const u8, const payload: ?[]const u8 = result: { + const sep = std.mem.indexOfScalar(u8, data, ';') orelse break :result .{ data, null }; + break :result .{ data[0..sep], data[sep + 1 .. data.len] }; + }; - const end = std.mem.indexOfScalarPos(u8, metadata, pos, ':') orelse metadata.len; - const start = pos + 1; - break :value std.mem.trim(u8, metadata[start..end], &std.ascii.whitespace); - } - return null; - }; + parser.command = .{ + .kitty_dnd_protocol = .{ + .metadata = metadata, + .payload = payload, + .terminator = .init(terminator_ch), + }, + }; - return switch (key) { - .t => .init(value), - .m, .i, .o, .x, .y, .X, .Y => std.fmt.parseInt(i32, value, 10) catch null, - }; - } -}; + return &parser.command; +} test "OSC 72: metadata only, no payload" { const testing = std.testing; @@ -192,134 +108,19 @@ test "OSC 72: metadata and non-empty payload" { try testing.expectEqualStrings("text/plain text/uri-list", cmd.kitty_dnd_protocol.payload.?); } -test "OSC 72: readOption .t valid event types" { +test "OSC 72: empty metadata with payload" { const testing = std.testing; var p: Parser = .init(testing.allocator); defer p.deinit(); - const cases = .{ - .{ "72;t=a", EventType.accept_drops }, - .{ "72;t=A", EventType.stop_accepting_drops }, - .{ "72;t=m", EventType.drop_move }, - .{ "72;t=M", EventType.drop_dropped }, - .{ "72;t=r", EventType.request_data }, - .{ "72;t=R", EventType.request_error }, - .{ "72;t=o", EventType.offer_drag }, - .{ "72;t=p", EventType.present_data }, - .{ "72;t=P", EventType.change_drag_image }, - .{ "72;t=e", EventType.drag_offer_event }, - .{ "72;t=E", EventType.drag_offer_error }, - .{ "72;t=k", EventType.uri_list_data }, - .{ "72;t=q", EventType.query }, - }; - - inline for (cases) |case| { - p.deinit(); - p = .init(testing.allocator); - for (case[0]) |ch| p.next(ch); - const cmd = p.end('\x1b').?.*; - try testing.expect(cmd == .kitty_dnd_protocol); - try testing.expectEqual(case[1], cmd.kitty_dnd_protocol.readOption(.t).?); - } -} - -test "OSC 72: readOption .t unknown value returns null" { - const testing = std.testing; - - var p: Parser = .init(testing.allocator); - defer p.deinit(); - - const input = "72;t=z"; + const input = "72;;payload"; for (input) |ch| p.next(ch); const cmd = p.end('\x1b').?.*; try testing.expect(cmd == .kitty_dnd_protocol); - try testing.expect(cmd.kitty_dnd_protocol.readOption(.t) == null); -} - -test "OSC 72: readOption integer keys" { - const testing = std.testing; - - var p: Parser = .init(testing.allocator); - defer p.deinit(); - - const input = "72;t=m:i=3:x=10:y=5:X=320:Y=200:o=1:m=0"; - for (input) |ch| p.next(ch); - - const cmd = p.end('\x1b').?.*; - try testing.expect(cmd == .kitty_dnd_protocol); - try testing.expectEqual(@as(i32, 3), cmd.kitty_dnd_protocol.readOption(.i).?); - try testing.expectEqual(@as(i32, 10), cmd.kitty_dnd_protocol.readOption(.x).?); - try testing.expectEqual(@as(i32, 5), cmd.kitty_dnd_protocol.readOption(.y).?); - try testing.expectEqual(@as(i32, 320), cmd.kitty_dnd_protocol.readOption(.X).?); - try testing.expectEqual(@as(i32, 200), cmd.kitty_dnd_protocol.readOption(.Y).?); - try testing.expectEqual(@as(i32, 1), cmd.kitty_dnd_protocol.readOption(.o).?); - try testing.expectEqual(@as(i32, 0), cmd.kitty_dnd_protocol.readOption(.m).?); -} - -test "OSC 72: readOption negative sentinel (-1 for drag leave)" { - const testing = std.testing; - - var p: Parser = .init(testing.allocator); - defer p.deinit(); - - const input = "72;t=m:x=-1:y=-1"; - for (input) |ch| p.next(ch); - - const cmd = p.end('\x1b').?.*; - try testing.expect(cmd == .kitty_dnd_protocol); - try testing.expectEqual(@as(i32, -1), cmd.kitty_dnd_protocol.readOption(.x).?); - try testing.expectEqual(@as(i32, -1), cmd.kitty_dnd_protocol.readOption(.y).?); -} - -test "OSC 72: readOption case-sensitive key matching" { - const testing = std.testing; - - var p: Parser = .init(testing.allocator); - defer p.deinit(); - - // x=10 must not be returned when asking for .X - const input = "72;x=10:Y=200"; - for (input) |ch| p.next(ch); - - const cmd = p.end('\x1b').?.*; - try testing.expect(cmd == .kitty_dnd_protocol); - try testing.expectEqual(@as(i32, 10), cmd.kitty_dnd_protocol.readOption(.x).?); - try testing.expect(cmd.kitty_dnd_protocol.readOption(.X) == null); - try testing.expectEqual(@as(i32, 200), cmd.kitty_dnd_protocol.readOption(.Y).?); - try testing.expect(cmd.kitty_dnd_protocol.readOption(.y) == null); -} - -test "OSC 72: readOption absent key returns null" { - const testing = std.testing; - - var p: Parser = .init(testing.allocator); - defer p.deinit(); - - const input = "72;t=a"; - for (input) |ch| p.next(ch); - - const cmd = p.end('\x1b').?.*; - try testing.expect(cmd == .kitty_dnd_protocol); - try testing.expect(cmd.kitty_dnd_protocol.readOption(.i) == null); - try testing.expect(cmd.kitty_dnd_protocol.readOption(.x) == null); - try testing.expect(cmd.kitty_dnd_protocol.readOption(.X) == null); - try testing.expect(cmd.kitty_dnd_protocol.readOption(.m) == null); -} - -test "OSC 72: readOption malformed integer returns null" { - const testing = std.testing; - - var p: Parser = .init(testing.allocator); - defer p.deinit(); - - const input = "72;x=notanumber"; - for (input) |ch| p.next(ch); - - const cmd = p.end('\x1b').?.*; - try testing.expect(cmd == .kitty_dnd_protocol); - try testing.expect(cmd.kitty_dnd_protocol.readOption(.x) == null); + try testing.expectEqualStrings("", cmd.kitty_dnd_protocol.metadata); + try testing.expectEqualStrings("payload", cmd.kitty_dnd_protocol.payload.?); } test "OSC 72: BEL terminator recorded" { @@ -335,29 +136,3 @@ test "OSC 72: BEL terminator recorded" { try testing.expect(cmd == .kitty_dnd_protocol); try testing.expect(cmd.kitty_dnd_protocol.terminator == .bel); } - -pub fn parse(parser: *Parser, terminator_ch: ?u8) ?*Command { - assert(parser.state == .@"72"); - - const cap = if (parser.capture) |*c| c else { - parser.state = .invalid; - return null; - }; - - const data = cap.trailing(); - - const metadata: []const u8, const payload: ?[]const u8 = result: { - const sep = std.mem.indexOfScalar(u8, data, ';') orelse break :result .{ data, null }; - break :result .{ data[0..sep], data[sep + 1 .. data.len] }; - }; - - parser.command = .{ - .kitty_dnd_protocol = .{ - .metadata = metadata, - .payload = payload, - .terminator = .init(terminator_ch), - }, - }; - - return &parser.command; -} diff --git a/vendor/libghostty-vt/src/terminal/page.zig b/vendor/libghostty-vt/src/terminal/page.zig index c2814ae..de3cdf1 100644 --- a/vendor/libghostty-vt/src/terminal/page.zig +++ b/vendor/libghostty-vt/src/terminal/page.zig @@ -1,6 +1,7 @@ const std = @import("std"); const builtin = @import("builtin"); const build_options = @import("terminal_options"); +const lib = @import("../lib/main.zig"); const Allocator = std.mem.Allocator; const ArenaAllocator = std.heap.ArenaAllocator; const assert = @import("../quirks.zig").inlineAssert; @@ -1290,6 +1291,18 @@ pub const Page = struct { @memset(@as([]u64, @ptrCast(cells)), 0); } + /// Reset the given row to the default state: all cells zeroed and + /// all row metadata reset, as if the row was never used. This + /// reclaims memory used by graphemes, styles, etc. like clearCells. + /// + /// This must be used instead of clearCells whenever a row's storage + /// is recycled. Clearing the cells alone is not enough because row + /// metadata such as the wrap state and semantic prompt remain. + pub inline fn resetRow(self: *Page, row: *Row) void { + self.clearCells(row, 0, self.size.cols); + row.reset(); + } + /// Returns the hyperlink ID for the given cell. pub inline fn lookupHyperlink(self: *const Page, cell: *const Cell) ?hyperlink.Id { const cell_offset = getOffset(Cell, self.memory, cell); @@ -2040,6 +2053,26 @@ pub const Row = packed struct(u64) { // Ordered on purpose for likelihood. return self.styled or self.hyperlink or self.grapheme; } + + /// Reset all row metadata to the default state, preserving only + /// the cells offset, and mark the row dirty. This is a single + /// 8-byte store. + /// + /// This must be applied to any row whose storage is recycled as a + /// blank row or retired into unused page capacity, in addition to + /// clearing its cells (in either order; this doesn't touch cell + /// memory). See Page.resetRow, which does both, for details. This + /// exists separately for callers that clear the cells in a + /// specialized way (e.g. filling with a background-colored blank + /// cell rather than zeroing). + /// + /// Asserts that the row has no managed memory: releasing that is + /// the cell-clearing side's job and must happen while the flags + /// are still accurate. + pub inline fn reset(self: *Row) void { + assert(!self.managedMemory()); + self.* = .{ .cells = self.cells, .dirty = true }; + } }; /// A cell represents a single terminal grid cell. @@ -2144,6 +2177,51 @@ pub const Cell = packed struct(u64) { prompt = 2, }; + /// Metadata for the C representation. All physical bit offsets and + /// widths are reflected from Cell. + pub const CLayout = lib.Packed(Cell, .{ .fields = .{ + .content_tag = .{ .type_name = "GhosttyCellContentTag" }, + .content = .{ .encoding = .{ .tagged_union = lib.PackedTaggedUnion( + Cell, + .content, + .content_tag, + .{ .arms = .{ + .codepoint = .{ .codepoint = lib.Packed( + @FieldType(@FieldType(Cell, "content"), "codepoint"), + .{ .fields = .{ + .data = .{ .name = "codepoint" }, + ._pad = .{ .omit = true }, + } }, + ) }, + .codepoint_grapheme = .{ .codepoint = lib.Packed( + @FieldType(@FieldType(Cell, "content"), "codepoint"), + .{ .fields = .{ + .data = .{ .name = "codepoint" }, + ._pad = .{ .omit = true }, + } }, + ) }, + .bg_color_palette = .{ .color_palette = lib.Packed( + @FieldType(@FieldType(Cell, "content"), "color_palette"), + .{ .fields = .{ + .data = .{ + .name = "index", + .type_name = "GhosttyColorPaletteIndex", + }, + ._pad = .{ .omit = true }, + } }, + ) }, + .bg_color_rgb = .{ .color_rgb = lib.Packed( + @FieldType(@FieldType(Cell, "content"), "color_rgb"), + .{}, + ) }, + } }, + ) } }, + .style_id = .{ .type_name = "GhosttyStyleId" }, + .wide = .{ .type_name = "GhosttyCellWide" }, + .semantic_content = .{ .type_name = "GhosttyCellSemanticContent" }, + ._padding = .{ .omit = true }, + } }); + /// The backing integer of this packed struct. Prefer this over /// hardcoding the integer type so that code is resilient to the /// size changing. diff --git a/vendor/libghostty-vt/src/terminal/parse_table.zig b/vendor/libghostty-vt/src/terminal/parse_table.zig index 01bd569..fa074fa 100644 --- a/vendor/libghostty-vt/src/terminal/parse_table.zig +++ b/vendor/libghostty-vt/src/terminal/parse_table.zig @@ -206,6 +206,13 @@ fn genTable() Table { single(&result, 0x19, source, source, .ignore); range(&result, 0, 0x17, source, source, .ignore); range(&result, 0x1C, 0x1F, source, source, .ignore); + + // High bytes are ignored payload data, overriding the + // "anywhere" C1 transitions. See dcs_passthrough below for more. + // In dcs_ignore the additional concern is that a UTF-8 payload in + // an ignored DCS could otherwise begin a live sequence mid-string + // (e.g. 0x9B => csi_entry). + range(&result, 0x80, 0xFF, source, source, .ignore); } // dcs_param @@ -241,6 +248,21 @@ fn genTable() Table { range(&result, 0x1C, 0x1F, source, source, .put); range(&result, 0x20, 0x7E, source, source, .put); single(&result, 0x7F, source, source, .ignore); + + // High bytes are payload data, overriding the "anywhere" C1 + // transitions, matching how osc_string handles them below. + // Ghostty is UTF-8 only, and DCS payloads carry UTF-8 text + // (e.g. tmux control mode pane content): without this, a + // UTF-8 continuation byte in the C1 range terminates or + // corrupts the string and 0xA0-0xFF are silently dropped. + // + // This includes 0x9C (8-bit ST) on purpose: a raw 0x9C is + // indistinguishable from a UTF-8 continuation byte ("Ü" is + // 0xC3 0x9C), and Ghostty doesn't honor 8-bit C1 controls in + // the ground state either (they go through UTF-8 decoding). + // DCS strings terminate via 7-bit ST (ESC \) and abort via + // CAN/SUB, which are unaffected here. + range(&result, 0x80, 0xFF, source, source, .put); } // csi_param @@ -386,3 +408,41 @@ test { // that it succeeds in creation. _ = table; } + +test "dcs_passthrough: high bytes are payload data" { + // Bytes 0x80-0xFF within a DCS string are payload data, not C1 + // controls. This includes 0x9C (8-bit ST): a raw 0x9C is + // indistinguishable from a UTF-8 continuation byte (e.g. "Ü" is + // 0xC3 0x9C) and Ghostty doesn't support 8-bit C1 controls + // anywhere else. + for (0x80..0x100) |c| { + const entry = table[c][@intFromEnum(State.dcs_passthrough)]; + try std.testing.expectEqual(State.dcs_passthrough, entry.state); + try std.testing.expectEqual(Action.put, entry.action); + } +} + +test "dcs_ignore: high bytes are ignored payload data" { + // Same as dcs_passthrough: a UTF-8 payload inside an ignored DCS + // must not trigger "anywhere" C1 transitions (e.g. 0x9B beginning + // a CSI mid-string). + for (0x80..0x100) |c| { + const entry = table[c][@intFromEnum(State.dcs_ignore)]; + try std.testing.expectEqual(State.dcs_ignore, entry.state); + try std.testing.expectEqual(Action.ignore, entry.action); + } +} + +test "dcs_passthrough: ESC, CAN, and SUB still exit" { + // 7-bit ST (ESC \) is the DCS terminator and CAN/SUB abort, so + // these must continue to leave dcs_passthrough. dcs_unhook is + // emitted by the parser on any transition out of dcs_passthrough. + const esc = table[0x1B][@intFromEnum(State.dcs_passthrough)]; + try std.testing.expectEqual(State.escape, esc.state); + + const can = table[0x18][@intFromEnum(State.dcs_passthrough)]; + try std.testing.expectEqual(State.ground, can.state); + + const sub = table[0x1A][@intFromEnum(State.dcs_passthrough)]; + try std.testing.expectEqual(State.ground, sub.state); +} diff --git a/vendor/libghostty-vt/src/terminal/paste.zig b/vendor/libghostty-vt/src/terminal/paste.zig new file mode 100644 index 0000000..05e088e --- /dev/null +++ b/vendor/libghostty-vt/src/terminal/paste.zig @@ -0,0 +1,247 @@ +//! Pasting into a terminal. +//! +//! This is the single place that turns "the user pasted" into bytes for +//! the pty, applying the terminal's current state: +//! +//! * Mode 5522 (Kitty clipboard protocol paste events) set, a +//! user-initiated clipboard paste, and the embedder able to serve +//! the program's follow-up clipboard read: send a paste event +//! listing the clipboard's MIME types with a fresh one-time password +//! and record a one-time read grant for it. No data is read. +//! * Otherwise: write the first text representation, with unsafe bytes +//! replaced (xterm behavior), framed per mode 2004 (bracketed paste) +//! or with newlines converted to carriage returns if not. +//! +//! The precedence (5522 event, else 2004 framing, else plain) and the +//! safety rule live only here so every embedder of the terminal shares +//! one implementation. + +const std = @import("std"); +const Allocator = std.mem.Allocator; + +const clipboard = @import("clipboard.zig"); +const kitty_clipboard = @import("kitty/clipboard.zig"); +const input_paste = @import("../input/paste.zig"); +const Terminal = @import("Terminal.zig"); + +/// Why a paste happened. Only clipboard pastes may become paste +/// events, which is why only they carry a location: it's meaningless +/// for text insertion. +/// +/// C: GhosttyPasteSource, flattened next to the location since C has +/// no tagged unions. +pub const Source = union(enum) { + /// The user pasted from a clipboard: keybind, menu, middle click. + /// The payload is the clipboard the contents came from. + clipboard: clipboard.Location, + + /// Text inserted some other way: IME commit, drag and drop, + /// scripted input. + text, +}; + +/// A paste of clipboard contents into the terminal. What actually gets +/// written depends on terminal state; see `paste`. +pub const Request = struct { + /// Why this paste happened, and for a clipboard paste, from which + /// clipboard. Only a user-initiated clipboard paste may become a + /// paste event; text insertion always writes text. + source: Source = .{ .clipboard = .standard }, + + /// The representations available. Borrowed only during the + /// duration of the paste function call. + contents: Contents, + + /// Write data that could inject commands (see `paste`). The usual + /// flow is to call with false, confirm with the user on + /// error.UnsafePaste, and call again with true. + allow_unsafe: bool = false, +}; + +/// The representations available for a paste, in the embedder's +/// preferred order. +pub const Contents = union(enum) { + /// Every representation already in memory. For text the embedder + /// holds anyway (an IME commit, dropped text) or small clipboards. + memory: []const clipboard.Content, + + /// Representations read on demand, so nothing is loaded that isn't + /// pasted. This is the form for a real clipboard, whose non-text + /// items may be huge. + reader: Reader, + + /// The on-demand form: the MIME types available plus the reader + /// that produces the data of any one of them. + pub const Reader = struct { + /// The MIME types available, in preferred order. + mimes: []const []const u8, + + /// Produces the data of any entry of `mimes`, which is passed + /// through to it as is. A paste reads at most once: the text + /// representation being pasted, never anything else and never + /// anything for a paste event. There is no requirement across + /// paste calls, so a source that changes between an unsafe + /// refusal and the embedder's confirmed retry simply pastes + /// its current contents. + read: clipboard.MimeReader, + }; + + /// The number of representations. + pub fn len(self: Contents) usize { + return switch (self) { + .memory => |v| v.len, + .reader => |v| v.mimes.len, + }; + } + + /// The MIME type of representation `index`. + pub fn mime(self: Contents, index: usize) []const u8 { + return switch (self) { + .memory => |v| v[index].mime, + .reader => |v| v.mimes[index], + }; + } +}; + +/// What a caller supplies to `paste`: the terminal state the decision +/// depends on, the session state an event records into, and the sink. +pub const Context = struct { + /// The terminal whose modes decide the encoding. + terminal: *const Terminal, + + /// Allocator for transient state: the buffered read and a paste + /// event's grant. Must be the allocator `kitty_clipboard.grants` + /// is freed with. + alloc: Allocator, + + /// Receives the encoded text in chunks, or the event. Must be + /// buffered (the encoder works in its buffer) and is not flushed + /// by `paste`; the caller flushes once it returns. + writer: *std.Io.Writer, + + /// Kitty clipboard protocol session state, or null if the embedder + /// does not serve Kitty clipboard reads (`clipboard.Read`). A + /// paste event is that protocol: it is only useful if the + /// program's follow-up read can be answered, since otherwise the + /// read would be refused and the user's paste would vanish. With + /// null, `paste` always writes text. + kitty_clipboard: ?KittyClipboard, + + pub const KittyClipboard = struct { + /// Session grants. A paste event records its one-time password + /// here so the program's follow-up read is served without a + /// prompt. + grants: *kitty_clipboard.Grants, + + /// Secure entropy for one-time passwords. See generateOtp for + /// why there is no fallback when this has none. + io: std.Io, + }; +}; + +pub const Error = Allocator.Error || + std.Io.RandomSecureError || + clipboard.MimeReader.Error || + error{ + /// The data could inject commands and allow_unsafe was false. + UnsafePaste, + }; + +/// Paste into the terminal, applying the terminal's current state as +/// described in the module docs. Returns true if anything was written +/// to `ctx.writer`: the encoded text or a paste event. False means +/// there was nothing to paste (no non-empty text representation). +/// +/// The safety rule for a text paste (`input.paste.isSafeWith`): a +/// bracketed paste is unsafe only if it contains the bracket terminator +/// (CSI 201~); an unbracketed paste is unsafe if it contains a newline +/// or the terminator. Embedders wanting a stricter rule check +/// `input.paste.isSafe` themselves before calling. A paste event never +/// puts the data on the input stream, so the rule doesn't apply to it. +/// +/// The contents are read at most once per call and buffered whole, so +/// the source needs no stability across reads. Nothing reaches the +/// writer until the read completed and the text passed the rule: +/// every error writes nothing, and a failed event records no grant. +pub fn paste(ctx: Context, req: Request) Error!bool { + // If the source is a clipboard and mode 5522 is enabled and + // the caller can handle kitty events, then do a kitty event. + if (req.source == .clipboard and + ctx.terminal.modes.get(.kitty_paste_events)) + { + if (ctx.kitty_clipboard) |kitty| { + try pasteKittyEvent(ctx, kitty, req); + return true; + } + } + + // For non-Kitty paste events we can only accept text content. + const index: usize = for (0..req.contents.len()) |i| { + if (clipboard.isTextMime(req.contents.mime(i))) break i; + } else return false; + + const opts: input_paste.Options = .fromTerminal(ctx.terminal); + + // In-memory contents are used directly to avoid a double copy but + // reader-based contents are read into memory so we can do the unsafe + // scan. + var aw: std.Io.Writer.Allocating = .init(ctx.alloc); + defer aw.deinit(); + const text: []const u8 = switch (req.contents) { + .memory => |v| v[index].data, + .reader => |v| text: { + v.read.read(v.mimes[index], &aw.writer) catch |err| switch (err) { + // An allocating writer only fails to allocate. + error.WriteFailed => return error.OutOfMemory, + error.ReadFailed => |e| return e, + }; + break :text aw.writer.buffered(); + }, + }; + if (text.len == 0) return false; + if (!req.allow_unsafe and !input_paste.isSafeWith( + text, + opts, + )) return error.UnsafePaste; + + // The text is copied exactly once per chunk, into the writer, + // where the encoder strips and converts it in place. + try input_paste.encodeWriter(ctx.writer, text, opts); + return true; +} + +fn pasteKittyEvent( + ctx: Context, + kitty: Context.KittyClipboard, + req: Request, +) Error!void { + const otp = try kitty_clipboard.generateOtp(kitty.io); + + // Every representation is listed, never read. The listing is + // bounded; a clipboard with more types than that is not a thing. + var mimes_buf: [kitty_clipboard.max_listing_mimes][]const u8 = undefined; + const mimes_len = @min(req.contents.len(), mimes_buf.len); + for (mimes_buf[0..mimes_len], 0..) |*mime, i| mime.* = req.contents.mime(i); + + // The grant is recorded before the event can reach the program, + // and revoked if the event fails to be written so a failure never + // leaves a grant for an event that was never sent. Using a + // one-time grant consumes it, which is the revocation. + try kitty.grants.grant(ctx.alloc, &otp, .read, true); + errdefer _ = kitty.grants.use(ctx.alloc, &otp, .read); + + try (kitty_clipboard.PasteEvent{ + // The protocol only distinguishes the clipboard from the + // primary selection, so both non-standard locations report as + // primary. Only a clipboard paste gets here; see `paste`. + .primary = req.source.clipboard != .standard, + .pw = &otp, + .available = mimes_buf[0..mimes_len], + }).encode(ctx.writer); +} + +test { + // The behavior is tested end to end through the stream handler + // (stream_terminal.zig), which is the primary caller. + std.testing.refAllDecls(@This()); +} diff --git a/vendor/libghostty-vt/src/terminal/point.zig b/vendor/libghostty-vt/src/terminal/point.zig index f6bf25c..5937576 100644 --- a/vendor/libghostty-vt/src/terminal/point.zig +++ b/vendor/libghostty-vt/src/terminal/point.zig @@ -71,7 +71,15 @@ pub const Point = union(Tag) { @This(), // Padding: largest variant is Coordinate (u16 + u32 = 6 bytes). // Use [2]u64 (16 bytes) for future expansion. - [2]u64, + .{ + .padding = [2]u64, + .field_renames = .{ + .active = "coordinate", + .viewport = "coordinate", + .screen = "coordinate", + .history = "coordinate", + }, + }, ); pub const C = c_union.C; pub const CValue = c_union.CValue; diff --git a/vendor/libghostty-vt/src/terminal/ref_counted_set.zig b/vendor/libghostty-vt/src/terminal/ref_counted_set.zig index 97fa870..a8e64fc 100644 --- a/vendor/libghostty-vt/src/terminal/ref_counted_set.zig +++ b/vendor/libghostty-vt/src/terminal/ref_counted_set.zig @@ -317,6 +317,19 @@ pub fn RefCountedSet( if (id < self.next_id) { if (items[id].meta.ref == 0) { + // Requested ID is dead, but if the value exists not under + // another ID then ref count that and increase the ID. + if (self.lookupContext(base, value, ctx)) |existing_id| { + // Notify the context that the value is "deleted" + // because we're reusing the existing value in the + // set. This allows callers to clean up any + // resources associated with the value. + if (comptime @hasDecl(Context, "deleted")) ctx.deleted(value); + + items[existing_id].meta.ref += 1; + return existing_id; + } + // See comment in `addContext` for details. if (self.psl_stats[self.psl_stats.len - 1] > 0) { @branchHint(.cold); @@ -324,8 +337,7 @@ pub fn RefCountedSet( } self.deleteItem(base, id, ctx); - - const added_id = self.upsert(base, value, id, ctx); + const added_id = self.insert(base, value, id, ctx); items[added_id].meta.ref += 1; @@ -589,24 +601,6 @@ pub fn RefCountedSet( return null; } - /// Find the provided value in the hash table, or add a new item - /// for it if not present. If a new item is added, `new_id` will - /// be used as the ID. If an existing item is found, the `new_id` - /// is ignored and the existing item's ID is returned. - fn upsert(self: *Self, base: anytype, value: T, new_id: Id, ctx: Context) Id { - // If the item already exists, return it. - if (self.lookupContext(base, value, ctx)) |id| { - // Notify the context that the value is "deleted" because - // we're reusing the existing value in the set. This allows - // callers to clean up any resources associated with the value. - if (comptime @hasDecl(Context, "deleted")) ctx.deleted(value); - - return id; - } - - return self.insert(base, value, new_id, ctx); - } - /// Insert the given value into the hash table with the given ID. /// /// If runtime safety is enabled, asserts that @@ -764,6 +758,65 @@ pub fn RefCountedSet( }; } +test "addWithId dead id resolving to an existing value" { + const alloc = testing.allocator; + const TestSet = RefCountedSet( + u32, + u16, + u16, + struct { + pub fn hash(_: *const @This(), value: u32) u64 { + return std.hash.int(value); + } + + pub fn eql(_: *const @This(), a: u32, b: u32) bool { + return a == b; + } + }, + ); + + const layout: TestSet.Layout = .init(8); + const buf = try alloc.alignedAlloc( + u8, + TestSet.base_align, + layout.total_size, + ); + defer alloc.free(buf); + + var set: TestSet = .init(.init(buf), layout, .{}); + + // Create a dead item between two live ones so that it can't + // be reaped by the trim loop in `add`, then release it. This + // mirrors a page style set after a styled run is erased. + const live = try set.add(buf, 11); + const released = try set.add(buf, 22); + const last = try set.add(buf, 33); + set.release(buf, released); + try testing.expectEqual(@as(usize, 2), set.count()); + + // Request the dead ID for a value that is already live under + // a different ID: we must resolve to the existing item and + // take a reference, without changing the living count. + const resolved = (try set.addWithId(buf, 11, released)).?; + try testing.expectEqual(live, resolved); + try testing.expectEqual(@as(u16, 2), set.refCount(buf, live)); + try testing.expectEqual(@as(usize, 2), set.count()); + + // The living count must agree with the iterator. + var it = set.iterator(buf); + var iterated: usize = 0; + while (it.next()) |_| iterated += 1; + try testing.expectEqual(set.count(), iterated); + + // The dead slot is still reusable for a value that is not + // in the set: the requested ID must be used (null return). + try testing.expectEqual(@as(?u16, null), try set.addWithId(buf, 44, released)); + try testing.expectEqual(@as(u16, 1), set.refCount(buf, released)); + try testing.expectEqual(@as(usize, 3), set.count()); + + _ = last; +} + test "iterator visits live entries in ID order" { const alloc = testing.allocator; const TestSet = RefCountedSet( diff --git a/vendor/libghostty-vt/src/terminal/render.zig b/vendor/libghostty-vt/src/terminal/render.zig index 867cd62..6bcddb2 100644 --- a/vendor/libghostty-vt/src/terminal/render.zig +++ b/vendor/libghostty-vt/src/terminal/render.zig @@ -235,6 +235,21 @@ pub const RenderState = struct { /// The highlights within this row. highlights: std.ArrayList(Highlight), + + /// The style runs applied to this row's per-cell style data + /// by the last `endUpdate` that touched it. This is what lets + /// `endUpdate` skip the (comparatively large) per-cell style + /// fill when a rebuilt row produced identical runs, which is + /// the common case: text changes far more often than styling. + /// + /// The invariant is that this always describes the current + /// contents of the cell style data for the covered ranges. It + /// is cleared whenever the cell storage is reallocated. + /// + /// This uses the general allocator (NOT the row arena) + /// because it must survive row rebuilds. `endUpdate` cannot + /// allocate, so `beginUpdate` reserves the capacity. + applied_styles: std.ArrayList(StyleRun), }; pub const Highlight = struct { @@ -304,10 +319,12 @@ pub const RenderState = struct { for ( self.row_data.items(.arena), self.row_data.items(.cells), - ) |state, *cells| { + self.row_data.items(.applied_styles), + ) |state, *cells, *applied| { var arena: ArenaAllocator = state.promote(alloc); arena.deinit(); cells.deinit(alloc); + applied.deinit(alloc); } self.row_data.deinit(alloc); self.pending_styles.deinit(alloc); @@ -464,6 +481,7 @@ pub const RenderState = struct { .dirty = true, .selection = null, .highlights = .empty, + .applied_styles = .empty, }); } } else { @@ -471,10 +489,12 @@ pub const RenderState = struct { for ( row_data.items(.arena)[self.rows..], row_data.items(.cells)[self.rows..], - ) |state, *cell| { + row_data.items(.applied_styles)[self.rows..], + ) |state, *cell, *applied| { var arena: ArenaAllocator = state.promote(alloc); arena.deinit(); cell.deinit(alloc); + applied.deinit(alloc); } self.row_data.shrinkRetainingCapacity(self.rows); } @@ -490,6 +510,7 @@ pub const RenderState = struct { const row_sels = row_data.items(.selection); const row_highlights = row_data.items(.highlights); const row_dirties = row_data.items(.dirty); + const row_applied = row_data.items(.applied_styles); // If we're redrawing then every row will be rebuilt, superseding // any pending style runs from prior updates. Clearing also @@ -512,6 +533,7 @@ pub const RenderState = struct { .highlights = row_highlights, .dirties = row_dirties, .pending_styles = &self.pending_styles, + .applied_styles = row_applied, }; var y: usize = 0; var any_dirty: bool = false; @@ -735,22 +757,113 @@ pub const RenderState = struct { const row_data = self.row_data.slice(); const row_cells = row_data.items(.cells); - for (self.pending_styles.items) |run| { + const row_applied = row_data.items(.applied_styles); + + // Process the pending runs one row segment at a time. All the + // runs for a row are appended contiguously by a single + // beginUpdate, so a segment boundary is simply a change in y. + const runs = self.pending_styles.items; + var i: usize = 0; + while (i < runs.len) { + const y = runs[i].y; + var j = i + 1; + while (j < runs.len and runs[j].y == y) j += 1; + const segment = runs[i..j]; + i = j; + // Defensive: the row data may have changed shape if the // caller violated ordering (e.g. an error path skipped an // endUpdate between updates). Any update that changes // dimensions clears the pending list (redraw), so this // should never actually trigger, but the cost is trivial. - if (run.y >= row_cells.len) continue; - const styles = row_cells[run.y].slice().items(.style); - const end = @min(run.end, styles.len); - const start = @min(run.start, end); + if (y >= row_cells.len) continue; + + // If the segment matches the runs already denormalized + // into this row's cell data then the per-cell styles are + // already correct and the (comparatively large) fill can + // be skipped entirely. This is the common case: rebuilt + // rows usually keep their styling (only the text + // changed), and full redraws of an unchanged screen keep + // both. + const applied = &row_applied[y]; + if (runsEql(applied.items, segment)) continue; + + for (segment) |run| { + const styles = row_cells[run.y].slice().items(.style); + const end = @min(run.end, styles.len); + const start = @min(run.start, end); + + fillStyles(styles[start..end], run.style); + } - @memset(styles[start..end], run.style); + // Record what we applied so the next rebuild of this row + // can skip the fill. beginUpdate reserved the capacity + // (we cannot allocate here); if it doesn't fit (e.g. + // segments merged across multiple begins without an end) + // leave the cache empty, which never matches and simply + // means the next rebuild applies its runs. + applied.clearRetainingCapacity(); + if (applied.capacity >= segment.len) { + applied.appendSliceAssumeCapacity(segment); + } } self.pending_styles.clearRetainingCapacity(); } + /// Mark all render-state data as consumed by the renderer. + /// + /// This clears both the global dirty state and every per-row dirty flag. + /// Callers that only consume part of a frame should clear the two layers + /// individually instead. + pub fn clean(self: *RenderState) void { + self.dirty = .false; + @memset(self.row_data.items(.dirty), false); + } + + /// Fill a slice of styles with one value. + /// + /// This is equivalent to `@memset(dst, value)` but manually vectorized: + /// `@memset` with a struct value lowers to a per-element field copy + /// that reloads the source at every iteration because LLVM cannot + /// prove the destination doesn't alias it. And, Zig 0.16 disables + /// auto-vectorization due to an LLVM bug. + /// + /// This complexity is justified by accounting for ~10% of the endUpdate + /// times under heavily styled cases. + fn fillStyles(dst: []Style, value: Style) void { + // Each element is written as two overlapping 16-byte vector + // stores held in registers, which requires 16 <= size <= 32. + const elem_size = @sizeOf(Style); + comptime assert(elem_size >= 16 and elem_size <= 32); + + const V = @Vector(16, u8); + const src: *align(@alignOf(Style)) const [elem_size]u8 = @ptrCast(&value); + const lo = @as(*align(4) const V, @ptrCast(src[0..16])).*; + const hi = @as(*align(1) const V, @ptrCast(src[elem_size - 16 ..][0..16])).*; + + const dst_bytes = std.mem.sliceAsBytes(dst); + var off: usize = 0; + for (0..dst.len) |_| { + @as(*align(1) V, @ptrCast(dst_bytes[off..][0..16])).* = lo; + @as(*align(1) V, @ptrCast(dst_bytes[off + elem_size - 16 ..][0..16])).* = hi; + off += elem_size; + } + } + + /// Returns true if the two style run lists denormalize to + /// identical per-cell style data. This is a semantic comparison + /// (styles are compared field-wise, never by bytes, since padding + /// is undefined). + fn runsEql(a: []const StyleRun, b: []const StyleRun) bool { + if (a.len != b.len) return false; + for (a, b) |ar, br| { + if (ar.start != br.start or + ar.end != br.end or + !ar.style.eql(br.style)) return false; + } + return true; + } + /// Update the highlights in the render state from the given flattened /// highlights. Because this uses flattened highlights, it does not require /// reading from the terminal state so it should be done outside of @@ -1022,6 +1135,7 @@ const RowBuilder = struct { highlights: []std.ArrayList(RenderState.Highlight), dirties: []bool, pending_styles: *std.ArrayList(RenderState.StyleRun), + applied_styles: []std.ArrayList(RenderState.StyleRun), fn row( b: *const RowBuilder, @@ -1058,7 +1172,14 @@ const RowBuilder = struct { // Our per-row arena is only used for temporary allocations // pertaining to cells directly (e.g. graphemes, hyperlinks). const cells: *std.MultiArrayList(RenderState.Cell) = &b.cells[vy]; - if (cells.len != b.cols) try cells.resize(b.alloc, b.cols); + if (cells.len != b.cols) { + // The cell storage (including the per-cell style data) is + // being reallocated, so the applied style cache no longer + // describes it. Clear it before the resize so an error + // can't leave it stale. + b.applied_styles[vy].clearRetainingCapacity(); + try cells.resize(b.alloc, b.cols); + } // We always copy our raw cell data. In the case we have no // managed memory, we can skip setting any other fields. @@ -1077,6 +1198,7 @@ const RowBuilder = struct { const arena_alloc = arena.allocator(); const cells_grapheme = cells_slice.items(.grapheme); const n = page_cells.len; + const runs_start = b.pending_styles.items.len; var x: usize = 0; scan: while (x < n) { // Skip runs of plain cells a group at a time. Cells that @@ -1210,6 +1332,15 @@ const RowBuilder = struct { }, } } + + // Reserve the applied style cache capacity for the runs we + // appended so that endUpdate (which cannot allocate) is able + // to record what it applies. See Row.applied_styles. + const runs_added = b.pending_styles.items.len - runs_start; + if (runs_added > 0) try b.applied_styles[vy].ensureTotalCapacity( + b.alloc, + runs_added, + ); } }; @@ -1601,6 +1732,62 @@ test "begin and end update" { } } +test "endUpdate skips unchanged style runs" { + const testing = std.testing; + const alloc = testing.allocator; + const io = testing.io; + + var t = try Terminal.init(io, alloc, .{ + .cols = 10, + .rows = 3, + }); + defer t.deinit(alloc); + + var s = t.vtStream(); + defer s.deinit(); + s.nextSlice("\x1b[1mAB"); // Bold + + var state: RenderState = .empty; + defer state.deinit(alloc); + try state.update(alloc, &t); + + // The applied cache should record the bold run for row 0. + { + const row_data = state.row_data.slice(); + const applied = row_data.items(.applied_styles); + try testing.expectEqual(1, applied[0].items.len); + try testing.expect(applied[0].items[0].style.flags.bold); + try testing.expect(state.row_data.items(.cells)[0].get(0).style.flags.bold); + } + + // Rewrite the text without changing the styling: the row is + // rebuilt, the run matches the cache, and the styles must remain + // correct (the fill is skipped internally). + s.nextSlice("\x1b[1;1H\x1b[1mXY"); + try state.update(alloc, &t); + { + const cells = &state.row_data.items(.cells)[0]; + try testing.expectEqual('X', cells.get(0).raw.codepoint()); + try testing.expect(cells.get(0).style.flags.bold); + try testing.expect(cells.get(1).style.flags.bold); + } + + // Change the styling: the cache mismatches and the new styles + // must be applied and recorded. + s.nextSlice("\x1b[1;1H\x1b[0;3mZW"); // Italic + try state.update(alloc, &t); + { + const cells = &state.row_data.items(.cells)[0]; + try testing.expectEqual('Z', cells.get(0).raw.codepoint()); + try testing.expect(!cells.get(0).style.flags.bold); + try testing.expect(cells.get(0).style.flags.italic); + + const applied = state.row_data.items(.applied_styles); + try testing.expectEqual(1, applied[0].items.len); + try testing.expect(applied[0].items[0].style.flags.italic); + } +} + test "bg color cells" { const testing = std.testing; const alloc = testing.allocator; diff --git a/vendor/libghostty-vt/src/terminal/search.zig b/vendor/libghostty-vt/src/terminal/search.zig index e69603c..6913df2 100644 --- a/vendor/libghostty-vt/src/terminal/search.zig +++ b/vendor/libghostty-vt/src/terminal/search.zig @@ -5,6 +5,7 @@ pub const options = @import("terminal_options"); pub const Active = @import("search/active.zig").ActiveSearch; pub const PageList = @import("search/pagelist.zig").PageListSearch; pub const Screen = @import("search/screen.zig").ScreenSearch; +pub const Terminal = @import("search/terminal.zig").TerminalSearch; pub const Viewport = @import("search/viewport.zig").ViewportSearch; // The search thread is not available in libghostty due to the xev dep diff --git a/vendor/libghostty-vt/src/terminal/search/Thread.zig b/vendor/libghostty-vt/src/terminal/search/Thread.zig index ecc7273..0b78132 100644 --- a/vendor/libghostty-vt/src/terminal/search/Thread.zig +++ b/vendor/libghostty-vt/src/terminal/search/Thread.zig @@ -12,7 +12,6 @@ const std = @import("std"); const builtin = @import("builtin"); const testing = std.testing; const Allocator = std.mem.Allocator; -const ArenaAllocator = std.heap.ArenaAllocator; const Mutex = std.Io.Mutex; const global = @import("../../global.zig"); const xev = global.xev; @@ -23,11 +22,10 @@ const point = @import("../point.zig"); const FlattenedHighlight = @import("../highlight.zig").Flattened; const UntrackedHighlight = @import("../highlight.zig").Untracked; const ScreenSet = @import("../ScreenSet.zig"); -const Selection = @import("../Selection.zig"); const Terminal = @import("../Terminal.zig"); const ScreenSearch = @import("screen.zig").ScreenSearch; -const ViewportSearch = @import("viewport.zig").ViewportSearch; +const TerminalSearch = @import("terminal.zig").TerminalSearch; const log = std.log.scoped(.search_thread); @@ -72,7 +70,10 @@ refresh_active: bool = false, /// Search state. Starts as null and is populated when a search is /// started (a needle is given). -search: ?Search = null, +search: ?TerminalSearch = null, + +/// The last-notified values used to diff search state into events. +notify_state: NotifyState = .{}, /// The options used to initialize this thread. opts: Options, @@ -190,7 +191,7 @@ fn threadMain_(self: *Thread) !void { return; } - const s: *Search = if (self.search) |*s| s else { + const s: *TerminalSearch = if (self.search) |*s| s else { // If we're not actively searching, we can block the loop // until it does some work. try self.loop.run(.once); @@ -201,11 +202,7 @@ fn threadMain_(self: *Thread) !void { // notifications. Even if the search is complete, there may be // notifications to send. if (self.opts.event_cb) |cb| { - s.notify( - self.alloc, - cb, - self.opts.event_userdata, - ); + self.notify(s, cb, self.opts.event_userdata); } if (s.isComplete()) { @@ -228,7 +225,7 @@ fn threadMain_(self: *Thread) !void { .blocked => { self.opts.mutex.lockUncancelable(global.io()); defer self.opts.mutex.unlock(global.io()); - s.feed(self.alloc, self.opts.terminal); + self.feedLocked(s); }, } @@ -241,6 +238,21 @@ fn threadMain_(self: *Thread) !void { } } +/// Feed the search from the terminal state. The terminal mutex must +/// be held by the caller. +fn feedLocked(self: *Thread, s: *TerminalSearch) void { + const t = self.opts.terminal; + + // See the `search_viewport_dirty` flag on the terminal to know + // what exactly this is for. But, if this is set, we know the renderer + // found the viewport/active area dirty, so the active area must be + // re-scanned. + const active_dirty = t.flags.search_viewport_dirty; + t.flags.search_viewport_dirty = false; + + s.feed(t, active_dirty); +} + /// Drain the mailbox. fn drainMailbox(self: *Thread) !void { while (self.mailbox.pop(global.io())) |message| { @@ -261,51 +273,12 @@ fn select(self: *Thread, sel: ScreenSearch.Select) !void { self.opts.mutex.lockUncancelable(global.io()); defer self.opts.mutex.unlock(global.io()); - // A screen can be removed or replaced between refresh ticks. Reconcile - // while holding the terminal lock before touching any ScreenSearch pins. - s.feed(self.alloc, self.opts.terminal); - const screen_search = s.screens.getPtr(s.last_screen.key) orelse return; - - // Make the selection. Ignore the result because we don't - // care if the selection didn't change. - _ = try screen_search.select(sel); - - // Grab our match if we have one. If we don't have a selection - // then we do nothing. - const flattened = screen_search.selectedMatch() orelse return; - - // No matter what we reset our selected match cache. This will - // trigger a callback which will trigger the renderer to wake up - // so it can be notified the screen scrolled. - s.last_screen.selected = null; - - // Grab the current screen and see if this match is visible within - // the viewport already. If it is, we do nothing. - const screen = self.opts.terminal.screens.get( - s.last_screen.key, - ) orelse return; - - // Grab the viewport. Viewports and selections are usually small - // so this check isn't very expensive, despite appearing O(N^2), - // both Ns are usually equal to 1. - var it = screen.pages.pageIterator( - .right_down, - .{ .viewport = .{} }, - null, - ); - const hl_chunks = flattened.chunks.slice(); - while (it.next()) |chunk| { - for (0..hl_chunks.len) |i| { - const hl_chunk = hl_chunks.get(i); - if (chunk.overlaps(.{ - .node = hl_chunk.node, - .start = hl_chunk.start, - .end = hl_chunk.end, - })) return; - } + if (try s.select(self.opts.terminal, sel, .if_needed)) { + // No matter what we reset our selected match cache. This will + // trigger a callback which will trigger the renderer to wake up + // so it can be notified the screen scrolled. + self.notify_state.selected = null; } - - screen.scroll(.{ .pin = flattened.startPin() }); } /// Change the search term to the given value. @@ -315,7 +288,7 @@ fn changeNeedle(self: *Thread, needle: []const u8) !void { // Stop the previous search if (self.search) |*s| { // If our search is unchanged, do nothing. - if (std.ascii.eqlIgnoreCase(s.viewport.needle(), needle)) return; + if (std.ascii.eqlIgnoreCase(s.needle(), needle)) return; { self.opts.mutex.lockUncancelable(global.io()); @@ -323,6 +296,7 @@ fn changeNeedle(self: *Thread, needle: []const u8) !void { s.deinit(self.opts.terminal); } self.search = null; + self.notify_state = .{}; // When the search changes then we need to emit that it stopped. if (self.opts.event_cb) |cb| { @@ -346,11 +320,98 @@ fn changeNeedle(self: *Thread, needle: []const u8) !void { // Setup our search state. self.search = try .init(self.alloc, needle); + self.notify_state = .{}; // We need to grab the terminal lock and do an initial feed. self.opts.mutex.lockUncancelable(global.io()); defer self.opts.mutex.unlock(global.io()); - self.search.?.feed(self.alloc, self.opts.terminal); + self.feedLocked(&self.search.?); +} + +/// Notify about any changes to the search state by diffing against +/// the last-notified values. +/// +/// This doesn't require any locking as it only reads search-owned state. +fn notify( + self: *Thread, + s: *TerminalSearch, + cb: EventCallback, + ud: ?*anyopaque, +) void { + const state = &self.notify_state; + + // A screen switch makes all previously notified per-screen state + // stale, so reset it to force recalculations and notifications. + if (state.key != s.active_key) { + state.key = s.active_key; + state.total = null; + state.selected = null; + } + + const screen_search = s.activeScreenSearch() orelse return; + + // Check our total match data + const total = screen_search.matchesLen(); + if (total != state.total) { + log.debug("notifying total matches={}", .{total}); + state.total = total; + cb(.{ .total_matches = total }, ud); + } + + // Check our viewport matches. If they're stale, we collect them + // now. We do this as part of notify and not tick because the + // viewport search is very fast and doesn't require ticked progress + // or feeds. + if (s.stale_viewport_matches) viewport: { + const matches = s.viewportMatches() catch |err| { + log.warn("error collecting viewport matches err={}", .{err}); + break :viewport; + }; + + log.debug("notifying viewport matches len={}", .{matches.len}); + cb(.{ .viewport_matches = matches }, ud); + } + + // Check our last selected match data. + if (screen_search.selected) |m| match: { + const flattened = screen_search.selectedMatch() orelse break :match; + const untracked = flattened.untracked(); + if (state.selected) |prev| { + if (prev.idx == m.idx and prev.highlight.eql(untracked)) { + // Same selection, don't update it. + break :match; + } + } + + // New selection, notify! + state.selected = .{ + .idx = m.idx, + .highlight = untracked, + }; + + log.debug("notifying selection updated idx={}", .{m.idx}); + cb( + .{ .selected_match = .{ + .idx = m.idx, + .highlight = flattened, + } }, + ud, + ); + } else if (state.selected != null) { + log.debug("notifying selection cleared", .{}); + state.selected = null; + cb( + .{ .selected_match = null }, + ud, + ); + } + + // Send our complete notification if we just completed. + if (!state.complete and s.isComplete()) { + log.debug("notifying search complete", .{}); + state.complete = true; + cb(.complete, ud); + } } fn startRefreshTimer(self: *Thread) void { @@ -426,7 +487,7 @@ fn refreshCallback( if (self.search) |*s| { self.opts.mutex.lockUncancelable(global.io()); defer self.opts.mutex.unlock(global.io()); - s.feed(self.alloc, self.opts.terminal); + self.feedLocked(s); } // Only continue if we're still active @@ -502,352 +563,25 @@ pub const Event = union(enum) { }; }; -/// Search state. -const Search = struct { - /// Active viewport search for the active screen. - viewport: ViewportSearch, +/// The last-notified values used by `notify` to diff search state +/// into events. +const NotifyState = struct { + /// The active screen key the state below was captured against. + key: ScreenSet.Key = .primary, - /// The searchers for all the screens. - screens: std.EnumMap(ScreenSet.Key, ScreenSearch), + /// Last notified total matches count + total: ?usize = null, - /// ScreenSet generations captured when each searcher was initialized. - /// Allocators may reuse a destroyed Screen address, so pointer equality - /// alone cannot distinguish replacement screens from stale handles. - screen_generations: std.EnumMap(ScreenSet.Key, usize), - - /// All state related to screen switches, collected so that when - /// we switch screens it makes everything related stale, too. - last_screen: ScreenState, + /// Last notified selected match + selected: ?Selected = null, /// True if we sent the complete notification yet. - last_complete: bool, - - /// The last viewport matches we found. - stale_viewport_matches: bool, + complete: bool = false, - const ScreenState = struct { - /// Last active screen key - key: ScreenSet.Key, - - /// Last notified total matches count - total: ?usize = null, - - /// Last notified selected match index - selected: ?SelectedMatch = null, - - const SelectedMatch = struct { - idx: usize, - highlight: UntrackedHighlight, - }; - }; - - pub fn init( - alloc: Allocator, - needle: []const u8, - ) Allocator.Error!Search { - var vp: ViewportSearch = try .init(alloc, needle); - errdefer vp.deinit(); - - // We use dirty tracking for active area changes. Start with it - // dirty so the first change is re-searched. - vp.active_dirty = true; - - return .{ - .viewport = vp, - .screens = .init(.{}), - .screen_generations = .init(.{}), - .last_screen = .{ .key = .primary }, - .last_complete = false, - .stale_viewport_matches = true, - }; - } - - pub fn deinit(self: *Search, t: *Terminal) void { - self.viewport.deinit(); - var it = self.screens.iterator(); - while (it.next()) |entry| { - if (self.screenIsValid(&t.screens, entry.key, entry.value)) { - entry.value.deinit(); - } else { - entry.value.deinitScreenInvalid(); - } - } - } - - fn screenIsValid( - self: *const Search, - screens: *const ScreenSet, - key: ScreenSet.Key, - search: *const ScreenSearch, - ) bool { - const generation = self.screen_generations.get(key) orelse return false; - if (generation != screens.generation(key)) return false; - const actual = screens.get(key) orelse return false; - return actual == search.screen; - } - - /// Returns true if all searches on all screens are complete. - pub fn isComplete(self: *Search) bool { - var it = self.screens.iterator(); - while (it.next()) |entry| { - if (!entry.value.state.isComplete()) return false; - } - - return true; - } - - pub const Tick = enum { - /// All searches are complete. - complete, - - /// Progress was made on at least one screen. - progress, - - /// All incomplete searches are blocked on feed. - blocked, + const Selected = struct { + idx: usize, + highlight: UntrackedHighlight, }; - - /// Tick the search forward as much as possible without acquiring - /// the big lock. Returns the overall tick progress. - pub fn tick(self: *Search) Tick { - var result: Tick = .complete; - var it = self.screens.iterator(); - while (it.next()) |entry| { - if (entry.value.tick()) { - result = .progress; - } else |err| switch (err) { - // Ignore... nothing we can do. - error.OutOfMemory => log.warn( - "error ticking screen search key={} err={}", - .{ entry.key, err }, - ), - - // Ignore, good for us. State remains whatever it is. - error.SearchComplete => {}, - - // Ignore, too, progressed - error.FeedRequired => switch (result) { - // If we think we're complete, we're not because we're - // blocked now (nothing made progress). - .complete => result = .blocked, - - // If we made some progress, we remain in progress - // since blocked means no progress at all. - .progress => {}, - - // If we're blocked already then we remain blocked. - .blocked => {}, - }, - } - } - - // log.debug("tick result={}", .{result}); - return result; - } - - /// Grab the mutex and update any state that requires it, such as - /// feeding additional data to the searches or updating the active screen. - pub fn feed( - self: *Search, - alloc: Allocator, - t: *Terminal, - ) void { - // Update our active screen - if (t.screens.active_key != self.last_screen.key) { - // The default values will force resets of a bunch of other - // state too to force recalculations and notifications. - self.last_screen = .{ .key = t.screens.active_key }; - } - - // Reconcile our screens with the terminal screens. Remove - // searchers for screens that no longer exist and add searchers - // for screens that do exist but we don't have yet. - { - // Remove screens we have that no longer exist or changed. - var it = self.screens.iterator(); - while (it.next()) |entry| { - const remove = !self.screenIsValid( - &t.screens, - entry.key, - entry.value, - ); - - if (remove) { - entry.value.deinitScreenInvalid(); - _ = self.screens.remove(entry.key); - _ = self.screen_generations.remove(entry.key); - } - } - } - { - // Add screens that exist but we don't have yet. - var it = t.screens.all.iterator(); - while (it.next()) |entry| { - if (self.screens.contains(entry.key)) continue; - const screen_search = ScreenSearch.init( - alloc, - entry.value.*, - self.viewport.needle(), - ) catch |err| switch (err) { - error.OutOfMemory => { - // OOM is probably going to sink the entire ship but - // we can just ignore it and wait on the next - // reconciliation to try again. - log.warn( - "error initializing screen search for key={} err={}", - .{ entry.key, err }, - ); - continue; - }, - }; - self.screens.put(entry.key, screen_search); - self.screen_generations.put( - entry.key, - t.screens.generation(entry.key), - ); - } - } - - // See the `search_viewport_dirty` flag on the terminal to know - // what exactly this is for. But, if this is set, we know the renderer - // found the viewport/active area dirty, so we should mark it as - // dirty in our viewport searcher so it forces a re-search. - if (t.flags.search_viewport_dirty) { - t.flags.search_viewport_dirty = false; - - // Mark our viewport dirty so it researches the active - self.viewport.active_dirty = true; - - // Reload our active area for our active screen - if (self.screens.getPtr(t.screens.active_key)) |screen_search| { - screen_search.reloadActive() catch |err| switch (err) { - error.OutOfMemory => log.warn( - "error reloading active area for screen key={} err={}", - .{ t.screens.active_key, err }, - ), - }; - } - } - - // Check our viewport for changes. - if (self.viewport.update(&t.screens.active.pages)) |updated| { - if (updated) self.stale_viewport_matches = true; - } else |err| switch (err) { - error.OutOfMemory => log.warn( - "error updating viewport search err={}", - .{err}, - ), - } - - // Feed data - var it = self.screens.iterator(); - while (it.next()) |entry| { - if (entry.value.state.needsFeed()) { - entry.value.feed() catch |err| switch (err) { - error.OutOfMemory => log.warn( - "error feeding screen search key={} err={}", - .{ entry.key, err }, - ), - }; - } - } - } - - /// Notify about any changes to the search state. - /// - /// This doesn't require any locking as it only reads internal state. - pub fn notify( - self: *Search, - alloc: Allocator, - cb: EventCallback, - ud: ?*anyopaque, - ) void { - const screen_search = self.screens.get(self.last_screen.key) orelse return; - - // Check our total match data - const total = screen_search.matchesLen(); - if (total != self.last_screen.total) { - log.debug("notifying total matches={}", .{total}); - self.last_screen.total = total; - cb(.{ .total_matches = total }, ud); - } - - // Check our viewport matches. If they're stale, we do the - // viewport search now. We do this as part of notify and not - // tick because the viewport search is very fast and doesn't - // require ticked progress or feeds. - if (self.stale_viewport_matches) viewport: { - // We always make stale as false. Even if we fail below - // we require a re-feed to re-search the viewport. The feed - // process will make it stale again. - self.stale_viewport_matches = false; - - var arena: ArenaAllocator = .init(alloc); - defer arena.deinit(); - const arena_alloc = arena.allocator(); - var results: std.ArrayList(FlattenedHighlight) = .empty; - while (self.viewport.next()) |hl| { - const hl_cloned = hl.clone(arena_alloc) catch continue; - results.append(arena_alloc, hl_cloned) catch |err| switch (err) { - error.OutOfMemory => { - log.warn( - "error collecting viewport matches err={}", - .{err}, - ); - - // Reset the viewport so we force an update on the - // next feed. - self.viewport.reset(); - break :viewport; - }, - }; - } - - log.debug("notifying viewport matches len={}", .{results.items.len}); - cb(.{ .viewport_matches = results.items }, ud); - } - - // Check our last selected match data. - if (screen_search.selected) |m| match: { - const flattened = screen_search.selectedMatch() orelse break :match; - const untracked = flattened.untracked(); - if (self.last_screen.selected) |prev| { - if (prev.idx == m.idx and prev.highlight.eql(untracked)) { - // Same selection, don't update it. - break :match; - } - } - - // New selection, notify! - self.last_screen.selected = .{ - .idx = m.idx, - .highlight = untracked, - }; - - log.debug("notifying selection updated idx={}", .{m.idx}); - cb( - .{ .selected_match = .{ - .idx = m.idx, - .highlight = flattened, - } }, - ud, - ); - } else if (self.last_screen.selected != null) { - log.debug("notifying selection cleared", .{}); - self.last_screen.selected = null; - cb( - .{ .selected_match = null }, - ud, - ); - } - - // Send our complete notification if we just completed. - if (!self.last_complete and self.isComplete()) { - log.debug("notifying search complete", .{}); - self.last_complete = true; - cb(.complete, ud); - } - } }; const TestUserData = struct { @@ -945,33 +679,3 @@ test { } }, t.screens.active.pages.pointFromPin(.screen, sel.end).?); } } - -test "select after active screen removal" { - const alloc = testing.allocator; - const io = testing.io; - var mutex: std.Io.Mutex = .init; - var t: Terminal = try .init(io, alloc, .{ .cols = 20, .rows = 2 }); - defer t.deinit(alloc); - - _ = try t.switchScreen(.alternate); - - var search: Search = try .init(alloc, "needle"); - search.feed(alloc, &t); - try testing.expectEqual(ScreenSet.Key.alternate, search.last_screen.key); - try testing.expect(search.screens.contains(.alternate)); - - var thread: Thread = undefined; - thread.search = search; - thread.opts = .{ - .mutex = &mutex, - .terminal = &t, - }; - defer if (thread.search) |*active| active.deinit(&t); - - _ = try t.switchScreen(.primary); - t.screens.remove(alloc, .alternate); - - try thread.select(.next); - try testing.expectEqual(ScreenSet.Key.primary, thread.search.?.last_screen.key); - try testing.expect(!thread.search.?.screens.contains(.alternate)); -} diff --git a/vendor/libghostty-vt/src/terminal/search/screen.zig b/vendor/libghostty-vt/src/terminal/search/screen.zig index 81e43ec..cbda5a3 100644 --- a/vendor/libghostty-vt/src/terminal/search/screen.zig +++ b/vendor/libghostty-vt/src/terminal/search/screen.zig @@ -765,24 +765,33 @@ pub const ScreenSearch = struct { } } - /// Return the selected match. + /// Return the match at the given index in newest-to-oldest order + /// (0 = most recent match). Returns null if the index is out of + /// range. /// /// This does not require read/write access to the underlying screen. - pub fn selectedMatch(self: *const ScreenSearch) ?FlattenedHighlight { - const sel = self.selected orelse return null; + pub fn matchAt(self: *const ScreenSearch, idx: usize) ?FlattenedHighlight { const active_len = self.active_results.items.len; - if (sel.idx < active_len) { - return self.active_results.items[active_len - 1 - sel.idx]; + if (idx < active_len) { + return self.active_results.items[active_len - 1 - idx]; } const history_len = self.history_results.items.len; - if (sel.idx < active_len + history_len) { - return self.history_results.items[sel.idx - active_len]; + if (idx < active_len + history_len) { + return self.history_results.items[idx - active_len]; } return null; } + /// Return the selected match. + /// + /// This does not require read/write access to the underlying screen. + pub fn selectedMatch(self: *const ScreenSearch) ?FlattenedHighlight { + const sel = self.selected orelse return null; + return self.matchAt(sel.idx); + } + pub const Select = enum { /// Next selection, in reverse order (newest to oldest), /// non-wrapping. diff --git a/vendor/libghostty-vt/src/terminal/search/terminal.zig b/vendor/libghostty-vt/src/terminal/search/terminal.zig new file mode 100644 index 0000000..062cc00 --- /dev/null +++ b/vendor/libghostty-vt/src/terminal/search/terminal.zig @@ -0,0 +1,655 @@ +const std = @import("std"); +const testing = std.testing; +const Allocator = std.mem.Allocator; +const point = @import("../point.zig"); +const FlattenedHighlight = @import("../highlight.zig").Flattened; +const ScreenSet = @import("../ScreenSet.zig"); +const Terminal = @import("../Terminal.zig"); + +const ScreenSearch = @import("screen.zig").ScreenSearch; +const ViewportSearch = @import("viewport.zig").ViewportSearch; + +const log = std.log.scoped(.search_terminal); + +/// Searches for a needle within an entire Terminal, orchestrating a +/// ScreenSearch per live screen plus a viewport search for the active +/// screen. +/// +/// This reconciles per-screen searchers against the terminal's active +/// ScreenSet, retains results across primary/alt screen switches, and +/// recovers from resize/reflow, resets, scrollback pruning, etc. +/// +/// The main ownership contract is that this is safe to call concurrently +/// with Terminal IO as long as you're not calling a function that takes +/// a terminal as an argument. Each function has more specific details about +/// its behavior. +pub const TerminalSearch = struct { + /// Allocator used for all search state. + alloc: Allocator, + + /// Active viewport search for the active screen. + viewport: ViewportSearch, + + /// The searchers for all the screens. + screens: std.EnumMap(ScreenSet.Key, ScreenSearch), + + /// ScreenSet generations captured when each searcher was initialized. + /// Allocators may reuse a destroyed Screen address, so pointer equality + /// alone cannot distinguish replacement screens from stale handles. + screen_generations: std.EnumMap(ScreenSet.Key, usize), + + /// The active screen key as of the last feed. All single-screen + /// reads (total matches, selection, etc.) resolve against this. + active_key: ScreenSet.Key, + + /// True when the cached viewport matches are stale and must be + /// recollected from the viewport searcher on the next + /// `viewportMatches` call. + stale_viewport_matches: bool, + + /// Cached viewport matches. The viewport search's sliding window + /// drains on read, so results are collected once per viewport + /// change and cached here. + viewport_matches: std.ArrayList(FlattenedHighlight), + + /// Overall status of the search. See `status`. + pub const Status = enum { + /// `tick` can make progress without terminal access. + running, + + /// Blocked until the next `feed`. This is also the initial + /// state, since a search that has never been fed has never + /// seen the terminal. + feed_required, + + /// Caught up with the terminal state as of the last feed. + complete, + }; + + /// Viewport scroll behavior applied by `select` when a match + /// becomes selected. + pub const SelectScroll = enum { + /// Scroll so the match is visible, only if it is not already. + if_needed, + + /// Never scroll. + none, + }; + + /// Initialize a search for the given needle. The needle is copied. + /// + /// This doesn't read any terminal state. The first `feed` does. + pub fn init( + alloc: Allocator, + needle_unowned: []const u8, + ) Allocator.Error!TerminalSearch { + var vp: ViewportSearch = try .init(alloc, needle_unowned); + errdefer vp.deinit(); + + // We use dirty tracking for active area changes. Start with it + // dirty so the first change is re-searched. + vp.active_dirty = true; + + return .{ + .alloc = alloc, + .viewport = vp, + .screens = .init(.{}), + .screen_generations = .init(.{}), + .active_key = .primary, + .stale_viewport_matches = true, + .viewport_matches = .empty, + }; + } + + /// Release all state. The terminal must be the same one given to + /// every other call, or null if the terminal has already been + /// deinitialized. When the terminal is alive this releases tracked + /// pins held within it. When it is null, those pins died with the + /// terminal's page storage, so only search-owned memory is freed. + pub fn deinit(self: *TerminalSearch, t_: ?*Terminal) void { + self.clearViewportMatches(); + self.viewport_matches.deinit(self.alloc); + self.viewport.deinit(); + var it = self.screens.iterator(); + while (it.next()) |entry| { + const valid = if (t_) |t| self.screenIsValid( + &t.screens, + entry.key, + entry.value, + ) else false; + if (valid) { + entry.value.deinit(); + } else { + entry.value.deinitScreenInvalid(); + } + } + } + + fn screenIsValid( + self: *const TerminalSearch, + screens: *const ScreenSet, + key: ScreenSet.Key, + search: *const ScreenSearch, + ) bool { + const generation = self.screen_generations.get(key) orelse return false; + if (generation != screens.generation(key)) return false; + const actual = screens.get(key) orelse return false; + return actual == search.screen; + } + + /// The needle that this search is using, borrowed. + pub fn needle(self: *const TerminalSearch) []const u8 { + return self.viewport.needle(); + } + + /// The searcher for the active screen as of the last feed. Null if + /// the search has never been fed (or the screen failed to + /// initialize). + pub fn activeScreenSearch(self: *TerminalSearch) ?*ScreenSearch { + return self.screens.getPtr(self.active_key); + } + + /// Returns true if all searches on all screens are complete. + pub fn isComplete(self: *TerminalSearch) bool { + var it = self.screens.iterator(); + while (it.next()) |entry| { + if (!entry.value.state.isComplete()) return false; + } + + return true; + } + + /// The overall search status derived from the per-screen search + /// states. + /// + /// Unlike `isComplete`, a search that has never been fed reports + /// `feed_required` rather than `complete`. An empty screen map + /// means the search has never seen the terminal, so reporting it + /// as complete would be technically true but useless to a caller + /// deciding what to do next. + pub fn status(self: *TerminalSearch) Status { + var saw_any = false; + var result: Status = .complete; + var it = self.screens.iterator(); + while (it.next()) |entry| { + saw_any = true; + switch (entry.value.state) { + // Progress is possible without a feed. + .active, .history => return .running, + + // Blocked until fed. + .history_feed => result = .feed_required, + + .complete => {}, + } + } + + if (!saw_any) return .feed_required; + return result; + } + + pub const Tick = enum { + /// All searches are complete. + complete, + + /// Progress was made on at least one screen. + progress, + + /// All incomplete searches are blocked on feed. + blocked, + }; + + /// Tick the search forward as much as possible without reading + /// any terminal state, so this is safe to call concurrently with + /// terminal IO. Returns the overall tick progress. + pub fn tick(self: *TerminalSearch) Tick { + var result: Tick = .complete; + var it = self.screens.iterator(); + while (it.next()) |entry| { + if (entry.value.tick()) { + result = .progress; + } else |err| switch (err) { + // Ignore... nothing we can do. + error.OutOfMemory => log.warn( + "error ticking screen search key={} err={}", + .{ entry.key, err }, + ), + + // Ignore, good for us. State remains whatever it is. + error.SearchComplete => {}, + + // Ignore, too, progressed + error.FeedRequired => switch (result) { + // If we think we're complete, we're not because we're + // blocked now (nothing made progress). + .complete => result = .blocked, + + // If we made some progress, we remain in progress + // since blocked means no progress at all. + .progress => {}, + + // If we're blocked already then we remain blocked. + .blocked => {}, + }, + } + } + + return result; + } + + /// Read the terminal to update any search state that requires it, + /// such as reconciling screens, feeding more data to the + /// searchers, and detecting viewport changes. + /// + /// This reads the terminal, so the caller must ensure the terminal + /// isn't modified for the duration of this call (e.g. by holding a + /// lock). Feeding is also the only way the search learns about + /// terminal changes, so callers should feed periodically while a + /// search is in use, even after it reports complete. + /// + /// `active_dirty` should be true when the active area may have + /// changed since the last feed, which triggers a re-scan of the + /// active area. Callers that know when the active area changes + /// (e.g. Ghostty's renderer-maintained dirty flag) can pass false + /// to skip the re-scan. Callers without that knowledge should + /// always pass true. That is always correct, just slightly more + /// work, and the active area search is cheap by design. + pub fn feed( + self: *TerminalSearch, + t: *Terminal, + active_dirty: bool, + ) void { + const alloc = self.alloc; + + // Update our active screen + if (t.screens.active_key != self.active_key) { + self.active_key = t.screens.active_key; + } + + // Reconcile our screens with the terminal screens. Remove + // searchers for screens that no longer exist and add searchers + // for screens that do exist but we don't have yet. + { + // Remove screens we have that no longer exist or changed. + var it = self.screens.iterator(); + while (it.next()) |entry| { + const remove = !self.screenIsValid( + &t.screens, + entry.key, + entry.value, + ); + + if (remove) { + entry.value.deinitScreenInvalid(); + _ = self.screens.remove(entry.key); + _ = self.screen_generations.remove(entry.key); + } + } + } + { + // Add screens that exist but we don't have yet. + var it = t.screens.all.iterator(); + while (it.next()) |entry| { + if (self.screens.contains(entry.key)) continue; + const screen_search = ScreenSearch.init( + alloc, + entry.value.*, + self.viewport.needle(), + ) catch |err| switch (err) { + error.OutOfMemory => { + // OOM is probably going to sink the entire ship but + // we can just ignore it and wait on the next + // reconciliation to try again. + log.warn( + "error initializing screen search for key={} err={}", + .{ entry.key, err }, + ); + continue; + }, + }; + self.screens.put(entry.key, screen_search); + self.screen_generations.put( + entry.key, + t.screens.generation(entry.key), + ); + } + } + + // The caller told us the active area may have changed, so mark + // our viewport searcher dirty (forcing a re-search) and reload + // the active area for the active screen. + if (active_dirty) { + // Mark our viewport dirty so it researches the active + self.viewport.active_dirty = true; + + // Reload our active area for our active screen + if (self.screens.getPtr(t.screens.active_key)) |screen_search| { + screen_search.reloadActive() catch |err| switch (err) { + error.OutOfMemory => log.warn( + "error reloading active area for screen key={} err={}", + .{ t.screens.active_key, err }, + ), + }; + } + } + + // Check our viewport for changes. + if (self.viewport.update(&t.screens.active.pages)) |updated| { + if (updated) self.stale_viewport_matches = true; + } else |err| switch (err) { + error.OutOfMemory => log.warn( + "error updating viewport search err={}", + .{err}, + ), + } + + // Feed data + var it = self.screens.iterator(); + while (it.next()) |entry| { + if (entry.value.state.needsFeed()) { + entry.value.feed() catch |err| switch (err) { + error.OutOfMemory => log.warn( + "error feeding screen search key={} err={}", + .{ entry.key, err }, + ), + }; + } + } + } + + /// Return the matches on the pages covering the viewport, as of + /// the last feed. Note this can include matches slightly outside + /// the visible viewport when they share a page with it. + /// + /// The results are cached, since the underlying viewport search + /// drains as it is read. Matches are collected once after a feed + /// notices a viewport change and the cached results are returned + /// otherwise. The returned slice is owned by the search and valid + /// until the next call to this function, the next feed, or deinit. + /// + /// This doesn't read any terminal state, so it is safe to call + /// concurrently with terminal IO. + pub fn viewportMatches( + self: *TerminalSearch, + ) Allocator.Error![]const FlattenedHighlight { + if (!self.stale_viewport_matches) return self.viewport_matches.items; + + // We always mark the cache fresh, even if collection fails + // below: a failed collection isn't retried until the next feed + // marks the cache stale again. + self.stale_viewport_matches = false; + + self.clearViewportMatches(); + errdefer { + self.clearViewportMatches(); + + // Reset the viewport so we force an update (and therefore a + // re-collection) on the next feed. + self.viewport.reset(); + } + while (self.viewport.next()) |hl| { + var hl_cloned = try hl.clone(self.alloc); + errdefer hl_cloned.deinit(self.alloc); + try self.viewport_matches.append(self.alloc, hl_cloned); + } + + return self.viewport_matches.items; + } + + fn clearViewportMatches(self: *TerminalSearch) void { + for (self.viewport_matches.items) |*hl| hl.deinit(self.alloc); + self.viewport_matches.clearRetainingCapacity(); + } + + /// Select the next or previous search result on the active screen, + /// wrapping around at the ends, and optionally scrolling the + /// viewport so the newly selected match is visible. + /// + /// This feeds first so the selection always works against current + /// terminal state, making it safe to call at any time relative to + /// feeds. Like `feed`, this reads (and possibly scrolls) the + /// terminal, so the caller must ensure the terminal isn't + /// otherwise being used for the duration of this call. + /// + /// Returns true if a match is selected after the operation, or + /// false if there are no matches to select. + pub fn select( + self: *TerminalSearch, + t: *Terminal, + to: ScreenSearch.Select, + scroll: SelectScroll, + ) Allocator.Error!bool { + // A screen can be removed or replaced between feeds. Reconcile + // while holding the terminal lock before touching any + // ScreenSearch pins. + self.feed(t, false); + const screen_search = self.screens.getPtr(self.active_key) orelse + return false; + + // Make the selection. Ignore the result because we don't + // care if the selection didn't change. + _ = try screen_search.select(to); + + // Grab our match if we have one. If we don't have a selection + // then there was nothing to select. + const flattened = screen_search.selectedMatch() orelse return false; + + switch (scroll) { + .none => return true, + .if_needed => {}, + } + + // Grab the current screen and see if this match is visible within + // the viewport already. If it is, we do nothing. + const screen = t.screens.get(self.active_key) orelse return true; + + // Grab the viewport. Viewports and selections are usually small + // so this check isn't very expensive, despite appearing O(N^2), + // both Ns are usually equal to 1. + var it = screen.pages.pageIterator( + .right_down, + .{ .viewport = .{} }, + null, + ); + const hl_chunks = flattened.chunks.slice(); + while (it.next()) |chunk| { + for (0..hl_chunks.len) |i| { + const hl_chunk = hl_chunks.get(i); + if (chunk.overlaps(.{ + .node = hl_chunk.node, + .start = hl_chunk.start, + .end = hl_chunk.end, + })) return true; + } + } + + screen.scroll(.{ .pin = flattened.startPin() }); + return true; + } +}; + +test "starts feed required and runs to complete" { + const alloc = testing.allocator; + const io = testing.io; + var t: Terminal = try .init(io, alloc, .{ .cols = 10, .rows = 2 }); + defer t.deinit(alloc); + + var stream = t.vtStream(); + defer stream.deinit(); + stream.nextSlice("Fizz\r\nBuzz\r\nFizz\r\nBang"); + + var search: TerminalSearch = try .init(alloc, "Fizz"); + defer search.deinit(&t); + + // A fresh search has never seen the terminal, so it must report + // feed_required even though isComplete() is trivially true. + try testing.expect(search.isComplete()); + try testing.expectEqual(TerminalSearch.Status.feed_required, search.status()); + + // Pump until complete. + while (search.status() != .complete) { + switch (search.status()) { + .feed_required => search.feed(&t, true), + .running => _ = search.tick(), + .complete => unreachable, + } + } + + const screen_search = search.activeScreenSearch().?; + try testing.expectEqual(2, screen_search.matchesLen()); +} + +test "viewport matches are cached until the next feed" { + const alloc = testing.allocator; + const io = testing.io; + var t: Terminal = try .init(io, alloc, .{ .cols = 10, .rows = 10 }); + defer t.deinit(alloc); + + var stream = t.vtStream(); + defer stream.deinit(); + stream.nextSlice("Fizz\r\nBuzz\r\nFizz\r\nBang"); + + var search: TerminalSearch = try .init(alloc, "Fizz"); + defer search.deinit(&t); + search.feed(&t, true); + + // The sliding window drains on read, so a second read must return + // the cached results rather than an empty list. + try testing.expectEqual(2, (try search.viewportMatches()).len); + try testing.expectEqual(2, (try search.viewportMatches()).len); + + // A feed that observes a change refreshes the cache. + stream.nextSlice("\r\nFizz"); + search.feed(&t, true); + try testing.expectEqual(3, (try search.viewportMatches()).len); +} + +test "select after active screen removal" { + const alloc = testing.allocator; + const io = testing.io; + var t: Terminal = try .init(io, alloc, .{ .cols = 20, .rows = 2 }); + defer t.deinit(alloc); + + _ = try t.switchScreen(.alternate); + + var search: TerminalSearch = try .init(alloc, "needle"); + defer search.deinit(&t); + search.feed(&t, false); + try testing.expectEqual(ScreenSet.Key.alternate, search.active_key); + try testing.expect(search.screens.contains(.alternate)); + + _ = try t.switchScreen(.primary); + t.screens.remove(alloc, .alternate); + + // The select must reconcile against the live ScreenSet before + // touching any pins from the removed screen. + _ = try search.select(&t, .next, .if_needed); + try testing.expectEqual(ScreenSet.Key.primary, search.active_key); + try testing.expect(!search.screens.contains(.alternate)); +} + +test "select scrolls the viewport only when needed" { + const alloc = testing.allocator; + const io = testing.io; + var t: Terminal = try .init(io, alloc, .{ + .cols = 10, + .rows = 2, + .max_scrollback_bytes = std.math.maxInt(usize), + }); + defer t.deinit(alloc); + + var stream = t.vtStream(); + defer stream.deinit(); + stream.nextSlice("Fizz\r\n"); + for (0..30) |_| stream.nextSlice("\r\n"); + stream.nextSlice("Fizz"); + + var search: TerminalSearch = try .init(alloc, "Fizz"); + defer search.deinit(&t); + while (search.status() != .complete) { + switch (search.status()) { + .feed_required => search.feed(&t, true), + .running => _ = search.tick(), + .complete => unreachable, + } + } + + // Whether the selected match is within the visible viewport rows. + // pointFromPin only bounds-checks the top of the region, so rows + // below the viewport must be rejected by the row count. + const Visible = struct { + fn check(term: *Terminal, s: *TerminalSearch) bool { + const pin = s.activeScreenSearch().?.selectedMatch().?.startPin(); + const pages = &term.screens.active.pages; + const pt = pages.pointFromPin(.viewport, pin) orelse return false; + return pt.viewport.y < pages.rows; + } + }; + + // First match is at the bottom which is already visible: no scroll. + try testing.expect(try search.select(&t, .next, .if_needed)); + try testing.expectEqual( + point.Point{ .active = .{ .x = 0, .y = 1 } }, + t.screens.active.pages.pointFromPin( + .active, + search.activeScreenSearch().?.selectedMatch().?.startPin(), + ).?, + ); + try testing.expect(Visible.check(&t, &search)); + + // Second match is in scrollback: selecting it must scroll the + // viewport so it becomes visible. + try testing.expect(try search.select(&t, .next, .if_needed)); + try testing.expect(Visible.check(&t, &search)); + + // With scrolling disabled the viewport must stay where it is even + // though the next selection (wrap back to the bottom) is not + // visible. + try testing.expect(try search.select(&t, .next, .none)); + try testing.expect(!Visible.check(&t, &search)); +} + +test "deinit after the terminal is gone" { + const alloc = testing.allocator; + const io = testing.io; + var t: Terminal = try .init(io, alloc, .{ + .cols = 10, + .rows = 2, + .max_scrollback_bytes = std.math.maxInt(usize), + }); + + var stream = t.vtStream(); + stream.nextSlice("Fizz\r\nBuzz\r\nFizz"); + + // Run to complete and select a match so the search holds tracked + // pins within the terminal's page storage. + var search: TerminalSearch = try .init(alloc, "Fizz"); + while (search.status() != .complete) { + switch (search.status()) { + .feed_required => search.feed(&t, true), + .running => _ = search.tick(), + .complete => unreachable, + } + } + try testing.expect(try search.select(&t, .next, .none)); + + // Deinitialize the terminal first. The pins died with the page + // storage, so deinit with a null terminal must free only + // search-owned memory without touching the terminal. + stream.deinit(); + t.deinit(alloc); + search.deinit(null); +} + +test "no matches selects nothing" { + const alloc = testing.allocator; + const io = testing.io; + var t: Terminal = try .init(io, alloc, .{ .cols = 10, .rows = 2 }); + defer t.deinit(alloc); + + var search: TerminalSearch = try .init(alloc, "Fizz"); + defer search.deinit(&t); + search.feed(&t, true); + try testing.expect(!try search.select(&t, .next, .if_needed)); + try testing.expect(!try search.select(&t, .prev, .if_needed)); +} diff --git a/vendor/libghostty-vt/src/terminal/sgr.zig b/vendor/libghostty-vt/src/terminal/sgr.zig index e9b9354..3fecd44 100644 --- a/vendor/libghostty-vt/src/terminal/sgr.zig +++ b/vendor/libghostty-vt/src/terminal/sgr.zig @@ -161,7 +161,18 @@ pub const Attribute = union(Tag) { // Largest variant is Unknown.C: 2 pointers + 2 usize = 32 bytes on 64-bit. // We use [8]u64 (64 bytes) to allow room for future expansion while // maintaining ABI compatibility. - [8]u64, + .{ + .padding = [8]u64, + .field_renames = .{ + .@"256_underline_color" = "underline_color_256", + .@"8_bg" = "bg_8", + .@"8_fg" = "fg_8", + .@"8_bright_bg" = "bright_bg_8", + .@"8_bright_fg" = "bright_fg_8", + .@"256_bg" = "bg_256", + .@"256_fg" = "fg_256", + }, + }, ); pub const Value = c_union.Value; pub const C = c_union.C; diff --git a/vendor/libghostty-vt/src/terminal/snapshot/grid.zig b/vendor/libghostty-vt/src/terminal/snapshot/grid.zig index fe5d055..3a89091 100644 --- a/vendor/libghostty-vt/src/terminal/snapshot/grid.zig +++ b/vendor/libghostty-vt/src/terminal/snapshot/grid.zig @@ -468,25 +468,21 @@ pub fn encode( const row = page.getRow(y); const cells = page.getCells(row); - // Trailing default cells decode implicitly. Wide/spacer pairs and + // Trailing default cells decode implicitly: wide/spacer pairs and // hyperlinked or styled cells are always nonzero, so eliding the - // zero suffix never drops encoded state. - const count: usize = count: { - var i: usize = cells.len; - while (i > 0) : (i -= 1) { - if (!cells[i - 1].isZero()) break :count i; - } - break :count 0; - }; + // zero suffix never drops encoded state. The scan also accumulates + // the OR of the row's cell words to select its encoded cell width. + const count: usize, const word_or: u64 = scanRow(cells); // Validate the wide state of every encoded cell so we don't encode - // corrupt data, and accumulate the OR of the row's cell words to - // select its encoded cell width. Trailing default cells are narrow, - // so checking the encoded prefix against the full row width covers - // every pair. - var word_or: u64 = 0; - for (cells[0..count], 0..) |*cell, x| { - switch (cell.wide) { + // corrupt data. The width bits of the OR word witness whether any + // encoded cell is non-narrow at all; rows without them, the common + // case, satisfy every pair rule vacuously. Trailing default cells + // are narrow, so checking the encoded prefix against the full row + // width covers every pair. + const wide_mask: u64 = comptime @bitCast(Cell{ .width = 3 }); + if (word_or & wide_mask != 0) { + for (cells[0..count], 0..) |*cell, x| switch (cell.wide) { .narrow => {}, .wide => if (x + 1 == cells.len or cells[x + 1].wide != .spacer_tail) @@ -501,21 +497,66 @@ pub fn encode( .spacer_head => if (x + 1 != cells.len or !row.wrap) { return error.InvalidWideCell; }, - } - word_or |= classifyWord(cell); + }; } // Canonical rows use the smallest admissible width. const cell_width: Cell.EncodedWidth = .select(word_or); + const row_header: Row = .{ + .wrap = row.wrap, + .wrap_continuation = row.wrap_continuation, + .semantic_prompt = @intFromEnum(row.semantic_prompt), + .cell_width = cell_width, + }; + + // The bulk codec emits the row header and its encoded cells directly + // into the destination's spare buffer capacity, so a row costs no + // writer call at all instead of one for the header and one per cell + // chunk. + if (comptime bulk_codec) emit: { + const words: [*]const u64 = @ptrCast(cells.ptr); + switch (cell_width) { + inline .one, .two, .four => |width| { + const size = comptime width.size(); + const needed = 3 + count * size; + if (writer.unusedCapacityLen() < needed) break :emit; + const out = writer.unusedCapacitySlice()[0..needed]; + out[0] = @bitCast(row_header); + std.mem.writeInt(u16, out[1..3], @intCast(count), .little); + encodeNarrowInto(width, words, count, out[3..]); + writer.advance(needed); + continue; + }, + .eight => { + // Hyperlink IDs live in a native side table, but we + // embed them in ours, so if we have any hyperlinks we + // need to fall back to the loop below. + const witness: Cell = @bitCast(word_or); + if (!witness.hyperlink and witness.hyperlink_id == 0) { + const needed = 3 + count * 8; + if (writer.unusedCapacityLen() < needed) break :emit; + const out = writer.unusedCapacitySlice()[0..needed]; + out[0] = @bitCast(row_header); + std.mem.writeInt( + u16, + out[1..3], + @intCast(count), + .little, + ); + @memcpy( + out[3..], + std.mem.sliceAsBytes(cells[0..count]), + ); + writer.advance(needed); + continue; + } + }, + } + } + // Row header: flags then the encoded cell count. { - const row_header: Row = .{ - .wrap = row.wrap, - .wrap_continuation = row.wrap_continuation, - .semantic_prompt = @intFromEnum(row.semantic_prompt), - .cell_width = cell_width, - }; var header_bytes: [3]u8 = undefined; header_bytes[0] = @bitCast(row_header); std.mem.writeInt(u16, header_bytes[1..3], @intCast(count), .little); @@ -559,6 +600,47 @@ pub fn encode( try encodeGraphemes(page, writer); } +/// The encoded cell count (through the last nonzero cell) and the bitwise +/// OR of every encoded cell word for one row. +fn scanRow(cells: []const TerminalCell) struct { usize, u64 } { + if (comptime bulk_codec) { + const words: [*]const u64 = @ptrCast(cells.ptr); + const V = @Vector(4, u64); + const VPtr = *align(@alignOf(u64)) const V; + + // Count the zero cells using vectorized instructions + var count = cells.len; + while (count >= 4) { + const tail = @as(VPtr, @ptrCast(words + count - 4)).*; + if (@reduce(.Or, tail) != 0) break; + count -= 4; + } + while (count > 0 and words[count - 1] == 0) count -= 1; + + // Accumulate the OR of the classifaction vectorized, with the + // scalar tail continuing from where the vector loop stopped. + const word_or: u64 = word_or: { + var acc: V = @splat(0); + var i: usize = 0; + while (i + 4 <= count) : (i += 4) { + acc |= @as(VPtr, @ptrCast(words + i)).*; + } + var word_or: u64 = @reduce(.Or, acc); + while (i < count) : (i += 1) word_or |= words[i]; + break :word_or word_or; + }; + + return .{ count, word_or }; + } + + // Scalar path, count backwards + var count: usize = cells.len; + while (count > 0 and cells[count - 1].isZero()) count -= 1; + var word_or: u64 = 0; + for (cells[0..count]) |*cell| word_or |= classifyWord(cell); + return .{ count, word_or }; +} + /// The word used to select a row's encoded cell width. This is the cell's /// wire word with the hyperlink flag reflecting the native cell, so linked /// cells and nonzero native padding disqualify every narrow width. @@ -584,70 +666,146 @@ fn encodeNarrowCells( var i: usize = 0; while (i < cells.len) { const n = @min(cells.len - i, chunk.len / size); + for (cells[i..][0..n], 0..) |*cell, j| { + std.mem.writeInt( + width.Int(), + chunk[j * size ..][0..size], + width.truncate(classifyWord(cell)), + .little, + ); + } + try writer.writeAll(chunk[0 .. n * size]); + i += n; + } +} - if (comptime native_matches_wire) { - // A pure truncating loop over integers that the compiler can - // vectorize. - const words: [*]const u64 = @ptrCast(cells.ptr); - for (0..n) |j| { - std.mem.writeInt( - width.Int(), - chunk[j * size ..][0..size], - width.truncate(words[i + j]), - .little, - ); +/// Truncate one row's cell words into `out` at the given encoded width. +fn encodeNarrowInto( + comptime width: Cell.EncodedWidth, + words: [*]const u64, + count: usize, + out: []u8, +) void { + comptime assert(bulk_codec); + const size = comptime width.size(); + const V = @Vector(2, u64); + const shift: V = @splat(comptime switch (width) { + .one, .two => @bitOffsetOf(Cell, "content"), + .four => 0, + .eight => unreachable, + }); + const mask: @Vector(size * 4, i32) = comptime mask: { + var mask: [size * 4]i32 = undefined; + for (0..2) |lane| { + for (0..size) |byte| { + mask[lane * size + byte] = @intCast(lane * 8 + byte); + mask[(lane + 2) * size + byte] = + ~@as(i32, @intCast(lane * 8 + byte)); } + } + break :mask mask; + }; + + var j: usize = 0; + while (j + 4 <= count) : (j += 4) encodeNarrowStep( + width, + words, + j, + out, + shift, + mask, + ); + if (j < count) { + if (count >= 4) { + encodeNarrowStep(width, words, count - 4, out, shift, mask); } else { - for (cells[i..][0..n], 0..) |*cell, j| { + while (j < count) : (j += 1) { std.mem.writeInt( width.Int(), - chunk[j * size ..][0..size], - width.truncate(classifyWord(cell)), + out[j * size ..][0..size], + width.truncate(words[j]), .little, ); } } - - try writer.writeAll(chunk[0 .. n * size]); - i += n; } } +/// Emit four truncated cell words starting at cell index `j`. +inline fn encodeNarrowStep( + comptime width: Cell.EncodedWidth, + words: [*]const u64, + j: usize, + out: []u8, + shift: @Vector(2, u64), + mask: @Vector(width.size() * 4, i32), +) void { + const size = comptime width.size(); + const VPtr = *align(@alignOf(u64)) const @Vector(2, u64); + const lo: @Vector(16, u8) = @bitCast(@as(VPtr, @ptrCast(words + j)).* >> shift); + const hi: @Vector(16, u8) = @bitCast(@as(VPtr, @ptrCast(words + j + 2)).* >> shift); + @as( + *align(1) @Vector(size * 4, u8), + @ptrCast(out[j * size ..].ptr), + ).* = @shuffle(u8, lo, hi, mask); +} + /// Encode the grapheme suffix section for every kind 1 cell in the grid. fn encodeGraphemes( page: *const TerminalPage, writer: *std.Io.Writer, ) EncodeError!void { - // Count and validate entries before the section header so the count is - // always exact. Rows without the native grapheme hint contain no - // grapheme cells in any intact page. - var entries: u32 = 0; - for (0..page.size.rows) |y| { - const row = page.getRow(y); - if (!row.grapheme) continue; - for (page.getCells(row)) |*cell| { - if (!cell.hasGrapheme()) continue; - const cps = page.lookupGrapheme(cell) orelse unreachable; - if (cps.len > std.math.maxInt(u16)) return error.TooManyGraphemes; - entries += 1; - } - } - - try io.writeInt(writer, u32, entries); + // Every grapheme cell owns exactly one entry in the page's grapheme + // map, so the section header comes straight from the page without a + // counting pass over the grid. Rows without the native grapheme hint + // contain no grapheme cells in any intact page. + const entries = page.graphemeCount(); + try io.writeInt(writer, u32, @intCast(entries)); if (entries == 0) return; + // Entries are batched into a local buffer so hot pages perform one + // writer call per flush instead of several per entry. + var buffer: [4096]u8 = undefined; + var used: usize = 0; + var emitted: usize = 0; for (0..page.size.rows) |y| { const row = page.getRow(y); if (!row.grapheme) continue; for (page.getCells(row), 0..) |*cell, x| { if (!cell.hasGrapheme()) continue; const cps = page.lookupGrapheme(cell) orelse unreachable; - try io.writeInt(writer, u16, @intCast(y)); - try io.writeInt(writer, u16, @intCast(x)); - try io.writeInt(writer, u16, @intCast(cps.len)); - for (cps) |cp| try io.writeInt(writer, u32, cp); + if (cps.len > std.math.maxInt(u16)) return error.TooManyGraphemes; + emitted += 1; + + const needed = 6 + cps.len * 4; + if (buffer.len - used < needed) { + try writer.writeAll(buffer[0..used]); + used = 0; + } + if (needed > buffer.len) { + // An entry larger than the whole buffer streams directly. + try io.writeInt(writer, u16, @intCast(y)); + try io.writeInt(writer, u16, @intCast(x)); + try io.writeInt(writer, u16, @intCast(cps.len)); + for (cps) |cp| try io.writeInt(writer, u32, cp); + continue; + } + + std.mem.writeInt(u16, buffer[used..][0..2], @intCast(y), .little); + std.mem.writeInt(u16, buffer[used + 2 ..][0..2], @intCast(x), .little); + std.mem.writeInt(u16, buffer[used + 4 ..][0..2], @intCast(cps.len), .little); + used += 6; + for (cps) |cp| { + std.mem.writeInt(u32, buffer[used..][0..4], cp, .little); + used += 4; + } } } + try writer.writeAll(buffer[0..used]); + + // The declared count is trusted by decoders for framing, so the grid + // must have produced exactly that many entries. + assert(emitted == entries); } pub const DecodeError = std.Io.Reader.Error || error{ @@ -707,13 +865,21 @@ pub fn decode( break :header .{ row_header, count }; }; + // A fully default row needs no work at all: decoded pages start + // zeroed, which is exactly the default row and cell state. + if (@as(u8, @bitCast(row_header)) == 0 and count == 0) continue; + + // Update the row fields through one load and store instead of a + // read-modify-write per packed field. const row = page.getRow(y); - row.wrap = row_header.wrap; - row.wrap_continuation = row_header.wrap_continuation; - row.semantic_prompt = std.enums.fromInt( + var row_value = row.*; + row_value.wrap = row_header.wrap; + row_value.wrap_continuation = row_header.wrap_continuation; + row_value.semantic_prompt = std.enums.fromInt( TerminalRow.SemanticPrompt, row_header.semantic_prompt, ) orelse .none; + row.* = row_value; const cells = page.getCells(row); if (count > cells.len) return error.InvalidRowCellCount; @@ -727,7 +893,7 @@ pub fn decode( reader, cells[0..count], ), - .four => try decodeWordCells( + .four => _ = try decodeWordCells( .four, page, row, @@ -747,7 +913,9 @@ pub fn decode( try reader.readSliceAll( std.mem.sliceAsBytes(cells[0..count]), ); + var row_or: u64 = 0; for (0..count) |x| { + row_or |= words[x]; applyCell( page, row, @@ -758,8 +926,9 @@ pub fn decode( hyperlink_remap, ); } + normalizeWideRow(.eight, row, cells, count, row_or); } else { - try decodeWordCells( + _ = try decodeWordCells( .eight, page, row, @@ -825,8 +994,34 @@ fn widenCells( ) void { const size = comptime width.size(); + // With the bulk codec layout this is a pure widening store: sixteen + // transported bytes per step, shuffling each pair of encoded values + // into u64 lane position against a zero vector and shifting them into + // the content field. Zig 0.16 disables loop auto-vectorization, so the + // scalar loop would issue one widening store per cell. + if (comptime bulk_codec) { + const words: [*]u64 = @ptrCast(cells.ptr); + const step = 16 / size; + var i: usize = 0; + while (i + step <= cells.len) : (i += step) { + widenStep(width, bytes, words, i); + } + if (i < cells.len) { + if (cells.len >= step) { + // Reprocess the final full window with overlapping stores, + // which rewrite the same widened values. + widenStep(width, bytes, words, cells.len - step); + } else { + while (i < cells.len) : (i += 1) { + words[i] = width.extend(widenValue(width, bytes[i * size ..])); + } + } + } + return; + } + // When the native cell matches the wire word, this is a pure widening - // loop over integers that the compiler can vectorize. + // loop over integers. if (comptime native_matches_wire) { const words: [*]u64 = @ptrCast(cells.ptr); for (0..cells.len) |i| { @@ -840,6 +1035,58 @@ fn widenCells( } } +/// Widen sixteen transported bytes into their cell words at cell index `i`: +/// shuffle each pair of encoded values into u64 lane position against a +/// zero vector, then shift the value into the content field. Width two +/// first degrades surrogate lanes to U+FFFD, matching `widenValue`. +inline fn widenStep( + comptime width: Cell.EncodedWidth, + bytes: []const u8, + words: [*]u64, + i: usize, +) void { + const size = comptime width.size(); + const step = 16 / size; + + var in: @Vector(16, u8) = @as( + *align(1) const @Vector(16, u8), + @ptrCast(bytes[i * size ..].ptr), + ).*; + + // Width two admits surrogates, which degrade to U+FFFD exactly + // like `widenValue`. Width one cannot encode an invalid scalar. + if (comptime width == .two) { + const values: @Vector(8, u16) = @bitCast(in); + const invalid = (values & @as(@Vector(8, u16), @splat(0xF800))) == + @as(@Vector(8, u16), @splat(0xD800)); + in = @bitCast(@select( + u16, + invalid, + @as(@Vector(8, u16), @splat(0xFFFD)), + values, + )); + } + + const zero: @Vector(16, u8) = @splat(0); + inline for (0..step / 2) |pair| { + const mask: @Vector(16, i32) = comptime mask: { + var mask: [16]i32 = @splat(~@as(i32, 0)); + for (0..size) |byte| { + mask[byte] = @intCast((2 * pair) * size + byte); + mask[8 + byte] = @intCast((2 * pair + 1) * size + byte); + } + break :mask mask; + }; + const lanes: @Vector(2, u64) = @bitCast( + @shuffle(u8, in, zero, mask), + ); + @as( + *align(@alignOf(u64)) @Vector(2, u64), + @ptrCast(words + i + 2 * pair), + ).* = lanes << @splat(@bitOffsetOf(Cell, "content")); + } +} + /// Read and validate one narrow transported value. inline fn widenValue( comptime width: Cell.EncodedWidth, @@ -858,6 +1105,9 @@ inline fn widenValue( /// Decode one row of width-four or fallback full-width cells through the /// complete per-cell normalization path. +/// +/// Returns the bitwise OR of every decoded wire word so callers can gate +/// the trailing wide-pair resolution pass without a second scan. fn decodeWordCells( comptime width: Cell.EncodedWidth, page: *TerminalPage, @@ -867,8 +1117,9 @@ fn decodeWordCells( reader: *std.Io.Reader, style_remap: *const StyleRemap, hyperlink_remap: *const HyperlinkRemap, -) DecodeError!void { +) DecodeError!u64 { const size = comptime width.size(); + var row_or: u64 = 0; // The staged payload path has the complete row buffered. const total = count * size; @@ -880,6 +1131,7 @@ fn decodeWordCells( bytes[x * size ..][0..size], .little, )); + row_or |= bits; applyCell( page, row, @@ -891,12 +1143,14 @@ fn decodeWordCells( ); } reader.toss(total); - return; + normalizeWideRow(width, row, cells, count, row_or); + return row_or; } // Streaming sources fall back to per-cell reads. for (0..count) |x| { const bits = width.extend(try io.readInt(reader, width.Int())); + row_or |= bits; applyCell( page, row, @@ -907,6 +1161,28 @@ fn decodeWordCells( hyperlink_remap, ); } + normalizeWideRow(width, row, cells, count, row_or); + return row_or; +} + +/// Resolve wide-pair relationships for one decoded row. +/// +/// Cell decoding stores every cell unresolved, so this pass applies +/// `normalizeWide` in order, which is equivalent to interleaving it with +/// the stores. Rows whose word OR carries no wide bits are already +/// normalized: every cell is narrow. Width four and narrower transports +/// cannot encode wide bits at all, so those rows skip the check entirely. +inline fn normalizeWideRow( + comptime width: Cell.EncodedWidth, + row: *const TerminalRow, + cells: []TerminalCell, + count: usize, + row_or: u64, +) void { + if (comptime width != .eight) return; + const wide_mask: u64 = comptime @bitCast(Cell{ .width = 3 }); + if (row_or & wide_mask == 0) return; + for (0..count) |x| normalizeWide(row, cells, x); } /// Whether the value is a valid Unicode scalar value. @@ -916,10 +1192,10 @@ inline fn validScalar(cp: u32) bool { /// Normalize one encoded cell word and store it at `cells[x]`. /// -/// This owns every per-cell decode rule except grapheme suffixes: content -/// validation, reserved-value degradation, style and hyperlink remapping -/// with reference counting, and wide-pair normalization against already -/// decoded neighbors. +/// This owns every per-cell decode rule except grapheme suffixes and +/// wide-pair resolution: content validation, reserved-value degradation, +/// and style and hyperlink remapping with reference counting. Callers run +/// `normalizeWideRow` over the stored row afterward. fn applyCell( page: *TerminalPage, row: *TerminalRow, @@ -935,7 +1211,6 @@ fn applyCell( // reference counting, or table lookups. if (bits_wire == 0) { storeCell(cell, 0); - normalizeWide(row, cells, x); return; } @@ -1003,8 +1278,6 @@ fn applyCell( page.hyperlink_set.release(page.memory, link_native); }; } - - normalizeWide(row, cells, x); } /// Resolve wide-pair relationships for the cell at `x` against its already @@ -1046,9 +1319,23 @@ fn decodeGraphemes( ) DecodeError!void { const entries = try io.readInt(reader, u32); for (0..entries) |_| { - const y = try io.readInt(reader, u16); - const x = try io.readInt(reader, u16); - const cp_count = try io.readInt(reader, u16); + // The staged and borrowed payload paths have every header buffered. + const y: u16, const x: u16, const cp_count: u16 = header: { + if (reader.bufferedLen() >= 6) { + const bytes = reader.buffered()[0..6]; + defer reader.toss(6); + break :header .{ + std.mem.readInt(u16, bytes[0..2], .little), + std.mem.readInt(u16, bytes[2..4], .little), + std.mem.readInt(u16, bytes[4..6], .little), + }; + } + break :header .{ + try io.readInt(reader, u16), + try io.readInt(reader, u16), + try io.readInt(reader, u16), + }; + }; // Resolve the target cell. Entries whose target cannot carry a // suffix are optional detail: their codepoints are consumed to @@ -1070,31 +1357,67 @@ fn decodeGraphemes( break :target .{ .row = row, .cell = cell }; }; - // Always consume every declared codepoint. Invalid scalars and NUL are - // not meaningful grapheme suffix components and are ignored. If native - // capacity is exhausted, remove any prefix already attached so the - // cell never exposes a truncated cluster. - var accept = target != null; - for (0..cp_count) |_| { - const cp = try io.readInt(reader, u32); - if (!accept) continue; - if (cp == 0 or !validScalar(cp)) continue; + // Always consume every declared codepoint. Invalid scalars and NUL + // are not meaningful grapheme suffix components and are ignored. A + // dropped entry's codepoints are discarded in bulk. + const resolved = target orelse { + try reader.discardAll(@as(usize, cp_count) * 4); + continue; + }; - page.appendGrapheme( - target.?.row, - target.?.cell, - @intCast(cp), - ) catch { - if (target.?.cell.hasGrapheme()) { - page.clearGrapheme(target.?.cell); - page.updateRowGraphemeFlag(target.?.row); - } - accept = false; - }; + var accept = true; + var index: usize = 0; + while (index < cp_count) { + const buffered = reader.buffered(); + if (buffered.len >= 4) { + const n = @min(cp_count - index, buffered.len / 4); + for (0..n) |i| applyGraphemeSuffix( + page, + resolved.row, + resolved.cell, + &accept, + std.mem.readInt(u32, buffered[i * 4 ..][0..4], .little), + ); + reader.toss(n * 4); + index += n; + } else { + applyGraphemeSuffix( + page, + resolved.row, + resolved.cell, + &accept, + try io.readInt(reader, u32), + ); + index += 1; + } } } } +/// Attach one decoded suffix codepoint to its resolved target cell. +/// +/// If native capacity is exhausted, any prefix already attached is removed +/// so the cell never exposes a truncated cluster, and `accept` latches +/// false so the entry's remaining codepoints are consumed but dropped. +fn applyGraphemeSuffix( + page: *TerminalPage, + row: *TerminalRow, + cell: *TerminalCell, + accept: *bool, + cp: u32, +) void { + if (!accept.*) return; + if (cp == 0 or !validScalar(cp)) return; + + page.appendGrapheme(row, cell, @intCast(cp)) catch { + if (cell.hasGrapheme()) { + page.clearGrapheme(cell); + page.updateRowGraphemeFlag(row); + } + accept.* = false; + }; +} + /// The encoded word for one native cell and its hyperlink ID. fn cellBits(cell: TerminalCell, link_id: TerminalHyperlinkId) u64 { if (comptime native_matches_wire) { @@ -1192,6 +1515,11 @@ fn Remap(comptime Id: type) type { /// semantics. seen: std.DynamicBitSetUnmanaged, + /// A remap with no entries at all: every lookup is unmapped. Use + /// this instead of `init` when the encoded table is empty so pages + /// without styles or hyperlinks allocate nothing. + pub const empty: Self = .{ .entries = &.{}, .seen = .{} }; + pub fn init(alloc: Allocator) Allocator.Error!Self { const entries = try alloc.alloc(Id, capacity); errdefer alloc.free(entries); @@ -1204,25 +1532,29 @@ fn Remap(comptime Id: type) type { } pub fn deinit(self: *Self, alloc: Allocator) void { - alloc.free(self.entries); - self.seen.deinit(alloc); + if (self.entries.len != 0) { + alloc.free(self.entries); + self.seen.deinit(alloc); + } self.* = undefined; } - /// Record one encoded-to-native mapping. + /// Record one encoded-to-native mapping. Illegal on `empty`. pub fn put(self: *Self, encoded: Id, native: Id) void { assert(!self.seen.isSet(encoded)); self.entries[encoded] = native; self.seen.set(encoded); } - /// Whether the encoded ID already has an entry, even a default one. + /// Whether the encoded ID already has an entry, even a default + /// one. Illegal on `empty`. pub fn contains(self: *const Self, encoded: Id) bool { return self.seen.isSet(encoded); } /// The native ID for an encoded ID, or zero when unmapped. pub inline fn get(self: *const Self, encoded: Id) Id { + if (self.entries.len == 0) return 0; return self.entries[encoded]; } }; diff --git a/vendor/libghostty-vt/src/terminal/snapshot/page.zig b/vendor/libghostty-vt/src/terminal/snapshot/page.zig index 0d166e3..6c8bb5c 100644 --- a/vendor/libghostty-vt/src/terminal/snapshot/page.zig +++ b/vendor/libghostty-vt/src/terminal/snapshot/page.zig @@ -233,7 +233,17 @@ pub const Decoder = struct { // payload, so this is the byte count of the tables and grid. const remaining = self.record_reader.header.payload_len - Header.len; - if (remaining <= max_staged_payload) { + if (self.record_reader.payloadReader().bufferedLen() >= remaining) { + // The complete payload is already buffered, e.g. borrowed from + // an in-memory snapshot. Parse it in place with no staging + // copy; `finish` still enforces the CRC and exact exhaustion. + try decodePayloadBody( + self.record_reader.payloadReader(), + alloc, + destination, + self.header, + ); + } else if (remaining <= max_staged_payload) { // Stage the payload with one bulk read. The whole payload passes // through the checksum hasher as one update and the payload // decoders then parse a flat buffer, which keeps per-row work free @@ -357,31 +367,51 @@ fn decodePayloadBody( page.pauseIntegrityChecks(true); defer page.pauseIntegrityChecks(false); - var style_remap = grid.StyleRemap.init(alloc) catch - return error.OutOfMemory; + // Pages without styles or hyperlinks, the common case for plain + // scrollback, skip the remap tables entirely: every encoded cell ID + // resolves to the default through the empty remap. + var style_remap: grid.StyleRemap = if (header.style_count > 0) + grid.StyleRemap.init(alloc) catch return error.OutOfMemory + else + .empty; defer style_remap.deinit(alloc); - var hyperlink_remap = grid.HyperlinkRemap.init(alloc) catch - return error.OutOfMemory; + var hyperlink_remap: grid.HyperlinkRemap = if (header.hyperlink_count > 0) + grid.HyperlinkRemap.init(alloc) catch return error.OutOfMemory + else + .empty; defer hyperlink_remap.deinit(alloc); - // Styles + // Styles. The complete fixed-size entry is parsed from the buffered + // payload when possible so each entry costs no reader calls. + const style_entry_len = @sizeOf(TerminalStyleId) + style.len; for (0..header.style_count) |_| { - const native_id = try io.readInt(reader, TerminalStyleId); - const value = try style.decodeOrNull(reader); + const native_id: TerminalStyleId, const value = entry: { + if (reader.bufferedLen() >= style_entry_len) { + const bytes = reader.buffered()[0..style_entry_len]; + defer reader.toss(style_entry_len); + break :entry .{ + std.mem.readInt(TerminalStyleId, bytes[0..2], .little), + style.parseOrNull(bytes[2..][0..style.len]), + }; + } + break :entry .{ + try io.readInt(reader, TerminalStyleId), + try style.decodeOrNull(reader), + }; + }; // Zero is reserved for the implicit default. For a duplicate encoded // ID, the first entry wins and this complete entry is simply ignored. if (native_id == 0 or style_remap.contains(native_id)) continue; - // Invalid/default styles map to the native default. Repeated concrete - // values share the existing native entry, while capacity failure also - // degrades only this style. + // Invalid/default styles map to the native default. `add` returns + // the existing entry for a repeated concrete value, taking one + // reference either way, while capacity failure degrades only this + // style. The references are surrendered through the remap below + // once every cell reference is installed. const decoded_id: TerminalStyleId = if (value) |valid| decoded: { if (valid.default()) break :decoded 0; - if (page.styles.lookup(page.memory, valid)) |existing| { - break :decoded existing; - } break :decoded page.styles.add( page.memory, valid, @@ -417,16 +447,17 @@ fn decodePayloadBody( &hyperlink_remap, ); - // A newly inserted table value starts with one reference so grid decoding - // can safely attach it to any number of cells. Unlike organically built - // pages, that initial reference does not itself represent a cell. Release - // it once per distinct live style after every cell reference is installed; - // unused entries then become dead and disappear from canonical re-encoding. - for (1..@as(usize, page.styles.next_id)) |raw_id| { - const id: TerminalStyleId = @intCast(raw_id); - if (page.styles.refCount(page.memory, id) > 0) { - page.styles.release(page.memory, id); - } + // Every accepted table entry took one reference through `add` so grid + // decoding can safely attach its style to any number of cells. Unlike + // organically built pages, those references do not themselves represent + // cells. Release through the encoded-ID remap, so duplicate values + // which deduplicated to the same native ID each surrender their own + // reference; unused entries then become dead and disappear from + // canonical re-encoding. + var style_it = style_remap.seen.iterator(.{}); + while (style_it.next()) |encoded_id| { + const id = style_remap.entries[encoded_id]; + if (id != 0) page.styles.release(page.memory, id); } // Hyperlink insertion likewise creates one temporary reference for every diff --git a/vendor/libghostty-vt/src/terminal/snapshot/record.zig b/vendor/libghostty-vt/src/terminal/snapshot/record.zig index e8dada9..397d00c 100644 --- a/vendor/libghostty-vt/src/terminal/snapshot/record.zig +++ b/vendor/libghostty-vt/src/terminal/snapshot/record.zig @@ -257,6 +257,18 @@ pub const Reader = struct { limited: std.Io.Reader.Limited, hashing: std.Io.Reader.Hashed(Crc32c), + /// When the source already has the complete payload buffered, for + /// example an in-memory snapshot, the payload is borrowed straight + /// from the source buffer instead of streaming through the limited + /// and hashing adapters. The checksum is then verified with one bulk + /// update in `finish`, and the source is not advanced until `finish`. + borrowed: ?Borrowed, + + const Borrowed = struct { + source: *std.Io.Reader, + payload: std.Io.Reader, + }; + pub const InitError = Header.DecodeError; /// Errors detected after a payload decoder returns. @@ -278,6 +290,21 @@ pub const Reader = struct { ) InitError!void { self.* = undefined; self.header = try Header.decode(source); + + // The complete payload is already sitting in the source buffer: + // borrow it in place. Payload decoders read from a fixed reader + // over the borrowed bytes and `finish` checksums them in one pass. + if (source.bufferedLen() >= self.header.payload_len) { + self.borrowed = .{ + .source = source, + .payload = .fixed( + source.buffered()[0..self.header.payload_len], + ), + }; + return; + } + + self.borrowed = null; self.limited = .init( source, .limited(self.header.payload_len), @@ -297,11 +324,34 @@ pub const Reader = struct { /// Return the length-limited, checksum-updating payload reader. pub fn payloadReader(self: *Reader) *std.Io.Reader { + if (self.borrowed) |*borrowed| return &borrowed.payload; return &self.hashing.reader; } /// Require exact payload exhaustion and validate its CRC32C. pub fn finish(self: *Reader) FinishError!void { + if (self.borrowed) |*borrowed| { + if (borrowed.payload.bufferedLen() != 0) { + return error.PayloadNotExhausted; + } + + var checksum: Checksum = .init( + self.header.tag, + self.header.payload_len, + ); + checksum.writer().writeAll( + borrowed.payload.buffer[0..borrowed.payload.end], + ) catch unreachable; + if (checksum.final() != self.header.crc32c) { + return error.InvalidChecksum; + } + + // The borrowed bytes validated, so consume them from the + // source only now, leaving it positioned at the next record. + borrowed.source.toss(self.header.payload_len); + return; + } + if (self.hashing.reader.bufferedLen() != 0 or self.limited.remaining != .nothing) { diff --git a/vendor/libghostty-vt/src/terminal/snapshot/snapshot.ksy b/vendor/libghostty-vt/src/terminal/snapshot/snapshot.ksy index 80a1d18..2da917e 100644 --- a/vendor/libghostty-vt/src/terminal/snapshot/snapshot.ksy +++ b/vendor/libghostty-vt/src/terminal/snapshot/snapshot.ksy @@ -390,13 +390,13 @@ types: Each named instance exposes one bit from the little-endian integer. Arithmetic division is used instead of bitwise operations because the JavaScript target implements those operations with signed 32-bit values. - All values remain exact because the registry occupies only 42 bits, + All values remain exact because the registry occupies only 43 bits, within JavaScript's 53-bit safe integer range. seq: - id: raw type: u8 valid: - max: 4398046511103 + max: 8796093022207 instances: disable_keyboard: value: (raw / 1) % 2 != 0 @@ -482,6 +482,8 @@ types: value: (raw / 1099511627776) % 2 != 0 in_band_size_reports: value: (raw / 2199023255552) % 2 != 0 + kitty_paste_events: + value: (raw / 4398046511104) % 2 != 0 tab_stops: params: diff --git a/vendor/libghostty-vt/src/terminal/snapshot/snapshot.zig b/vendor/libghostty-vt/src/terminal/snapshot/snapshot.zig index b2dfbe1..9c6dc96 100644 --- a/vendor/libghostty-vt/src/terminal/snapshot/snapshot.zig +++ b/vendor/libghostty-vt/src/terminal/snapshot/snapshot.zig @@ -50,7 +50,10 @@ pub fn encode( // Continuation errors must not emit even the snapshot envelope. try continuation.validate(options.continuation); - var stream: record.Writer = .init(alloc, destination); + // A measured 512-byte buffer handles our smallest records on the stack + // before larger records fall back to the heap. + var stack_alloc = std.heap.stackFallback(512, alloc); + var stream: record.Writer = .init(stack_alloc.get(), destination); defer stream.deinit(); // 1. Envelope diff --git a/vendor/libghostty-vt/src/terminal/snapshot/style.zig b/vendor/libghostty-vt/src/terminal/snapshot/style.zig index 176190b..bd0d445 100644 --- a/vendor/libghostty-vt/src/terminal/snapshot/style.zig +++ b/vendor/libghostty-vt/src/terminal/snapshot/style.zig @@ -47,19 +47,14 @@ const std = @import("std"); const test_fixture = @import("fixture.zig"); -const io = @import("io.zig"); const sgr = @import("../sgr.zig"); const terminal_style = @import("../style.zig"); -/// Number of bytes written by `encode`, calculated using the encoder itself -/// so this remains synchronized with the field-by-field wire format. -pub const len = computeLen(); - -comptime { - // This size is part of the wire format. If it changes, the snapshot - // version and golden fixtures must also change. - std.debug.assert(len == 16); -} +/// Number of bytes in one encoded style entry. This size is part of the +/// wire format: the codec reads and writes fixed offsets within an entry +/// of exactly this size, so if the layout changes, the snapshot version +/// and golden fixtures must also change. +pub const len = 16; const Flags = packed struct(u16) { bold: bool = false, @@ -80,8 +75,8 @@ const ColorKind = enum(u8) { rgb = 2, }; -/// Errors possible while decoding one style entry. -pub const DecodeError = std.Io.Reader.Error || error{ +/// Semantic validation errors for one complete style entry buffer. +const ParseError = error{ /// A color kind is not defined by snapshot version 1. InvalidColorKind, @@ -98,14 +93,21 @@ pub const DecodeError = std.Io.Reader.Error || error{ InvalidReserved, }; +/// Errors possible while decoding one style entry. +pub const DecodeError = std.Io.Reader.Error || ParseError; + /// Encode one terminal style as a fixed-size snapshot style entry. +/// +/// The entry is assembled in a fixed buffer and written once, so hot +/// encoders perform a single writer call per style. pub fn encode( value: terminal_style.Style, writer: *std.Io.Writer, ) std.Io.Writer.Error!void { - try encodeColor(value.fg_color, writer); - try encodeColor(value.bg_color, writer); - try encodeColor(value.underline_color, writer); + var encoded: [len]u8 = @splat(0); + encodeColorBuf(encoded[0..4], value.fg_color); + encodeColorBuf(encoded[4..8], value.bg_color); + encodeColorBuf(encoded[8..12], value.underline_color); const flags: Flags = .{ .bold = value.flags.bold, @@ -118,17 +120,26 @@ pub fn encode( .overline = value.flags.overline, .underline = @intFromEnum(value.flags.underline), }; - try io.writeInt(writer, u16, @bitCast(flags)); - try io.writeInt(writer, u16, 0); + std.mem.writeInt(u16, encoded[12..14], @bitCast(flags), .little); + try writer.writeAll(&encoded); } /// Decode and validate one fixed-size snapshot style entry. pub fn decode(reader: *std.Io.Reader) DecodeError!terminal_style.Style { - const fg_color = try decodeColor(reader); - const bg_color = try decodeColor(reader); - const underline_color = try decodeColor(reader); + var encoded: [len]u8 = undefined; + try reader.readSliceAll(&encoded); + return parse(&encoded); +} + +/// Decode and validate one complete fixed-size style entry buffer. +fn parse(encoded: *const [len]u8) ParseError!terminal_style.Style { + const fg_color = try parseColor(encoded[0..4]); + const bg_color = try parseColor(encoded[4..8]); + const underline_color = try parseColor(encoded[8..12]); - const flags: Flags = @bitCast(try io.readInt(reader, u16)); + const flags: Flags = @bitCast( + std.mem.readInt(u16, encoded[12..14], .little), + ); if (flags.reserved != 0) return error.InvalidFlags; const underline = std.enums.fromInt( @@ -136,7 +147,7 @@ pub fn decode(reader: *std.Io.Reader) DecodeError!terminal_style.Style { flags.underline, ) orelse return error.InvalidUnderline; - const reserved = try io.readInt(reader, u16); + const reserved = std.mem.readInt(u16, encoded[14..16], .little); if (reserved != 0) return error.InvalidReserved; return .{ @@ -167,9 +178,7 @@ pub fn decodeOrDiscard( ) DecodeError!terminal_style.Style { var encoded: [len]u8 = undefined; try reader.readSliceAll(&encoded); - - var source: std.Io.Reader = .fixed(&encoded); - return decode(&source); + return parse(&encoded); } /// Decode one complete entry, returning null for invalid semantic contents. @@ -193,11 +202,16 @@ pub fn decodeOrNull( }; } -fn encodeColor( +/// `decodeOrNull` over one already-buffered entry, for enclosing codecs +/// that parse many entries from a flat payload without reader calls. +pub fn parseOrNull(encoded: *const [len]u8) ?terminal_style.Style { + return parse(encoded) catch null; +} + +fn encodeColorBuf( + encoded: *[4]u8, value: terminal_style.Style.Color, - writer: *std.Io.Writer, -) std.Io.Writer.Error!void { - var encoded: [4]u8 = @splat(0); +) void { switch (value) { .none => encoded[0] = @intFromEnum(ColorKind.none), .palette => |index| { @@ -211,23 +225,18 @@ fn encodeColor( encoded[3] = rgb.b; }, } - try writer.writeAll(&encoded); } -fn decodeColor( - reader: *std.Io.Reader, -) DecodeError!terminal_style.Style.Color { - // Colors are always 4 bytes - var encoded: [4]u8 = undefined; - try reader.readSliceAll(&encoded); - +fn parseColor( + encoded: *const [4]u8, +) ParseError!terminal_style.Style.Color { // Kind must be something we know about. const kind = std.enums.fromInt(ColorKind, encoded[0]) orelse { return error.InvalidColorKind; }; return switch (kind) { - .none => if (std.mem.eql(u8, encoded[1..], &.{ 0, 0, 0 })) + .none => if (encoded[1] == 0 and encoded[2] == 0 and encoded[3] == 0) .none else error.InvalidColor, @@ -243,15 +252,6 @@ fn decodeColor( }; } -fn computeLen() usize { - comptime { - var buf: [128]u8 = undefined; - var writer: std.Io.Writer = .fixed(&buf); - encode(.{}, &writer) catch unreachable; - return writer.end; - } -} - const test_golden_fixture = test_fixture.parse(@embedFile("testdata/style-v1.hex")); test "golden encoding and decoding" { diff --git a/vendor/libghostty-vt/src/terminal/snapshot/terminal.zig b/vendor/libghostty-vt/src/terminal/snapshot/terminal.zig index f20b977..11c9004 100644 --- a/vendor/libghostty-vt/src/terminal/snapshot/terminal.zig +++ b/vendor/libghostty-vt/src/terminal/snapshot/terminal.zig @@ -206,7 +206,8 @@ //! bit 39 report_color_scheme //! bit 40 report_visibility //! bit 41 in_band_size_reports -//! bits 42-63 reserved, zero +//! bit 42 kitty_paste_events +//! bits 43-63 reserved, zero //! ``` //! //! This is the packed field order of native `ModePacked`. Its layout is @@ -470,7 +471,7 @@ pub const Header = struct { try writer.writeByte(@intCast(@intFromEnum(self.mouse_shape))); try writer.writeByte(@intFromBool(self.password_input)); - // Runtime, saved, and reset mode sets. ModePacked occupies 41 bits; + // Runtime, saved, and reset mode sets. ModePacked occupies 43 bits; // its eight-byte wire slots zero-extend the native packed value. const mode_values = [_]terminal_modes.ModePacked{ self.current_modes, @@ -1203,7 +1204,7 @@ const test_header_fixture = test_fixture.parse( test "TERMINAL mode bit layout" { try std.testing.expectEqual( - @as(usize, 42), + @as(usize, 43), @bitSizeOf(terminal_modes.ModePacked), ); @@ -1212,8 +1213,8 @@ test "TERMINAL mode bit layout" { ); first.disable_keyboard = true; try std.testing.expectEqual( - @as(u42, 1) << 0, - @as(u42, @bitCast(first)), + @as(u43, 1) << 0, + @as(u43, @bitCast(first)), ); var visibility: terminal_modes.ModePacked = std.mem.zeroes( @@ -1221,17 +1222,26 @@ test "TERMINAL mode bit layout" { ); visibility.report_visibility = true; try std.testing.expectEqual( - @as(u42, 1) << 40, - @as(u42, @bitCast(visibility)), + @as(u43, 1) << 40, + @as(u43, @bitCast(visibility)), + ); + + var size_reports: terminal_modes.ModePacked = std.mem.zeroes( + terminal_modes.ModePacked, + ); + size_reports.in_band_size_reports = true; + try std.testing.expectEqual( + @as(u43, 1) << 41, + @as(u43, @bitCast(size_reports)), ); var last: terminal_modes.ModePacked = std.mem.zeroes( terminal_modes.ModePacked, ); - last.in_band_size_reports = true; + last.kitty_paste_events = true; try std.testing.expectEqual( - @as(u42, 1) << 41, - @as(u42, @bitCast(last)), + @as(u43, 1) << 42, + @as(u43, @bitCast(last)), ); } diff --git a/vendor/libghostty-vt/src/terminal/snapshot/testdata/complete-v1.hex b/vendor/libghostty-vt/src/terminal/snapshot/testdata/complete-v1.hex index 02df1fd..1fa59f6 100644 --- a/vendor/libghostty-vt/src/terminal/snapshot/testdata/complete-v1.hex +++ b/vendor/libghostty-vt/src/terminal/snapshot/testdata/complete-v1.hex @@ -90,9 +90,9 @@ ee ee 80 00 00 00 00 00 00 00 00 00 00 00 00 00 # 0x0000037a 00 08 00 00 00 00 00 00 00 00 00 00 00 00 00 00 # 0x0000046a # offset 0x0000047a: page record, payload 38 bytes -03 00 26 00 00 00 45 61 97 32 02 00 03 00 00 00 # 0x0000047a +03 00 26 00 00 00 62 1c ab 7b 02 00 03 00 00 00 # 0x0000047a 00 00 80 00 c0 00 00 02 00 00 00 08 00 00 03 02 # 0x0000048a -00 72 6e 03 02 00 61 74 03 01 00 65 00 00 00 00 # 0x0000049a +00 72 6e 03 02 00 61 74 02 01 00 65 00 00 00 00 # 0x0000049a # offset 0x000004aa: continuation record, payload 0 bytes 07 00 00 00 00 00 27 80 63 d1 # 0x000004aa diff --git a/vendor/libghostty-vt/src/terminal/stream.zig b/vendor/libghostty-vt/src/terminal/stream.zig index 729b6fc..29bdde7 100644 --- a/vendor/libghostty-vt/src/terminal/stream.zig +++ b/vendor/libghostty-vt/src/terminal/stream.zig @@ -128,6 +128,8 @@ pub const Action = union(Key) { kitty_color_report: kitty.color.OSC, color_operation: ColorOperation, semantic_prompt: SemanticPrompt, + kitty_clipboard: KittyClipboard, + kitty_dnd: KittyDnd, pub const Key = lib.Enum( lib.target, @@ -227,6 +229,8 @@ pub const Action = union(Key) { "kitty_color_report", "color_operation", "semantic_prompt", + "kitty_clipboard", + "kitty_dnd", }, ); @@ -236,7 +240,7 @@ pub const Action = union(Key) { @This(), // TODO: Before shipping an ABI-compatible libghostty, verify this. // This was just arbitrarily chosen for now. - [16]u64, + .{ .padding = [16]u64 }, ); pub const Tag = c_union.Tag; pub const Value = c_union.Value; @@ -412,6 +416,7 @@ pub const Action = union(Key) { pub const ClipboardContents = struct { kind: u8, data: []const u8, + terminator: osc.Terminator, pub const C = extern struct { kind: u8, @@ -444,6 +449,10 @@ pub const Action = union(Key) { }; pub const SemanticPrompt = osc.Command.SemanticPrompt; + + pub const KittyClipboard = osc.Command.KittyClipboardProtocol; + + pub const KittyDnd = osc.Command.KittyDndProtocol; }; /// Returns a type that can process a stream of tty control characters. @@ -646,9 +655,12 @@ pub fn Stream(comptime H: type) type { } inline fn nextSliceUntracked(self: *Self, input: []const u8) void { - // Disable SIMD optimizations if build requests it or if our - // manual debug mode is on. - if (comptime debug or !build_options.simd) { + // Byte-at-a-time parsing in debug mode only. Even without + // SIMD support (build_options.simd == false, e.g. wasm), the + // batched path below is much faster than per-byte dispatch + // because it decodes UTF-8 in bulk (via scalar fallbacks) and + // hands printable runs to the handler as print_slice actions. + if (comptime debug) { for (input) |c| self.nextUntracked(c); return; } @@ -706,9 +718,25 @@ pub fn Stream(comptime H: type) type { var i: usize = 0; while (i < cps.len) { const cp = cps[i]; - if (cp <= 0xF) { + // C0 and UTF-8-decoded C1 controls never enter + // printable runs. A codepoint is one of those + // exactly when it has no bit set outside 0x9F: + // bits 0-4 and bit 7 cover 0x00-0x1F and + // 0x80-0x9F and nothing else, so a single + // AND-test classifies both ranges. + if ((cp & ~@as(u32, 0x9F)) == 0) { @branchHint(.unlikely); - self.execute(@intCast(cp)); + if (cp <= 0x1F) { + // C0 controls execute rather than print. + self.execute(@intCast(cp)); + } else { + // C1 controls decoded from UTF-8 are ignored. + logUnsupportedOnce( + "ignoring UTF-8-decoded C1 controls, first: 0x{x}", + .{cp}, + 0x80, + ); + } i += 1; continue; } @@ -723,19 +751,23 @@ pub fn Stream(comptime H: type) type { scan: { if (simd.lanes(u32)) |lanes| { const V = @Vector(lanes, u32); - const threshold: V = @splat(0xF); + // Non-printable (C0 or decoded C1) is + // equivalent to "no bit outside 0x9F is + // set": one AND-test per lane. + const mask: V = @splat(~@as(u32, 0x9F)); + const zero: V = @splat(0); while (end + lanes <= cps.len) { const v: V = cps[end..][0..lanes].*; - const gt = v > threshold; - if (!@reduce(.And, gt)) { - const bits: std.meta.Int(.unsigned, lanes) = @bitCast(gt); - end += @ctz(~bits); + const stop = (v & mask) == zero; + if (@reduce(.Or, stop)) { + const bits: std.meta.Int(.unsigned, lanes) = @bitCast(stop); + end += @ctz(bits); break :scan; } end += lanes; } } - while (end < cps.len and cps[end] > 0xF) end += 1; + while (end < cps.len and (cps[end] & ~@as(u32, 0x9F)) != 0) end += 1; } self.handler.vt(.print_slice, .{ .cps = cps[i..end] }); i = end; @@ -845,6 +877,47 @@ pub fn Stream(comptime H: type) type { // Aborting transitions need the scalar path so the // handler can distinguish them from terminators. } + + // Bulk-consume OSC string bytes into the OSC parser. + // OSC payloads (e.g. OSC 52 clipboard operations and + // the kitty clipboard protocol) can be megabytes of + // base64 data, so per-byte dispatch is far too slow. + // This can't be used for handlers with a vtRaw hook + // because the bytes never produce osc_put actions. + if (self.parser.state == .osc_string) { + offset += self.consumeOscString(input[offset..]); + if (offset >= input.len) return input.len; + + // Fast-path normal string termination. This matches + // Parser.next's exit and entry actions while avoiding + // the generic action loop for every completed OSC. + switch (input[offset]) { + // The sequence should be terminated by a full + // ST ("ESC \"); the "\" that should follow is + // dispatched as a normal escape sequence. + std.ascii.control_code.esc => { + if (self.parser.osc_parser.end( + std.ascii.control_code.esc, + )) |cmd| self.oscDispatch(cmd.*); + self.parser.clear(); + self.parser.state = .escape; + offset += 1; + continue; + }, + // BEL terminates the string directly. + std.ascii.control_code.bel => { + if (self.parser.osc_parser.end( + std.ascii.control_code.bel, + )) |cmd| self.oscDispatch(cmd.*); + self.parser.state = .ground; + offset += 1; + continue; + }, + // CAN/SUB abort and ignored C0 bytes go + // through the state machine below. + else => {}, + } + } } self.nextNonUtf8(input[offset]); @@ -999,6 +1072,43 @@ pub fn Stream(comptime H: type) type { return end; } + /// Bulk-consume OSC string bytes into the OSC parser. Returns + /// the number of bytes consumed. Stops at the first byte that + /// is not an osc_put byte in the parse table, leaving it for + /// the caller to process through the state machine. Every byte + /// >= 0x20 is an osc_put byte; bytes below either terminate or + /// abort the string (BEL, CAN, SUB, ESC) or are ignored. + /// + /// Must not be used by handlers with a vtRaw hook because the + /// consumed bytes never produce osc_put actions. + fn consumeOscString(self: *Self, input: []const u8) usize { + comptime assert(!@hasDecl(T, "vtRaw")); + assert(self.parser.state == .osc_string); + + var end: usize = 0; + if (comptime std.simd.suggestVectorLength(u8)) |vector_len| { + const ByteVector = @Vector(vector_len, u8); + while (end + vector_len <= input.len) { + const bytes: ByteVector = input[end..][0..vector_len].*; + const stop = bytes < @as(ByteVector, @splat(0x20)); + if (@reduce(.Or, stop)) break; + end += vector_len; + } + } + while (end < input.len) { + switch (input[end]) { + // Not osc_put bytes: BEL/CAN/SUB/ESC terminate or + // abort the state; other C0 bytes are ignored by it. + 0x00...0x1F => break, + // Everything else is an osc_put byte. + else => end += 1, + } + } + + if (end > 0) self.parser.osc_parser.nextSlice(input[0..end]); + return end; + } + /// Like nextSlice but takes one byte and is necessarily a scalar /// operation that can't use SIMD. Prefer nextSlice if you can and /// try to get multiple bytes at once. @@ -1055,18 +1165,34 @@ pub fn Stream(comptime H: type) type { // a chain of inline functions. @setEvalBranchQuota(200_000); - // C0 control - if (c <= 0xF) { + // C0 control or a C1 control decoded from UTF-8: exactly + // the codepoints with no bit set outside 0x9F (bits 0-4 + // and bit 7 cover 0x00-0x1F and 0x80-0x9F and nothing + // else), so the printable fast path stays a single + // AND-test. + if ((c & ~@as(u21, 0x9F)) == 0) { @branchHint(.unlikely); + + // ESC + if (c == 0x1B) { + self.parser.state = .escape; + self.parser.clear(); + return; + } + + // Ignore C1 that came via UTF-8 decoding, matching xterm. + if (c > 0x1F) { + logUnsupportedOnce( + "ignoring UTF-8-decoded C1 controls, first: 0x{x}", + .{c}, + 0x80, + ); + return; + } + self.execute(@intCast(c)); return; } - // ESC - if (c == 0x1B) { - self.parser.state = .escape; - self.parser.clear(); - return; - } self.print(@intCast(c)); } @@ -2493,6 +2619,7 @@ pub fn Stream(comptime H: type) type { self.handler.vt(.clipboard_contents, .{ .kind = clip.kind, .data = clip.data, + .terminator = clip.terminator, }); }, @@ -2548,6 +2675,14 @@ pub fn Stream(comptime H: type) type { self.handler.vt(.progress_report, v); }, + .kitty_clipboard_protocol => |v| { + self.handler.vt(.kitty_clipboard, v); + }, + + .kitty_dnd_protocol => |v| { + self.handler.vt(.kitty_dnd, v); + }, + .conemu_sleep, .conemu_show_message_box, .conemu_change_tab_title, @@ -2558,8 +2693,7 @@ pub fn Stream(comptime H: type) type { .conemu_output_environment_variable, .conemu_run_process, .kitty_text_sizing, - .kitty_clipboard_protocol, - .kitty_dnd_protocol, + .kitty_desktop_notification, .context_signal, => { log.debug("unimplemented OSC callback: {}", .{cmd}); @@ -3017,6 +3151,166 @@ test "simd: complete incomplete utf-8" { try testing.expectEqual(@as(u21, 0x800), s.handler.c.?); } +test "stream: ground state C0 controls are executed, not printed" { + const H = struct { + buf: [128]u21 = undefined, + len: usize = 0, + + pub fn vt( + self: *@This(), + comptime action: Action.Tag, + value: Action.Value(action), + ) void { + switch (action) { + .print => { + self.buf[self.len] = value.cp; + self.len += 1; + }, + .print_slice => for (value.cps) |cp| { + self.buf[self.len] = @intCast(cp); + self.len += 1; + }, + else => {}, + } + } + }; + + // Every C0 control except ESC must never produce a print action + // in the ground state. ESC is excluded because it begins an + // escape sequence rather than executing. + for (0..0x20) |c| { + if (c == 0x1B) continue; + + // Scalar path. + { + var s: Stream(H) = .init(.{ .handler = .{} }); + s.next('A'); + s.next(@intCast(c)); + s.next('B'); + try testing.expectEqual(@as(usize, 2), s.handler.len); + try testing.expectEqual(@as(u21, 'A'), s.handler.buf[0]); + try testing.expectEqual(@as(u21, 'B'), s.handler.buf[1]); + } + + // Batched path. + { + var s: Stream(H) = .init(.{ .handler = .{} }); + s.nextSlice(&.{ 'A', 'B', @intCast(c), 'C', 'D' }); + try testing.expectEqual(@as(usize, 4), s.handler.len); + for ("ABCD", 0..) |expected, i| { + try testing.expectEqual(@as(u21, expected), s.handler.buf[i]); + } + } + + // Batched path with a run long enough to exercise the + // vectorized printable-run scan on either side of the control. + { + var s: Stream(H) = .init(.{ .handler = .{} }); + var input: [65]u8 = @splat('A'); + input[32] = @intCast(c); + s.nextSlice(&input); + try testing.expectEqual(@as(usize, 64), s.handler.len); + for (s.handler.buf[0..s.handler.len]) |cp| { + try testing.expectEqual(@as(u21, 'A'), cp); + } + } + } +} + +test "stream: ground state UTF-8-decoded C1 controls are ignored" { + const H = struct { + buf: [128]u21 = undefined, + len: usize = 0, + + pub fn vt( + self: *@This(), + comptime action: Action.Tag, + value: Action.Value(action), + ) void { + switch (action) { + .print => { + self.buf[self.len] = value.cp; + self.len += 1; + }, + .print_slice => for (value.cps) |cp| { + self.buf[self.len] = @intCast(cp); + self.len += 1; + }, + else => {}, + } + } + }; + + // Every C1 control that arrives as well-formed UTF-8 (a two byte + // 0xC2-lead sequence) must be dropped: not printed and not + // interpreted as a control (e.g. U+009B must not start a CSI). + for (0x80..0xA0) |c| { + const enc: [2]u8 = .{ 0xC2, @intCast(c) }; + + // Scalar path. + { + var s: Stream(H) = .init(.{ .handler = .{} }); + s.next('A'); + s.next(enc[0]); + s.next(enc[1]); + s.next('B'); + try testing.expectEqual(@as(usize, 2), s.handler.len); + try testing.expectEqual(@as(u21, 'A'), s.handler.buf[0]); + try testing.expectEqual(@as(u21, 'B'), s.handler.buf[1]); + } + + // Batched path. + { + var s: Stream(H) = .init(.{ .handler = .{} }); + s.nextSlice(&.{ 'A', 'B', enc[0], enc[1], 'C', 'D' }); + try testing.expectEqual(@as(usize, 4), s.handler.len); + for ("ABCD", 0..) |expected, i| { + try testing.expectEqual(@as(u21, expected), s.handler.buf[i]); + } + } + + // Batched path with a run long enough to exercise the + // vectorized printable-run scan on either side of the control. + { + var s: Stream(H) = .init(.{ .handler = .{} }); + var input: [66]u8 = @splat('A'); + input[32] = enc[0]; + input[33] = enc[1]; + s.nextSlice(&input); + try testing.expectEqual(@as(usize, 64), s.handler.len); + for (s.handler.buf[0..s.handler.len]) |cp| { + try testing.expectEqual(@as(u21, 'A'), cp); + } + } + } + + // U+00A0 (NBSP), just past the C1 range, must still print. + { + var s: Stream(H) = .init(.{ .handler = .{} }); + s.nextSlice(&.{ 0xC2, 0xA0 }); + try testing.expectEqual(@as(usize, 1), s.handler.len); + try testing.expectEqual(@as(u21, 0xA0), s.handler.buf[0]); + } + + // A codepoint whose continuation byte falls in the C1 range must + // still print: "Ü" is 0xC3 0x9C. + { + var s: Stream(H) = .init(.{ .handler = .{} }); + s.nextSlice("Ü"); + try testing.expectEqual(@as(usize, 1), s.handler.len); + try testing.expectEqual(@as(u21, 0xDC), s.handler.buf[0]); + } + + // A raw C1 byte is ill-formed UTF-8, not a decoded C1: it must + // still produce a U+FFFD replacement. + { + var s: Stream(H) = .init(.{ .handler = .{} }); + s.nextSlice(&.{0x9B}); + try testing.expectEqual(@as(usize, 1), s.handler.len); + try testing.expectEqual(@as(u21, 0xFFFD), s.handler.buf[0]); + } +} + test "stream: cursor right (CUF)" { const H = struct { amount: u16 = 0, @@ -3514,6 +3808,191 @@ test "stream: change window title with invalid utf-8" { } } +test "stream: osc 52 large payload in chunks" { + const alloc = testing.allocator; + + const H = struct { + const Self = @This(); + data: std.ArrayListUnmanaged(u8) = .empty, + kind: u8 = 0, + terminator: ?osc.Terminator = null, + count: usize = 0, + + pub fn vt( + self: *Self, + comptime action: Action.Tag, + value: Action.Value(action), + ) void { + switch (action) { + .clipboard_contents => { + self.count += 1; + self.kind = value.kind; + self.terminator = value.terminator; + self.data.appendSlice( + testing.allocator, + value.data, + ) catch @panic("OOM"); + }, + else => {}, + } + } + }; + + // A payload that far exceeds the fixed buffer so the allocating + // capture is used, fed in chunk sizes that don't align with the + // sequence so the bulk path sees every kind of boundary. + const prefix = "\x1b]52;c;"; + const payload_len = 150_000; + var input: std.ArrayListUnmanaged(u8) = .empty; + defer input.deinit(alloc); + try input.appendSlice(alloc, prefix); + for (0..payload_len) |i| try input.append( + alloc, + 'A' + @as(u8, @intCast(i % 26)), + ); + try input.appendSlice(alloc, "\x1b\\"); + + var s: Stream(H) = .init(.{ .handler = .{}, .allocator = alloc }); + defer s.parser.deinit(); + defer s.handler.data.deinit(alloc); + + var i: usize = 0; + while (i < input.items.len) { + const end = @min(i + 4093, input.items.len); + s.nextSlice(input.items[i..end]); + i = end; + } + + try testing.expectEqual(@as(usize, 1), s.handler.count); + try testing.expectEqual(@as(u8, 'c'), s.handler.kind); + try testing.expectEqual(osc.Terminator.st, s.handler.terminator.?); + try testing.expectEqualSlices( + u8, + input.items[prefix.len .. prefix.len + payload_len], + s.handler.data.items, + ); +} + +test "stream: osc bulk path matches per-byte path" { + const alloc = testing.allocator; + + // Records every dispatch relevant to OSC processing in a + // normalized text form so streams fed different ways can be + // compared byte-for-byte. + const H = struct { + const Self = @This(); + alloc: Allocator, + journal: std.ArrayListUnmanaged(u8) = .empty, + + fn record(self: *Self, comptime fmt: []const u8, args: anytype) void { + const s = std.fmt.allocPrint( + self.alloc, + fmt, + args, + ) catch @panic("OOM"); + defer self.alloc.free(s); + self.journal.appendSlice(self.alloc, s) catch @panic("OOM"); + } + + pub fn vt( + self: *Self, + comptime action: Action.Tag, + value: Action.Value(action), + ) void { + switch (action) { + .clipboard_contents => self.record( + "clip kind={c} term={s} data={s}\n", + .{ value.kind, @tagName(value.terminator), value.data }, + ), + .kitty_clipboard => self.record( + "kitty meta={s} payload={?s}\n", + .{ value.metadata, value.payload }, + ), + .window_title => self.record( + "title {s}\n", + .{value.title}, + ), + // Normalize prints to per-codepoint so the per-byte + // and slice paths journal identically. + .print => self.record("print {u}\n", .{value.cp}), + .print_slice => for (value.cps) |cp| self.record( + "print {u}\n", + .{@as(u21, @intCast(cp))}, + ), + else => {}, + } + } + }; + + const cases = [_][]const u8{ + "\x1b]52;c;aGVsbG8=\x1b\\", + "\x1b]52;c;aGVsbG8=\x07", + "\x1b]52;;aGVsbG8=\x07", + // Ignored C0 byte embedded in the payload. + "\x1b]52;c;aGVs\x01bG8=\x07", + // CAN and SUB aborts. + "\x1b]52;c;aGVsbG8=\x18", + "\x1b]52;c;aGVsbG8=\x1a", + // C1 byte embedded in the payload is data. + "\x1b]0;ab\x9ccd\x07", + // Terminated with trailing printable text. + "\x1b]0;a title\x07x", + // Back-to-back sequences. + "\x1b]2;another title\x1b\\\x1b]0;t2\x07", + "\x1b]5522;type=write;aGVsbG8=\x1b\\", + // Invalid OSC number. + "\x1b]999;junk\x07", + // Exceeds the fixed buffer: allocating capture. + "\x1b]52;c;" ++ "y" ** 3000 ++ "\x1b\\", + // Exceeds the fixed buffer: overflow, no dispatch. + "\x1b]0;" ++ "x" ** 3000 ++ "\x07", + }; + + for (cases) |case| { + // Reference: byte-at-a-time. + var ref: Stream(H) = .init(.{ + .handler = .{ .alloc = alloc }, + .allocator = alloc, + }); + defer ref.parser.deinit(); + defer ref.handler.journal.deinit(alloc); + for (case) |c| ref.next(c); + + // The whole slice at once. + { + var s: Stream(H) = .init(.{ + .handler = .{ .alloc = alloc }, + .allocator = alloc, + }); + defer s.parser.deinit(); + defer s.handler.journal.deinit(alloc); + s.nextSlice(case); + try testing.expectEqualSlices( + u8, + ref.handler.journal.items, + s.handler.journal.items, + ); + } + + // Split into two slices at every possible boundary. + for (0..case.len + 1) |split| { + var s: Stream(H) = .init(.{ + .handler = .{ .alloc = alloc }, + .allocator = alloc, + }); + defer s.parser.deinit(); + defer s.handler.journal.deinit(alloc); + s.nextSlice(case[0..split]); + s.nextSlice(case[split..]); + try testing.expectEqualSlices( + u8, + ref.handler.journal.items, + s.handler.journal.items, + ); + } + } +} + test "stream: insert characters" { const H = struct { const Self = @This(); diff --git a/vendor/libghostty-vt/src/terminal/stream_terminal.zig b/vendor/libghostty-vt/src/terminal/stream_terminal.zig index 2599097..6117242 100644 --- a/vendor/libghostty-vt/src/terminal/stream_terminal.zig +++ b/vendor/libghostty-vt/src/terminal/stream_terminal.zig @@ -1,4 +1,5 @@ const std = @import("std"); +const Allocator = std.mem.Allocator; const build_options = @import("terminal_options"); const testing = std.testing; const apc = @import("apc.zig"); @@ -14,7 +15,10 @@ const color = @import("color.zig"); const modes = @import("modes.zig"); const osc = @import("osc.zig"); const osc_color = @import("osc/parsers/color.zig"); +const kitty_clipboard = @import("kitty/clipboard.zig"); const kitty_color = @import("kitty/color.zig"); +const paste_pkg = @import("paste.zig"); +const kitty_dnd = @import("kitty/dnd.zig"); const size_report = @import("size_report.zig"); const simd = @import("../simd/main.zig"); const terminfo = @import("../terminfo/main.zig"); @@ -71,6 +75,22 @@ pub const Handler = struct { /// The DCS command handler maintains state for DCS queries. dcs_handler: dcs.Handler = .{}, + /// The in-flight Kitty clipboard protocol (OSC 5522) write + /// transaction, if any. Null means no transaction is active. + /// Heap-allocated since transactions are rare and short-lived. + kitty_clipboard_write: ?*kitty_clipboard.WriteState = null, + + /// Kitty clipboard protocol (OSC 5522) session password grants, + /// recorded when a clipboard_read or clipboard_write reply asks to + /// remember the user's decision. + kitty_clipboard_grants: kitty_clipboard.Grants = .{}, + + /// Maximum total decoded bytes accumulated by one Kitty clipboard + /// protocol (OSC 5522) write transaction, captured when the + /// transaction begins. Data beyond the limit fails the transaction + /// with EFBIG. + kitty_clipboard_write_max_bytes: usize = kitty_clipboard.max_write_size, + /// Called for sequence identifiers not supported by this library. /// Currently, only APC is reported. Content is borrowed and only valid /// for the duration of the callback. Set `apc_handler.unknown_max_bytes` @@ -93,7 +113,7 @@ pub const Handler = struct { /// e.g. in response to a DECRQM query. The data is only valid /// during the lifetime of the call so callers must copy it /// if it needs to be stored or used after the call returns. - write_pty: ?*const fn (*Handler, [:0]const u8) void, + write_pty: ?*const fn (*Handler, []const u8) void, /// Called when the bell is rung (BEL). bell: ?*const fn (*Handler) void, @@ -103,6 +123,15 @@ pub const Handler = struct { /// valid for the duration of the callback. desktop_notification: ?*const fn (*Handler, Action.ShowDesktopNotification) void, + /// Called when drag and drop protocol state changes in a way the + /// embedder may need to act on: the running program registering + /// or unregistering to accept drops, answering a drag, or + /// concluding a drop. The event says what changed; the details + /// are read from `handler.terminal.kitty_dnd` (Kitty's OSC 72 is + /// the only drag and drop protocol today). Native drag events + /// flow the other way, by calling `kitty.dnd.State` directly. + drag_and_drop: ?*const fn (*Handler, kitty_dnd.Event) void, + /// Called in response to a color scheme DSR query (CSI ? 996 n). /// Returns the current color scheme. Return null to silently /// ignore the query. @@ -118,9 +147,10 @@ pub const Handler = struct { /// valid for the lifetime of the call. enquiry: ?*const fn (*Handler) []const u8, - /// Called in response to XTWINOPS size queries (CSI 14/16/18 t). - /// Returns the current terminal geometry used for encoding. - /// Return null to silently ignore the query. + /// Called for XTWINOPS size queries (CSI 14/16/18 t) and when VT input + /// enables in-band size reports (mode 2048). Returns the current + /// terminal geometry used for encoding. Return null to suppress the + /// XTWINOPS response or mode 2048 report. size: ?*const fn (*Handler) ?size_report.Size, /// Called when the terminal title changes via escape sequences @@ -136,19 +166,34 @@ pub const Handler = struct { /// Called when the running program reports progress via OSC 9;4. progress_report: ?*const fn (*Handler, osc.Command.ProgressReport) void, - /// Called when the running program writes to a clipboard. The write - /// has a normalized destination and one or more decoded MIME - /// representations. All request, MIME, and data memory is borrowed - /// and only valid for the duration of the callback. + /// Called when the running program writes to a clipboard. + clipboard_write: ?*const fn (*Handler, clipboard.Write) void, + + /// Called when the running program requests clipboard contents + /// (OSC 52 with a "?" payload, or a Kitty clipboard (OSC 5522) + /// read). Answering one lets the program read the user's + /// clipboard, so the embedder is expected to mediate consent. /// - /// A write with no contents clears the destination. A content entry - /// with empty data is a distinct empty representation. + /// Reads are synchronous: the callback must answer through + /// `read.reply` before it returns, so an embedder that needs to + /// ask the user must block (e.g. run a modal prompt) while the + /// stream waits. Returning without a reply, or replying with any + /// failure, answers the program with an empty clipboard (OSC 52) + /// or the matching protocol status (OSC 5522) so it doesn't hang. + /// If this is null, OSC 52 reads are ignored and OSC 5522 reads + /// are refused with EPERM. /// - /// Clipboard read requests (OSC 52 with a "?" payload) are never - /// forwarded: answering one would let any program running in the - /// terminal silently read the user's clipboard, and a VT state - /// library has no way to mediate that with user consent. - clipboard_write: ?*const fn (*Handler, clipboard.Write) clipboard.WriteResult, + /// OSC 5522 requests carry the program's MIME list, name, and + /// password grant state; a reply that sets `remember` records a + /// session grant so later requests with the same password arrive + /// with `granted` set. Kitty itself serves a request for only the + /// targets listing (`list` with no `mimes`) without prompting. + /// + /// Installing this also enables Kitty paste events (mode 5522): + /// `paste` sends the program an event instead of the text, and + /// the program's follow-up read arrives here with `granted` set + /// since the user already pasted. See `paste`. + clipboard_read: ?*const fn (*Handler, clipboard.Read) void, /// Called in response to an XTVERSION query. Returns the version /// string to report (e.g. "ghostty 1.2.3"). The returned memory @@ -161,10 +206,12 @@ pub const Handler = struct { /// effects beyond that. pub const readonly: Effects = .{ .bell = null, + .clipboard_read = null, .clipboard_write = null, .color_scheme = null, .desktop_notification = null, .device_attributes = null, + .drag_and_drop = null, .enquiry = null, .progress_report = null, .size = null, @@ -194,6 +241,8 @@ pub const Handler = struct { } pub fn deinit(self: *Handler) void { + self.kittyClipboardAbort(); + self.kitty_clipboard_grants.deinit(self.terminal.gpa()); self.apc_handler.deinit(); self.dcs_handler.deinit(); } @@ -230,6 +279,63 @@ pub const Handler = struct { write_pty(self, buf[0..writer.end :0]); } + /// A paste request; see `paste`. + pub const Paste = paste_pkg.Request; + + pub const PasteError = Allocator.Error || std.Io.RandomSecureError || error{ + /// The data could inject commands and allow_unsafe was false. + /// Nothing was written. + UnsafePaste, + + /// The contents reader failed. Nothing was written. + ReadFailed, + + /// No write_pty effect is set, so nothing can be written. + NoWritePty, + }; + + /// The size of the chunks a paste streams to write_pty in. + pub const paste_chunk_size = 4096; + + /// Paste into the terminal, applying the terminal's current state + /// as necessary to owner mode 5522, bracketed paste, unsafe paste, etc. + /// Returns true if anything was written to the pty. + /// + /// The output streams to write_pty in chunks of `paste_chunk_size`. + /// The contents are read at most once and only the pasted text + /// representation is ever read, buffered whole while it is checked + /// and encoded; see `terminal.paste`. + pub fn paste(self: *Handler, req: Paste) PasteError!bool { + if (self.effects.write_pty == null) return error.NoWritePty; + + var buf: [paste_chunk_size]u8 = undefined; + var pty: PtyWriter = .init(self, &buf); + // Delivered on error too: a partial paste has its frame closed + // and the program must see that. + defer pty.writer.flush() catch unreachable; + + return paste_pkg.paste(.{ + .terminal = self.terminal, + .alloc = self.terminal.gpa(), + // Paste events need the program's follow-up Kitty + // clipboard read served. + .kitty_clipboard = if (self.effects.clipboard_read != null) .{ + .grants = &self.kitty_clipboard_grants, + .io = self.terminal.io(), + } else null, + .writer = &pty.writer, + }, req) catch |err| switch (err) { + // The pty writer never fails. + error.WriteFailed => unreachable, + error.ReadFailed, + error.OutOfMemory, + error.UnsafePaste, + error.EntropyUnavailable, + error.Canceled, + => |e| e, + }; + } + pub fn vt( self: *Handler, comptime action: Action.Tag, @@ -344,7 +450,16 @@ pub const Handler = struct { }, .active_status_display => self.terminal.status_display = value, .decaln => try self.terminal.decaln(), - .full_reset => self.terminal.fullReset(), + .full_reset => { + self.terminal.fullReset(); + + // Full reset clears grants + self.kitty_clipboard_grants.deinit(self.terminal.gpa()); + self.kitty_clipboard_grants = .{}; + + // Clear the progress bar + self.progressReport(.{ .state = .remove }); + }, .start_hyperlink => try self.terminal.screens.active.startHyperlink(value.uri, value.id), .end_hyperlink => self.terminal.screens.active.endHyperlink(), .semantic_prompt => try self.terminal.semanticPrompt(value), @@ -356,6 +471,11 @@ pub const Handler = struct { .kitty_color_report => self.kittyColorOperation(value) catch |err| { log.warn("error reporting Kitty colors err={}", .{err}); }, + .kitty_clipboard => self.kittyClipboard(value) catch |err| { + // Clipboard operations are external effects, not terminal + // state; a failed transaction was already answered. + log.warn("error handling kitty clipboard err={}", .{err}); + }, // APC .apc_start => self.apc_handler.start(), @@ -380,9 +500,16 @@ pub const Handler = struct { .clipboard_contents => self.clipboardContents( value.kind, value.data, + value.terminator, ) catch |err| { - // Clipboard writes are external effects, not terminal state. - log.warn("error handling clipboard write err={}", .{err}); + // Clipboard operations are external effects, not terminal state. + log.warn("error handling clipboard operation err={}", .{err}); + }, + .kitty_dnd => self.kittyDnd(value) catch |err| { + // Drag and drop is a self-contained subsystem: an OOM + // updating its state or a failure writing a response + // degrades it without corrupting terminal state, so we log. + log.warn("error handling kitty dnd err={}", .{err}); }, .dcs_hook => try self.dcsHook(value), @@ -396,7 +523,7 @@ pub const Handler = struct { } } - inline fn writePty(self: *Handler, data: [:0]const u8) void { + inline fn writePty(self: *Handler, data: []const u8) void { const func = self.effects.write_pty orelse return; func(self, data); } @@ -499,43 +626,695 @@ pub const Handler = struct { func(self, report); } - fn clipboardContents(self: *Handler, kind: u8, data: []const u8) !void { - const func = self.effects.clipboard_write orelse return; - - // Read requests are deliberately not forwarded; see the effect docs. - if (data.len == 1 and data[0] == '?') return; - + fn clipboardContents( + self: *Handler, + kind: u8, + data: []const u8, + terminator: osc.Terminator, + ) !void { const location: clipboard.Location = switch (kind) { 's' => .selection, 'p' => .primary, else => .standard, }; + // OSC 52 uses a "?" payload to request the clipboard contents. + if (data.len == 1 and data[0] == '?') { + self.clipboardRead(location, terminator); + return; + } + + const func = self.effects.clipboard_write orelse return; + // OSC 52 uses an empty payload to clear the selected clipboard. if (data.len == 0) { - _ = func(self, .{ + func(self, .{ .location = location, .contents = &.{}, + .name = "", + .granted = false, + .can_remember = false, + .reply_ctx = self, + .reply_fn = &ignoreWriteReply, }); return; } // Decode the base64 payload with the SIMD decoder (the same one - // used for Kitty graphics payloads) rather than the scalar std - // implementation; clipboard payloads can be megabytes. + // used for Kitty clipboard payloads) rather than the scalar std + // implementation; clipboard payloads can be megabytes. The + // Kitty clipboard spec governs OSC 52 base64 handling too: a + // request with characters outside the base64 alphabet is + // discarded entirely (never partially decoded), while a + // missing-padding tail is tolerated since OSC 52 has no way + // to report errors to the client. const alloc = self.terminal.gpa(); const buf = try alloc.alloc(u8, simd.base64.maxLen(data)); defer alloc.free(buf); - const decoded = try simd.base64.decode(data, buf); + const decoded = simd.base64.decodeStrict(data, buf, .optional) catch { + log.warn("OSC 52 clipboard write is not valid base64, ignoring", .{}); + return; + }; const contents = [_]clipboard.Content{.{ .mime = "text/plain", .data = decoded, }}; - _ = func(self, .{ + func(self, .{ .location = location, .contents = &contents, + .name = "", + .granted = false, + .can_remember = false, + .reply_ctx = self, + .reply_fn = &ignoreWriteReply, + }); + } + + /// Reply target for clipboard writes on protocols without a write + /// acknowledgement (OSC 52, OSC 1337 Copy): the reply is accepted + /// and discarded. + fn ignoreWriteReply(_: *anyopaque, _: clipboard.Write.Result) void {} + + fn clipboardRead( + self: *Handler, + location: clipboard.Location, + terminator: osc.Terminator, + ) void { + const func = self.effects.clipboard_read orelse return; + + var state: ClipboardReadState = .{ + .handler = self, + .location = location, + .terminator = terminator, + }; + func(self, .{ + .location = location, + .mimes = &.{"text/plain"}, + .list = false, + .name = "", + .granted = false, + .can_remember = false, + .reply_ctx = &state, + .reply_fn = &ClipboardReadState.reply, }); + + // The program is waiting on us, so a callback that returned + // without a (successful) reply gets an empty clipboard rather + // than silence. + if (!state.replied) state.respond("") catch |err| { + log.warn("error replying to clipboard read err={}", .{err}); + }; + } + + /// Reply state for one synchronous clipboard read. This lives on the + /// clipboardRead stack frame, so it is only valid during the callback. + const ClipboardReadState = struct { + handler: *Handler, + location: clipboard.Location, + terminator: osc.Terminator, + replied: bool = false, + + fn reply(ctx: *anyopaque, result: clipboard.Read.Result) void { + const self: *ClipboardReadState = @ptrCast(@alignCast(ctx)); + if (self.replied) { + log.warn("clipboard read replied more than once, ignoring", .{}); + return; + } + + // OSC 52 carries a single text value. + const data: []const u8 = switch (result) { + .denied, .unsupported, .busy, .io_error => "", + .success => |s| for (s.contents) |c| { + if (clipboard.isTextMime(c.mime)) break c.data; + } else "", + }; + + self.respond(data) catch |err| { + // Leave replied unset so clipboardRead falls back to the + // empty reply. + log.warn("error replying to clipboard read err={}", .{err}); + return; + }; + self.replied = true; + } + + fn respond( + self: *ClipboardReadState, + data: []const u8, + ) error{ OutOfMemory, WriteFailed }!void { + const handler = self.handler; + var stack = std.heap.stackFallback(256, handler.terminal.gpa()); + const alloc = stack.get(); + + var aw: std.Io.Writer.Allocating = .init(alloc); + defer aw.deinit(); + const kind: u8 = switch (self.location) { + .selection => 's', + .primary => 'p', + .standard, _ => 'c', + }; + try aw.writer.print("\x1b]52;{c};", .{kind}); + try std.base64.standard.Encoder.encodeWriter(&aw.writer, data); + try aw.writer.writeAll(self.terminator.string()); + + const written = try aw.toOwnedSliceSentinel(0); + defer alloc.free(written); + handler.writePty(written); + } + }; + + /// Handle one Kitty clipboard protocol (OSC 5522) packet. + fn kittyClipboard( + self: *Handler, + v: Action.Value(.kitty_clipboard), + ) error{OutOfMemory}!void { + // Decode and validate the metadata. + var arena: std.heap.ArenaAllocator = .init(self.terminal.gpa()); + defer arena.deinit(); + const meta = (kitty_clipboard.Metadata.parse( + arena.allocator(), + v.metadata, + ) catch |err| switch (err) { + error.OutOfMemory => return error.OutOfMemory, + error.InvalidValue => { + const state = self.kitty_clipboard_write orelse return; + switch (kitty_clipboard.Metadata.operation(v.metadata) orelse return) { + .wdata, .walias => self.kittyClipboardFinish( + state, + .EINVAL, + v.terminator, + ), + .read, .write => {}, + } + return; + }, + }) orelse return; + + const payload = v.payload orelse ""; + switch (meta.op) { + .read => try self.kittyClipboardRead(&meta, payload, v.terminator), + .write => try self.kittyClipboardWriteBegin(&meta, v.terminator), + .wdata => try self.kittyClipboardData(&meta, payload, v.terminator), + .walias => try self.kittyClipboardAlias(&meta, payload, v.terminator), + } + } + + fn kittyClipboardRead( + self: *Handler, + meta: *const kitty_clipboard.Metadata, + payload: []const u8, + terminator: osc.Terminator, + ) error{OutOfMemory}!void { + // The payload is the requested MIME list. Kitty drops a read + // request with an undecodable payload without any response. + const alloc = self.terminal.gpa(); + const decoded = kitty_clipboard.Payload.init( + alloc, + payload, + ) catch |err| switch (err) { + error.OutOfMemory => return error.OutOfMemory, + error.Invalid => return, + }; + defer decoded.deinit(alloc); + if (!decoded.isValidUtf8()) return; + + // Without a clipboard_read effect nothing can serve the read. + // EPERM is the protocol's denial so clients degrade gracefully. + const func = self.effects.clipboard_read orelse { + self.kittyClipboardRespond(&.{ + .op = .read, + .status = .EPERM, + .id = meta.id, + .terminator = terminator, + }); + return; + }; + + // The targets type ('.') asks for the listing of available + // types rather than data. Requested types beyond the cap are + // dropped and simply never served, which is how the protocol + // reports an unavailable type anyway. + var mimes_buf: [kitty_clipboard.max_read_mimes][]const u8 = undefined; + const mimes, const list = mimes: { + var targets = false; + var len: usize = 0; + var it = decoded.mimeIterator(); + while (it.next()) |mime| { + if (std.mem.eql(u8, mime, kitty_clipboard.targets_mime)) { + targets = true; + continue; + } + if (len == mimes_buf.len) continue; + mimes_buf[len] = mime; + len += 1; + } + break :mimes .{ mimes_buf[0..len], targets }; + }; + + // Per the spec a password without a name is no password. A + // stored grant for it lets the embedder skip its prompt. A + // prompt-exempt request never consults the grants: the + // embedder serves it without a prompt anyway, and consuming a + // one-time paste password on a listing would burn the grant + // before the follow-up data read. + const pw: []const u8 = if (meta.name.len > 0) meta.pw else ""; + const granted = !kitty_clipboard.readPromptExempt(mimes.len) and + self.kitty_clipboard_grants.use(alloc, pw, .read); + + var state: KittyClipboardReadState = .{ + .handler = self, + .primary = meta.loc == .primary, + .id = meta.id, + .pw = pw, + .mimes = mimes, + .list = list, + .terminator = terminator, + }; + func(self, .{ + .location = meta.loc, + .mimes = mimes, + .list = list, + .name = meta.name, + .granted = granted, + .can_remember = pw.len > 0, + .reply_ctx = &state, + .reply_fn = &KittyClipboardReadState.reply, + }); + + // The program is waiting on us, so a callback that returned + // without a reply is answered as a denial rather than silence. + if (!state.replied) state.respondStatus(.EPERM); + } + + /// Reply state for one synchronous Kitty clipboard read. This lives + /// on the kittyClipboardRead stack frame, so it is only valid during + /// the callback. + const KittyClipboardReadState = struct { + handler: *Handler, + primary: bool, + id: []const u8, + + /// The effective password, empty when the request had none. + pw: []const u8, + + /// The requested types; only these are served from a reply. + mimes: []const []const u8, + list: bool, + terminator: osc.Terminator, + replied: bool = false, + + fn reply(ctx: *anyopaque, result: clipboard.Read.Result) void { + const self: *KittyClipboardReadState = @ptrCast(@alignCast(ctx)); + if (self.replied) { + log.warn("clipboard read replied more than once, ignoring", .{}); + return; + } + self.replied = true; + + const success = switch (result) { + .denied => return self.respondStatus(.EPERM), + .unsupported => return self.respondStatus(.ENOSYS), + .busy => return self.respondStatus(.EBUSY), + .io_error => return self.respondStatus(.EIO), + .success => |s| s, + }; + + // Remembering is only offered when the request carried a + // usable password. + if (success.remember and self.pw.len > 0) { + self.handler.kitty_clipboard_grants.grant( + self.handler.terminal.gpa(), + self.pw, + .read, + false, + ) catch |err| { + log.warn("error recording clipboard grant err={}", .{err}); + }; + } + + self.respondSuccess(&success) catch |err| { + log.warn("error replying to clipboard read err={}", .{err}); + self.respondStatus(.EIO); + }; + } + + /// Answer with a single status packet. + fn respondStatus( + self: *const KittyClipboardReadState, + status: kitty_clipboard.Status, + ) void { + self.handler.kittyClipboardRespond(&.{ + .op = .read, + .status = status, + .id = self.id, + .terminator = self.terminator, + }); + } + + /// Answer with the full success sequence (OK, listing, DATA + /// chunks, DONE), serving only the requested representations + /// in request order. + fn respondSuccess( + self: *const KittyClipboardReadState, + success: *const clipboard.Read.Result.Success, + ) error{ OutOfMemory, WriteFailed }!void { + const handler = self.handler; + if (handler.effects.write_pty == null) return; + + var served_buf: [kitty_clipboard.max_read_mimes]clipboard.Content = undefined; + var served_len: usize = 0; + for (self.mimes) |mime| { + for (success.contents) |content| { + if (!std.mem.eql(u8, content.mime, mime)) continue; + served_buf[served_len] = content; + served_len += 1; + break; + } + } + + // Status packets fit on the stack; DATA packets carry the + // clipboard contents and fall back to the heap. + var stack = std.heap.stackFallback(1024, handler.terminal.gpa()); + const alloc = stack.get(); + var aw: std.Io.Writer.Allocating = .init(alloc); + defer aw.deinit(); + try (kitty_clipboard.ReadSuccess{ + .primary = self.primary, + .id = self.id, + .list = self.list, + .available = success.available, + .contents = served_buf[0..served_len], + .terminator = self.terminator, + }).encode(&aw.writer); + + const written = try aw.toOwnedSliceSentinel(0); + defer alloc.free(written); + handler.writePty(written); + } + }; + + fn kittyClipboardWriteBegin( + self: *Handler, + meta: *const kitty_clipboard.Metadata, + terminator: osc.Terminator, + ) error{OutOfMemory}!void { + // A new write silently replaces any in-flight transaction. + self.kittyClipboardAbort(); + + // Without a clipboard_write effect a commit can never succeed, + // so fail the transaction up front instead of spooling data + // we'd only throw away. Later wdata packets are ignored. + if (self.effects.clipboard_write == null) { + self.kittyClipboardRespond(&.{ + .op = .write, + .status = .ENOSYS, + .id = meta.id, + .terminator = terminator, + }); + return; + } + + // Setup our write state + const alloc = self.terminal.gpa(); + const state = try alloc.create(kitty_clipboard.WriteState); + errdefer alloc.destroy(state); + state.* = try .init(alloc, meta, .{ + .max_size = self.kitty_clipboard_write_max_bytes, + }); + self.kitty_clipboard_write = state; + } + + fn kittyClipboardData( + self: *Handler, + meta: *const kitty_clipboard.Metadata, + payload: []const u8, + terminator: osc.Terminator, + ) error{OutOfMemory}!void { + // Data without a transaction is silently ignored. + const state = self.kitty_clipboard_write orelse return; + + // A wdata packet without a MIME type commits the transaction. + if (meta.mime.len == 0) return self.kittyClipboardCommit( + state, + terminator, + ); + + state.data( + self.terminal.gpa(), + meta, + payload, + ) catch |err| switch (err) { + // Failing to spool matches kitty's EIO for a failed buffer + // write. + error.OutOfMemory => { + self.kittyClipboardFinish( + state, + .EIO, + terminator, + ); + return error.OutOfMemory; + }, + + // Data over the write limit aborts the transaction and is + // reported to the client. + error.TooLarge => self.kittyClipboardFinish( + state, + .EFBIG, + terminator, + ), + + // An invalid base64 payload stream aborts the transaction. + error.Invalid => self.kittyClipboardFinish( + state, + .EINVAL, + terminator, + ), + }; + } + + fn kittyClipboardAlias( + self: *Handler, + meta: *const kitty_clipboard.Metadata, + payload: []const u8, + terminator: osc.Terminator, + ) error{OutOfMemory}!void { + // Aliases without a transaction are silently ignored. Once a + // transaction exists, a missing target MIME type is invalid and + // aborts the transaction. + const state = self.kitty_clipboard_write orelse return; + if (meta.mime.len == 0) return self.kittyClipboardFinish( + state, + .EINVAL, + terminator, + ); + + state.alias( + self.terminal.gpa(), + meta, + payload, + ) catch |err| switch (err) { + error.OutOfMemory => { + self.kittyClipboardFinish( + state, + .EIO, + terminator, + ); + return error.OutOfMemory; + }, + + // An undecodable alias payload aborts the transaction. + error.Invalid => self.kittyClipboardFinish( + state, + .EINVAL, + terminator, + ), + }; + } + + fn kittyClipboardCommit( + self: *Handler, + state: *kitty_clipboard.WriteState, + terminator: osc.Terminator, + ) error{OutOfMemory}!void { + const alloc = self.terminal.gpa(); + const committed = state.commit(alloc) catch |err| switch (err) { + error.OutOfMemory => { + self.kittyClipboardFinish(state, .EIO, terminator); + return error.OutOfMemory; + }, + + // The last MIME type's payload stream was not correctly + // padded, which aborts the transaction. + error.Invalid => { + self.kittyClipboardFinish(state, .EINVAL, terminator); + return; + }, + }; + defer committed.deinit(alloc); + + // The effect can't be null here (checked when the transaction + // began) but if an embedder cleared it mid-transaction that's + // ENOSYS. + const func = self.effects.clipboard_write orelse { + self.kittyClipboardFinish(state, .ENOSYS, terminator); + return; + }; + + // Per the spec a password without a name is no password. A + // stored grant for it lets the embedder skip its prompt. + const pw: []const u8 = if (committed.name.len > 0) committed.pw else ""; + const granted = self.kitty_clipboard_grants.use(alloc, pw, .write); + + var reply_state: KittyClipboardWriteReplyState = .{ + .handler = self, + .pw = pw, + }; + func(self, .{ + .location = committed.loc, + .contents = committed.contents, + .name = committed.name, + .granted = granted, + .can_remember = pw.len > 0, + .reply_ctx = &reply_state, + .reply_fn = &KittyClipboardWriteReplyState.reply, + }); + + // The program is waiting on the commit status, so a callback + // that returned without a reply is answered as a denial rather + // than silence. + self.kittyClipboardFinish( + state, + reply_state.status orelse .EPERM, + terminator, + ); + } + + /// Reply state for one synchronous Kitty clipboard write. This lives + /// on the kittyClipboardCommit stack frame, so it is only valid + /// during the callback. + const KittyClipboardWriteReplyState = struct { + handler: *Handler, + + /// The effective password, empty when the request had none. + pw: []const u8, + + /// The replied commit status, mapped 1:1 from the reply result; + /// null until the callback replies. + status: ?kitty_clipboard.Status = null, + + fn reply(ctx: *anyopaque, result: clipboard.Write.Result) void { + const self: *KittyClipboardWriteReplyState = @ptrCast(@alignCast(ctx)); + if (self.status != null) { + log.warn("clipboard write replied more than once, ignoring", .{}); + return; + } + self.status = switch (result) { + .denied => .EPERM, + .unsupported => .ENOSYS, + .busy => .EBUSY, + .invalid_data => .EINVAL, + .io_error => .EIO, + .success => |success| status: { + // Remembering is only offered when the request + // carried a usable password. + if (success.remember and self.pw.len > 0) { + self.handler.kitty_clipboard_grants.grant( + self.handler.terminal.gpa(), + self.pw, + .write, + false, + ) catch |err| { + log.warn("error recording clipboard grant err={}", .{err}); + }; + } + break :status .DONE; + }, + }; + } + }; + + /// Answer a write transaction with its final status and drop it. + /// The id echoed is the one from the transaction's opening write + /// packet, matching kitty. + fn kittyClipboardFinish( + self: *Handler, + state: *const kitty_clipboard.WriteState, + status: kitty_clipboard.Status, + terminator: osc.Terminator, + ) void { + self.kittyClipboardRespond(&.{ + .op = .write, + .status = status, + .id = state.id, + .terminator = terminator, + }); + self.kittyClipboardAbort(); + } + + /// Drop any in-flight write transaction without responding. + fn kittyClipboardAbort(self: *Handler) void { + if (self.kitty_clipboard_write) |state| { + const alloc = self.terminal.gpa(); + state.deinit(alloc); + alloc.destroy(state); + self.kitty_clipboard_write = null; + } + } + + /// Encode and write a single response packet. Unlike kitty, which + /// always terminates responses with ST, we echo the terminator of + /// the request being answered, matching our other OSC responses. + fn kittyClipboardRespond( + self: *Handler, + response: *const kitty_clipboard.Response, + ) void { + if (self.effects.write_pty == null) return; + + // Our responses carry at most a status and the echoed id so + // they virtually always fit on the stack. + var stack = std.heap.stackFallback(1024, self.terminal.gpa()); + const alloc = stack.get(); + var aw: std.Io.Writer.Allocating = .init(alloc); + defer aw.deinit(); + response.encode(&aw.writer) catch return; + const resp = aw.toOwnedSliceSentinel(0) catch return; + defer alloc.free(resp); + self.writePty(resp); + } + + /// Handle an OSC 72 drag and drop command. + fn kittyDnd( + self: *Handler, + v: Action.KittyDnd, + ) (Allocator.Error || std.Io.Writer.Error)!void { + // Responses are usually small (queries, errors) but data + // serving can produce many chunks, so fall back to the heap. + var stack = std.heap.stackFallback(512, self.terminal.gpa()); + const response_alloc = stack.get(); + var aw: std.Io.Writer.Allocating = .init(response_alloc); + defer aw.deinit(); + + // The state is allocated on registration and owned by the + // terminal, so it uses the terminal's allocator, not the + // response's. + const event = try kitty_dnd.handleCommand( + &self.terminal.kitty_dnd, + self.terminal.gpa(), + &aw.writer, + v, + ); + + if (aw.written().len > 0) { + const written = aw.toOwnedSliceSentinel(0) catch return; + defer response_alloc.free(written); + self.writePty(written); + } + + if (event) |ev| { + const func = self.effects.drag_and_drop orelse return; + func(self, ev); + } } fn reportDeviceAttributes(self: *Handler, req: device_attributes.Req) void { @@ -674,6 +1453,17 @@ pub const Handler = struct { self.writePty(resp); } + fn reportMode2048(self: *Handler) void { + const get_size = self.effects.size orelse return; + const current = get_size(self) orelse return; + + var buf: [128]u8 = undefined; + var writer: std.Io.Writer = .fixed(buf[0 .. buf.len - 1]); + size_report.encode(&writer, .mode_2048, current) catch return; + buf[writer.end] = 0; + self.writePty(buf[0..writer.end :0]); + } + fn windowTitle(self: *Handler, title_raw: []const u8) !void { // Prevent DoS attacks by limiting title length. const max_title_len = 1024; @@ -712,7 +1502,16 @@ pub const Handler = struct { } fn requestMode(self: *Handler, mode: modes.Mode) void { - const report = self.terminal.modes.getReport(.fromMode(mode)); + var report = self.terminal.modes.getReport(.fromMode(mode)); + + // Kitty paste events (mode 5522) can't work without a clipboard + // read effect, so if that isn't set mark it as unrecognized. + if (mode == .kitty_paste_events and + self.effects.clipboard_read == null) + { + report.state = .not_recognized; + } + self.sendModeReport(report); } @@ -797,10 +1596,14 @@ pub const Handler = struct { .synchronized_output, .linefeed, - .in_band_size_reports, .focus_event, => {}, + // Enabling mode 2048 reports already-committed pixel geometry. + // Waiting for the next resize leaves late-enabling clients without + // the dimensions they need for their first image frame. + .in_band_size_reports => if (enabled) self.reportMode2048(), + .report_visibility => if (enabled) self.sendVisibilityReport(), .mouse_event_x10 => { @@ -1054,7 +1857,7 @@ pub const Handler = struct { } }, - .glyph => |*glyph_req| { + .glyph => |*glyph_req| if (comptime build_options.glyph_protocol) { const resp = self.terminal.glyphProtocol(alloc, glyph_req); if (resp) |r| resp_block: { // Don't waste time encoding if we can't write responses @@ -1076,6 +1879,51 @@ pub const Handler = struct { } }; +/// A writer that delivers everything through the write_pty effect: +/// the buffer as it fills, and data that doesn't fit it directly. +/// Never fails, since the effect can't. +const PtyWriter = struct { + handler: *Handler, + writer: std.Io.Writer, + + fn init(handler: *Handler, buffer: []u8) PtyWriter { + return .{ + .handler = handler, + .writer = .{ + .vtable = &.{ .drain = drain }, + .buffer = buffer, + }, + }; + } + + fn drain( + w: *std.Io.Writer, + data: []const []const u8, + splat: usize, + ) std.Io.Writer.Error!usize { + const self: *PtyWriter = @alignCast(@fieldParentPtr("writer", w)); + + // Buffered bytes go first to keep the order. + if (w.end > 0) { + self.handler.writePty(w.buffer[0..w.end]); + w.end = 0; + } + + var consumed: usize = 0; + for (data[0 .. data.len - 1]) |slice| { + if (slice.len > 0) self.handler.writePty(slice); + consumed += slice.len; + } + + const pattern = data[data.len - 1]; + for (0..splat) |_| { + if (pattern.len > 0) self.handler.writePty(pattern); + consumed += pattern.len; + } + return consumed; + } +}; + test "resize clears synchronized output on unchanged cell dimensions" { var t: Terminal = try .init(testing.io, testing.allocator, .{ .cols = 80, .rows = 24 }); defer t.deinit(testing.allocator); @@ -1148,7 +1996,7 @@ test "resize reports mode 2048 geometry" { var response: [128]u8 = undefined; var response_len: usize = 0; - fn writePty(_: *Handler, data: [:0]const u8) void { + fn writePty(_: *Handler, data: []const u8) void { @memcpy(response[0..data.len], data); response_len = data.len; } @@ -1177,7 +2025,7 @@ test "resize suppresses mode 2048 reports" { const S = struct { var calls: usize = 0; - fn writePty(_: *Handler, _: [:0]const u8) void { + fn writePty(_: *Handler, _: []const u8) void { calls += 1; } }; @@ -1233,7 +2081,7 @@ test "resize failure preserves terminal state and does not write" { const S = struct { var called: bool = false; - fn writePty(_: *Handler, _: [:0]const u8) void { + fn writePty(_: *Handler, _: []const u8) void { called = true; } }; @@ -1275,7 +2123,7 @@ test "resize effects do not change canonical terminal state" { defer readonly.deinit(testing.allocator); const S = struct { - fn writePty(_: *Handler, _: [:0]const u8) void {} + fn writePty(_: *Handler, _: []const u8) void {} }; var authoritative_handler: Handler = .init(&authoritative); authoritative_handler.effects.write_pty = &S.writePty; @@ -1547,7 +2395,7 @@ test "DECRQSS responses" { calls = 0; } - fn writePty(_: *Handler, data: [:0]const u8) void { + fn writePty(_: *Handler, data: []const u8) void { @memcpy(response[0..data.len], data); response_len = data.len; calls += 1; @@ -1619,7 +2467,7 @@ test "XTGETTCAP responses" { calls = 0; } - fn writePty(_: *Handler, data: [:0]const u8) void { + fn writePty(_: *Handler, data: []const u8) void { @memcpy(response[0..data.len], data); response_len = data.len; calls += 1; @@ -1701,7 +2549,7 @@ test "XTGETTCAP TN responses" { calls = 0; } - fn writePty(_: *Handler, data: [:0]const u8) void { + fn writePty(_: *Handler, data: []const u8) void { @memcpy(response[0..data.len], data); response_len = data.len; calls += 1; @@ -1824,12 +2672,14 @@ test "full reset" { } test "glyph protocol APC with write_pty callback" { + if (comptime !build_options.glyph_protocol) return error.SkipZigTest; + var t: Terminal = try .init(testing.io, testing.allocator, .{ .cols = 80, .rows = 24 }); defer t.deinit(testing.allocator); const S = struct { var last_response: ?[:0]const u8 = null; - fn writePty(_: *Handler, data: [:0]const u8) void { + fn writePty(_: *Handler, data: []const u8) void { if (last_response) |old| testing.allocator.free(old); last_response = testing.allocator.dupeZ(u8, data) catch @panic("OOM"); } @@ -2008,7 +2858,7 @@ test "OSC color query responses" { last_response = null; } - fn writePty(_: *Handler, data: [:0]const u8) void { + fn writePty(_: *Handler, data: []const u8) void { reset(); last_response = testing.allocator.dupeZ(u8, data) catch @panic("OOM"); } @@ -2159,7 +3009,7 @@ test "kitty color protocol query responses" { last_response = null; } - fn writePty(_: *Handler, data: [:0]const u8) void { + fn writePty(_: *Handler, data: []const u8) void { reset(); last_response = testing.allocator.dupeZ(u8, data) catch @panic("OOM"); } @@ -2486,6 +3336,15 @@ test "progress_report effect callback" { try testing.expectEqual(case.state, S.last_state); try testing.expectEqual(case.progress, S.last_progress); } + + // A full reset (RIS) removes any active progress bar. + s.nextSlice("\x1B]9;4;1;50\x1B\\"); + try testing.expectEqual(@as(usize, cases.len + 1), S.count); + try testing.expectEqual(osc.Command.ProgressReport.State.set, S.last_state); + s.nextSlice("\x1Bc"); + try testing.expectEqual(@as(usize, cases.len + 2), S.count); + try testing.expectEqual(osc.Command.ProgressReport.State.remove, S.last_state); + try testing.expectEqual(@as(?u8, null), S.last_progress); } test "clipboard_write effect callback" { @@ -2510,7 +3369,7 @@ test "clipboard_write effect callback" { const S = struct { var count: usize = 0; - var result: clipboard.WriteResult = .success; + var result: clipboard.Write.Result = .{ .success = .{} }; var last_location: clipboard.Location = .standard; var last_contents_len: usize = 0; var last_mime: ?[]u8 = null; @@ -2524,7 +3383,7 @@ test "clipboard_write effect callback" { last_contents_len = 0; } - fn clipboardWrite(_: *Handler, write: clipboard.Write) clipboard.WriteResult { + fn clipboardWrite(_: *Handler, write: clipboard.Write) void { clearCapture(); count += 1; last_location = write.location; @@ -2535,7 +3394,7 @@ test "clipboard_write effect callback" { last_data = testing.allocator.dupe(u8, write.contents[0].data) catch @panic("failed to capture clipboard data"); } - return result; + write.reply(result); } }; S.count = 0; @@ -2561,6 +3420,9 @@ test "clipboard_write effect callback" { .{ .sequence = "\x1B]52;0;Y3V0\x1B\\", .location = .standard, .data = "cut" }, .{ .sequence = "\x1B]52;x;ZmFsbGJhY2s=\x1B\\", .location = .standard, .data = "fallback" }, .{ .sequence = "\x1B]52;c;YQBi\x1B\\", .location = .standard, .data = "a\x00b" }, + // Missing padding is tolerated for OSC 52 since it has no way + // to report errors to the client, matching kitty. + .{ .sequence = "\x1B]52;c;dW5wYWRkZWQ\x1B\\", .location = .standard, .data = "unpadded" }, }; for (cases, 1..) |case, expected_count| { @@ -2580,9 +3442,14 @@ test "clipboard_write effect callback" { try testing.expect(S.last_mime == null); try testing.expect(S.last_data == null); - // Reads and malformed base64 are ignored. + // Reads and malformed base64 are ignored. The whole request is + // discarded on invalid characters (including whitespace) rather + // than decoding around them, per the Kitty clipboard spec that + // governs OSC 52 base64 handling. s.nextSlice("\x1B]52;c;?\x1B\\"); s.nextSlice("\x1B]52;c;***\x1B\\"); + s.nextSlice("\x1B]52;c;SGVs!!!bG8=\x1B\\"); + s.nextSlice("\x1B]52;c;aGVs bG8=\x1B\\"); try testing.expectEqual(@as(usize, cases.len + 1), S.count); // OSC 1337 Copy shares the normalized clipboard write path. @@ -2601,9 +3468,123 @@ test "clipboard_write effect callback" { try testing.expectEqualStrings("text/plain", S.last_mime.?); try testing.expectEqualStrings("fragmented", S.last_data.?); - // Callback results are intentionally ignored for protocols without a - // write acknowledgement. The denied result above did not stop later writes. - try testing.expectEqual(clipboard.WriteResult.denied, S.result); + // Reply results are intentionally ignored for protocols without a + // write acknowledgement. The denied reply above did not stop later writes. + try testing.expect(S.result == .denied); +} + +test "clipboard_read effect callback" { + var t: Terminal = try .init(testing.io, testing.allocator, .{ .cols = 80, .rows = 24 }); + defer t.deinit(testing.allocator); + + const S = struct { + var written: std.ArrayList(u8) = .empty; + var count: usize = 0; + var last_location: clipboard.Location = .standard; + var last_mimes: []const []const u8 = &.{}; + var last_list: bool = true; + var last_name: []const u8 = "unset"; + var last_granted: bool = true; + var last_can_remember: bool = true; + var result: ?clipboard.Read.Result = .{ .success = .{ .contents = &.{.{ + .mime = "text/plain", + .data = "hello", + }} } }; + var reply_twice: bool = false; + + fn writePty(_: *Handler, data: []const u8) void { + written.appendSlice(testing.allocator, data) catch @panic("OOM"); + } + + fn clipboardRead(_: *Handler, read: clipboard.Read) void { + count += 1; + last_location = read.location; + last_mimes = read.mimes; + last_list = read.list; + last_name = read.name; + last_granted = read.granted; + last_can_remember = read.can_remember; + if (result) |r| read.reply(r); + if (reply_twice) read.reply(.{ .success = .{ .contents = &.{.{ + .mime = "text/plain", + .data = "again", + }} } }); + } + }; + defer S.written.deinit(testing.allocator); + + // A null callback (the default readonly effects) silently ignores reads. + { + var handler: Handler = .init(&t); + handler.effects.write_pty = &S.writePty; + var s: Stream = .init(.{ .allocator = testing.allocator, .handler = handler }); + defer s.deinit(); + + s.nextSlice("\x1B]52;c;?\x1B\\"); + try testing.expectEqual(0, S.written.items.len); + } + + var handler: Handler = .init(&t); + handler.effects.write_pty = &S.writePty; + handler.effects.clipboard_read = &S.clipboardRead; + var s: Stream = .init(.{ .allocator = testing.allocator, .handler = handler }); + defer s.deinit(); + + // Success echoes the normalized selector and request terminator. + s.nextSlice("\x1B]52;c;?\x1B\\"); + try testing.expectEqual(1, S.count); + try testing.expectEqual(clipboard.Location.standard, S.last_location); + try testing.expectEqual(1, S.last_mimes.len); + try testing.expectEqualStrings("text/plain", S.last_mimes[0]); + try testing.expect(!S.last_list); + try testing.expectEqualStrings("", S.last_name); + try testing.expect(!S.last_granted); + try testing.expect(!S.last_can_remember); + try testing.expectEqualStrings("\x1B]52;c;aGVsbG8=\x1B\\", S.written.items); + + S.written.clearRetainingCapacity(); + s.nextSlice("\x1B]52;p;?\x07"); + try testing.expectEqual(clipboard.Location.primary, S.last_location); + try testing.expectEqualStrings("\x1B]52;p;aGVsbG8=\x07", S.written.items); + + // Only the first text representation is used. + S.written.clearRetainingCapacity(); + S.result = .{ + .success = .{ + .contents = &.{ + .{ .mime = "image/png", .data = "\x89PNG" }, + .{ .mime = "UTF8_STRING", .data = "hi" }, + }, + // OSC 52 has no session passwords, so remember is ignored. + .remember = true, + }, + }; + s.nextSlice("\x1B]52;s;?\x1B\\"); + try testing.expectEqual(clipboard.Location.selection, S.last_location); + try testing.expectEqualStrings("\x1B]52;s;aGk=\x1B\\", S.written.items); + + // Every failure, no text, and no reply all answer with an empty + // clipboard. + for ([_]?clipboard.Read.Result{ + .denied, + .unsupported, + .busy, + .io_error, + .{ .success = .{} }, + null, + }) |result| { + S.written.clearRetainingCapacity(); + S.result = result; + s.nextSlice("\x1B]52;c;?\x1B\\"); + try testing.expectEqualStrings("\x1B]52;c;\x1B\\", S.written.items); + } + + // A second reply is ignored. + S.written.clearRetainingCapacity(); + S.result = .{ .success = .{ .contents = &.{.{ .mime = "text/plain", .data = "hello" }} } }; + S.reply_twice = true; + s.nextSlice("\x1B]52;c;?\x1B\\"); + try testing.expectEqualStrings("\x1B]52;c;aGVsbG8=\x1B\\", S.written.items); } test "clipboard_write allocation failure is ignored" { @@ -2613,9 +3594,9 @@ test "clipboard_write allocation failure is ignored" { const S = struct { var count: usize = 0; - fn clipboardWrite(_: *Handler, _: clipboard.Write) clipboard.WriteResult { + fn clipboardWrite(_: *Handler, write: clipboard.Write) void { count += 1; - return .success; + write.reply(.{ .success = .{} }); } }; S.count = 0; @@ -2638,6 +3619,950 @@ test "clipboard_write allocation failure is ignored" { try testing.expect(!s.handler.semantic_failure); } +/// Shared capture state for the Kitty clipboard (OSC 5522) tests below: +/// records every pty response and the most recent clipboard write. +const KittyClipboardCapture = struct { + var responses: [1024]u8 = undefined; + var responses_len: usize = 0; + + // Write capture. A null write_result returns without replying. + var write_count: usize = 0; + var write_result: ?clipboard.Write.Result = .{ .success = .{} }; + var write_reply_twice: bool = false; + var last_location: clipboard.Location = .standard; + var last_contents_len: usize = 0; + var last_mimes: [8][64]u8 = undefined; + var last_mime_lens: [8]usize = @splat(0); + var last_data: [8][256]u8 = undefined; + var last_data_lens: [8]usize = @splat(0); + var last_write_name: [64]u8 = undefined; + var last_write_name_len: usize = 0; + var last_write_granted: bool = false; + var last_write_can_remember: bool = false; + + // Read capture. A null read_result returns without replying. + var read_count: usize = 0; + var read_result: ?clipboard.Read.Result = null; + var read_reply_twice: bool = false; + var last_read_location: clipboard.Location = .standard; + var last_read_mimes: [8][64]u8 = undefined; + var last_read_mime_lens: [8]usize = @splat(0); + var last_read_mimes_len: usize = 0; + var last_read_list: bool = false; + var last_read_name: [64]u8 = undefined; + var last_read_name_len: usize = 0; + var last_read_granted: bool = false; + var last_read_can_remember: bool = false; + + fn reset() void { + responses_len = 0; + write_count = 0; + write_result = .{ .success = .{} }; + write_reply_twice = false; + last_location = .standard; + last_contents_len = 0; + last_mime_lens = @splat(0); + last_data_lens = @splat(0); + last_write_name_len = 0; + last_write_granted = false; + last_write_can_remember = false; + read_count = 0; + read_result = null; + read_reply_twice = false; + last_read_location = .standard; + last_read_mime_lens = @splat(0); + last_read_mimes_len = 0; + last_read_list = false; + last_read_name_len = 0; + last_read_granted = false; + last_read_can_remember = false; + } + + fn writePty(_: *Handler, data: []const u8) void { + @memcpy(responses[responses_len..][0..data.len], data); + responses_len += data.len; + } + + fn clipboardWrite(_: *Handler, write: clipboard.Write) void { + write_count += 1; + last_location = write.location; + last_contents_len = write.contents.len; + for (write.contents[0..@min(write.contents.len, last_mimes.len)], 0..) |content, i| { + last_mime_lens[i] = content.mime.len; + @memcpy(last_mimes[i][0..content.mime.len], content.mime); + last_data_lens[i] = content.data.len; + @memcpy(last_data[i][0..content.data.len], content.data); + } + last_write_name_len = write.name.len; + @memcpy(last_write_name[0..write.name.len], write.name); + last_write_granted = write.granted; + last_write_can_remember = write.can_remember; + if (write_result) |r| write.reply(r); + if (write_reply_twice) write.reply(.io_error); + } + + fn clipboardRead(_: *Handler, read: clipboard.Read) void { + read_count += 1; + last_read_location = read.location; + last_read_mimes_len = read.mimes.len; + for (read.mimes[0..@min(read.mimes.len, last_read_mimes.len)], 0..) |mime, i| { + last_read_mime_lens[i] = mime.len; + @memcpy(last_read_mimes[i][0..mime.len], mime); + } + last_read_list = read.list; + last_read_name_len = read.name.len; + @memcpy(last_read_name[0..read.name.len], read.name); + last_read_granted = read.granted; + last_read_can_remember = read.can_remember; + if (read_result) |r| read.reply(r); + if (read_reply_twice) read.reply(.denied); + } + + fn responseSlice() []const u8 { + return responses[0..responses_len]; + } + + fn readMimeAt(i: usize) []const u8 { + return last_read_mimes[i][0..last_read_mime_lens[i]]; + } + + fn readName() []const u8 { + return last_read_name[0..last_read_name_len]; + } + + fn writeName() []const u8 { + return last_write_name[0..last_write_name_len]; + } + + fn mimeAt(i: usize) []const u8 { + return last_mimes[i][0..last_mime_lens[i]]; + } + + fn dataAt(i: usize) []const u8 { + return last_data[i][0..last_data_lens[i]]; + } +}; + +test "kitty clipboard write transaction round trip" { + var t: Terminal = try .init(testing.io, testing.allocator, .{ .cols = 80, .rows = 24 }); + defer t.deinit(testing.allocator); + + const S = KittyClipboardCapture; + S.reset(); + + var handler: Handler = .init(&t); + handler.effects.write_pty = &S.writePty; + handler.effects.clipboard_write = &S.clipboardWrite; + var s: Stream = .init(.{ .allocator = testing.allocator, .handler = handler }); + defer s.deinit(); + + // Begin a write, stream two MIME types (one chunked), alias the + // plain text, and commit. Only the commit produces a response. + s.nextSlice("\x1B]5522;type=write:id=42\x1B\\"); + s.nextSlice("\x1B]5522;type=wdata:mime=dGV4dC9wbGFpbg==;R2hvc3Q=\x1B\\"); // "Ghost" + s.nextSlice("\x1B]5522;type=wdata:mime=dGV4dC9wbGFpbg==;dHk=\x1B\\"); // "ty" + s.nextSlice("\x1B]5522;type=wdata:mime=dGV4dC9odG1s;PGI+aGk8L2I+\x1B\\"); // "<b>hi</b>" + // Alias "TEXT UTF8_STRING" -> text/plain. + s.nextSlice("\x1B]5522;type=walias:mime=dGV4dC9wbGFpbg==;VEVYVCBVVEY4X1NUUklORw==\x1B\\"); + try testing.expectEqual(@as(usize, 0), S.write_count); + try testing.expectEqual(@as(usize, 0), S.responses_len); + + s.nextSlice("\x1B]5522;type=wdata\x1B\\"); + try testing.expectEqual(@as(usize, 1), S.write_count); + try testing.expectEqual(clipboard.Location.standard, S.last_location); + try testing.expectEqual(@as(usize, 4), S.last_contents_len); + try testing.expectEqualStrings("text/plain", S.mimeAt(0)); + try testing.expectEqualStrings("Ghostty", S.dataAt(0)); + try testing.expectEqualStrings("text/html", S.mimeAt(1)); + try testing.expectEqualStrings("<b>hi</b>", S.dataAt(1)); + try testing.expectEqualStrings("TEXT", S.mimeAt(2)); + try testing.expectEqualStrings("Ghostty", S.dataAt(2)); + try testing.expectEqualStrings("UTF8_STRING", S.mimeAt(3)); + try testing.expectEqualStrings("Ghostty", S.dataAt(3)); + try testing.expectEqualStrings( + "\x1B]5522;type=write:status=DONE:id=42\x1B\\", + S.responseSlice(), + ); + + // A commit with no transaction in flight is silently ignored. + s.nextSlice("\x1B]5522;type=wdata\x1B\\"); + try testing.expectEqual(@as(usize, 1), S.write_count); +} + +test "kitty clipboard write result maps to response status" { + var t: Terminal = try .init(testing.io, testing.allocator, .{ .cols = 80, .rows = 24 }); + defer t.deinit(testing.allocator); + + const S = KittyClipboardCapture; + + var handler: Handler = .init(&t); + handler.effects.write_pty = &S.writePty; + handler.effects.clipboard_write = &S.clipboardWrite; + var s: Stream = .init(.{ .allocator = testing.allocator, .handler = handler }); + defer s.deinit(); + + const cases = [_]struct { + result: ?clipboard.Write.Result, + response: []const u8, + }{ + .{ .result = .{ .success = .{} }, .response = "\x1B]5522;type=write:status=DONE\x1B\\" }, + .{ .result = .denied, .response = "\x1B]5522;type=write:status=EPERM\x1B\\" }, + .{ .result = .unsupported, .response = "\x1B]5522;type=write:status=ENOSYS\x1B\\" }, + .{ .result = .busy, .response = "\x1B]5522;type=write:status=EBUSY\x1B\\" }, + .{ .result = .invalid_data, .response = "\x1B]5522;type=write:status=EINVAL\x1B\\" }, + .{ .result = .io_error, .response = "\x1B]5522;type=write:status=EIO\x1B\\" }, + // No reply at all is a denial rather than silence. + .{ .result = null, .response = "\x1B]5522;type=write:status=EPERM\x1B\\" }, + }; + + for (cases) |case| { + S.reset(); + S.write_result = case.result; + + // An immediately-committed write with no data is a clear. + s.nextSlice("\x1B]5522;type=write\x1B\\"); + s.nextSlice("\x1B]5522;type=wdata\x1B\\"); + try testing.expectEqual(@as(usize, 1), S.write_count); + try testing.expectEqual(@as(usize, 0), S.last_contents_len); + try testing.expectEqualStrings(case.response, S.responseSlice()); + } + + // A second reply is ignored. + S.reset(); + S.write_reply_twice = true; + s.nextSlice("\x1B]5522;type=write\x1B\\"); + s.nextSlice("\x1B]5522;type=wdata\x1B\\"); + try testing.expectEqualStrings( + "\x1B]5522;type=write:status=DONE\x1B\\", + S.responseSlice(), + ); + + // The response echoes the request terminator, unlike kitty which + // always uses ST. + S.reset(); + s.nextSlice("\x1B]5522;type=write:loc=primary\x07"); + s.nextSlice("\x1B]5522;type=wdata\x07"); + try testing.expectEqual(clipboard.Location.primary, S.last_location); + try testing.expectEqualStrings( + "\x1B]5522;type=write:status=DONE\x07", + S.responseSlice(), + ); + + // A system without a primary selection answers a loc=primary write + // with ENOSYS, echoing the id. + S.reset(); + S.write_result = .unsupported; + s.nextSlice("\x1B]5522;type=write:loc=primary:id=p1\x1B\\"); + s.nextSlice("\x1B]5522;type=wdata\x1B\\"); + try testing.expectEqual(clipboard.Location.primary, S.last_location); + try testing.expectEqualStrings( + "\x1B]5522;type=write:status=ENOSYS:id=p1\x1B\\", + S.responseSlice(), + ); +} + +test "kitty clipboard write without clipboard effect responds ENOSYS" { + var t: Terminal = try .init(testing.io, testing.allocator, .{ .cols = 80, .rows = 24 }); + defer t.deinit(testing.allocator); + + const S = KittyClipboardCapture; + S.reset(); + + var handler: Handler = .init(&t); + handler.effects.write_pty = &S.writePty; + var s: Stream = .init(.{ .allocator = testing.allocator, .handler = handler }); + defer s.deinit(); + + // The transaction fails as soon as it begins; the rest of it is + // ignored without further responses. + s.nextSlice("\x1B]5522;type=write:id=x\x1B\\"); + try testing.expectEqualStrings( + "\x1B]5522;type=write:status=ENOSYS:id=x\x1B\\", + S.responseSlice(), + ); + s.nextSlice("\x1B]5522;type=wdata:mime=dGV4dC9wbGFpbg==;R2hvc3Q=\x1B\\"); + s.nextSlice("\x1B]5522;type=wdata\x1B\\"); + try testing.expectEqualStrings( + "\x1B]5522;type=write:status=ENOSYS:id=x\x1B\\", + S.responseSlice(), + ); +} + +test "kitty clipboard read without effect is denied with EPERM" { + var t: Terminal = try .init(testing.io, testing.allocator, .{ .cols = 80, .rows = 24 }); + defer t.deinit(testing.allocator); + + const S = KittyClipboardCapture; + S.reset(); + + var handler: Handler = .init(&t); + handler.effects.write_pty = &S.writePty; + handler.effects.clipboard_write = &S.clipboardWrite; + var s: Stream = .init(.{ .allocator = testing.allocator, .handler = handler }); + defer s.deinit(); + + // The denial never includes loc (only OK responses do) and echoes + // the sanitized id. + s.nextSlice("\x1B]5522;type=read:loc=primary:id=*4 2*;dGV4dC9wbGFpbg==\x1B\\"); + try testing.expectEqualStrings( + "\x1B]5522;type=read:status=EPERM:id=42\x1B\\", + S.responseSlice(), + ); + + // A missing payload is an empty MIME list, still answered. + S.reset(); + s.nextSlice("\x1B]5522;type=read\x07"); + try testing.expectEqualStrings( + "\x1B]5522;type=read:status=EPERM\x07", + S.responseSlice(), + ); + + // An undecodable payload is dropped without a response. + S.reset(); + s.nextSlice("\x1B]5522;type=read;!!!\x1B\\"); + try testing.expectEqual(@as(usize, 0), S.responses_len); +} + +test "kitty clipboard read round trip" { + var t: Terminal = try .init(testing.io, testing.allocator, .{ .cols = 80, .rows = 24 }); + defer t.deinit(testing.allocator); + + const S = KittyClipboardCapture; + S.reset(); + + var handler: Handler = .init(&t); + handler.effects.write_pty = &S.writePty; + handler.effects.clipboard_read = &S.clipboardRead; + var s: Stream = .init(.{ .allocator = testing.allocator, .handler = handler }); + defer s.deinit(); + + S.read_result = .{ + .success = .{ + .contents = &.{ + // Unrequested representations are never served, and the + // served ones follow request order, not reply order. + .{ .mime = "image/png", .data = "\x89PNG" }, + .{ .mime = "text/html", .data = "<b>hi</b>" }, + .{ .mime = "text/plain", .data = "Ghostty" }, + }, + .available = &.{ "text/plain", "text/html" }, + }, + }; + + // Request the targets listing plus two types from the primary + // selection: ". text/plain text/html". + s.nextSlice("\x1B]5522;type=read:loc=primary:id=r1;LiB0ZXh0L3BsYWluIHRleHQvaHRtbA==\x1B\\"); + try testing.expectEqual(@as(usize, 1), S.read_count); + try testing.expectEqual(clipboard.Location.primary, S.last_read_location); + try testing.expectEqual(@as(usize, 2), S.last_read_mimes_len); + try testing.expectEqualStrings("text/plain", S.readMimeAt(0)); + try testing.expectEqualStrings("text/html", S.readMimeAt(1)); + try testing.expect(S.last_read_list); + try testing.expectEqualStrings("", S.readName()); + try testing.expect(!S.last_read_granted); + try testing.expect(!S.last_read_can_remember); + try testing.expectEqualStrings( + "\x1B]5522;type=read:status=OK:loc=primary:id=r1\x1B\\" ++ + "\x1B]5522;type=read:status=DATA:id=r1:mime=Lg==;dGV4dC9wbGFpbiB0ZXh0L2h0bWwK\x1B\\" ++ + "\x1B]5522;type=read:status=DATA:id=r1:mime=dGV4dC9wbGFpbg==;R2hvc3R0eQ==\x1B\\" ++ + "\x1B]5522;type=read:status=DATA:id=r1:mime=dGV4dC9odG1s;PGI+aGk8L2I+\x1B\\" ++ + "\x1B]5522;type=read:status=DONE:id=r1\x1B\\", + S.responseSlice(), + ); + + // Without the listing request `available` is ignored. The response + // echoes the request terminator. + S.responses_len = 0; + s.nextSlice("\x1B]5522;type=read:id=r2;dGV4dC9wbGFpbg==\x07"); + try testing.expectEqual(clipboard.Location.standard, S.last_read_location); + try testing.expectEqual(@as(usize, 1), S.last_read_mimes_len); + try testing.expect(!S.last_read_list); + try testing.expectEqualStrings( + "\x1B]5522;type=read:status=OK:id=r2\x07" ++ + "\x1B]5522;type=read:status=DATA:id=r2:mime=dGV4dC9wbGFpbg==;R2hvc3R0eQ==\x07" ++ + "\x1B]5522;type=read:status=DONE:id=r2\x07", + S.responseSlice(), + ); + + // A listing-only request carries no types. + S.responses_len = 0; + s.nextSlice("\x1B]5522;type=read;Lg==\x1B\\"); + try testing.expectEqual(@as(usize, 0), S.last_read_mimes_len); + try testing.expect(S.last_read_list); + try testing.expectEqualStrings( + "\x1B]5522;type=read:status=OK\x1B\\" ++ + "\x1B]5522;type=read:status=DATA:mime=Lg==;dGV4dC9wbGFpbiB0ZXh0L2h0bWwK\x1B\\" ++ + "\x1B]5522;type=read:status=DONE\x1B\\", + S.responseSlice(), + ); +} + +test "kitty clipboard read result maps to response status" { + var t: Terminal = try .init(testing.io, testing.allocator, .{ .cols = 80, .rows = 24 }); + defer t.deinit(testing.allocator); + + const S = KittyClipboardCapture; + + var handler: Handler = .init(&t); + handler.effects.write_pty = &S.writePty; + handler.effects.clipboard_read = &S.clipboardRead; + var s: Stream = .init(.{ .allocator = testing.allocator, .handler = handler }); + defer s.deinit(); + + const cases = [_]struct { + result: ?clipboard.Read.Result, + response: []const u8, + }{ + .{ .result = .denied, .response = "\x1B]5522;type=read:status=EPERM:id=x\x1B\\" }, + .{ .result = .unsupported, .response = "\x1B]5522;type=read:status=ENOSYS:id=x\x1B\\" }, + .{ .result = .busy, .response = "\x1B]5522;type=read:status=EBUSY:id=x\x1B\\" }, + .{ .result = .io_error, .response = "\x1B]5522;type=read:status=EIO:id=x\x1B\\" }, + // No reply at all is a denial rather than silence. + .{ .result = null, .response = "\x1B]5522;type=read:status=EPERM:id=x\x1B\\" }, + // A success with nothing to serve is still OK then DONE. + .{ .result = .{ .success = .{} }, .response = "\x1B]5522;type=read:status=OK:id=x\x1B\\" ++ + "\x1B]5522;type=read:status=DONE:id=x\x1B\\" }, + }; + + for (cases) |case| { + S.reset(); + S.read_result = case.result; + s.nextSlice("\x1B]5522;type=read:id=x;dGV4dC9wbGFpbg==\x1B\\"); + try testing.expectEqual(@as(usize, 1), S.read_count); + try testing.expectEqualStrings(case.response, S.responseSlice()); + } + + // A second reply is ignored. + S.reset(); + S.read_result = .{ .success = .{ .contents = &.{.{ .mime = "text/plain", .data = "hello" }} } }; + S.read_reply_twice = true; + s.nextSlice("\x1B]5522;type=read;dGV4dC9wbGFpbg==\x1B\\"); + try testing.expectEqualStrings( + "\x1B]5522;type=read:status=OK\x1B\\" ++ + "\x1B]5522;type=read:status=DATA:mime=dGV4dC9wbGFpbg==;aGVsbG8=\x1B\\" ++ + "\x1B]5522;type=read:status=DONE\x1B\\", + S.responseSlice(), + ); +} + +test "kitty clipboard read caps requested types" { + var t: Terminal = try .init(testing.io, testing.allocator, .{ .cols = 80, .rows = 24 }); + defer t.deinit(testing.allocator); + + const S = KittyClipboardCapture; + S.reset(); + + var handler: Handler = .init(&t); + handler.effects.write_pty = &S.writePty; + handler.effects.clipboard_read = &S.clipboardRead; + var s: Stream = .init(.{ .allocator = testing.allocator, .handler = handler }); + defer s.deinit(); + + // "a/0 a/1 a/2 a/3 a/4 a/5 .": extras are dropped but the listing + // request after them still counts. + S.read_result = .{ .success = .{} }; + s.nextSlice("\x1B]5522;type=read;YS8wIGEvMSBhLzIgYS8zIGEvNCBhLzUgLg==\x1B\\"); + try testing.expectEqual(@as(usize, 1), S.read_count); + try testing.expectEqual(kitty_clipboard.max_read_mimes, S.last_read_mimes_len); + try testing.expectEqualStrings("a/0", S.readMimeAt(0)); + try testing.expectEqualStrings("a/3", S.readMimeAt(kitty_clipboard.max_read_mimes - 1)); + try testing.expect(S.last_read_list); +} + +test "kitty clipboard read password grants" { + var t: Terminal = try .init(testing.io, testing.allocator, .{ .cols = 80, .rows = 24 }); + defer t.deinit(testing.allocator); + + const S = KittyClipboardCapture; + S.reset(); + + var handler: Handler = .init(&t); + handler.effects.write_pty = &S.writePty; + handler.effects.clipboard_read = &S.clipboardRead; + var s: Stream = .init(.{ .allocator = testing.allocator, .handler = handler }); + defer s.deinit(); + + // Every read requests a data type ("text/plain"): a request with + // no data types never consults the grants at all. + // + // pw="secret", name="app": the first request isn't granted but the + // reply may ask to remember it. + S.read_result = .{ .success = .{ .remember = true } }; + s.nextSlice("\x1B]5522;type=read:pw=c2VjcmV0:name=YXBw;dGV4dC9wbGFpbg==\x1B\\"); + try testing.expectEqual(@as(usize, 1), S.read_count); + try testing.expectEqualStrings("app", S.readName()); + try testing.expect(!S.last_read_granted); + try testing.expect(S.last_read_can_remember); + + // The same password is now granted; a different one is not. + S.read_result = .{ .success = .{} }; + s.nextSlice("\x1B]5522;type=read:pw=c2VjcmV0:name=YXBw;dGV4dC9wbGFpbg==\x1B\\"); + try testing.expect(S.last_read_granted); + s.nextSlice("\x1B]5522;type=read:pw=b3RoZXI=:name=YXBw;dGV4dC9wbGFpbg==\x1B\\"); + try testing.expect(!S.last_read_granted); + try testing.expect(S.last_read_can_remember); + + // A password without a name doesn't count: it is neither granted + // nor rememberable, even if the reply asks. + S.read_result = .{ .success = .{ .remember = true } }; + s.nextSlice("\x1B]5522;type=read:pw=c2VjcmV0;dGV4dC9wbGFpbg==\x1B\\"); + try testing.expectEqualStrings("", S.readName()); + try testing.expect(!S.last_read_granted); + try testing.expect(!S.last_read_can_remember); + s.nextSlice("\x1B]5522;type=read:pw=b3RoZXI=;dGV4dC9wbGFpbg==\x1B\\"); + try testing.expect(!S.last_read_can_remember); + S.read_result = .{ .success = .{} }; + s.nextSlice("\x1B]5522;type=read:pw=b3RoZXI=:name=YXBw;dGV4dC9wbGFpbg==\x1B\\"); + try testing.expect(!S.last_read_granted); + + // A grant is advisory: the request is still forwarded and the + // embedder may deny it. + S.responses_len = 0; + S.read_result = .denied; + s.nextSlice("\x1B]5522;type=read:id=d:pw=c2VjcmV0:name=YXBw;dGV4dC9wbGFpbg==\x1B\\"); + try testing.expect(S.last_read_granted); + try testing.expectEqualStrings( + "\x1B]5522;type=read:status=EPERM:id=d\x1B\\", + S.responseSlice(), + ); + + // Grants are freed with the stream (the testing allocator catches + // the leak otherwise). +} + +test "kitty clipboard read targets-only never consumes a one-time grant" { + var t: Terminal = try .init(testing.io, testing.allocator, .{ .cols = 80, .rows = 24 }); + defer t.deinit(testing.allocator); + + const S = KittyClipboardCapture; + S.reset(); + + var handler: Handler = .init(&t); + handler.effects.write_pty = &S.writePty; + handler.effects.clipboard_read = &S.clipboardRead; + var s: Stream = .init(.{ .allocator = testing.allocator, .handler = handler }); + defer s.deinit(); + + // A one-time read grant, as minted for a paste event. + try s.handler.kitty_clipboard_grants.grant(testing.allocator, "otp", .read, true); + + // A targets-only read (payload ".") is prompt-exempt so it never + // consults, and must not burn, the one-time password. + S.read_result = .{ .success = .{} }; + s.nextSlice("\x1B]5522;type=read:pw=b3Rw:name=YXBw;Lg==\x1B\\"); + try testing.expectEqual(@as(usize, 1), S.read_count); + try testing.expect(S.last_read_list); + try testing.expectEqual(@as(usize, 0), S.last_read_mimes_len); + try testing.expect(!S.last_read_granted); + + // The follow-up data read still consumes the grant, exactly once. + s.nextSlice("\x1B]5522;type=read:pw=b3Rw:name=YXBw;dGV4dC9wbGFpbg==\x1B\\"); + try testing.expect(S.last_read_granted); + s.nextSlice("\x1B]5522;type=read:pw=b3Rw:name=YXBw;dGV4dC9wbGFpbg==\x1B\\"); + try testing.expect(!S.last_read_granted); +} + +test "kitty clipboard write password grants" { + var t: Terminal = try .init(testing.io, testing.allocator, .{ .cols = 80, .rows = 24 }); + defer t.deinit(testing.allocator); + + const S = KittyClipboardCapture; + S.reset(); + + var handler: Handler = .init(&t); + handler.effects.write_pty = &S.writePty; + handler.effects.clipboard_write = &S.clipboardWrite; + handler.effects.clipboard_read = &S.clipboardRead; + var s: Stream = .init(.{ .allocator = testing.allocator, .handler = handler }); + defer s.deinit(); + + // pw="secret", name="app": the first commit isn't granted but the + // reply may ask to remember it. + S.write_result = .{ .success = .{ .remember = true } }; + s.nextSlice("\x1B]5522;type=write:pw=c2VjcmV0:name=YXBw\x1B\\"); + s.nextSlice("\x1B]5522;type=wdata\x1B\\"); + try testing.expectEqual(@as(usize, 1), S.write_count); + try testing.expectEqualStrings("app", S.writeName()); + try testing.expect(!S.last_write_granted); + try testing.expect(S.last_write_can_remember); + + // The same password is now granted; a different one is not. + S.write_result = .{ .success = .{} }; + s.nextSlice("\x1B]5522;type=write:pw=c2VjcmV0:name=YXBw\x1B\\"); + s.nextSlice("\x1B]5522;type=wdata\x1B\\"); + try testing.expect(S.last_write_granted); + s.nextSlice("\x1B]5522;type=write:pw=b3RoZXI=:name=YXBw\x1B\\"); + s.nextSlice("\x1B]5522;type=wdata\x1B\\"); + try testing.expect(!S.last_write_granted); + try testing.expect(S.last_write_can_remember); + + // Directions are independent: a write grant doesn't satisfy reads. + S.read_result = .{ .success = .{} }; + s.nextSlice("\x1B]5522;type=read:pw=c2VjcmV0:name=YXBw\x1B\\"); + try testing.expect(!S.last_read_granted); + + // A password without a name doesn't count: it is neither granted + // nor rememberable, even if the reply asks. + S.write_result = .{ .success = .{ .remember = true } }; + s.nextSlice("\x1B]5522;type=write:pw=c2VjcmV0\x1B\\"); + s.nextSlice("\x1B]5522;type=wdata\x1B\\"); + try testing.expectEqualStrings("", S.writeName()); + try testing.expect(!S.last_write_granted); + try testing.expect(!S.last_write_can_remember); + + // A grant is advisory: the request is still forwarded and the + // embedder may deny it. + S.responses_len = 0; + S.write_result = .denied; + s.nextSlice("\x1B]5522;type=write:id=d:pw=c2VjcmV0:name=YXBw\x1B\\"); + s.nextSlice("\x1B]5522;type=wdata\x1B\\"); + try testing.expect(S.last_write_granted); + try testing.expectEqualStrings( + "\x1B]5522;type=write:status=EPERM:id=d\x1B\\", + S.responseSlice(), + ); + + // Grants are freed with the stream (the testing allocator catches + // the leak otherwise). +} + +test "kitty clipboard malformed packets are silently dropped" { + var t: Terminal = try .init(testing.io, testing.allocator, .{ .cols = 80, .rows = 24 }); + defer t.deinit(testing.allocator); + + const S = KittyClipboardCapture; + S.reset(); + + var handler: Handler = .init(&t); + handler.effects.write_pty = &S.writePty; + handler.effects.clipboard_write = &S.clipboardWrite; + var s: Stream = .init(.{ .allocator = testing.allocator, .handler = handler }); + defer s.deinit(); + + // Missing type, unknown type, bare metadata record, invalid mime + // base64, and orphaned transaction packets all drop silently. + s.nextSlice("\x1B]5522;loc=primary\x1B\\"); + s.nextSlice("\x1B]5522;type=bobr\x1B\\"); + s.nextSlice("\x1B]5522;type=read:bare\x1B\\"); + s.nextSlice("\x1B]5522;type=wdata:mime=!!!;R2hvc3Q=\x1B\\"); + s.nextSlice("\x1B]5522;type=wdata:mime=dGV4dC9wbGFpbg==;R2hvc3Q=\x1B\\"); + s.nextSlice("\x1B]5522;type=walias:mime=dGV4dC9wbGFpbg==;VEVYVA==\x1B\\"); + try testing.expectEqual(@as(usize, 0), S.write_count); + try testing.expectEqual(@as(usize, 0), S.responses_len); + try testing.expect(!s.handler.semantic_failure); + + // The terminal is still functional afterwards. + s.nextSlice("ok"); + const str = try t.plainString(testing.allocator); + defer testing.allocator.free(str); + try testing.expectEqualStrings("ok", str); +} + +test "kitty clipboard new write replaces in-flight transaction" { + var t: Terminal = try .init(testing.io, testing.allocator, .{ .cols = 80, .rows = 24 }); + defer t.deinit(testing.allocator); + + const S = KittyClipboardCapture; + S.reset(); + + var handler: Handler = .init(&t); + handler.effects.write_pty = &S.writePty; + handler.effects.clipboard_write = &S.clipboardWrite; + var s: Stream = .init(.{ .allocator = testing.allocator, .handler = handler }); + defer s.deinit(); + + s.nextSlice("\x1B]5522;type=write:id=old\x1B\\"); + s.nextSlice("\x1B]5522;type=wdata:mime=dGV4dC9wbGFpbg==;b2xk\x1B\\"); // "old" + s.nextSlice("\x1B]5522;type=write:id=new\x1B\\"); + s.nextSlice("\x1B]5522;type=wdata:mime=dGV4dC9wbGFpbg==;bmV3\x1B\\"); // "new" + s.nextSlice("\x1B]5522;type=wdata\x1B\\"); + + try testing.expectEqual(@as(usize, 1), S.write_count); + try testing.expectEqual(@as(usize, 1), S.last_contents_len); + try testing.expectEqualStrings("new", S.dataAt(0)); + try testing.expectEqualStrings( + "\x1B]5522;type=write:status=DONE:id=new\x1B\\", + S.responseSlice(), + ); +} + +test "kitty clipboard invalid write packets abort with EINVAL" { + var t: Terminal = try .init(testing.io, testing.allocator, .{ .cols = 80, .rows = 24 }); + defer t.deinit(testing.allocator); + + const S = KittyClipboardCapture; + S.reset(); + + var handler: Handler = .init(&t); + handler.effects.write_pty = &S.writePty; + handler.effects.clipboard_write = &S.clipboardWrite; + var s: Stream = .init(.{ .allocator = testing.allocator, .handler = handler }); + defer s.deinit(); + + const invalid_packets = [_][]const u8{ + // Alias payload decodes to a non-UTF-8 byte. + "\x1B]5522;type=walias:mime=dGV4dC9wbGFpbg==;/w==\x1B\\", + // Alias has no target MIME type. + "\x1B]5522;type=walias;VEVYVA==\x1B\\", + // Alias target MIME decodes to non-UTF-8 bytes. + "\x1B]5522;type=walias:mime=//4=;VEVYVA==\x1B\\", + // Write data MIME decodes to non-UTF-8 bytes. + "\x1B]5522;type=wdata:mime=//4=;R2hvc3Q=\x1B\\", + }; + + for (invalid_packets) |packet| { + S.responses_len = 0; + s.nextSlice("\x1B]5522;type=write:id=w\x1B\\"); + s.nextSlice("\x1B]5522;type=wdata:mime=dGV4dC9wbGFpbg==;R2hvc3Q=\x1B\\"); + s.nextSlice(packet); + try testing.expectEqualStrings( + "\x1B]5522;type=write:status=EINVAL:id=w\x1B\\", + S.responseSlice(), + ); + try testing.expect(!s.handler.semantic_failure); + + // The transaction is gone: a commit does nothing further. + s.nextSlice("\x1B]5522;type=wdata\x1B\\"); + try testing.expectEqual(@as(usize, 0), S.write_count); + try testing.expectEqualStrings( + "\x1B]5522;type=write:status=EINVAL:id=w\x1B\\", + S.responseSlice(), + ); + } +} + +test "kitty clipboard invalid read text does not abort a write" { + var t: Terminal = try .init(testing.io, testing.allocator, .{ .cols = 80, .rows = 24 }); + defer t.deinit(testing.allocator); + + const S = KittyClipboardCapture; + S.reset(); + S.read_result = .{ .success = .{} }; + + var handler: Handler = .init(&t); + handler.effects.write_pty = &S.writePty; + handler.effects.clipboard_read = &S.clipboardRead; + handler.effects.clipboard_write = &S.clipboardWrite; + var s: Stream = .init(.{ .allocator = testing.allocator, .handler = handler }); + defer s.deinit(); + + s.nextSlice("\x1B]5522;type=write:id=w\x1B\\"); + s.nextSlice("\x1B]5522;type=wdata:mime=dGV4dC9wbGFpbg==;R2hvc3Q=\x1B\\"); + + // The read payload decodes to a non-UTF-8 byte. It is dropped without + // invoking the clipboard effect or disturbing the write transaction. + s.nextSlice("\x1B]5522;type=read;/w==\x1B\\"); + try testing.expectEqual(@as(usize, 0), S.read_count); + try testing.expectEqual(@as(usize, 0), S.responses_len); + + s.nextSlice("\x1B]5522;type=wdata\x1B\\"); + try testing.expectEqual(@as(usize, 1), S.write_count); + try testing.expectEqualStrings( + "\x1B]5522;type=write:status=DONE:id=w\x1B\\", + S.responseSlice(), + ); +} + +test "kitty clipboard oversized text write aborts with EFBIG" { + var t: Terminal = try .init(testing.io, testing.allocator, .{ .cols = 80, .rows = 24 }); + defer t.deinit(testing.allocator); + + const S = KittyClipboardCapture; + S.reset(); + + var handler: Handler = .init(&t); + handler.effects.write_pty = &S.writePty; + handler.effects.clipboard_write = &S.clipboardWrite; + var s: Stream = .init(.{ .allocator = testing.allocator, .handler = handler }); + defer s.deinit(); + + // Shrink the limit so the test doesn't have to stream the + // default 64MiB. + s.handler.kitty_clipboard_write_max_bytes = 4; + + s.nextSlice("\x1B]5522;type=write:id=w\x1B\\"); + s.nextSlice("\x1B]5522;type=wdata:mime=dGV4dC9wbGFpbg==;SGVsbA==\x1B\\"); // "Hell" + try testing.expectEqual(@as(usize, 0), S.responses_len); + s.nextSlice("\x1B]5522;type=wdata:mime=dGV4dC9wbGFpbg==;bw==\x1B\\"); // "o" + try testing.expectEqualStrings( + "\x1B]5522;type=write:status=EFBIG:id=w\x1B\\", + S.responseSlice(), + ); + try testing.expect(!s.handler.semantic_failure); + + // The transaction is gone: later data and the commit do nothing. + s.nextSlice("\x1B]5522;type=wdata:mime=dGV4dC9wbGFpbg==;IQ==\x1B\\"); + s.nextSlice("\x1B]5522;type=wdata\x1B\\"); + try testing.expectEqual(@as(usize, 0), S.write_count); + try testing.expectEqualStrings( + "\x1B]5522;type=write:status=EFBIG:id=w\x1B\\", + S.responseSlice(), + ); +} + +test "kitty clipboard invalid wdata chunk aborts with EINVAL" { + var t: Terminal = try .init(testing.io, testing.allocator, .{ .cols = 80, .rows = 24 }); + defer t.deinit(testing.allocator); + + const S = KittyClipboardCapture; + S.reset(); + + var handler: Handler = .init(&t); + handler.effects.write_pty = &S.writePty; + handler.effects.clipboard_write = &S.clipboardWrite; + var s: Stream = .init(.{ .allocator = testing.allocator, .handler = handler }); + defer s.deinit(); + + s.nextSlice("\x1B]5522;type=write:id=w\x1B\\"); + s.nextSlice("\x1B]5522;type=wdata:mime=dGV4dC9wbGFpbg==;SGVsbG8=\x1B\\"); // "Hello" + s.nextSlice("\x1B]5522;type=wdata:mime=dGV4dC9wbGFpbg==;!!!bad!!!\x1B\\"); + try testing.expectEqualStrings( + "\x1B]5522;type=write:status=EINVAL:id=w\x1B\\", + S.responseSlice(), + ); + try testing.expect(!s.handler.semantic_failure); + + // The transaction is gone: later data and the commit do nothing. + s.nextSlice("\x1B]5522;type=wdata:mime=dGV4dC9wbGFpbg==;V29ybGQ=\x1B\\"); // "World" + s.nextSlice("\x1B]5522;type=wdata\x1B\\"); + try testing.expectEqual(@as(usize, 0), S.write_count); + try testing.expectEqualStrings( + "\x1B]5522;type=write:status=EINVAL:id=w\x1B\\", + S.responseSlice(), + ); +} + +test "kitty clipboard wdata chunks split one base64 stream" { + var t: Terminal = try .init(testing.io, testing.allocator, .{ .cols = 80, .rows = 24 }); + defer t.deinit(testing.allocator); + + const S = KittyClipboardCapture; + S.reset(); + + var handler: Handler = .init(&t); + handler.effects.write_pty = &S.writePty; + handler.effects.clipboard_write = &S.clipboardWrite; + var s: Stream = .init(.{ .allocator = testing.allocator, .handler = handler }); + defer s.deinit(); + + // "some data" encoded as one stream, split at non-group + // boundaries across packets. + s.nextSlice("\x1B]5522;type=write\x1B\\"); + s.nextSlice("\x1B]5522;type=wdata:mime=dGV4dC9wbGFpbg==;c29\x1B\\"); + s.nextSlice("\x1B]5522;type=wdata:mime=dGV4dC9wbGFpbg==;tZSBk\x1B\\"); + s.nextSlice("\x1B]5522;type=wdata:mime=dGV4dC9wbGFpbg==;YXRh\x1B\\"); + s.nextSlice("\x1B]5522;type=wdata\x1B\\"); + + try testing.expectEqual(@as(usize, 1), S.write_count); + try testing.expectEqualStrings("some data", S.dataAt(0)); + try testing.expectEqualStrings( + "\x1B]5522;type=write:status=DONE\x1B\\", + S.responseSlice(), + ); +} + +test "kitty clipboard unpadded wdata stream aborts at commit" { + var t: Terminal = try .init(testing.io, testing.allocator, .{ .cols = 80, .rows = 24 }); + defer t.deinit(testing.allocator); + + const S = KittyClipboardCapture; + S.reset(); + + var handler: Handler = .init(&t); + handler.effects.write_pty = &S.writePty; + handler.effects.clipboard_write = &S.clipboardWrite; + var s: Stream = .init(.{ .allocator = testing.allocator, .handler = handler }); + defer s.deinit(); + + // "Hello" without its final padding byte: every packet decodes, + // but the stream ends mid-group so the commit reports EINVAL. + s.nextSlice("\x1B]5522;type=write:id=w\x1B\\"); + s.nextSlice("\x1B]5522;type=wdata:mime=dGV4dC9wbGFpbg==;SGVsbG8\x1B\\"); + try testing.expectEqual(@as(usize, 0), S.responses_len); + s.nextSlice("\x1B]5522;type=wdata\x1B\\"); + try testing.expectEqual(@as(usize, 0), S.write_count); + try testing.expectEqualStrings( + "\x1B]5522;type=write:status=EINVAL:id=w\x1B\\", + S.responseSlice(), + ); + try testing.expect(!s.handler.semantic_failure); +} + +test "kitty clipboard in-flight transaction is freed on deinit" { + var t: Terminal = try .init(testing.io, testing.allocator, .{ .cols = 80, .rows = 24 }); + defer t.deinit(testing.allocator); + + const S = KittyClipboardCapture; + S.reset(); + + var handler: Handler = .init(&t); + handler.effects.write_pty = &S.writePty; + handler.effects.clipboard_write = &S.clipboardWrite; + var s: Stream = .init(.{ .allocator = testing.allocator, .handler = handler }); + defer s.deinit(); + + // Never committed: stream deinit must free the transaction (the + // testing allocator catches the leak otherwise). + s.nextSlice("\x1B]5522;type=write\x1B\\"); + s.nextSlice("\x1B]5522;type=wdata:mime=dGV4dC9wbGFpbg==;R2hvc3Q=\x1B\\"); + try testing.expectEqual(@as(usize, 0), S.write_count); +} + +test "kitty clipboard allocation failure is ignored" { + var t: Terminal = try .init(testing.io, testing.allocator, .{ .cols = 80, .rows = 24 }); + defer t.deinit(testing.allocator); + + const S = KittyClipboardCapture; + S.reset(); + + var handler: Handler = .init(&t); + handler.effects.write_pty = &S.writePty; + handler.effects.clipboard_write = &S.clipboardWrite; + var s: Stream = .init(.{ .allocator = testing.allocator, .handler = handler }); + defer s.deinit(); + + // Only transaction state uses the terminal allocator here. Swap in + // an allocator that always fails, then restore it before teardown. + { + const alloc = t.screens.active.alloc; + t.screens.active.alloc = testing.failing_allocator; + defer t.screens.active.alloc = alloc; + s.nextSlice("\x1B]5522;type=write\x1B\\"); + } + + // Clipboard writes are external effects, best-effort like OSC 52; + // the failed transaction never started and is not a semantic + // failure. + try testing.expect(!s.handler.semantic_failure); + s.nextSlice("\x1B]5522;type=wdata:mime=dGV4dC9wbGFpbg==;R2hvc3Q=\x1B\\"); + s.nextSlice("\x1B]5522;type=wdata\x1B\\"); + try testing.expectEqual(@as(usize, 0), S.write_count); + try testing.expectEqual(@as(usize, 0), S.responses_len); +} + +test "kitty clipboard without write_pty still commits writes" { + var t: Terminal = try .init(testing.io, testing.allocator, .{ .cols = 80, .rows = 24 }); + defer t.deinit(testing.allocator); + + const S = KittyClipboardCapture; + S.reset(); + + var handler: Handler = .init(&t); + handler.effects.clipboard_write = &S.clipboardWrite; + var s: Stream = .init(.{ .allocator = testing.allocator, .handler = handler }); + defer s.deinit(); + + s.nextSlice("\x1B]5522;type=write\x1B\\"); + s.nextSlice("\x1B]5522;type=wdata:mime=dGV4dC9wbGFpbg==;R2hvc3Q=\x1B\\"); + s.nextSlice("\x1B]5522;type=wdata\x1B\\"); + try testing.expectEqual(@as(usize, 1), S.write_count); + try testing.expectEqualStrings("Ghost", S.dataAt(0)); + + // Reads are dropped without a way to respond. + s.nextSlice("\x1B]5522;type=read\x1B\\"); + try testing.expectEqual(@as(usize, 0), S.responses_len); +} + test "request mode DECRQM with write_pty callback" { var t: Terminal = try .init(testing.io, testing.allocator, .{ .cols = 80, .rows = 24 }); defer t.deinit(testing.allocator); @@ -2657,7 +4582,7 @@ test "request mode DECRQM with write_pty callback" { { const S = struct { var last_response: ?[:0]const u8 = null; - fn writePty(_: *Handler, data: [:0]const u8) void { + fn writePty(_: *Handler, data: []const u8) void { if (last_response) |old| testing.allocator.free(old); last_response = testing.allocator.dupeZ(u8, data) catch @panic("OOM"); } @@ -2779,9 +4704,12 @@ test "kitty_keyboard_query" { defer t.deinit(testing.allocator); const S = struct { - var written: ?[:0]const u8 = null; - fn writePty(_: *Handler, data: [:0]const u8) void { - written = data; + var written: ?[]const u8 = null; + var written_buf: [64]u8 = undefined; + fn writePty(_: *Handler, data: []const u8) void { + std.debug.assert(data.len <= written_buf.len); + @memcpy(written_buf[0..data.len], data); + written = written_buf[0..data.len]; } }; S.written = null; @@ -2808,9 +4736,12 @@ test "xtversion default" { defer t.deinit(testing.allocator); const S = struct { - var written: ?[:0]const u8 = null; - fn writePty(_: *Handler, data: [:0]const u8) void { - written = data; + var written: ?[]const u8 = null; + var written_buf: [64]u8 = undefined; + fn writePty(_: *Handler, data: []const u8) void { + std.debug.assert(data.len <= written_buf.len); + @memcpy(written_buf[0..data.len], data); + written = written_buf[0..data.len]; } }; S.written = null; @@ -2831,9 +4762,12 @@ test "xtversion with effect" { defer t.deinit(testing.allocator); const S = struct { - var written: ?[:0]const u8 = null; - fn writePty(_: *Handler, data: [:0]const u8) void { - written = data; + var written: ?[]const u8 = null; + var written_buf: [64]u8 = undefined; + fn writePty(_: *Handler, data: []const u8) void { + std.debug.assert(data.len <= written_buf.len); + @memcpy(written_buf[0..data.len], data); + written = written_buf[0..data.len]; } fn xtversion(_: *Handler) []const u8 { return "ghostty 1.2.3"; @@ -2857,9 +4791,12 @@ test "xtversion with empty string effect" { defer t.deinit(testing.allocator); const S = struct { - var written: ?[:0]const u8 = null; - fn writePty(_: *Handler, data: [:0]const u8) void { - written = data; + var written: ?[]const u8 = null; + var written_buf: [64]u8 = undefined; + fn writePty(_: *Handler, data: []const u8) void { + std.debug.assert(data.len <= written_buf.len); + @memcpy(written_buf[0..data.len], data); + written = written_buf[0..data.len]; } fn xtversion(_: *Handler) []const u8 { return ""; @@ -2885,7 +4822,7 @@ test "size report csi_14_t with effect" { const S = struct { var written: ?[]const u8 = null; - fn writePty(_: *Handler, data: [:0]const u8) void { + fn writePty(_: *Handler, data: []const u8) void { written = testing.allocator.dupe(u8, data) catch @panic("OOM"); } fn getSize(_: *Handler) ?size_report.Size { @@ -2907,13 +4844,104 @@ test "size report csi_14_t with effect" { try testing.expectEqualStrings("\x1b[4;432;720t", S.written.?); } +test "mode 2048 enable reports current geometry and disable is silent" { + var t: Terminal = try .init(testing.io, testing.allocator, .{ .cols = 80, .rows = 24 }); + defer t.deinit(testing.allocator); + + const S = struct { + var response: [128]u8 = undefined; + var response_len: usize = 0; + var calls: usize = 0; + + fn writePty(_: *Handler, data: []const u8) void { + @memcpy(response[0..data.len], data); + response_len = data.len; + calls += 1; + } + + fn getSize(_: *Handler) ?size_report.Size { + return .{ .rows = 24, .columns = 80, .cell_width = 8, .cell_height = 16 }; + } + }; + S.response_len = 0; + S.calls = 0; + + var handler: Handler = .init(&t); + handler.effects.write_pty = &S.writePty; + handler.effects.size = &S.getSize; + + var s: Stream = .init(.{ .allocator = testing.allocator, .handler = handler }); + defer s.deinit(); + + s.nextSlice("\x1b[?2048h"); + s.nextSlice("\x1b[?2048h"); + + try testing.expectEqual(@as(usize, 2), S.calls); + try testing.expectEqualStrings("\x1b[48;24;80;384;640t", S.response[0..S.response_len]); + + s.nextSlice("\x1b[?2048l"); + try testing.expectEqual(@as(usize, 2), S.calls); + try testing.expect(!t.modes.get(.in_band_size_reports)); +} + +test "mode 2048 enable tolerates missing effects" { + const S = struct { + var calls: usize = 0; + + fn writePty(_: *Handler, _: []const u8) void { + calls += 1; + } + + fn getSize(_: *Handler) ?size_report.Size { + return .{ .rows = 24, .columns = 80, .cell_width = 8, .cell_height = 16 }; + } + }; + S.calls = 0; + + var no_size_terminal: Terminal = try .init( + testing.io, + testing.allocator, + .{ .cols = 80, .rows = 24 }, + ); + defer no_size_terminal.deinit(testing.allocator); + var no_size_handler: Handler = .init(&no_size_terminal); + no_size_handler.effects.write_pty = &S.writePty; + var no_size_stream: Stream = .init(.{ + .allocator = testing.allocator, + .handler = no_size_handler, + }); + defer no_size_stream.deinit(); + + no_size_stream.nextSlice("\x1b[?2048h"); + try testing.expect(no_size_terminal.modes.get(.in_band_size_reports)); + try testing.expectEqual(@as(usize, 0), S.calls); + + var no_write_terminal: Terminal = try .init( + testing.io, + testing.allocator, + .{ .cols = 80, .rows = 24 }, + ); + defer no_write_terminal.deinit(testing.allocator); + var no_write_handler: Handler = .init(&no_write_terminal); + no_write_handler.effects.size = &S.getSize; + var no_write_stream: Stream = .init(.{ + .allocator = testing.allocator, + .handler = no_write_handler, + }); + defer no_write_stream.deinit(); + + no_write_stream.nextSlice("\x1b[?2048h"); + try testing.expect(no_write_terminal.modes.get(.in_band_size_reports)); + try testing.expectEqual(@as(usize, 0), S.calls); +} + test "size report csi_16_t with effect" { var t: Terminal = try .init(testing.io, testing.allocator, .{ .cols = 80, .rows = 24 }); defer t.deinit(testing.allocator); const S = struct { var written: ?[]const u8 = null; - fn writePty(_: *Handler, data: [:0]const u8) void { + fn writePty(_: *Handler, data: []const u8) void { written = testing.allocator.dupe(u8, data) catch @panic("OOM"); } fn getSize(_: *Handler) ?size_report.Size { @@ -2941,7 +4969,7 @@ test "size report csi_18_t with effect" { const S = struct { var written: ?[]const u8 = null; - fn writePty(_: *Handler, data: [:0]const u8) void { + fn writePty(_: *Handler, data: []const u8) void { written = testing.allocator.dupe(u8, data) catch @panic("OOM"); } fn getSize(_: *Handler) ?size_report.Size { @@ -2969,7 +4997,7 @@ test "size report no effect callback" { const S = struct { var written: ?[]const u8 = null; - fn writePty(_: *Handler, data: [:0]const u8) void { + fn writePty(_: *Handler, data: []const u8) void { written = testing.allocator.dupe(u8, data) catch @panic("OOM"); } }; @@ -2992,7 +5020,7 @@ test "size report csi_21_t title disabled by default" { const S = struct { var written: ?[]const u8 = null; - fn writePty(_: *Handler, data: [:0]const u8) void { + fn writePty(_: *Handler, data: []const u8) void { written = testing.allocator.dupe(u8, data) catch @panic("OOM"); } }; @@ -3018,7 +5046,7 @@ test "size report csi_21_t title enabled" { const S = struct { var written: ?[]const u8 = null; - fn writePty(_: *Handler, data: [:0]const u8) void { + fn writePty(_: *Handler, data: []const u8) void { written = testing.allocator.dupe(u8, data) catch @panic("OOM"); } }; @@ -3046,7 +5074,7 @@ test "enquiry no effect" { const S = struct { var written: ?[]const u8 = null; - fn writePty(_: *Handler, data: [:0]const u8) void { + fn writePty(_: *Handler, data: []const u8) void { written = testing.allocator.dupe(u8, data) catch @panic("OOM"); } }; @@ -3069,7 +5097,7 @@ test "enquiry with effect" { const S = struct { var written: ?[]const u8 = null; - fn writePty(_: *Handler, data: [:0]const u8) void { + fn writePty(_: *Handler, data: []const u8) void { written = testing.allocator.dupe(u8, data) catch @panic("OOM"); } fn enquiry(_: *Handler) []const u8 { @@ -3096,7 +5124,7 @@ test "enquiry with empty response" { const S = struct { var written: ?[]const u8 = null; - fn writePty(_: *Handler, data: [:0]const u8) void { + fn writePty(_: *Handler, data: []const u8) void { written = testing.allocator.dupe(u8, data) catch @panic("OOM"); } fn enquiry(_: *Handler) []const u8 { @@ -3123,7 +5151,7 @@ test "device status: operating status" { const S = struct { var written: ?[]const u8 = null; - fn writePty(_: *Handler, data: [:0]const u8) void { + fn writePty(_: *Handler, data: []const u8) void { if (written) |old| testing.allocator.free(old); written = testing.allocator.dupe(u8, data) catch @panic("OOM"); } @@ -3148,7 +5176,7 @@ test "device status: cursor position" { const S = struct { var written: ?[]const u8 = null; - fn writePty(_: *Handler, data: [:0]const u8) void { + fn writePty(_: *Handler, data: []const u8) void { if (written) |old| testing.allocator.free(old); written = testing.allocator.dupe(u8, data) catch @panic("OOM"); } @@ -3178,7 +5206,7 @@ test "device status: cursor position with origin mode" { const S = struct { var written: ?[]const u8 = null; - fn writePty(_: *Handler, data: [:0]const u8) void { + fn writePty(_: *Handler, data: []const u8) void { if (written) |old| testing.allocator.free(old); written = testing.allocator.dupe(u8, data) catch @panic("OOM"); } @@ -3210,7 +5238,7 @@ test "device status: color scheme dark" { const S = struct { var written: ?[]const u8 = null; - fn writePty(_: *Handler, data: [:0]const u8) void { + fn writePty(_: *Handler, data: []const u8) void { if (written) |old| testing.allocator.free(old); written = testing.allocator.dupe(u8, data) catch @panic("OOM"); } @@ -3239,7 +5267,7 @@ test "device status: color scheme light" { const S = struct { var written: ?[]const u8 = null; - fn writePty(_: *Handler, data: [:0]const u8) void { + fn writePty(_: *Handler, data: []const u8) void { if (written) |old| testing.allocator.free(old); written = testing.allocator.dupe(u8, data) catch @panic("OOM"); } @@ -3268,7 +5296,7 @@ test "device status: color scheme without callback" { const S = struct { var written: ?[]const u8 = null; - fn writePty(_: *Handler, data: [:0]const u8) void { + fn writePty(_: *Handler, data: []const u8) void { if (written) |old| testing.allocator.free(old); written = testing.allocator.dupe(u8, data) catch @panic("OOM"); } @@ -3295,7 +5323,7 @@ test "visibility reports" { var written: ?[]const u8 = null; var count: usize = 0; - fn writePty(_: *Handler, data: [:0]const u8) void { + fn writePty(_: *Handler, data: []const u8) void { if (written) |old| testing.allocator.free(old); written = testing.allocator.dupe(u8, data) catch @panic("OOM"); count += 1; @@ -3364,7 +5392,7 @@ test "device attributes: primary DA" { const S = struct { var written: ?[]const u8 = null; - fn writePty(_: *Handler, data: [:0]const u8) void { + fn writePty(_: *Handler, data: []const u8) void { if (written) |old| testing.allocator.free(old); written = testing.allocator.dupe(u8, data) catch @panic("OOM"); } @@ -3392,7 +5420,7 @@ test "device attributes: secondary DA" { const S = struct { var written: ?[]const u8 = null; - fn writePty(_: *Handler, data: [:0]const u8) void { + fn writePty(_: *Handler, data: []const u8) void { if (written) |old| testing.allocator.free(old); written = testing.allocator.dupe(u8, data) catch @panic("OOM"); } @@ -3420,7 +5448,7 @@ test "device attributes: tertiary DA" { const S = struct { var written: ?[]const u8 = null; - fn writePty(_: *Handler, data: [:0]const u8) void { + fn writePty(_: *Handler, data: []const u8) void { if (written) |old| testing.allocator.free(old); written = testing.allocator.dupe(u8, data) catch @panic("OOM"); } @@ -3467,7 +5495,7 @@ test "device attributes: custom response" { const S = struct { var written: ?[]const u8 = null; - fn writePty(_: *Handler, data: [:0]const u8) void { + fn writePty(_: *Handler, data: []const u8) void { if (written) |old| testing.allocator.free(old); written = testing.allocator.dupe(u8, data) catch @panic("OOM"); } @@ -3509,7 +5537,7 @@ test "kitty graphics APC response" { const S = struct { var written: ?[]const u8 = null; - fn writePty(_: *Handler, data: [:0]const u8) void { + fn writePty(_: *Handler, data: []const u8) void { if (written) |old| testing.allocator.free(old); written = testing.allocator.dupe(u8, data) catch @panic("OOM"); } @@ -3566,7 +5594,7 @@ test "continuation reconstructs standard stream without duplicate effects" { title_count += 1; } - fn writePty(_: *Handler, _: [:0]const u8) void { + fn writePty(_: *Handler, _: []const u8) void { write_count += 1; } @@ -3579,10 +5607,10 @@ test "continuation reconstructs standard stream without duplicate effects" { fn clipboardWrite( _: *Handler, - _: clipboard.Write, - ) clipboard.WriteResult { + write: clipboard.Write, + ) void { clipboard_count += 1; - return .success; + write.reply(.{ .success = .{} }); } fn reset() void { @@ -3699,3 +5727,812 @@ test "continuation reconstructs standard stream without duplicate effects" { restored_terminal.screens.active.cursor.style_id, ); } + +test "kitty dnd: query response" { + const S = struct { + var pty: std.ArrayListUnmanaged(u8) = .empty; + fn writePty(_: *Handler, data: []const u8) void { + pty.appendSlice(testing.allocator, data) catch unreachable; + } + }; + S.pty = .empty; + defer S.pty.deinit(testing.allocator); + + var t: Terminal = try .init(testing.io, testing.allocator, .{ .cols = 80, .rows = 24 }); + defer t.deinit(testing.allocator); + + var handler: Handler = .init(&t); + handler.effects.write_pty = &S.writePty; + var s: Stream = .init(.{ .allocator = testing.allocator, .handler = handler }); + defer s.deinit(); + + s.nextSlice("\x1B]72;t=q:i=3\x1B\\"); + try testing.expectEqualStrings("\x1b]72;t=q:i=3\x1b\\", S.pty.items); +} + +test "kitty dnd: register, drop, and serve data" { + const S = struct { + var pty: std.ArrayListUnmanaged(u8) = .empty; + fn writePty(_: *Handler, data: []const u8) void { + pty.appendSlice(testing.allocator, data) catch unreachable; + } + }; + S.pty = .empty; + defer S.pty.deinit(testing.allocator); + + var t: Terminal = try .init(testing.io, testing.allocator, .{ .cols = 80, .rows = 24 }); + defer t.deinit(testing.allocator); + + var handler: Handler = .init(&t); + handler.effects.write_pty = &S.writePty; + var s: Stream = .init(.{ .allocator = testing.allocator, .handler = handler }); + defer s.deinit(); + + // Client registers to accept drops. + s.nextSlice("\x1B]72;t=a;text/plain text/uri-list\x1B\\"); + try testing.expectEqualStrings("", S.pty.items); + try testing.expect(t.kitty_dnd != null); + + // A native drop arrives; the embedder feeds it to the terminal + // state and delivers the produced event bytes itself. + { + var aw: std.Io.Writer.Allocating = .init(testing.allocator); + defer aw.deinit(); + try t.kitty_dnd.?.dragDrop(testing.allocator, &aw.writer, .{ + .cell_x = 2, + .cell_y = 1, + .pixel_x = 20, + .pixel_y = 18, + .operations = .{ .copy = true }, + }, &.{ + .{ .mime = "text/plain", .data = "hello" }, + }); + try testing.expectEqualStrings( + "\x1b]72;t=M:x=2:y=1:X=20:Y=18:o=1:m=0;text/plain \x1b\\", + aw.written(), + ); + } + + // The client requests the data and concludes. + s.nextSlice("\x1B]72;t=r:x=1\x1B\\"); + try testing.expectEqualStrings( + "\x1b]72;t=r:x=1:m=0;aGVsbG8=\x1b\\" ++ "\x1b]72;t=r:x=1\x1b\\", + S.pty.items, + ); + S.pty.clearRetainingCapacity(); + + s.nextSlice("\x1B]72;t=r\x1B\\"); + try testing.expectEqualStrings("", S.pty.items); + try testing.expect(t.kitty_dnd.?.drop.items == null); +} + +test "kitty dnd: state updates work without write_pty effect" { + var t: Terminal = try .init(testing.io, testing.allocator, .{ .cols = 80, .rows = 24 }); + defer t.deinit(testing.allocator); + + var s: Stream = .init(.{ .allocator = testing.allocator, .handler = .init(&t) }); + defer s.deinit(); + + // Queries produce no output (nowhere to write) but registration + // state still updates. + s.nextSlice("\x1B]72;t=q\x1B\\"); + s.nextSlice("\x1B]72;t=a\x1B\\"); + try testing.expect(t.kitty_dnd != null); + + // The terminal remains functional. + s.nextSlice("ok"); + const str = try t.plainString(testing.allocator); + defer testing.allocator.free(str); + try testing.expectEqualStrings("ok", str); +} + +test "kitty dnd: registration survives terminal reset" { + const S = struct { + var pty: std.ArrayListUnmanaged(u8) = .empty; + fn writePty(_: *Handler, data: []const u8) void { + pty.appendSlice(testing.allocator, data) catch unreachable; + } + }; + S.pty = .empty; + defer S.pty.deinit(testing.allocator); + + var t: Terminal = try .init(testing.io, testing.allocator, .{ .cols = 80, .rows = 24 }); + defer t.deinit(testing.allocator); + + var handler: Handler = .init(&t); + handler.effects.write_pty = &S.writePty; + var s: Stream = .init(.{ .allocator = testing.allocator, .handler = handler }); + defer s.deinit(); + + // Start a chunked command, then reset mid-chunk. + s.nextSlice("\x1B]72;t=a:i=5\x1B\\"); + s.nextSlice("\x1B]72;t=m:o=1:m=1;text/pl\x1B\\"); + s.nextSlice("\x1Bc"); + + // Registration survives (matching kitty), chunking was interrupted + // so a new command is not treated as a continuation. + try testing.expect(t.kitty_dnd != null); + try testing.expect(!t.kitty_dnd.?.chunking.active); + s.nextSlice("\x1B]72;t=q\x1B\\"); + try testing.expectEqualStrings("\x1b]72;t=q\x1b\\", S.pty.items); +} + +test "kitty dnd: effect reports registration, acceptance, and conclusion" { + const S = struct { + var events: std.ArrayListUnmanaged(kitty_dnd.Event) = .empty; + var mimes: std.ArrayListUnmanaged(u8) = .empty; + + fn clear() void { + events.deinit(testing.allocator); + events = .empty; + mimes.deinit(testing.allocator); + mimes = .empty; + } + + fn dragAndDrop(handler: *Handler, ev: kitty_dnd.Event) void { + events.append(testing.allocator, ev) catch unreachable; + // Registration details are read from the terminal state. + if (ev == .registration) { + mimes.clearRetainingCapacity(); + const state = handler.terminal.kitty_dnd orelse return; + var it = state.registeredMimes(); + while (it.next()) |m| { + mimes.appendSlice(testing.allocator, m) catch unreachable; + mimes.append(testing.allocator, ',') catch unreachable; + } + } + } + }; + S.clear(); + defer S.clear(); + + var t: Terminal = try .init(testing.io, testing.allocator, .{ .cols = 80, .rows = 24 }); + defer t.deinit(testing.allocator); + + var handler: Handler = .init(&t); + handler.effects.drag_and_drop = &S.dragAndDrop; + var s: Stream = .init(.{ .allocator = testing.allocator, .handler = handler }); + defer s.deinit(); + + // Registration with a MIME list, read back from the state. + s.nextSlice("\x1B]72;t=a;image/png text/plain\x1B\\"); + try testing.expectEqual(@as(usize, 1), S.events.items.len); + try testing.expect(S.events.items[0] == .registration); + try testing.expectEqualStrings("image/png,text/plain,", S.mimes.items); + + // A native drag and the client's answer. + { + var aw: std.Io.Writer.Allocating = .init(testing.allocator); + defer aw.deinit(); + try t.kitty_dnd.?.dragDrop(testing.allocator, &aw.writer, .{ + .cell_x = 0, + .cell_y = 0, + .pixel_x = 0, + .pixel_y = 0, + .operations = .{ .copy = true }, + }, &.{.{ .mime = "text/plain", .data = "x" }}); + } + s.nextSlice("\x1B]72;t=m:o=2;text/plain\x1B\\"); + try testing.expectEqual(@as(usize, 2), S.events.items.len); + try testing.expect(S.events.items[1] == .acceptance); + + // Conclusion carries the performed operation. + s.nextSlice("\x1B]72;t=r:o=2\x1B\\"); + try testing.expectEqual(@as(usize, 3), S.events.items.len); + try testing.expectEqual(kitty_dnd.Event.concluded_move, S.events.items[2]); + + // Unregistration reports with the state gone. + s.nextSlice("\x1B]72;t=A\x1B\\"); + try testing.expectEqual(@as(usize, 4), S.events.items.len); + try testing.expect(S.events.items[3] == .registration); + try testing.expect(t.kitty_dnd == null); + try testing.expectEqualStrings("", S.mimes.items); +} + +/// Capture state for the Handler.paste tests below: every pty write and +/// the clipboard reads the program makes afterwards. +const PasteCapture = struct { + var written: std.ArrayList(u8) = .empty; + var write_count: usize = 0; + var read_count: usize = 0; + var read_granted_count: usize = 0; + var last_read_granted: bool = false; + var last_read_name: [64]u8 = undefined; + var last_read_name_len: usize = 0; + + fn reset() void { + written.clearRetainingCapacity(); + write_count = 0; + read_count = 0; + read_granted_count = 0; + last_read_granted = false; + last_read_name_len = 0; + } + + fn deinit() void { + written.deinit(testing.allocator); + written = .empty; + } + + fn writePty(_: *Handler, data: []const u8) void { + written.appendSlice(testing.allocator, data) catch @panic("OOM"); + write_count += 1; + } + + fn clipboardRead(_: *Handler, read: clipboard.Read) void { + read_count += 1; + last_read_granted = read.granted; + if (read.granted) read_granted_count += 1; + last_read_name_len = read.name.len; + @memcpy(last_read_name[0..read.name.len], read.name); + read.reply(.{ .success = .{ .contents = &.{.{ + .mime = "text/plain", + .data = "Ghostty", + }} } }); + } + + fn readName() []const u8 { + return last_read_name[0..last_read_name_len]; + } +}; + +test "paste: no write_pty effect is an error" { + var t: Terminal = try .init(testing.io, testing.allocator, .{ .cols = 80, .rows = 24 }); + defer t.deinit(testing.allocator); + + var handler: Handler = .init(&t); + defer handler.deinit(); + try testing.expectError(error.NoWritePty, handler.paste(.{ + .contents = .{ .memory = &.{.{ .mime = "text/plain", .data = "hello" }} }, + })); +} + +test "paste: plain text converts newlines and strips unsafe bytes" { + var t: Terminal = try .init(testing.io, testing.allocator, .{ .cols = 80, .rows = 24 }); + defer t.deinit(testing.allocator); + + const S = PasteCapture; + S.reset(); + defer S.deinit(); + + var handler: Handler = .init(&t); + defer handler.deinit(); + handler.effects.write_pty = &S.writePty; + + // Newlines are unsafe unbracketed; the embedder confirmed. + try testing.expect(try handler.paste(.{ + .contents = .{ .memory = &.{.{ .mime = "text/plain", .data = "hel\x1blo\nwor\x00ld" }} }, + .allow_unsafe = true, + })); + try testing.expectEqualStrings("hel lo\rwor ld", S.written.items); + try testing.expectEqual(@as(usize, 1), S.write_count); + + // The first text representation is used; others are ignored. + S.reset(); + try testing.expect(try handler.paste(.{ + .contents = .{ .memory = &.{ + .{ .mime = "image/png", .data = "\x89PNG" }, + .{ .mime = "UTF8_STRING", .data = "hi" }, + .{ .mime = "text/plain", .data = "ignored" }, + } }, + })); + try testing.expectEqualStrings("hi", S.written.items); + try testing.expectEqual(@as(usize, 1), S.write_count); +} + +test "paste: unsafe text is refused unless allowed" { + var t: Terminal = try .init(testing.io, testing.allocator, .{ .cols = 80, .rows = 24 }); + defer t.deinit(testing.allocator); + + const S = PasteCapture; + S.reset(); + defer S.deinit(); + + var handler: Handler = .init(&t); + defer handler.deinit(); + handler.effects.write_pty = &S.writePty; + + try testing.expectError(error.UnsafePaste, handler.paste(.{ + .contents = .{ .memory = &.{.{ .mime = "text/plain", .data = "rm -rf /\n" }} }, + })); + try testing.expectEqual(@as(usize, 0), S.write_count); + + try testing.expect(try handler.paste(.{ + .contents = .{ .memory = &.{.{ .mime = "text/plain", .data = "rm -rf /\n" }} }, + .allow_unsafe = true, + })); + try testing.expectEqualStrings("rm -rf /\r", S.written.items); +} + +test "paste: bracketed paste frames the text" { + var t: Terminal = try .init(testing.io, testing.allocator, .{ .cols = 80, .rows = 24 }); + defer t.deinit(testing.allocator); + + const S = PasteCapture; + S.reset(); + defer S.deinit(); + + var handler: Handler = .init(&t); + defer handler.deinit(); + handler.effects.write_pty = &S.writePty; + t.modes.set(.bracketed_paste, true); + + // Newlines are safe inside the frame and are preserved. + try testing.expect(try handler.paste(.{ + .contents = .{ .memory = &.{.{ .mime = "text/plain", .data = "hello\nworld" }} }, + })); + try testing.expectEqualStrings("\x1b[200~hello\nworld\x1b[201~", S.written.items); + try testing.expectEqual(@as(usize, 1), S.write_count); + + // The frame terminator is not. + S.reset(); + try testing.expectError(error.UnsafePaste, handler.paste(.{ + .contents = .{ .memory = &.{.{ .mime = "text/plain", .data = "he\x1b[201~llo" }} }, + })); + try testing.expectEqual(@as(usize, 0), S.write_count); + + // Allowed, the stripper still defuses it: ESC becomes a space. + try testing.expect(try handler.paste(.{ + .contents = .{ .memory = &.{.{ .mime = "text/plain", .data = "he\x1b[201~llo" }} }, + .allow_unsafe = true, + })); + try testing.expectEqualStrings("\x1b[200~he [201~llo\x1b[201~", S.written.items); +} + +test "paste: no text representation writes nothing" { + var t: Terminal = try .init(testing.io, testing.allocator, .{ .cols = 80, .rows = 24 }); + defer t.deinit(testing.allocator); + + const S = PasteCapture; + S.reset(); + defer S.deinit(); + + var handler: Handler = .init(&t); + defer handler.deinit(); + handler.effects.write_pty = &S.writePty; + + try testing.expect(!try handler.paste(.{ + .contents = .{ .memory = &.{.{ .mime = "image/png", .data = "\x89PNG" }} }, + })); + try testing.expect(!try handler.paste(.{ + .contents = .{ .memory = &.{} }, + })); + try testing.expect(!try handler.paste(.{ + .contents = .{ .memory = &.{.{ .mime = "text/plain", .data = "" }} }, + })); + try testing.expectEqual(@as(usize, 0), S.write_count); +} + +test "paste: large text streams to the pty in chunks" { + var t: Terminal = try .init(testing.io, testing.allocator, .{ .cols = 80, .rows = 24 }); + defer t.deinit(testing.allocator); + + const S = PasteCapture; + S.reset(); + defer S.deinit(); + + var handler: Handler = .init(&t); + defer handler.deinit(); + handler.effects.write_pty = &S.writePty; + t.modes.set(.bracketed_paste, true); + + // Two full chunks and a partial one with the frame, never the + // whole thing at once. + const data = "x" ** 10_000; + try testing.expect(try handler.paste(.{ + .contents = .{ .memory = &.{.{ .mime = "text/plain", .data = data }} }, + })); + const total = data.len + "\x1b[200~\x1b[201~".len; + try testing.expectEqual( + @as(usize, (total + Handler.paste_chunk_size - 1) / Handler.paste_chunk_size), + S.write_count, + ); + try testing.expectEqual(total, S.written.items.len); + try testing.expect(std.mem.startsWith(u8, S.written.items, "\x1b[200~xxx")); + try testing.expect(std.mem.endsWith(u8, S.written.items, "xxx\x1b[201~")); +} + +/// A paste contents reader for the tests: serves fixed data per MIME +/// type in pieces, counting the reads of each representation. +const PasteReader = struct { + mimes: []const []const u8, + data: []const []const u8, + piece: usize = 3, + reads: [4]usize = @splat(0), + /// Fail after this many bytes of a read. + fail_after: ?usize = null, + + fn contents(self: *PasteReader) paste_pkg.Contents { + return .{ .reader = .{ + .mimes = self.mimes, + .read = .{ .ctx = self, .read_fn = &read }, + } }; + } + + fn read(ctx: ?*anyopaque, mime: []const u8, sink: *std.Io.Writer) clipboard.MimeReader.Error!void { + const self: *PasteReader = @ptrCast(@alignCast(ctx.?)); + const index: usize = for (self.mimes, 0..) |m, i| { + if (std.mem.eql(u8, m, mime)) break i; + } else return error.ReadFailed; + self.reads[index] += 1; + const data = self.data[index]; + var offset: usize = 0; + while (offset < data.len) { + if (self.fail_after) |limit| if (offset >= limit) return error.ReadFailed; + const n = @min(self.piece, data.len - offset); + try sink.writeAll(data[offset..][0..n]); + offset += n; + } + } +}; + +test "paste: reader contents are read on demand" { + var t: Terminal = try .init(testing.io, testing.allocator, .{ .cols = 80, .rows = 24 }); + defer t.deinit(testing.allocator); + + const S = PasteCapture; + S.reset(); + defer S.deinit(); + + var handler: Handler = .init(&t); + defer handler.deinit(); + handler.effects.write_pty = &S.writePty; + handler.effects.clipboard_read = &S.clipboardRead; + + // Unsafe text is refused from one read with nothing written; the + // image is never read. + var reader: PasteReader = .{ + .mimes = &.{ "image/png", "text/plain" }, + .data = &.{ "\x89PNG", "echo hi\nrm -rf /\n" }, + }; + try testing.expectError(error.UnsafePaste, handler.paste(.{ + .contents = reader.contents(), + })); + try testing.expectEqual(@as(usize, 0), S.write_count); + try testing.expectEqual(@as(usize, 0), reader.reads[0]); + try testing.expectEqual(@as(usize, 1), reader.reads[1]); + + // Allowed, the text is read once, encoded. + reader.reads = @splat(0); + try testing.expect(try handler.paste(.{ + .contents = reader.contents(), + .allow_unsafe = true, + })); + try testing.expectEqualStrings("echo hi\rrm -rf /\r", S.written.items); + try testing.expectEqual(@as(usize, 0), reader.reads[0]); + try testing.expectEqual(@as(usize, 1), reader.reads[1]); + + // Safe text is read once too: buffered for the check, then written. + S.reset(); + reader = .{ + .mimes = &.{"text/plain"}, + .data = &.{"hello world"}, + }; + try testing.expect(try handler.paste(.{ .contents = reader.contents() })); + try testing.expectEqualStrings("hello world", S.written.items); + try testing.expectEqual(@as(usize, 1), reader.reads[0]); + + // Empty text is nothing to paste, found on the one read. + S.reset(); + reader = .{ + .mimes = &.{"text/plain"}, + .data = &.{""}, + }; + try testing.expect(!try handler.paste(.{ .contents = reader.contents() })); + try testing.expectEqual(@as(usize, 0), S.write_count); + try testing.expectEqual(@as(usize, 1), reader.reads[0]); + + // A paste event lists the types and reads nothing at all. + S.reset(); + t.modes.set(.kitty_paste_events, true); + reader = .{ + .mimes = &.{ "text/plain", "image/png" }, + .data = &.{ "secret", "\x89PNG" }, + }; + try testing.expect(try handler.paste(.{ .contents = reader.contents() })); + try testing.expect(std.mem.indexOf(u8, S.written.items, ";dGV4dC9wbGFpbiBpbWFnZS9wbmcK\x1b\\") != null); + try testing.expect(std.mem.indexOf(u8, S.written.items, "secret") == null); + try testing.expectEqual(@as(usize, 0), reader.reads[0]); + try testing.expectEqual(@as(usize, 0), reader.reads[1]); +} + +test "paste: reader failure writes nothing" { + var t: Terminal = try .init(testing.io, testing.allocator, .{ .cols = 80, .rows = 24 }); + defer t.deinit(testing.allocator); + + const S = PasteCapture; + S.reset(); + defer S.deinit(); + + var handler: Handler = .init(&t); + defer handler.deinit(); + handler.effects.write_pty = &S.writePty; + t.modes.set(.bracketed_paste, true); + + // The read is buffered whole before anything is written, so a + // mid-read failure discards the buffer, checked or not. + var reader: PasteReader = .{ + .mimes = &.{"text/plain"}, + .data = &.{"hello world"}, + .fail_after = 6, + }; + try testing.expectError(error.ReadFailed, handler.paste(.{ + .contents = reader.contents(), + })); + try testing.expectEqual(@as(usize, 0), S.write_count); + + try testing.expectError(error.ReadFailed, handler.paste(.{ + .contents = reader.contents(), + .allow_unsafe = true, + })); + try testing.expectEqual(@as(usize, 0), S.write_count); +} + +test "paste: mode 5522 sends an event the program can read with" { + var t: Terminal = try .init(testing.io, testing.allocator, .{ .cols = 80, .rows = 24 }); + defer t.deinit(testing.allocator); + + const S = PasteCapture; + S.reset(); + defer S.deinit(); + + var handler: Handler = .init(&t); + handler.effects.write_pty = &S.writePty; + handler.effects.clipboard_read = &S.clipboardRead; + var s: Stream = .init(.{ .allocator = testing.allocator, .handler = handler }); + defer s.deinit(); + t.modes.set(.kitty_paste_events, true); + t.modes.set(.bracketed_paste, true); + + // Every representation is listed, the data is never written, and + // the one-time password rides on every packet. + try testing.expect(try s.handler.paste(.{ + .contents = .{ .memory = &.{ + .{ .mime = "text/plain", .data = "secret" }, + .{ .mime = "image/png", .data = "" }, + } }, + })); + try testing.expectEqual(@as(usize, 1), S.write_count); + try testing.expectEqual(@as(usize, 3), std.mem.count(u8, S.written.items, "\x1b]5522;")); + try testing.expect(std.mem.indexOf(u8, S.written.items, "secret") == null); + try testing.expect(std.mem.indexOf(u8, S.written.items, "\x1b[200~") == null); + + // OK packet: parse the (base64) password out. + const ok_prefix = "\x1b]5522;type=read:status=OK:pw="; + try testing.expect(std.mem.startsWith(u8, S.written.items, ok_prefix)); + const pw_end = std.mem.indexOfPos(u8, S.written.items, ok_prefix.len, "\x1b\\").?; + // Copied out since the capture buffer is reused below. + var pw_buf: [64]u8 = undefined; + const pw_b64 = pw_buf[0 .. pw_end - ok_prefix.len]; + @memcpy(pw_b64, S.written.items[ok_prefix.len..pw_end]); + try testing.expectEqual( + std.base64.standard.Encoder.calcSize(kitty_clipboard.otp_len), + pw_b64.len, + ); + + // Listing packet: base64 of "text/plain image/png\n". + var expected_buf: [256]u8 = undefined; + const expected = try std.fmt.bufPrint( + &expected_buf, + "\x1b]5522;type=read:status=OK:pw={s}\x1b\\" ++ + "\x1b]5522;type=read:status=DATA:mime=Lg==:pw={s};dGV4dC9wbGFpbiBpbWFnZS9wbmcK\x1b\\" ++ + "\x1b]5522;type=read:status=DONE:pw={s}\x1b\\", + .{ pw_b64, pw_b64, pw_b64 }, + ); + try testing.expectEqualStrings(expected, S.written.items); + + // The program reads with the password and the name "Paste event": + // the read arrives granted, exactly once. + var read_buf: [256]u8 = undefined; + const read = try std.fmt.bufPrint( + &read_buf, + "\x1b]5522;type=read:pw={s}:name=UGFzdGUgZXZlbnQ=;dGV4dC9wbGFpbg==\x1b\\", + .{pw_b64}, + ); + S.reset(); + s.nextSlice(read); + try testing.expectEqual(@as(usize, 1), S.read_count); + try testing.expect(S.last_read_granted); + try testing.expectEqualStrings("Paste event", S.readName()); + try testing.expectEqualStrings( + "\x1b]5522;type=read:status=OK\x1b\\" ++ + "\x1b]5522;type=read:status=DATA:mime=dGV4dC9wbGFpbg==;R2hvc3R0eQ==\x1b\\" ++ + "\x1b]5522;type=read:status=DONE\x1b\\", + S.written.items, + ); + + // The password was one-time: a second read is not granted. + S.reset(); + s.nextSlice(read); + try testing.expectEqual(@as(usize, 1), S.read_count); + try testing.expect(!S.last_read_granted); + try testing.expectEqual(@as(usize, 0), S.read_granted_count); + + // Every event mints a fresh password. + S.reset(); + try testing.expect(try s.handler.paste(.{ + .contents = .{ .memory = &.{.{ .mime = "text/plain", .data = "secret" }} }, + })); + try testing.expect(std.mem.indexOf(u8, S.written.items, pw_b64) == null); +} + +test "paste: mode 5522 reports the selection as primary" { + var t: Terminal = try .init(testing.io, testing.allocator, .{ .cols = 80, .rows = 24 }); + defer t.deinit(testing.allocator); + + const S = PasteCapture; + S.reset(); + defer S.deinit(); + + var handler: Handler = .init(&t); + defer handler.deinit(); + handler.effects.write_pty = &S.writePty; + handler.effects.clipboard_read = &S.clipboardRead; + t.modes.set(.kitty_paste_events, true); + + for ([_]clipboard.Location{ .primary, .selection }) |location| { + S.reset(); + try testing.expect(try handler.paste(.{ + .source = .{ .clipboard = location }, + .contents = .{ .memory = &.{.{ .mime = "text/plain", .data = "x" }} }, + })); + try testing.expect(std.mem.startsWith( + u8, + S.written.items, + "\x1b]5522;type=read:status=OK:loc=primary:pw=", + )); + // Only on the OK packet. + try testing.expectEqual(@as(usize, 1), std.mem.count(u8, S.written.items, "loc=primary")); + } + + S.reset(); + try testing.expect(try handler.paste(.{ + .source = .{ .clipboard = .standard }, + .contents = .{ .memory = &.{.{ .mime = "text/plain", .data = "x" }} }, + })); + try testing.expect(std.mem.indexOf(u8, S.written.items, "loc=") == null); +} + +test "paste: mode 5522 without clipboard_read pastes text" { + var t: Terminal = try .init(testing.io, testing.allocator, .{ .cols = 80, .rows = 24 }); + defer t.deinit(testing.allocator); + + const S = PasteCapture; + S.reset(); + defer S.deinit(); + + var handler: Handler = .init(&t); + defer handler.deinit(); + handler.effects.write_pty = &S.writePty; + t.modes.set(.kitty_paste_events, true); + + try testing.expect(try handler.paste(.{ + .contents = .{ .memory = &.{.{ .mime = "text/plain", .data = "hello" }} }, + })); + try testing.expectEqualStrings("hello", S.written.items); + try testing.expectEqual(@as(usize, 0), handler.kitty_clipboard_grants.entries.items.len); +} + +test "paste: text source never becomes an event" { + var t: Terminal = try .init(testing.io, testing.allocator, .{ .cols = 80, .rows = 24 }); + defer t.deinit(testing.allocator); + + const S = PasteCapture; + S.reset(); + defer S.deinit(); + + var handler: Handler = .init(&t); + defer handler.deinit(); + handler.effects.write_pty = &S.writePty; + handler.effects.clipboard_read = &S.clipboardRead; + t.modes.set(.kitty_paste_events, true); + + try testing.expect(try handler.paste(.{ + .source = .text, + .contents = .{ .memory = &.{.{ .mime = "text/plain", .data = "committed" }} }, + })); + try testing.expectEqualStrings("committed", S.written.items); + try testing.expectEqual(@as(usize, 0), handler.kitty_clipboard_grants.entries.items.len); +} + +test "paste: mode 5522 without entropy fails and records no grant" { + var t: Terminal = try .init(std.Io.failing, testing.allocator, .{ .cols = 80, .rows = 24 }); + defer t.deinit(testing.allocator); + + const S = PasteCapture; + S.reset(); + defer S.deinit(); + + var handler: Handler = .init(&t); + defer handler.deinit(); + handler.effects.write_pty = &S.writePty; + handler.effects.clipboard_read = &S.clipboardRead; + t.modes.set(.kitty_paste_events, true); + + try testing.expectError(error.EntropyUnavailable, handler.paste(.{ + .contents = .{ .memory = &.{.{ .mime = "text/plain", .data = "secret" }} }, + })); + try testing.expectEqual(@as(usize, 0), S.write_count); + try testing.expectEqual(@as(usize, 0), handler.kitty_clipboard_grants.entries.items.len); + + // Text pastes need no entropy and still work. + try testing.expect(try handler.paste(.{ + .source = .text, + .contents = .{ .memory = &.{.{ .mime = "text/plain", .data = "hello" }} }, + })); + try testing.expectEqualStrings("hello", S.written.items); +} + +test "paste: mode 5522 DECRQM requires clipboard read effect" { + if (comptime build_options.artifact != .lib) return error.SkipZigTest; + + var t: Terminal = try .init(testing.io, testing.allocator, .{ .cols = 80, .rows = 24 }); + defer t.deinit(testing.allocator); + + const S = PasteCapture; + S.reset(); + defer S.deinit(); + + var handler: Handler = .init(&t); + handler.effects.write_pty = &S.writePty; + var s: Stream = .init(.{ .allocator = testing.allocator, .handler = handler }); + defer s.deinit(); + + // A paste event is unusable if its follow-up read can't be served, so + // the mode isn't advertised without a clipboard read effect. + s.nextSlice("\x1B[?5522$p"); + try testing.expectEqualStrings("\x1B[?5522;0$y", S.written.items); + + S.reset(); + s.nextSlice("\x1B[?5522h"); + try testing.expect(t.modes.get(.kitty_paste_events)); + s.nextSlice("\x1B[?5522$p"); + try testing.expectEqualStrings("\x1B[?5522;0$y", S.written.items); + + // Installing the effect makes the current mode state reportable. + S.reset(); + s.handler.effects.clipboard_read = &S.clipboardRead; + s.nextSlice("\x1B[?5522$p"); + try testing.expectEqualStrings("\x1B[?5522;1$y", S.written.items); + + S.reset(); + s.nextSlice("\x1B[?5522l"); + try testing.expect(!t.modes.get(.kitty_paste_events)); + s.nextSlice("\x1B[?5522$p"); + try testing.expectEqualStrings("\x1B[?5522;2$y", S.written.items); + + // Removing the effect stops advertising the capability again. + S.reset(); + s.handler.effects.clipboard_read = null; + s.nextSlice("\x1B[?5522$p"); + try testing.expectEqualStrings("\x1B[?5522;0$y", S.written.items); +} + +test "full reset drops kitty clipboard grants" { + var t: Terminal = try .init(testing.io, testing.allocator, .{ .cols = 80, .rows = 24 }); + defer t.deinit(testing.allocator); + + const S = PasteCapture; + S.reset(); + defer S.deinit(); + + var handler: Handler = .init(&t); + handler.effects.write_pty = &S.writePty; + handler.effects.clipboard_read = &S.clipboardRead; + var s: Stream = .init(.{ .allocator = testing.allocator, .handler = handler }); + defer s.deinit(); + + try s.handler.kitty_clipboard_grants.grant(testing.allocator, "pw", .read, false); + try testing.expectEqual(@as(usize, 1), s.handler.kitty_clipboard_grants.entries.items.len); + + s.nextSlice("\x1Bc"); + try testing.expectEqual(@as(usize, 0), s.handler.kitty_clipboard_grants.entries.items.len); + + // A read with the old password is no longer granted, and the + // handler keeps working (grants can be recorded again). + S.reset(); + s.nextSlice("\x1b]5522;type=read:pw=cHc=:name=YXBw;dGV4dC9wbGFpbg==\x1b\\"); + try testing.expectEqual(@as(usize, 1), S.read_count); + try testing.expect(!S.last_read_granted); + try s.handler.kitty_clipboard_grants.grant(testing.allocator, "pw", .read, false); +} diff --git a/vendor/libghostty-vt/src/terminal/style.zig b/vendor/libghostty-vt/src/terminal/style.zig index ee4c0e5..701356e 100644 --- a/vendor/libghostty-vt/src/terminal/style.zig +++ b/vendor/libghostty-vt/src/terminal/style.zig @@ -1,4 +1,5 @@ const std = @import("std"); +const builtin = @import("builtin"); const assert = @import("../quirks.zig").inlineAssert; const fastprint = @import("../fastprint.zig"); const color = @import("color.zig"); @@ -89,11 +90,29 @@ pub const Style = struct { /// True if the style is the default style. pub inline fn default(self: Style) bool { + // On wasm, eql converts both sides to packed form; the default + // side is comptime-known, so bake it and convert only self. + // This is called on every SGR change so it's worth it. + if (comptime builtin.cpu.arch.isWasm()) { + const d: u128 = comptime @bitCast(PackedStyle.fromStyle(.{})); + return @as(u128, @bitCast(PackedStyle.fromStyle(self))) == d; + } + return self.eql(.{}); } /// True if the style is equal to another style. pub fn eql(self: Style, other: Style) bool { + // On wasm, comparing packed forms wins (~13% on SGR-heavy streams). + // On native, the branchy early-exit field compare below measures + // ~5% faster (unequal styles usually differ in the first + // field or two), so each target keeps its own strategy. + if (comptime builtin.cpu.arch.isWasm()) { + const a: u128 = @bitCast(PackedStyle.fromStyle(self)); + const b: u128 = @bitCast(PackedStyle.fromStyle(other)); + return a == b; + } + return self.flags == other.flags and self.fg_color.eql(other.fg_color) and self.bg_color.eql(other.bg_color) and diff --git a/vendor/libghostty-vt/src/terminal/sys.zig b/vendor/libghostty-vt/src/terminal/sys.zig index f0c64da..3f6b501 100644 --- a/vendor/libghostty-vt/src/terminal/sys.zig +++ b/vendor/libghostty-vt/src/terminal/sys.zig @@ -52,3 +52,51 @@ fn decodePngWuffs( .data = result.data, }; } + +/// Fill a buffer with cryptographically secure random bytes. If null, +/// the terminal's `std.Io` (`randomSecure`) is used. This is an override +/// for embedders whose Io has no entropy source (e.g. wasm32-freestanding, +/// where TinyIo degrades to `std.Io.failing`) or that want to control +/// the source; when set it is used on every target. +/// +/// This is used for secrets, so it must be a real CSPRNG. An error +/// makes the operation that needed the entropy fail; nothing falls back +/// to weaker randomness. +pub var random_secure: ?RandomSecureFn = null; + +pub const RandomSecureError = error{EntropyUnavailable}; +pub const RandomSecureFn = *const fn ([]u8) RandomSecureError!void; + +/// Fill `buffer` with secure random bytes from `random_secure` if set, +/// otherwise from `io`. Every use of secure entropy in the terminal +/// package goes through this so the override applies uniformly. +pub fn randomSecure(io: std.Io, buffer: []u8) std.Io.RandomSecureError!void { + if (random_secure) |func| return func(buffer); + return io.randomSecure(buffer); +} + +test "randomSecure: override is preferred over the Io" { + const testing = std.testing; + const S = struct { + fn fill(buffer: []u8) RandomSecureError!void { + @memset(buffer, 0xAB); + } + fn fail(_: []u8) RandomSecureError!void { + return error.EntropyUnavailable; + } + }; + + // Without the override a failing Io fails. + var buf: [8]u8 = @splat(0); + try testing.expectError(error.EntropyUnavailable, randomSecure(std.Io.failing, &buf)); + + // With it, the Io is never consulted. + random_secure = &S.fill; + defer random_secure = null; + try randomSecure(std.Io.failing, &buf); + try testing.expect(std.mem.allEqual(u8, &buf, 0xAB)); + + // An override failure surfaces as the Io's error. + random_secure = &S.fail; + try testing.expectError(error.EntropyUnavailable, randomSecure(testing.io, &buf)); +} diff --git a/vendor/libghostty-vt/src/terminfo/ghostty.zig b/vendor/libghostty-vt/src/terminfo/ghostty.zig index 2b7434c..ea64ffc 100644 --- a/vendor/libghostty-vt/src/terminfo/ghostty.zig +++ b/vendor/libghostty-vt/src/terminfo/ghostty.zig @@ -392,6 +392,15 @@ pub const ghostty: Source = .{ }, }; +/// A content-derived version of the encoded terminfo source. +pub const version = version: { + @setEvalBranchQuota(100_000); + var hashing: std.Io.Writer.Hashing(std.hash.Wyhash) = + .initHasher(.init(0), &.{}); + ghostty.encode(&hashing.writer) catch unreachable; + break :version std.fmt.comptimePrint("{x}", .{hashing.hasher.final()}); +}; + test "encode" { // Encode var buf: [1024 * 16]u8 = undefined; diff --git a/vendor/libghostty-vt/src/terminfo/main.zig b/vendor/libghostty-vt/src/terminfo/main.zig index c76c328..f6c709b 100644 --- a/vendor/libghostty-vt/src/terminfo/main.zig +++ b/vendor/libghostty-vt/src/terminfo/main.zig @@ -6,6 +6,7 @@ //! extract this into a more full-featured library on its own. pub const ghostty = @import("ghostty.zig").ghostty; +pub const version = @import("ghostty.zig").version; pub const Source = @import("Source.zig"); test { diff --git a/vendor/libghostty-vt/src/termio/Termio.zig b/vendor/libghostty-vt/src/termio/Termio.zig index 7d6e179..7d9fd45 100644 --- a/vendor/libghostty-vt/src/termio/Termio.zig +++ b/vendor/libghostty-vt/src/termio/Termio.zig @@ -119,13 +119,15 @@ const ThreadEnterState = struct { ) (Allocator.Error || error{InputNotFound})![]const Input { const alloc = self.arena.allocator(); - var input = try alloc.alloc( - Input, + var inputs: std.ArrayList(Input) = try .initCapacity( + alloc, self.input.list.items.len, ); - for (self.input.list.items, 0..) |item, i| { - input[i] = switch (item) { - .raw => |v| .{ .string = try alloc.dupe(u8, v) }, + errdefer for (inputs.items) |item| item.deinit(); + + for (self.input.list.items) |item| { + inputs.appendAssumeCapacity(switch (item) { + .raw => |v| .{ .string = v }, .path => |path| file: { const f = std.Io.Dir.cwd().openFile( global.io(), @@ -141,15 +143,22 @@ const ThreadEnterState = struct { break :file .{ .file = f }; }, - }; + }); } - return input; + return inputs.items; } const Input = union(enum) { string: []const u8, file: std.Io.File, + + fn deinit(self: Input) void { + switch (self) { + .string => {}, + .file => |f| f.close(global.io()), + } + } }; }; @@ -168,6 +177,7 @@ pub const DerivedConfig = struct { background: configpkg.Config.Color, osc_color_report_format: configpkg.Config.OSCColorReportFormat, clipboard_write: configpkg.ClipboardAccess, + clipboard_write_limit: usize, enquiry_response: []const u8, conditional_state: configpkg.ConditionalState, @@ -204,6 +214,7 @@ pub const DerivedConfig = struct { .background = config.background, .osc_color_report_format = config.@"osc-color-report-format", .clipboard_write = config.@"clipboard-write", + .clipboard_write_limit = config.@"clipboard-write-limit-bytes".value, .enquiry_response = try alloc.dupe(u8, config.@"enquiry-response"), .conditional_state = config._conditional_state, @@ -288,6 +299,7 @@ pub fn init(self: *Termio, alloc: Allocator, opts: termio.Options) !void { .terminal = &self.terminal, .osc_color_report_format = opts.config.osc_color_report_format, .clipboard_write = opts.config.clipboard_write, + .clipboard_write_limit = opts.config.clipboard_write_limit, .enquiry_response = opts.config.enquiry_response, }; @@ -346,6 +358,9 @@ pub fn threadEnter( try v.prepareInput() else null; + defer if (inputs) |items| { + for (items) |input| input.deinit(); + }; data.* = .{ .alloc = self.alloc, @@ -366,20 +381,21 @@ pub fn threadEnter( log.warn("failed to queue input string err={}", .{err}); return error.InputFailed; }, - .file => |f| self.queueWrite( - data, - compat_file.readToEndAlloc( + .file => |f| { + const contents = compat_file.readToEndAlloc( f, self.alloc, 10 * 1024 * 1024, // 10 MiB max ) catch |err| { log.warn("failed to read input file err={}", .{err}); return error.InputFailed; - }, - false, - ) catch |err| { - log.warn("failed to queue input file err={}", .{err}); - return error.InputFailed; + }; + defer self.alloc.free(contents); + + self.queueWrite(data, contents, false) catch |err| { + log.warn("failed to queue input file err={}", .{err}); + return error.InputFailed; + }; }, }; } @@ -704,6 +720,19 @@ fn processOutputLocked(self: *Termio, buf: []const u8) void { } /// Sends a DSR response for the current color scheme to the pty. +/// Record a Kitty clipboard protocol session grant so future requests +/// carrying the password skip the permission prompt. +pub fn kittyClipboardGrant( + self: *Termio, + pw: []const u8, + dir: terminalpkg.kitty.clipboard.Grants.Direction, +) error{OutOfMemory}!void { + self.renderer_state.mutex.lockUncancelable(global.io()); + defer self.renderer_state.mutex.unlock(global.io()); + + try self.terminal_stream.handler.kittyClipboardGrant(pw, dir); +} + pub fn colorSchemeReport(self: *Termio, td: *ThreadData, force: bool) !void { self.renderer_state.mutex.lockUncancelable(global.io()); defer self.renderer_state.mutex.unlock(global.io()); diff --git a/vendor/libghostty-vt/src/termio/Thread.zig b/vendor/libghostty-vt/src/termio/Thread.zig index 6b860a0..de26835 100644 --- a/vendor/libghostty-vt/src/termio/Thread.zig +++ b/vendor/libghostty-vt/src/termio/Thread.zig @@ -336,6 +336,14 @@ fn drainMailbox( } }, .jump_to_prompt => |v| try io.jumpToPrompt(v), + .kitty_clipboard_grant_read => |v| { + defer v.alloc.free(v.pw); + try io.kittyClipboardGrant(v.pw, .read); + }, + .kitty_clipboard_grant_write => |v| { + defer v.alloc.free(v.pw); + try io.kittyClipboardGrant(v.pw, .write); + }, .start_synchronized_output => self.startSynchronizedOutput(cb), .linefeed_mode => |v| self.flags.linefeed_mode = v, .focused => |v| try io.focusGained(data, v), diff --git a/vendor/libghostty-vt/src/termio/message.zig b/vendor/libghostty-vt/src/termio/message.zig index e51865e..2416874 100644 --- a/vendor/libghostty-vt/src/termio/message.zig +++ b/vendor/libghostty-vt/src/termio/message.zig @@ -82,6 +82,12 @@ pub const Message = union(enum) { /// The surface gained or lost focus. focused: bool, + /// Record a Kitty clipboard protocol session grant for a password + /// so future requests carrying it skip the permission prompt, for + /// reads and writes respectively. + kitty_clipboard_grant_read: KittyClipboardGrant, + kitty_clipboard_grant_write: KittyClipboardGrant, + /// Write where the data fits in the union. write_small: WriteReq.Small, @@ -91,6 +97,13 @@ pub const Message = union(enum) { /// Write where the data is allocated and must be freed. write_alloc: WriteReq.Alloc, + /// The payload of the kitty_clipboard_grant_* messages. The + /// password is allocated and must be freed. + pub const KittyClipboardGrant = struct { + alloc: Allocator, + pw: []const u8, + }; + /// Return a write request for the given data. This will use /// write_small if it fits or write_alloc otherwise. This should NOT /// be used for stable pointers which can be manually set to write_stable. @@ -111,6 +124,9 @@ pub const Message = union(enum) { v.alloc.destroy(v.ptr); }, .write_alloc => |v| v.alloc.free(v.data), + .kitty_clipboard_grant_read, + .kitty_clipboard_grant_write, + => |v| v.alloc.free(v.pw), else => {}, } } diff --git a/vendor/libghostty-vt/src/termio/shell_integration.zig b/vendor/libghostty-vt/src/termio/shell_integration.zig index c23ff9a..d56a84c 100644 --- a/vendor/libghostty-vt/src/termio/shell_integration.zig +++ b/vendor/libghostty-vt/src/termio/shell_integration.zig @@ -407,7 +407,7 @@ fn setupBash( } // Return a copy of our modified command line to use as the shell command. - return .{ .shell = try alloc.dupeZ(u8, try cmd.toOwnedSlice()) }; + return .{ .shell = try alloc.dupeZ(u8, cmd.buffer.written()) }; } test "bash" { @@ -839,7 +839,7 @@ fn setupNushell( } // Return a copy of our modified command line to use as the shell command. - return .{ .shell = try alloc.dupeZ(u8, try cmd.toOwnedSlice()) }; + return .{ .shell = try alloc.dupeZ(u8, cmd.buffer.written()) }; } test "nushell" { diff --git a/vendor/libghostty-vt/src/termio/stream_handler.zig b/vendor/libghostty-vt/src/termio/stream_handler.zig index ac269e7..27e06dc 100644 --- a/vendor/libghostty-vt/src/termio/stream_handler.zig +++ b/vendor/libghostty-vt/src/termio/stream_handler.zig @@ -51,6 +51,10 @@ pub const StreamHandler = struct { /// The clipboard write access configuration. clipboard_write: configpkg.ClipboardAccess, + /// Maximum total decoded bytes per Kitty clipboard protocol + /// (OSC 5522) write transaction; exceeding it aborts with EFBIG. + clipboard_write_limit: usize, + //--------------------------------------------------------------- // Internal state @@ -68,6 +72,14 @@ pub const StreamHandler = struct { /// The tmux control mode viewer state. tmux_viewer: if (tmux_enabled) ?*terminal.tmux.Viewer else void = if (tmux_enabled) null else {}, + /// Session password grants for the Kitty clipboard protocol. + /// Requests carrying a granted password skip the permission prompt. + kitty_clipboard_grants: terminal.kitty.clipboard.Grants = .{}, + + /// The in-flight Kitty clipboard protocol (OSC 5522) write + /// transaction, if any. + kitty_clipboard_write: ?*terminal.kitty.clipboard.WriteState = null, + /// This is set to true when a message was written to the termio /// mailbox. This can be used by callers to determine if they need /// to wake up the termio thread. @@ -85,6 +97,8 @@ pub const StreamHandler = struct { pub fn deinit(self: *StreamHandler) void { self.apc.deinit(); self.dcs.deinit(); + self.kittyClipboardWriteAbort(); + self.kitty_clipboard_grants.deinit(self.alloc); if (comptime tmux_enabled) tmux: { const viewer = self.tmux_viewer orelse break :tmux; viewer.deinit(); @@ -104,6 +118,7 @@ pub const StreamHandler = struct { pub fn changeConfig(self: *StreamHandler, config: *termio.DerivedConfig) void { self.osc_color_report_format = config.osc_color_report_format; self.clipboard_write = config.clipboard_write; + self.clipboard_write_limit = config.clipboard_write_limit; self.enquiry_response = config.enquiry_response; self.terminal.setDefaultCursorStyle(config.cursor_style); self.terminal.setDefaultCursorBlink(config.cursor_blink); @@ -352,10 +367,12 @@ pub const StreamHandler = struct { .apc_end => try self.apcEnd(), .apc_put => self.apc.feed(self.alloc, value), .apc_put_slice => self.apc.feedSlice(self.alloc, value.bytes), + .kitty_clipboard => try self.kittyClipboard(value), // Unimplemented .title_push, .title_pop, + .kitty_dnd, => {}, } } @@ -867,6 +884,10 @@ pub const StreamHandler = struct { self.terminal.fullReset(); try self.setMouseShape(.text); + // Full reset clears Kitty clipboard session grants. + self.kitty_clipboard_grants.deinit(self.alloc); + self.kitty_clipboard_grants = .{}; + // Reset resets our palette so we report it for mode 2031. self.messageWriter(.{ .color_scheme_report = .{ .force = false } }); @@ -874,6 +895,16 @@ pub const StreamHandler = struct { self.progressReport(.{ .state = .remove }); } + /// Record a Kitty clipboard protocol session grant so future + /// requests with this password skip the permission prompt. + pub fn kittyClipboardGrant( + self: *StreamHandler, + pw: []const u8, + dir: terminal.kitty.clipboard.Grants.Direction, + ) error{OutOfMemory}!void { + try self.kitty_clipboard_grants.grant(self.alloc, pw, dir, false); + } + pub fn queryKittyKeyboard(self: *StreamHandler) !void { log.debug("querying kitty keyboard mode", .{}); var data: termio.Message.WriteReq.Small.Array = undefined; @@ -988,6 +1019,410 @@ pub const StreamHandler = struct { }); } + /// Handle one Kitty clipboard protocol (OSC 5522) packet. + fn kittyClipboard( + self: *StreamHandler, + v: terminal.osc.Command.KittyClipboardProtocol, + ) error{ OutOfMemory, WriteFailed }!void { + const kitty_clipboard = terminal.kitty.clipboard; + + // Decode and validate the metadata. Malformed structure drops + // the packet without a response. Invalid decoded text on a write + // data or alias packet aborts an in-flight transaction. + var arena: std.heap.ArenaAllocator = .init(self.alloc); + defer arena.deinit(); + const meta = (kitty_clipboard.Metadata.parse( + arena.allocator(), + v.metadata, + ) catch |err| switch (err) { + error.OutOfMemory => return error.OutOfMemory, + error.InvalidValue => { + const state = self.kitty_clipboard_write orelse return; + switch (kitty_clipboard.Metadata.operation(v.metadata) orelse return) { + .wdata, .walias => try self.kittyClipboardWriteFinish( + state, + .EINVAL, + v.terminator, + ), + .read, .write => {}, + } + return; + }, + }) orelse return; + + switch (meta.op) { + .read => try self.kittyClipboardRead( + &meta, + v.payload orelse "", + v.terminator, + ), + + .write => try self.kittyClipboardWriteBegin( + &meta, + v.terminator, + ), + + .wdata => try self.kittyClipboardWriteData( + &meta, + v.payload orelse "", + v.terminator, + ), + + .walias => try self.kittyClipboardWriteAlias( + &meta, + v.payload orelse "", + v.terminator, + ), + } + } + + fn kittyClipboardRead( + self: *StreamHandler, + meta: *const terminal.kitty.clipboard.Metadata, + payload: []const u8, + terminator: terminal.osc.Terminator, + ) !void { + const kitty_clipboard = terminal.kitty.clipboard; + + // Everything about the request, including the request struct + // itself, lives in a single arena that crosses to the surface + // thread, which owns it from the moment the message is sent. + var arena: std.heap.ArenaAllocator = .init(self.alloc); + errdefer arena.deinit(); + const alloc = arena.allocator(); + + // The payload is the requested MIME list. A read request with + // an undecodable payload is dropped without any response, + // matching kitty. + const decoded = kitty_clipboard.Payload.init( + alloc, + payload, + ) catch |err| switch (err) { + error.OutOfMemory => return error.OutOfMemory, + error.Invalid => { + arena.deinit(); + return; + }, + }; + if (!decoded.isValidUtf8()) { + arena.deinit(); + return; + } + + // The targets type ('.') asks for the listing of available + // types rather than data. Requested types beyond the cap are + // dropped and simply never served, which is how the protocol + // reports an unavailable type anyway. + var mimes_buf: [kitty_clipboard.max_read_mimes][]const u8 = undefined; + var mimes_len: usize = 0; + var list = false; + var it = decoded.mimeIterator(); + while (it.next()) |mime| { + if (std.mem.eql(u8, mime, kitty_clipboard.targets_mime)) { + list = true; + continue; + } + if (mimes_len == mimes_buf.len) continue; + mimes_buf[mimes_len] = mime; + mimes_len += 1; + } + + // Per the spec a password without a name is no password. A + // stored session grant for it lets the surface skip its + // permission prompt. + const pw: []const u8 = if (meta.name.len > 0) meta.pw else ""; + const granted = self.kittyClipboardReadGranted(pw, mimes_len); + + const req = try alloc.create(apprt.ClipboardRequest.KittyRead); + const mimes = try alloc.alloc([:0]const u8, mimes_len); + for (mimes_buf[0..mimes_len], mimes) |src, *dst| { + dst.* = try alloc.dupeZ(u8, src); + } + const id = try alloc.dupe(u8, meta.id); + const pw_owned = try alloc.dupe(u8, pw); + const name_owned = try alloc.dupeZ(u8, meta.name); + req.* = .{ + // The arena must be copied in last so it tracks every + // allocation above. + .arena = arena, + .location = switch (meta.loc) { + .primary => .primary, + else => .standard, + }, + .mimes = mimes, + .list = list, + .id = id, + .pw = pw_owned, + .name = name_owned, + .granted = granted, + .terminator = terminator, + }; + + self.surfaceMessageWriter(.{ .kitty_clipboard_read = req }); + } + + /// Whether a session grant covers a read request, consuming + /// one-time grants. A prompt-exempt request never consults the + /// grants: consuming a one-time paste password on a listing would + /// burn the grant before the follow-up data read. + fn kittyClipboardReadGranted( + self: *StreamHandler, + pw: []const u8, + mimes_len: usize, + ) bool { + if (terminal.kitty.clipboard.readPromptExempt(mimes_len)) return false; + return self.kitty_clipboard_grants.use(self.alloc, pw, .read); + } + + /// Begin a Kitty clipboard write transaction (type=write). + fn kittyClipboardWriteBegin( + self: *StreamHandler, + meta: *const terminal.kitty.clipboard.Metadata, + terminator: terminal.osc.Terminator, + ) error{ OutOfMemory, WriteFailed }!void { + // A new write silently replaces any in-flight transaction. + self.kittyClipboardWriteAbort(); + + // A write denied by policy can never succeed, so fail the + // transaction up front instead of spooling data we'd only + // throw away. Later wdata packets are ignored. + if (self.clipboard_write == .deny) { + log.info("application attempted to write clipboard, but 'clipboard-write' is set to deny", .{}); + try self.kittyClipboardWriteStatus( + .EPERM, + meta.id, + terminator, + ); + return; + } + + const state = try self.alloc.create(terminal.kitty.clipboard.WriteState); + errdefer self.alloc.destroy(state); + state.* = try .init(self.alloc, meta, .{ + .max_size = self.clipboard_write_limit, + }); + self.kitty_clipboard_write = state; + } + + /// Accumulate one wdata chunk, or commit the transaction when the + /// chunk carries no MIME type. + fn kittyClipboardWriteData( + self: *StreamHandler, + meta: *const terminal.kitty.clipboard.Metadata, + payload: []const u8, + terminator: terminal.osc.Terminator, + ) error{ OutOfMemory, WriteFailed }!void { + // Data without a transaction is silently ignored, matching + // kitty. + const state = self.kitty_clipboard_write orelse return; + + // A wdata packet without a MIME type commits the transaction. + if (meta.mime.len == 0) return self.kittyClipboardWriteCommit( + state, + terminator, + ); + + state.data( + self.alloc, + meta, + payload, + ) catch |err| switch (err) { + // Failing to spool matches kitty's EIO for a failed buffer write. + error.OutOfMemory => { + try self.kittyClipboardWriteFinish( + state, + .EIO, + terminator, + ); + return error.OutOfMemory; + }, + + // Data over the write limit aborts the transaction and is + // reported to the client. + error.TooLarge => try self.kittyClipboardWriteFinish( + state, + .EFBIG, + terminator, + ), + + // An invalid base64 payload stream aborts the transaction. + error.Invalid => try self.kittyClipboardWriteFinish( + state, + .EINVAL, + terminator, + ), + }; + } + + /// Register aliases from a walias packet. + fn kittyClipboardWriteAlias( + self: *StreamHandler, + meta: *const terminal.kitty.clipboard.Metadata, + payload: []const u8, + terminator: terminal.osc.Terminator, + ) error{ OutOfMemory, WriteFailed }!void { + // Aliases without a transaction are silently ignored. Once a + // transaction exists, a missing target MIME type is invalid and + // aborts the transaction. + const state = self.kitty_clipboard_write orelse return; + if (meta.mime.len == 0) return self.kittyClipboardWriteFinish( + state, + .EINVAL, + terminator, + ); + + state.alias( + self.alloc, + meta, + payload, + ) catch |err| switch (err) { + error.OutOfMemory => { + try self.kittyClipboardWriteFinish( + state, + .EIO, + terminator, + ); + return error.OutOfMemory; + }, + + // An undecodable alias payload aborts the transaction. + error.Invalid => try self.kittyClipboardWriteFinish( + state, + .EINVAL, + terminator, + ), + }; + } + + /// Commit the transaction: resolve the final contents and forward + /// them to the surface thread, which owns policy, any permission + /// prompt, the clipboard write itself, and the final reply. + fn kittyClipboardWriteCommit( + self: *StreamHandler, + state: *terminal.kitty.clipboard.WriteState, + terminator: terminal.osc.Terminator, + ) error{ OutOfMemory, WriteFailed }!void { + self.kittyClipboardWriteSend( + state, + terminator, + ) catch |err| switch (err) { + error.OutOfMemory => { + try self.kittyClipboardWriteFinish( + state, + .EIO, + terminator, + ); + return error.OutOfMemory; + }, + + // The last MIME type's payload stream was not correctly + // padded, which aborts the transaction. + error.Invalid => return try self.kittyClipboardWriteFinish( + state, + .EINVAL, + terminator, + ), + }; + + // The transaction is complete; the surface owns the reply. + self.kittyClipboardWriteAbort(); + } + + fn kittyClipboardWriteSend( + self: *StreamHandler, + state: *terminal.kitty.clipboard.WriteState, + terminator: terminal.osc.Terminator, + ) error{ OutOfMemory, Invalid }!void { + const committed = try state.commit(self.alloc); + defer committed.deinit(self.alloc); + + // Per the spec a password without a name is no password. A + // stored session grant for it lets the surface skip its + // permission prompt. + const pw: []const u8 = if (committed.name.len > 0) committed.pw else ""; + const granted = self.kitty_clipboard_grants.use(self.alloc, pw, .write); + + // Everything about the request, including the request struct + // itself, lives in a single arena that crosses to the surface + // thread, which owns it from the moment the message is sent. + var arena: std.heap.ArenaAllocator = .init(self.alloc); + errdefer arena.deinit(); + const alloc = arena.allocator(); + + const req = try alloc.create(apprt.ClipboardRequest.KittyWrite); + const contents = try alloc.alloc( + apprt.ClipboardContent, + committed.contents.len, + ); + for (committed.contents, contents) |src, *dst| dst.* = .{ + .mime = try alloc.dupeZ(u8, src.mime), + .data = try alloc.dupeZ(u8, src.data), + }; + const id = try alloc.dupe(u8, committed.id); + const pw_owned = try alloc.dupe(u8, pw); + const name_owned = try alloc.dupeZ(u8, committed.name); + req.* = .{ + // The arena must be copied in last so it tracks every + // allocation above. + .arena = arena, + .location = switch (committed.loc) { + .primary => .primary, + else => .standard, + }, + .contents = contents, + .id = id, + .pw = pw_owned, + .name = name_owned, + .granted = granted, + .terminator = terminator, + }; + + self.surfaceMessageWriter(.{ .kitty_clipboard_write = req }); + } + + /// Answer the write transaction with its final status and drop it. + /// The id echoed is the one from the transaction's opening write + /// packet, matching kitty. + fn kittyClipboardWriteFinish( + self: *StreamHandler, + state: *const terminal.kitty.clipboard.WriteState, + status: terminal.kitty.clipboard.Status, + terminator: terminal.osc.Terminator, + ) error{ OutOfMemory, WriteFailed }!void { + defer self.kittyClipboardWriteAbort(); + try self.kittyClipboardWriteStatus(status, state.id, terminator); + } + + /// Reply to a write transaction with a single status packet. + fn kittyClipboardWriteStatus( + self: *StreamHandler, + status: terminal.kitty.clipboard.Status, + id: []const u8, + terminator: terminal.osc.Terminator, + ) error{ OutOfMemory, WriteFailed }!void { + var stream: std.Io.Writer.Allocating = .init(self.alloc); + defer stream.deinit(); + try (terminal.kitty.clipboard.Response{ + .op = .write, + .status = status, + .id = id, + .terminator = terminator, + }).encode(&stream.writer); + self.messageWriter(.{ .write_alloc = .{ + .alloc = self.alloc, + .data = try stream.toOwnedSlice(), + } }); + } + + /// Drop any in-flight write transaction without responding. + fn kittyClipboardWriteAbort(self: *StreamHandler) void { + if (self.kitty_clipboard_write) |state| { + state.deinit(self.alloc); + self.alloc.destroy(state); + self.kitty_clipboard_write = null; + } + } + fn semanticPrompt( self: *StreamHandler, cmd: Stream.Action.SemanticPrompt, @@ -1346,17 +1781,9 @@ pub const StreamHandler = struct { title: []const u8, body: []const u8, ) !void { - var message = apprt.surface.Message{ .desktop_notification = undefined }; - - const title_len = @min(title.len, message.desktop_notification.title.len); - @memcpy(message.desktop_notification.title[0..title_len], title[0..title_len]); - message.desktop_notification.title[title_len] = 0; - - const body_len = @min(body.len, message.desktop_notification.body.len); - @memcpy(message.desktop_notification.body[0..body_len], body[0..body_len]); - message.desktop_notification.body[body_len] = 0; - - self.surfaceMessageWriter(message); + self.surfaceMessageWriter(.{ + .desktop_notification = .init(title, body), + }); } /// Send a report to the pty. @@ -1466,3 +1893,78 @@ pub const StreamHandler = struct { self.surfaceMessageWriter(.{ .progress_report = report }); } }; + +test "kitty clipboard read: targets-only never consumes a one-time grant" { + const testing = std.testing; + + var handler: StreamHandler = undefined; + handler.alloc = testing.allocator; + handler.kitty_clipboard_grants = .{}; + defer handler.kitty_clipboard_grants.deinit(testing.allocator); + try handler.kitty_clipboard_grants.grant(testing.allocator, "otp", .read, true); + + // A listing request must not burn the one-time paste password... + try testing.expect(!handler.kittyClipboardReadGranted("otp", 0)); + // ...so the follow-up data read is still granted, exactly once. + try testing.expect(handler.kittyClipboardReadGranted("otp", 1)); + try testing.expect(!handler.kittyClipboardReadGranted("otp", 1)); +} + +test "kitty clipboard write: oversized text replies EFBIG" { + const testing = std.testing; + + var mailbox = try termio.Mailbox.initSPSC(testing.allocator); + defer mailbox.deinit(testing.allocator); + + var mutex: std.Io.Mutex = .init; + mutex.lockUncancelable(global.io()); + defer mutex.unlock(global.io()); + + var renderer_state: renderer.State = .{ + .mutex = &mutex, + .terminal = undefined, + }; + var handler: StreamHandler = undefined; + handler.alloc = testing.allocator; + handler.termio_mailbox = &mailbox; + handler.renderer_state = &renderer_state; + handler.clipboard_write = .allow; + handler.clipboard_write_limit = 4; + handler.kitty_clipboard_write = null; + defer handler.kittyClipboardWriteAbort(); + + const begin: terminal.kitty.clipboard.Metadata = .{ + .op = .write, + .id = "macos", + }; + try handler.kittyClipboardWriteBegin(&begin, .st); + const state = handler.kitty_clipboard_write.?; + try state.data( + testing.allocator, + &.{ .op = .wdata, .mime = "text/plain" }, + "SGVsbA==", // "Hell" + ); + try testing.expectError(error.TooLarge, state.data( + testing.allocator, + &.{ .op = .wdata, .mime = "text/plain" }, + "bw==", // "o" + )); + try handler.kittyClipboardWriteFinish(state, .EFBIG, .st); + try testing.expect(handler.kitty_clipboard_write == null); + + const response = mailbox.spsc.queue.pop(global.io()); + try testing.expect(response != null); + const msg = response.?; + defer msg.deinit(); + switch (msg) { + .write_alloc => |v| try testing.expectEqualStrings( + "\x1B]5522;type=write:status=EFBIG:id=macos\x1B\\", + v.data, + ), + else => try testing.expect(false), + } + + // Teardown leaves no transaction that could be committed and + // forwarded to the macOS clipboard path. + try testing.expect(mailbox.spsc.queue.pop(global.io()) == null); +} diff --git a/vendor/libghostty-vt/typos.toml b/vendor/libghostty-vt/typos.toml index 46ac28d..2158484 100644 --- a/vendor/libghostty-vt/typos.toml +++ b/vendor/libghostty-vt/typos.toml @@ -59,6 +59,7 @@ extend-ignore-re = [ Pn = "Pn" thr = "thr" # Swift oddities +Datas = "Datas" Requestor = "Requestor" iterm = "iterm" ACCES = "ACCES" diff --git a/vendor/libghostty-vt/valgrind.supp b/vendor/libghostty-vt/valgrind.supp index 27479fd..ea1d723 100644 --- a/vendor/libghostty-vt/valgrind.supp +++ b/vendor/libghostty-vt/valgrind.supp @@ -13,6 +13,15 @@ # You must gracefully exit Ghostty (do not SIGINT) by closing all windows # and quitting. Otherwise, we leave a number of GTK resources around. +# Zig's DEFLATE encoder intentionally increments an undefined base[0] entry +# for unused Huffman symbols. The corresponding output code is also unused; +# see the comment in std.compress.flate.Compress.huffman.buildValues. +{ + Zig std DEFLATE unused Huffman code + Memcheck:Cond + fun:compress.flate.Compress.huffman.buildValues +} + # Reproduction: # 1. Launch Ghostty (no config) diff --git a/vendor/patches/0001-keep-kitty-images-across-a-screen-clear.patch b/vendor/patches/0001-keep-kitty-images-across-a-screen-clear.patch index 905cb50..54eaee4 100644 --- a/vendor/patches/0001-keep-kitty-images-across-a-screen-clear.patch +++ b/vendor/patches/0001-keep-kitty-images-across-a-screen-clear.patch @@ -1,44 +1,38 @@ -diff --git a/vendor/libghostty-vt/src/terminal/Terminal.zig b/vendor/libghostty-vt/src/terminal/Terminal.zig -index b157994..e10132a 100644 ---- a/vendor/libghostty-vt/src/terminal/Terminal.zig -+++ b/vendor/libghostty-vt/src/terminal/Terminal.zig -@@ -3341,12 +3341,17 @@ pub fn eraseDisplay( - self.screens.active.cursor.pending_wrap = false; +--- a/vendor/libghostty-vt/src/terminal/kitty/graphics_storage.zig 2026-09-01 19:25:35.000000000 +0200 ++++ b/vendor/libghostty-vt/src/terminal/kitty/graphics_storage.zig 2026-09-02 15:41:50.671153167 +0200 +@@ -1163,9 +1163,14 @@ + return min_delay; + } - if (comptime build_options.kitty_graphics) { -- // Clear all Kitty graphics state for this screen -+ // Clear the placements on this screen, but keep the images -+ // themselves: a client transmits with `a=t` once and places -+ // with `a=p` per frame, and freeing the data on a screen clear -+ // makes every later placement of that id draw nothing. The -+ // storage limit (320MB, LRU) is what bounds this, which is why -+ // it exists. slosh patch; see vendor/patches. - self.screens.active.kitty_images.delete( - self.io(), - self.screens.active.alloc, - self, -- .{ .all = true }, -+ .{ .all = false }, - ); - } - }, -@@ -3399,12 +3404,17 @@ pub fn eraseDisplay( - self.screens.active.cursor.pending_wrap = false; +- /// Clear placements intersecting the active screen, then reclaim every +- /// image with no remaining placement. Unlike protocol d=A, a terminal +- /// clear also reclaims images that were already unplaced. ++ /// Clear placements intersecting the active screen. slosh patch 0001: ++ /// upstream also reclaims every image with no remaining placement here, ++ /// but the kitty protocol separates transmitting (a=t, once) from ++ /// placing (a=p, per frame) precisely so a client can upload up front -- ++ /// freeing the data on a screen clear makes every later placement of ++ /// that id draw nothing. Clearing the screen should clear what is *on* ++ /// it; the storage limit and LRU eviction are what bound retained data, ++ /// which is exactly the mechanism that makes keeping it safe. + pub fn clearScreen( + self: *ImageStorage, + io: std.Io, +@@ -1175,15 +1180,10 @@ + const placements_before = self.placements.count(); + const images_before = self.images.count(); - if (comptime build_options.kitty_graphics) { -- // Clear all Kitty graphics state for this screen -+ // Clear the placements on this screen, but keep the images -+ // themselves: a client transmits with `a=t` once and places -+ // with `a=p` per frame, and freeing the data on a screen clear -+ // makes every later placement of that id draw nothing. The -+ // storage limit (320MB, LRU) is what bounds this, which is why -+ // it exists. slosh patch; see vendor/patches. - self.screens.active.kitty_images.delete( - self.io(), - self.screens.active.alloc, - self, -- .{ .all = true }, -+ .{ .all = false }, - ); - } +- // Delete unused placements and images. Orphaned relative +- // placements must be reaped before the image sweep so that +- // their images are reclaimable too. ++ // Delete visible placements and reap orphaned relative placements; ++ // the image data itself stays (see above). + self.deleteVisiblePlacements(alloc, t, true); + _ = self.removeOrphans(t.screens.active, null); +- var image_it = self.images.iterator(); +- while (image_it.next()) |entry| { +- self.deleteIfUnused(alloc, entry.key_ptr.*); +- } + // Mark mutated only if things changed. + if (self.placements.count() != placements_before or |
