diff options
| author | Ogulcan Celik <ogulcancelik@gmail.com> | 2026-07-16 23:28:05 +0300 |
|---|---|---|
| committer | Ogulcan Celik <ogulcancelik@gmail.com> | 2026-07-16 23:29:28 +0300 |
| commit | 4a3302d19a2baa3a33e910e0088c24e780905b90 (patch) | |
| tree | dcaf76fae1c82102d933c9225b17ca7ac5d3a847 /vendor | |
| parent | 550e9f8cc085f428429f5cf2da0bb2dba897e8b8 (diff) | |
chore: update vendored libghostty-vt
refs #276
Diffstat (limited to 'vendor')
146 files changed, 24512 insertions, 3389 deletions
diff --git a/vendor/libghostty-vt.patches.md b/vendor/libghostty-vt.patches.md index 94bb998b..c01fa9b6 100644 --- a/vendor/libghostty-vt.patches.md +++ b/vendor/libghostty-vt.patches.md @@ -2,81 +2,39 @@ This file tracks intentional local changes applied on top of the vendored `libghostty-vt` source. Remove a patch only when the vendored source commit -contains the upstream fix and the listed verification still passes. +contains the upstream behavior and the listed verification still passes. -## 0001 backport resizeCols cursor subtraction saturation +## 0001 default lib-vt panes to grapheme clustering status: active -patch: `vendor/patches/libghostty-vt/0001-backport-resizecols-cursor-subtraction.patch` +patch: `vendor/patches/libghostty-vt/0001-default-grapheme-cluster-mode.patch` -herdr issue: https://github.com/ogulcancelik/herdr/issues/465 +herdr issue: https://github.com/ogulcancelik/herdr/issues/243 -upstream discussion: https://github.com/ghostty-org/ghostty/discussions/12905 +upstream discussion: not opened; libghostty-vt currently exposes current mode mutation but no C API for configuring terminal default modes -upstream pr: https://github.com/ghostty-org/ghostty/pull/12907 +upstream pr: not opened -introduced upstream: `c44afa625` - -vendored base: `0f7cd84b880b203c98683e520e84b9db0c5938d8` +vendored base: `c5a21edfcbc2d5b46540ad91b7980aca31f5f1f3` local files: -- `vendor/libghostty-vt/src/terminal/PageList.zig` - `vendor/libghostty-vt/src/terminal/c/terminal.zig` -reason: shrinking rows and columns in one resize can leave the pre-resize -cursor row past the new row count. `PageList.resizeCols` then computed rows -below the cursor with checked unsigned subtraction and aborted in safety builds. - -remove when: the vendored source commit contains upstream PR #12907 and the -local ReleaseSafe resize regression tests pass without this patch. - -verification: - -```sh -zig build test-lib-vt -Demit-lib-vt -Doptimize=ReleaseSafe -Dtest-filter="resize shrinks both axes with cursor at bottom" -zig build test-lib-vt -Demit-lib-vt -Doptimize=ReleaseSafe -Dtest-filter="PageList resize less rows and cols cursor at bottom" -``` - -## 0002 expose kitty image transmit time in the C API - -status: active - -patch: `vendor/patches/libghostty-vt/0002-expose-kitty-image-transmit-time-ns.patch` - -herdr issue: https://github.com/ogulcancelik/herdr/issues/947 - -upstream discussion: https://github.com/ghostty-org/ghostty/discussions/13177 -(proposes extending the kitty graphics inspection C API from -https://github.com/ghostty-org/ghostty/pull/12145, which has no transmit -time/serial accessor yet) - -introduced upstream: not yet - -vendored base: `0f7cd84b880b203c98683e520e84b9db0c5938d8` - -local files: - -- `vendor/libghostty-vt/include/ghostty/vt/kitty_graphics.h` -- `vendor/libghostty-vt/src/terminal/c/kitty_graphics.zig` - -reason: herdr fingerprints kitty image data to decide when to re-encode an -image for render clients. Hashing the full payload on every render is too -expensive for multi-megabyte images, and sampling windows misses small -changes, freezing streaming sources. The image's transmit time already -refreshes on every (re)transmission, so exposing it as -`GHOSTTY_KITTY_IMAGE_DATA_TRANSMIT_TIME_NS` gives herdr an exact, O(1) change -serial to invalidate a cached full-data fingerprint. +reason: Herdr renders terminal cells directly and requires DEC private mode +2027 to store flags, ZWJ emoji, and other multi-codepoint grapheme clusters in +one cell. This patch makes clustering active for new terminals and keeps it as +the reset default so RIS (`ESC c`) does not disable it. -remove when: the vendored source commit exposes the image transmit time (or an -equivalent transmission serial) in the C API and -`ghostty::tests::kitty_image_fingerprint_refreshes_on_retransmission` passes -without this patch. +remove when: libghostty-vt exposes a C API for setting default mode 2027, or +upstream makes grapheme clustering the lib-vt default, and the reset-survival +regression passes without this patch. verification: ```sh -zig build test-lib-vt -Dtest-filter="image_get transmit_time_ns changes on retransmission" -cargo nextest run kitty_image_fingerprint +cargo nextest run --locked grapheme_cluster_mode_is_default_and_survives_full_reset +cargo nextest run --locked grapheme_cluster_mode_renders_flag_emoji_in_single_wide_cell +cargo nextest run --locked grapheme_cluster_mode_renders_zwj_family_in_single_wide_cell ``` diff --git a/vendor/libghostty-vt.vendor.json b/vendor/libghostty-vt.vendor.json index 4d68f2c4..3dd123b2 100644 --- a/vendor/libghostty-vt.vendor.json +++ b/vendor/libghostty-vt.vendor.json @@ -1,6 +1,5 @@ { - "source_repo": "/home/can/Projects/ghostty-worktrees/herdr-vendor-0f7cd84b", - "source_commit": "0f7cd84b880b203c98683e520e84b9db0c5938d8", - "dist_archive": "libghostty-vt-1.3.2-HEAD-+0f7cd84b8.tar.gz", - "extracted_dir": "libghostty-vt-1.3.2-HEAD-+0f7cd84b8" + "source_commit": "c5a21edfcbc2d5b46540ad91b7980aca31f5f1f3", + "dist_archive": "libghostty-vt-1.3.2-HEAD-+c5a21edfc.tar.gz", + "extracted_dir": "libghostty-vt-1.3.2-HEAD-+c5a21edfc" } diff --git a/vendor/libghostty-vt/.github/VOUCHED.td b/vendor/libghostty-vt/.github/VOUCHED.td index 1749611a..e6d365c8 100644 --- a/vendor/libghostty-vt/.github/VOUCHED.td +++ b/vendor/libghostty-vt/.github/VOUCHED.td @@ -33,10 +33,12 @@ adrum agoodkind aindriu80 ajiblock +ajr-khll akimiojr alaasdk alanmoyano alaviss +alexbathome alexfeijoo44 alexjuca alosarjos @@ -45,8 +47,10 @@ andrejdaskalov anhthang anmitalidev anthonyzhoon +arvin7liu athaapa atomk +aymanbagabas b0uks b1nar10 balazs-szucs @@ -62,6 +66,7 @@ bleikurr bo2themax brentschroeter brianc442 +c0x0o cespare charliie-dev chernetskyi @@ -100,6 +105,9 @@ ephemera -eric-assetpass Try talking, not botting eriksremess erral +escalonc +eunos-1128 +exlee -f1813483-netizen faukah filip7 @@ -128,6 +136,7 @@ illiakrauchanka j0hnm4r5 jacobsandlund jake-stewart +jamesarch jamylak jarred-sumner jcollie @@ -154,6 +163,7 @@ kloneets knu -kody-w koranir +kostich kristina8888 kristofersoler kylesower @@ -195,12 +205,15 @@ mpatankar6 mrconnorkenway mrmage mtak +mustafa0x +nagato-yuzuru natesmyth neo773 neurosnap nicholas-ochoa nicosuave nikicat +nilsherzig nmggithub noib3 nolinmcfarland @@ -213,6 +226,7 @@ paaloeye pan93412 pangoraw pauley-unsaturated +pearkes peilingjiang peterdavehello philocalyst @@ -224,6 +238,8 @@ poweruser64 prakhar54-byte priyans-hu puzza007 +qappell +quinnypig qwerasd205 raphamorim reo101 @@ -236,14 +252,18 @@ rmengelbrecht rmunn rockorager rpfaeffle +ruoyouz +sahilkmishra samasaur1 sandydoo +-schickling-assistant NHITL secrus seruman seyoungjeong silveirapf slsrepo sunshine-syz +tasselx tbrundige tdgroot tdslot @@ -260,11 +280,14 @@ unphased uzaaft vancluever vaughanandrews +-vectorpeak Stupid bot cosplaying as a maintainer viruslobster vlsi voidnv +williamhcarter wyounas yabbal +yak3d yamshta ydah -zaviro diff --git a/vendor/libghostty-vt/.github/workflows/nix.yml b/vendor/libghostty-vt/.github/workflows/nix.yml index 4d5ecba4..0a3ebdc5 100644 --- a/vendor/libghostty-vt/.github/workflows/nix.yml +++ b/vendor/libghostty-vt/.github/workflows/nix.yml @@ -39,15 +39,15 @@ jobs: ZIG_GLOBAL_CACHE_DIR: /zig/global-cache steps: - name: Checkout code - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 - name: Setup Cache - uses: namespacelabs/nscloud-cache-action@15799a6b54e5765f85b2aac25b3f0df43ed571c0 # v1.4.3 + uses: namespacelabs/nscloud-cache-action@58bf6e08898e88803c098e2b522668541cd3b2e3 # v1.6.0 with: path: | /nix /zig - name: Setup Nix - uses: cachix/install-nix-action@8aa03977d8d733052d78f4e008a241fd1dbf36b3 # v31.10.6 + uses: cachix/install-nix-action@a49548c11d9846ad46ecc0115273879b045f001c # v31.10.7 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 eec81248..3fd92990 100644 --- a/vendor/libghostty-vt/.github/workflows/release-tag.yml +++ b/vendor/libghostty-vt/.github/workflows/release-tag.yml @@ -56,7 +56,7 @@ jobs: fi - name: Checkout code - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 with: # Important so that build number generation works fetch-depth: 0 @@ -80,16 +80,16 @@ jobs: ZIG_LOCAL_CACHE_DIR: /zig/local-cache ZIG_GLOBAL_CACHE_DIR: /zig/global-cache steps: - - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 - name: Setup Cache - uses: namespacelabs/nscloud-cache-action@15799a6b54e5765f85b2aac25b3f0df43ed571c0 # v1.4.3 + uses: namespacelabs/nscloud-cache-action@58bf6e08898e88803c098e2b522668541cd3b2e3 # v1.6.0 with: path: | /nix /zig - - uses: cachix/install-nix-action@8aa03977d8d733052d78f4e008a241fd1dbf36b3 # v31.10.6 + - uses: cachix/install-nix-action@a49548c11d9846ad46ecc0115273879b045f001c # v31.10.7 with: nix_path: nixpkgs=channel:nixos-unstable @@ -134,10 +134,10 @@ jobs: ZIG_GLOBAL_CACHE_DIR: /Users/runner/zig/global-cache steps: - name: Checkout code - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 - name: Setup Cache - uses: namespacelabs/nscloud-cache-action@15799a6b54e5765f85b2aac25b3f0df43ed571c0 # v1.4.3 + uses: namespacelabs/nscloud-cache-action@58bf6e08898e88803c098e2b522668541cd3b2e3 # v1.6.0 with: cache: | xcode @@ -319,7 +319,7 @@ jobs: GHOSTTY_COMMIT_LONG: ${{ needs.setup.outputs.commit_long }} steps: - name: Checkout code - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 - name: Download macOS Artifacts uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 diff --git a/vendor/libghostty-vt/.github/workflows/release-tip.yml b/vendor/libghostty-vt/.github/workflows/release-tip.yml index 37443cb0..721057a1 100644 --- a/vendor/libghostty-vt/.github/workflows/release-tip.yml +++ b/vendor/libghostty-vt/.github/workflows/release-tip.yml @@ -33,16 +33,16 @@ jobs: commit: ${{ steps.extract_build_info.outputs.commit }} commit_long: ${{ steps.extract_build_info.outputs.commit_long }} steps: - - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 with: # Important so that build number generation works fetch-depth: 0 - name: Setup Cache - uses: namespacelabs/nscloud-cache-action@15799a6b54e5765f85b2aac25b3f0df43ed571c0 # v1.4.3 + uses: namespacelabs/nscloud-cache-action@58bf6e08898e88803c098e2b522668541cd3b2e3 # v1.6.0 with: path: | /nix - - uses: cachix/install-nix-action@8aa03977d8d733052d78f4e008a241fd1dbf36b3 # v31.10.6 + - uses: cachix/install-nix-action@a49548c11d9846ad46ecc0115273879b045f001c # v31.10.7 with: nix_path: nixpkgs=channel:nixos-unstable - uses: cachix/cachix-action@5f2d7c5294214f71b873db4b969586b980625e71 # v17 @@ -58,16 +58,60 @@ jobs: echo "build=$GHOSTTY_BUILD" >> $GITHUB_OUTPUT echo "commit=$GHOSTTY_COMMIT" >> $GITHUB_OUTPUT echo "commit_long=$GHOSTTY_COMMIT_LONG" >> $GITHUB_OUTPUT - - name: Check if tip already exists + # Classify what changed since the last tip. `build` matches any file that + # can reach the built artifact; `release_tip` re-adds release-tip.yml, + # which defines the build/tag/publish jobs. Everything else (all of + # .github plus docs and repo/lint/editor metadata) never changes the + # artifact. Skipped on manual dispatches, which always build. + - name: Detect changes since last tip + id: changes + if: github.event_name == 'workflow_run' + uses: dorny/paths-filter@7b450fff21473bca461d4b92ce414b9d0420d706 # v4.0.2 + with: + token: "" + base: tip + predicate-quantifier: "every" + filters: | + build: + - '**' + - '!.github/**' + - '!README.md' + - '!CONTRIBUTING.md' + - '!HACKING.md' + - '!PACKAGING.md' + - '!AI_POLICY.md' + - '!AGENTS.md' + - '!CLAUDE.md' + - '!Doxyfile' + - '!DoxygenLayout.xml' + - '!LICENSE' + - '!CODEOWNERS' + - '!.clang-format' + - '!.editorconfig' + - '!.envrc' + - '!.gitattributes' + - '!.gitignore' + - '!.mailmap' + - '!.prettierignore' + - '!.shellcheckrc' + - '!.swiftlint.yml' + - '!typos.toml' + - '!valgrind.supp' + release_tip: + - '.github/workflows/release-tip.yml' + + - name: Determine skip id: check run: | - GHOSTTY_COMMIT_LONG=$(git rev-parse HEAD) - if nix develop -c nu .github/scripts/ghostty-tip $GHOSTTY_COMMIT_LONG; then - echo "Tip release already exists for commit $GHOSTTY_COMMIT_LONG" - echo "should_skip=true" >> $GITHUB_OUTPUT + if nix develop -c nu .github/scripts/ghostty-tip "$(git rev-parse HEAD)"; then + echo "Tip already built for this commit, skipping" + echo "should_skip=true" >> "$GITHUB_OUTPUT" + elif [ "${{ steps.changes.outputs.build }}" = "false" ] && + [ "${{ steps.changes.outputs.release_tip }}" = "false" ]; then + echo "Only non-artifact files changed since the last tip, skipping" + echo "should_skip=true" >> "$GITHUB_OUTPUT" else - echo "No tip release found for commit $GHOSTTY_COMMIT_LONG" - echo "should_skip=false" >> $GITHUB_OUTPUT + echo "should_skip=false" >> "$GITHUB_OUTPUT" fi tag: @@ -75,7 +119,7 @@ jobs: needs: [setup, build-macos] if: needs.setup.outputs.should_skip != 'true' steps: - - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 - name: Tip Tag run: | git config user.name "github-actions[bot]" @@ -90,7 +134,7 @@ jobs: env: GHOSTTY_COMMIT_LONG: ${{ needs.setup.outputs.commit_long }} steps: - - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 - name: Install sentry-cli run: | @@ -113,7 +157,7 @@ jobs: env: GHOSTTY_COMMIT_LONG: ${{ needs.setup.outputs.commit_long }} steps: - - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 - name: Install sentry-cli run: | @@ -136,7 +180,7 @@ jobs: env: GHOSTTY_COMMIT_LONG: ${{ needs.setup.outputs.commit_long }} steps: - - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 - name: Install sentry-cli run: | @@ -168,14 +212,14 @@ jobs: ZIG_LOCAL_CACHE_DIR: /zig/local-cache ZIG_GLOBAL_CACHE_DIR: /zig/global-cache steps: - - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 - name: Setup Cache - uses: namespacelabs/nscloud-cache-action@15799a6b54e5765f85b2aac25b3f0df43ed571c0 # v1.4.3 + uses: namespacelabs/nscloud-cache-action@58bf6e08898e88803c098e2b522668541cd3b2e3 # v1.6.0 with: path: | /nix /zig - - uses: cachix/install-nix-action@8aa03977d8d733052d78f4e008a241fd1dbf36b3 # v31.10.6 + - uses: cachix/install-nix-action@a49548c11d9846ad46ecc0115273879b045f001c # v31.10.7 with: nix_path: nixpkgs=channel:nixos-unstable - uses: cachix/cachix-action@5f2d7c5294214f71b873db4b969586b980625e71 # v17 @@ -195,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@b4309332981a82ec1c5618f44dd2e27cc8bfbfda # v3.0.0 + uses: softprops/action-gh-release@3d0d9888cb7fd7b750713d6e236d1fcb99157228 # v3.0.2 with: name: 'Ghostty Tip ("Nightly")' prerelease: true @@ -223,14 +267,14 @@ jobs: ZIG_LOCAL_CACHE_DIR: /zig/local-cache ZIG_GLOBAL_CACHE_DIR: /zig/global-cache steps: - - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 - name: Setup Cache - uses: namespacelabs/nscloud-cache-action@15799a6b54e5765f85b2aac25b3f0df43ed571c0 # v1.4.3 + uses: namespacelabs/nscloud-cache-action@58bf6e08898e88803c098e2b522668541cd3b2e3 # v1.6.0 with: path: | /nix /zig - - uses: cachix/install-nix-action@8aa03977d8d733052d78f4e008a241fd1dbf36b3 # v31.10.6 + - uses: cachix/install-nix-action@a49548c11d9846ad46ecc0115273879b045f001c # v31.10.7 with: nix_path: nixpkgs=channel:nixos-unstable - uses: cachix/cachix-action@5f2d7c5294214f71b873db4b969586b980625e71 # v17 @@ -250,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@b4309332981a82ec1c5618f44dd2e27cc8bfbfda # v3.0.0 + uses: softprops/action-gh-release@3d0d9888cb7fd7b750713d6e236d1fcb99157228 # v3.0.2 with: name: 'Ghostty Tip ("Nightly")' prerelease: true @@ -298,10 +342,10 @@ jobs: ZIG_GLOBAL_CACHE_DIR: /Users/runner/zig/global-cache steps: - name: Checkout code - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 - name: Setup Cache - uses: namespacelabs/nscloud-cache-action@15799a6b54e5765f85b2aac25b3f0df43ed571c0 # v1.4.3 + uses: namespacelabs/nscloud-cache-action@58bf6e08898e88803c098e2b522668541cd3b2e3 # v1.6.0 with: cache: | xcode @@ -335,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@b4309332981a82ec1c5618f44dd2e27cc8bfbfda # v3.0.0 + uses: softprops/action-gh-release@3d0d9888cb7fd7b750713d6e236d1fcb99157228 # v3.0.2 with: name: 'Ghostty Tip ("Nightly")' prerelease: true @@ -387,13 +431,13 @@ jobs: ZIG_GLOBAL_CACHE_DIR: /Users/runner/zig/global-cache steps: - name: Checkout code - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 with: # Important so that build number generation works fetch-depth: 0 - name: Setup Cache - uses: namespacelabs/nscloud-cache-action@15799a6b54e5765f85b2aac25b3f0df43ed571c0 # v1.4.3 + uses: namespacelabs/nscloud-cache-action@58bf6e08898e88803c098e2b522668541cd3b2e3 # v1.6.0 with: cache: | xcode @@ -537,7 +581,7 @@ jobs: # Update Release - name: Release - uses: softprops/action-gh-release@b4309332981a82ec1c5618f44dd2e27cc8bfbfda # v3.0.0 + uses: softprops/action-gh-release@3d0d9888cb7fd7b750713d6e236d1fcb99157228 # v3.0.2 with: name: 'Ghostty Tip ("Nightly")' prerelease: true @@ -643,13 +687,13 @@ jobs: ZIG_GLOBAL_CACHE_DIR: /Users/runner/zig/global-cache steps: - name: Checkout code - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 with: # Important so that build number generation works fetch-depth: 0 - name: Setup Cache - uses: namespacelabs/nscloud-cache-action@15799a6b54e5765f85b2aac25b3f0df43ed571c0 # v1.4.3 + uses: namespacelabs/nscloud-cache-action@58bf6e08898e88803c098e2b522668541cd3b2e3 # v1.6.0 with: cache: | xcode @@ -786,7 +830,7 @@ jobs: # Update Release - name: Release - uses: softprops/action-gh-release@b4309332981a82ec1c5618f44dd2e27cc8bfbfda # v3.0.0 + uses: softprops/action-gh-release@3d0d9888cb7fd7b750713d6e236d1fcb99157228 # v3.0.2 with: name: 'Ghostty Tip ("Nightly")' prerelease: true @@ -840,13 +884,13 @@ jobs: ZIG_GLOBAL_CACHE_DIR: /Users/runner/zig/global-cache steps: - name: Checkout code - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 with: # Important so that build number generation works fetch-depth: 0 - name: Setup Cache - uses: namespacelabs/nscloud-cache-action@15799a6b54e5765f85b2aac25b3f0df43ed571c0 # v1.4.3 + uses: namespacelabs/nscloud-cache-action@58bf6e08898e88803c098e2b522668541cd3b2e3 # v1.6.0 with: cache: | xcode @@ -983,7 +1027,7 @@ jobs: # Update Release - name: Release - uses: softprops/action-gh-release@b4309332981a82ec1c5618f44dd2e27cc8bfbfda # v3.0.0 + uses: softprops/action-gh-release@3d0d9888cb7fd7b750713d6e236d1fcb99157228 # v3.0.2 with: name: 'Ghostty Tip ("Nightly")' prerelease: true diff --git a/vendor/libghostty-vt/.github/workflows/snap.yml b/vendor/libghostty-vt/.github/workflows/snap.yml index f908110c..3108d583 100644 --- a/vendor/libghostty-vt/.github/workflows/snap.yml +++ b/vendor/libghostty-vt/.github/workflows/snap.yml @@ -38,7 +38,7 @@ jobs: tar --verbose --extract --strip-components 1 --directory dist --file ghostty-source.tar.gz - name: Setup Cache - uses: namespacelabs/nscloud-cache-action@15799a6b54e5765f85b2aac25b3f0df43ed571c0 # v1.4.3 + uses: namespacelabs/nscloud-cache-action@58bf6e08898e88803c098e2b522668541cd3b2e3 # v1.6.0 with: path: | /nix diff --git a/vendor/libghostty-vt/.github/workflows/test.yml b/vendor/libghostty-vt/.github/workflows/test.yml index 421abfce..05787c18 100644 --- a/vendor/libghostty-vt/.github/workflows/test.yml +++ b/vendor/libghostty-vt/.github/workflows/test.yml @@ -30,8 +30,8 @@ jobs: zig: ${{ steps.filter_any.outputs.zig }} steps: - - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - - uses: dorny/paths-filter@fbd0ab8f3e69293af611ebaee6363fc25e6d187d # v4.0.1 + - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + - uses: dorny/paths-filter@7b450fff21473bca461d4b92ce414b9d0420d706 # v4.0.2 id: filter_every with: token: "" @@ -40,7 +40,7 @@ jobs: code: - '**' - '!.github/VOUCHED.td' - - uses: dorny/paths-filter@fbd0ab8f3e69293af611ebaee6363fc25e6d187d # v4.0.1 + - uses: dorny/paths-filter@7b450fff21473bca461d4b92ce414b9d0420d706 # v4.0.2 id: filter_any with: token: "" @@ -156,17 +156,17 @@ jobs: ZIG_GLOBAL_CACHE_DIR: /zig/global-cache steps: - name: Checkout code - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 - name: Setup Cache - uses: namespacelabs/nscloud-cache-action@15799a6b54e5765f85b2aac25b3f0df43ed571c0 # v1.4.3 + uses: namespacelabs/nscloud-cache-action@58bf6e08898e88803c098e2b522668541cd3b2e3 # v1.6.0 with: path: | /nix /zig # Install Nix and use that to run our tests so our environment matches exactly. - - uses: cachix/install-nix-action@8aa03977d8d733052d78f4e008a241fd1dbf36b3 # v31.10.6 + - uses: cachix/install-nix-action@a49548c11d9846ad46ecc0115273879b045f001c # v31.10.7 with: nix_path: nixpkgs=channel:nixos-unstable - uses: cachix/cachix-action@5f2d7c5294214f71b873db4b969586b980625e71 # v17 @@ -187,7 +187,7 @@ jobs: swift: ${{ steps.list.outputs.swift }} steps: - name: Checkout code - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 - id: list name: List example directories run: | @@ -214,17 +214,17 @@ jobs: ZIG_GLOBAL_CACHE_DIR: /zig/global-cache steps: - name: Checkout code - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 - name: Setup Cache - uses: namespacelabs/nscloud-cache-action@15799a6b54e5765f85b2aac25b3f0df43ed571c0 # v1.4.3 + uses: namespacelabs/nscloud-cache-action@58bf6e08898e88803c098e2b522668541cd3b2e3 # v1.6.0 with: path: | /nix /zig # Install Nix and use that to run our tests so our environment matches exactly. - - uses: cachix/install-nix-action@8aa03977d8d733052d78f4e008a241fd1dbf36b3 # v31.10.6 + - uses: cachix/install-nix-action@a49548c11d9846ad46ecc0115273879b045f001c # v31.10.7 with: nix_path: nixpkgs=channel:nixos-unstable - uses: cachix/cachix-action@5f2d7c5294214f71b873db4b969586b980625e71 # v17 @@ -250,17 +250,17 @@ jobs: ZIG_GLOBAL_CACHE_DIR: /zig/global-cache steps: - name: Checkout code - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 - name: Setup Cache - uses: namespacelabs/nscloud-cache-action@15799a6b54e5765f85b2aac25b3f0df43ed571c0 # v1.4.3 + uses: namespacelabs/nscloud-cache-action@58bf6e08898e88803c098e2b522668541cd3b2e3 # v1.6.0 with: path: | /nix /zig # Install Nix and use that to run our tests so our environment matches exactly. - - uses: cachix/install-nix-action@8aa03977d8d733052d78f4e008a241fd1dbf36b3 # v31.10.6 + - uses: cachix/install-nix-action@a49548c11d9846ad46ecc0115273879b045f001c # v31.10.7 with: nix_path: nixpkgs=channel:nixos-unstable - uses: cachix/cachix-action@5f2d7c5294214f71b873db4b969586b980625e71 # v17 @@ -290,7 +290,7 @@ jobs: needs: [test, list-examples] steps: - name: Checkout code - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 - name: Install zig uses: mlugg/setup-zig@d1434d08867e3ee9daa34448df10607b98908d29 # v2.2.1 @@ -315,10 +315,10 @@ jobs: ZIG_GLOBAL_CACHE_DIR: /Users/runner/zig/global-cache steps: - name: Checkout code - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 - name: Setup Cache - uses: namespacelabs/nscloud-cache-action@15799a6b54e5765f85b2aac25b3f0df43ed571c0 # v1.4.3 + uses: namespacelabs/nscloud-cache-action@58bf6e08898e88803c098e2b522668541cd3b2e3 # v1.6.0 with: cache: | xcode @@ -356,17 +356,17 @@ jobs: ZIG_GLOBAL_CACHE_DIR: /zig/global-cache steps: - name: Checkout code - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 - name: Setup Cache - uses: namespacelabs/nscloud-cache-action@15799a6b54e5765f85b2aac25b3f0df43ed571c0 # v1.4.3 + uses: namespacelabs/nscloud-cache-action@58bf6e08898e88803c098e2b522668541cd3b2e3 # v1.6.0 with: path: | /nix /zig # Install Nix and use that to run our tests so our environment matches exactly. - - uses: cachix/install-nix-action@8aa03977d8d733052d78f4e008a241fd1dbf36b3 # v31.10.6 + - uses: cachix/install-nix-action@a49548c11d9846ad46ecc0115273879b045f001c # v31.10.7 with: nix_path: nixpkgs=channel:nixos-unstable - uses: cachix/cachix-action@5f2d7c5294214f71b873db4b969586b980625e71 # v17 @@ -394,17 +394,17 @@ jobs: ZIG_GLOBAL_CACHE_DIR: /zig/global-cache steps: - name: Checkout code - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 - name: Setup Cache - uses: namespacelabs/nscloud-cache-action@15799a6b54e5765f85b2aac25b3f0df43ed571c0 # v1.4.3 + uses: namespacelabs/nscloud-cache-action@58bf6e08898e88803c098e2b522668541cd3b2e3 # v1.6.0 with: path: | /nix /zig # Install Nix and use that to run our tests so our environment matches exactly. - - uses: cachix/install-nix-action@8aa03977d8d733052d78f4e008a241fd1dbf36b3 # v31.10.6 + - uses: cachix/install-nix-action@a49548c11d9846ad46ecc0115273879b045f001c # v31.10.7 with: nix_path: nixpkgs=channel:nixos-unstable - uses: cachix/cachix-action@5f2d7c5294214f71b873db4b969586b980625e71 # v17 @@ -511,17 +511,17 @@ jobs: ZIG_GLOBAL_CACHE_DIR: /zig/global-cache steps: - name: Checkout code - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 - name: Setup Cache - uses: namespacelabs/nscloud-cache-action@15799a6b54e5765f85b2aac25b3f0df43ed571c0 # v1.4.3 + uses: namespacelabs/nscloud-cache-action@58bf6e08898e88803c098e2b522668541cd3b2e3 # v1.6.0 with: path: | /nix /zig # Install Nix and use that to run our tests so our environment matches exactly. - - uses: cachix/install-nix-action@8aa03977d8d733052d78f4e008a241fd1dbf36b3 # v31.10.6 + - uses: cachix/install-nix-action@a49548c11d9846ad46ecc0115273879b045f001c # v31.10.7 with: nix_path: nixpkgs=channel:nixos-unstable - uses: cachix/cachix-action@5f2d7c5294214f71b873db4b969586b980625e71 # v17 @@ -545,17 +545,17 @@ jobs: ZIG_GLOBAL_CACHE_DIR: /zig/global-cache steps: - name: Checkout code - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 - name: Setup Cache - uses: namespacelabs/nscloud-cache-action@15799a6b54e5765f85b2aac25b3f0df43ed571c0 # v1.4.3 + uses: namespacelabs/nscloud-cache-action@58bf6e08898e88803c098e2b522668541cd3b2e3 # v1.6.0 with: path: | /nix /zig # Install Nix and use that to run our tests so our environment matches exactly. - - uses: cachix/install-nix-action@8aa03977d8d733052d78f4e008a241fd1dbf36b3 # v31.10.6 + - uses: cachix/install-nix-action@a49548c11d9846ad46ecc0115273879b045f001c # v31.10.7 with: nix_path: nixpkgs=channel:nixos-unstable - uses: cachix/cachix-action@5f2d7c5294214f71b873db4b969586b980625e71 # v17 @@ -590,17 +590,17 @@ jobs: ZIG_GLOBAL_CACHE_DIR: /zig/global-cache steps: - name: Checkout code - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 - name: Setup Cache - uses: namespacelabs/nscloud-cache-action@15799a6b54e5765f85b2aac25b3f0df43ed571c0 # v1.4.3 + uses: namespacelabs/nscloud-cache-action@58bf6e08898e88803c098e2b522668541cd3b2e3 # v1.6.0 with: path: | /nix /zig # Install Nix and use that to run our tests so our environment matches exactly. - - uses: cachix/install-nix-action@8aa03977d8d733052d78f4e008a241fd1dbf36b3 # v31.10.6 + - uses: cachix/install-nix-action@a49548c11d9846ad46ecc0115273879b045f001c # v31.10.7 with: nix_path: nixpkgs=channel:nixos-unstable - uses: cachix/cachix-action@5f2d7c5294214f71b873db4b969586b980625e71 # v17 @@ -625,10 +625,10 @@ jobs: ZIG_GLOBAL_CACHE_DIR: /Users/runner/zig/global-cache steps: - name: Checkout code - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 - name: Setup Cache - uses: namespacelabs/nscloud-cache-action@15799a6b54e5765f85b2aac25b3f0df43ed571c0 # v1.4.3 + uses: namespacelabs/nscloud-cache-action@58bf6e08898e88803c098e2b522668541cd3b2e3 # v1.6.0 with: cache: | xcode @@ -666,17 +666,17 @@ jobs: ANDROID_NDK_VERSION: r29 steps: - name: Checkout code - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 - name: Setup Cache - uses: namespacelabs/nscloud-cache-action@15799a6b54e5765f85b2aac25b3f0df43ed571c0 # v1.4.3 + uses: namespacelabs/nscloud-cache-action@58bf6e08898e88803c098e2b522668541cd3b2e3 # v1.6.0 with: path: | /nix /zig # Install Nix and use that to run our tests so our environment matches exactly. - - uses: cachix/install-nix-action@8aa03977d8d733052d78f4e008a241fd1dbf36b3 # v31.10.6 + - uses: cachix/install-nix-action@a49548c11d9846ad46ecc0115273879b045f001c # v31.10.7 with: nix_path: nixpkgs=channel:nixos-unstable - uses: cachix/cachix-action@5f2d7c5294214f71b873db4b969586b980625e71 # v17 @@ -706,7 +706,7 @@ jobs: needs: test steps: - name: Checkout code - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 - name: Setup Zig uses: mlugg/setup-zig@d1434d08867e3ee9daa34448df10607b98908d29 # v2.2.1 @@ -723,7 +723,7 @@ jobs: needs: test steps: - name: Checkout code - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 - name: Setup Zig uses: mlugg/setup-zig@d1434d08867e3ee9daa34448df10607b98908d29 # v2.2.1 @@ -743,17 +743,17 @@ jobs: ZIG_GLOBAL_CACHE_DIR: /zig/global-cache steps: - name: Checkout code - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 - name: Setup Cache - uses: namespacelabs/nscloud-cache-action@15799a6b54e5765f85b2aac25b3f0df43ed571c0 # v1.4.3 + uses: namespacelabs/nscloud-cache-action@58bf6e08898e88803c098e2b522668541cd3b2e3 # v1.6.0 with: path: | /nix /zig # Install Nix and use that to run our tests so our environment matches exactly. - - uses: cachix/install-nix-action@8aa03977d8d733052d78f4e008a241fd1dbf36b3 # v31.10.6 + - uses: cachix/install-nix-action@a49548c11d9846ad46ecc0115273879b045f001c # v31.10.7 with: nix_path: nixpkgs=channel:nixos-unstable - uses: cachix/cachix-action@5f2d7c5294214f71b873db4b969586b980625e71 # v17 @@ -772,17 +772,17 @@ jobs: ZIG_GLOBAL_CACHE_DIR: /zig/global-cache steps: - name: Checkout code - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 - name: Setup Cache - uses: namespacelabs/nscloud-cache-action@15799a6b54e5765f85b2aac25b3f0df43ed571c0 # v1.4.3 + uses: namespacelabs/nscloud-cache-action@58bf6e08898e88803c098e2b522668541cd3b2e3 # v1.6.0 with: path: | /nix /zig # Install Nix and use that to run our tests so our environment matches exactly. - - uses: cachix/install-nix-action@8aa03977d8d733052d78f4e008a241fd1dbf36b3 # v31.10.6 + - uses: cachix/install-nix-action@a49548c11d9846ad46ecc0115273879b045f001c # v31.10.7 with: nix_path: nixpkgs=channel:nixos-unstable - uses: cachix/cachix-action@5f2d7c5294214f71b873db4b969586b980625e71 # v17 @@ -805,17 +805,17 @@ jobs: ZIG_GLOBAL_CACHE_DIR: /zig/global-cache steps: - name: Checkout code - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 - name: Setup Cache - uses: namespacelabs/nscloud-cache-action@15799a6b54e5765f85b2aac25b3f0df43ed571c0 # v1.4.3 + uses: namespacelabs/nscloud-cache-action@58bf6e08898e88803c098e2b522668541cd3b2e3 # v1.6.0 with: path: | /nix /zig # Install Nix and use that to run our tests so our environment matches exactly. - - uses: cachix/install-nix-action@8aa03977d8d733052d78f4e008a241fd1dbf36b3 # v31.10.6 + - uses: cachix/install-nix-action@a49548c11d9846ad46ecc0115273879b045f001c # v31.10.7 with: nix_path: nixpkgs=channel:nixos-unstable - uses: cachix/cachix-action@5f2d7c5294214f71b873db4b969586b980625e71 # v17 @@ -887,17 +887,17 @@ jobs: ZIG_GLOBAL_CACHE_DIR: /zig/global-cache steps: - name: Checkout code - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 - name: Setup Cache - uses: namespacelabs/nscloud-cache-action@15799a6b54e5765f85b2aac25b3f0df43ed571c0 # v1.4.3 + uses: namespacelabs/nscloud-cache-action@58bf6e08898e88803c098e2b522668541cd3b2e3 # v1.6.0 with: path: | /nix /zig # Install Nix and use that to run our tests so our environment matches exactly. - - uses: cachix/install-nix-action@8aa03977d8d733052d78f4e008a241fd1dbf36b3 # v31.10.6 + - uses: cachix/install-nix-action@a49548c11d9846ad46ecc0115273879b045f001c # v31.10.7 with: nix_path: nixpkgs=channel:nixos-unstable - uses: cachix/cachix-action@5f2d7c5294214f71b873db4b969586b980625e71 # v17 @@ -927,17 +927,17 @@ jobs: ZIG_GLOBAL_CACHE_DIR: /zig/global-cache steps: - name: Checkout code - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 - name: Setup Cache - uses: namespacelabs/nscloud-cache-action@15799a6b54e5765f85b2aac25b3f0df43ed571c0 # v1.4.3 + uses: namespacelabs/nscloud-cache-action@58bf6e08898e88803c098e2b522668541cd3b2e3 # v1.6.0 with: path: | /nix /zig # Install Nix and use that to run our tests so our environment matches exactly. - - uses: cachix/install-nix-action@8aa03977d8d733052d78f4e008a241fd1dbf36b3 # v31.10.6 + - uses: cachix/install-nix-action@a49548c11d9846ad46ecc0115273879b045f001c # v31.10.7 with: nix_path: nixpkgs=channel:nixos-unstable - uses: cachix/cachix-action@5f2d7c5294214f71b873db4b969586b980625e71 # v17 @@ -967,7 +967,7 @@ jobs: needs: [build-dist, build-snap] steps: - name: Checkout code - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 - name: Trigger Snap workflow run: | @@ -985,7 +985,7 @@ jobs: needs: [build-dist, build-flatpak] steps: - name: Checkout code - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 - name: Trigger Flatpak workflow run: | @@ -1002,10 +1002,10 @@ jobs: # needs: test # steps: # - name: Checkout code - # uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + # uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 # # Install Nix and use that to run our tests so our environment matches exactly. - # - uses: cachix/install-nix-action@8aa03977d8d733052d78f4e008a241fd1dbf36b3 # v31.10.6 + # - uses: cachix/install-nix-action@a49548c11d9846ad46ecc0115273879b045f001c # v31.10.7 # with: # nix_path: nixpkgs=channel:nixos-unstable # - uses: cachix/cachix-action@5f2d7c5294214f71b873db4b969586b980625e71 # v17 @@ -1057,10 +1057,10 @@ jobs: ZIG_GLOBAL_CACHE_DIR: /Users/runner/zig/global-cache steps: - name: Checkout code - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 - name: Setup Cache - uses: namespacelabs/nscloud-cache-action@15799a6b54e5765f85b2aac25b3f0df43ed571c0 # v1.4.3 + uses: namespacelabs/nscloud-cache-action@58bf6e08898e88803c098e2b522668541cd3b2e3 # v1.6.0 with: cache: | xcode @@ -1117,10 +1117,10 @@ jobs: ZIG_GLOBAL_CACHE_DIR: /Users/runner/zig/global-cache steps: - name: Checkout code - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 - name: Setup Cache - uses: namespacelabs/nscloud-cache-action@15799a6b54e5765f85b2aac25b3f0df43ed571c0 # v1.4.3 + uses: namespacelabs/nscloud-cache-action@58bf6e08898e88803c098e2b522668541cd3b2e3 # v1.6.0 with: cache: | xcode @@ -1167,7 +1167,7 @@ jobs: ZIG_GLOBAL_CACHE_DIR: /zig/global-cache steps: - name: Checkout code - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 - name: Get required Zig version id: zig @@ -1175,14 +1175,14 @@ jobs: echo "version=$(sed -n -E 's/^\s*\.?minimum_zig_version\s*=\s*"([^"]+)".*/\1/p' build.zig.zon)" >> $GITHUB_OUTPUT - name: Setup Cache - uses: namespacelabs/nscloud-cache-action@15799a6b54e5765f85b2aac25b3f0df43ed571c0 # v1.4.3 + uses: namespacelabs/nscloud-cache-action@58bf6e08898e88803c098e2b522668541cd3b2e3 # v1.6.0 with: path: | /nix /zig # Install Nix and use that to run our tests so our environment matches exactly. - - uses: cachix/install-nix-action@8aa03977d8d733052d78f4e008a241fd1dbf36b3 # v31.10.6 + - uses: cachix/install-nix-action@a49548c11d9846ad46ecc0115273879b045f001c # v31.10.7 with: nix_path: nixpkgs=channel:nixos-unstable - uses: cachix/cachix-action@5f2d7c5294214f71b873db4b969586b980625e71 # v17 @@ -1209,17 +1209,17 @@ jobs: ZIG_GLOBAL_CACHE_DIR: /zig/global-cache steps: - name: Checkout code - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 - name: Setup Cache - uses: namespacelabs/nscloud-cache-action@15799a6b54e5765f85b2aac25b3f0df43ed571c0 # v1.4.3 + uses: namespacelabs/nscloud-cache-action@58bf6e08898e88803c098e2b522668541cd3b2e3 # v1.6.0 with: path: | /nix /zig # Install Nix and use that to run our tests so our environment matches exactly. - - uses: cachix/install-nix-action@8aa03977d8d733052d78f4e008a241fd1dbf36b3 # v31.10.6 + - uses: cachix/install-nix-action@a49548c11d9846ad46ecc0115273879b045f001c # v31.10.7 with: nix_path: nixpkgs=channel:nixos-unstable - uses: cachix/cachix-action@5f2d7c5294214f71b873db4b969586b980625e71 # v17 @@ -1244,17 +1244,17 @@ jobs: ZIG_GLOBAL_CACHE_DIR: /zig/global-cache steps: - name: Checkout code - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 - name: Setup Cache - uses: namespacelabs/nscloud-cache-action@15799a6b54e5765f85b2aac25b3f0df43ed571c0 # v1.4.3 + uses: namespacelabs/nscloud-cache-action@58bf6e08898e88803c098e2b522668541cd3b2e3 # v1.6.0 with: path: | /nix /zig # Install Nix and use that to run our tests so our environment matches exactly. - - uses: cachix/install-nix-action@8aa03977d8d733052d78f4e008a241fd1dbf36b3 # v31.10.6 + - uses: cachix/install-nix-action@a49548c11d9846ad46ecc0115273879b045f001c # v31.10.7 with: nix_path: nixpkgs=channel:nixos-unstable - uses: cachix/cachix-action@5f2d7c5294214f71b873db4b969586b980625e71 # v17 @@ -1292,17 +1292,17 @@ jobs: ZIG_GLOBAL_CACHE_DIR: /zig/global-cache steps: - name: Checkout code - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 - name: Setup Cache - uses: namespacelabs/nscloud-cache-action@15799a6b54e5765f85b2aac25b3f0df43ed571c0 # v1.4.3 + uses: namespacelabs/nscloud-cache-action@58bf6e08898e88803c098e2b522668541cd3b2e3 # v1.6.0 with: path: | /nix /zig # Install Nix and use that to run our tests so our environment matches exactly. - - uses: cachix/install-nix-action@8aa03977d8d733052d78f4e008a241fd1dbf36b3 # v31.10.6 + - uses: cachix/install-nix-action@a49548c11d9846ad46ecc0115273879b045f001c # v31.10.7 with: nix_path: nixpkgs=channel:nixos-unstable - uses: cachix/cachix-action@5f2d7c5294214f71b873db4b969586b980625e71 # v17 @@ -1327,17 +1327,17 @@ jobs: ZIG_GLOBAL_CACHE_DIR: /zig/global-cache steps: - name: Checkout code - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 - name: Setup Cache - uses: namespacelabs/nscloud-cache-action@15799a6b54e5765f85b2aac25b3f0df43ed571c0 # v1.4.3 + uses: namespacelabs/nscloud-cache-action@58bf6e08898e88803c098e2b522668541cd3b2e3 # v1.6.0 with: path: | /nix /zig # Install Nix and use that to run our tests so our environment matches exactly. - - uses: cachix/install-nix-action@8aa03977d8d733052d78f4e008a241fd1dbf36b3 # v31.10.6 + - uses: cachix/install-nix-action@a49548c11d9846ad46ecc0115273879b045f001c # v31.10.7 with: nix_path: nixpkgs=channel:nixos-unstable - uses: cachix/cachix-action@5f2d7c5294214f71b873db4b969586b980625e71 # v17 @@ -1357,10 +1357,10 @@ jobs: ZIG_GLOBAL_CACHE_DIR: /Users/runner/zig/global-cache steps: - name: Checkout code - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 - name: Setup Cache - uses: namespacelabs/nscloud-cache-action@15799a6b54e5765f85b2aac25b3f0df43ed571c0 # v1.4.3 + uses: namespacelabs/nscloud-cache-action@58bf6e08898e88803c098e2b522668541cd3b2e3 # v1.6.0 with: cache: | xcode @@ -1396,7 +1396,7 @@ jobs: timeout-minutes: 45 steps: - name: Checkout code - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 - name: Setup Zig uses: mlugg/setup-zig@d1434d08867e3ee9daa34448df10607b98908d29 # v2.2.1 @@ -1417,17 +1417,17 @@ jobs: ZIG_GLOBAL_CACHE_DIR: /zig/global-cache steps: - name: Checkout code - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 - name: Setup Cache - uses: namespacelabs/nscloud-cache-action@15799a6b54e5765f85b2aac25b3f0df43ed571c0 # v1.4.3 + uses: namespacelabs/nscloud-cache-action@58bf6e08898e88803c098e2b522668541cd3b2e3 # v1.6.0 with: path: | /nix /zig # Install Nix and use that to run our tests so our environment matches exactly. - - uses: cachix/install-nix-action@8aa03977d8d733052d78f4e008a241fd1dbf36b3 # v31.10.6 + - uses: cachix/install-nix-action@a49548c11d9846ad46ecc0115273879b045f001c # v31.10.7 with: nix_path: nixpkgs=channel:nixos-unstable - uses: cachix/cachix-action@5f2d7c5294214f71b873db4b969586b980625e71 # v17 @@ -1448,17 +1448,17 @@ jobs: ZIG_GLOBAL_CACHE_DIR: /zig/global-cache steps: - name: Checkout code - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 - name: Setup Cache - uses: namespacelabs/nscloud-cache-action@15799a6b54e5765f85b2aac25b3f0df43ed571c0 # v1.4.3 + uses: namespacelabs/nscloud-cache-action@58bf6e08898e88803c098e2b522668541cd3b2e3 # v1.6.0 with: path: | /nix /zig # Install Nix and use that to run our tests so our environment matches exactly. - - uses: cachix/install-nix-action@8aa03977d8d733052d78f4e008a241fd1dbf36b3 # v31.10.6 + - uses: cachix/install-nix-action@a49548c11d9846ad46ecc0115273879b045f001c # v31.10.7 with: nix_path: nixpkgs=channel:nixos-unstable - uses: cachix/cachix-action@5f2d7c5294214f71b873db4b969586b980625e71 # v17 @@ -1493,14 +1493,14 @@ jobs: ZIG_LOCAL_CACHE_DIR: /zig/local-cache ZIG_GLOBAL_CACHE_DIR: /zig/global-cache steps: - - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 - name: Setup Cache - uses: namespacelabs/nscloud-cache-action@15799a6b54e5765f85b2aac25b3f0df43ed571c0 # v1.4.3 + uses: namespacelabs/nscloud-cache-action@58bf6e08898e88803c098e2b522668541cd3b2e3 # v1.6.0 with: path: | /nix /zig - - uses: cachix/install-nix-action@8aa03977d8d733052d78f4e008a241fd1dbf36b3 # v31.10.6 + - uses: cachix/install-nix-action@a49548c11d9846ad46ecc0115273879b045f001c # v31.10.7 with: nix_path: nixpkgs=channel:nixos-unstable - uses: cachix/cachix-action@5f2d7c5294214f71b873db4b969586b980625e71 # v17 @@ -1524,14 +1524,14 @@ jobs: ZIG_LOCAL_CACHE_DIR: /zig/local-cache ZIG_GLOBAL_CACHE_DIR: /zig/global-cache steps: - - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 - name: Setup Cache - uses: namespacelabs/nscloud-cache-action@15799a6b54e5765f85b2aac25b3f0df43ed571c0 # v1.4.3 + uses: namespacelabs/nscloud-cache-action@58bf6e08898e88803c098e2b522668541cd3b2e3 # v1.6.0 with: path: | /nix /zig - - uses: cachix/install-nix-action@8aa03977d8d733052d78f4e008a241fd1dbf36b3 # v31.10.6 + - uses: cachix/install-nix-action@a49548c11d9846ad46ecc0115273879b045f001c # v31.10.7 with: nix_path: nixpkgs=channel:nixos-unstable - uses: cachix/cachix-action@5f2d7c5294214f71b873db4b969586b980625e71 # v17 @@ -1554,14 +1554,14 @@ jobs: ZIG_LOCAL_CACHE_DIR: /zig/local-cache ZIG_GLOBAL_CACHE_DIR: /zig/global-cache steps: - - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 - name: Setup Cache - uses: namespacelabs/nscloud-cache-action@15799a6b54e5765f85b2aac25b3f0df43ed571c0 # v1.4.3 + uses: namespacelabs/nscloud-cache-action@58bf6e08898e88803c098e2b522668541cd3b2e3 # v1.6.0 with: path: | /nix /zig - - uses: cachix/install-nix-action@8aa03977d8d733052d78f4e008a241fd1dbf36b3 # v31.10.6 + - uses: cachix/install-nix-action@a49548c11d9846ad46ecc0115273879b045f001c # v31.10.7 with: nix_path: nixpkgs=channel:nixos-unstable - uses: cachix/cachix-action@5f2d7c5294214f71b873db4b969586b980625e71 # v17 @@ -1579,10 +1579,10 @@ jobs: needs: skip timeout-minutes: 60 steps: - - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 - name: Setup Cache - uses: namespacelabs/nscloud-cache-action@15799a6b54e5765f85b2aac25b3f0df43ed571c0 # v1.4.3 + uses: namespacelabs/nscloud-cache-action@58bf6e08898e88803c098e2b522668541cd3b2e3 # v1.6.0 with: cache: | xcode @@ -1612,14 +1612,14 @@ jobs: ZIG_LOCAL_CACHE_DIR: /zig/local-cache ZIG_GLOBAL_CACHE_DIR: /zig/global-cache steps: - - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 - name: Setup Cache - uses: namespacelabs/nscloud-cache-action@15799a6b54e5765f85b2aac25b3f0df43ed571c0 # v1.4.3 + uses: namespacelabs/nscloud-cache-action@58bf6e08898e88803c098e2b522668541cd3b2e3 # v1.6.0 with: path: | /nix /zig - - uses: cachix/install-nix-action@8aa03977d8d733052d78f4e008a241fd1dbf36b3 # v31.10.6 + - uses: cachix/install-nix-action@a49548c11d9846ad46ecc0115273879b045f001c # v31.10.7 with: nix_path: nixpkgs=channel:nixos-unstable - uses: cachix/cachix-action@5f2d7c5294214f71b873db4b969586b980625e71 # v17 @@ -1640,14 +1640,14 @@ jobs: ZIG_LOCAL_CACHE_DIR: /zig/local-cache ZIG_GLOBAL_CACHE_DIR: /zig/global-cache steps: - - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 - name: Setup Cache - uses: namespacelabs/nscloud-cache-action@15799a6b54e5765f85b2aac25b3f0df43ed571c0 # v1.4.3 + uses: namespacelabs/nscloud-cache-action@58bf6e08898e88803c098e2b522668541cd3b2e3 # v1.6.0 with: path: | /nix /zig - - uses: cachix/install-nix-action@8aa03977d8d733052d78f4e008a241fd1dbf36b3 # v31.10.6 + - uses: cachix/install-nix-action@a49548c11d9846ad46ecc0115273879b045f001c # v31.10.7 with: nix_path: nixpkgs=channel:nixos-unstable - uses: cachix/cachix-action@5f2d7c5294214f71b873db4b969586b980625e71 # v17 @@ -1668,14 +1668,14 @@ jobs: ZIG_LOCAL_CACHE_DIR: /zig/local-cache ZIG_GLOBAL_CACHE_DIR: /zig/global-cache steps: - - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 - name: Setup Cache - uses: namespacelabs/nscloud-cache-action@15799a6b54e5765f85b2aac25b3f0df43ed571c0 # v1.4.3 + uses: namespacelabs/nscloud-cache-action@58bf6e08898e88803c098e2b522668541cd3b2e3 # v1.6.0 with: path: | /nix /zig - - uses: cachix/install-nix-action@8aa03977d8d733052d78f4e008a241fd1dbf36b3 # v31.10.6 + - uses: cachix/install-nix-action@a49548c11d9846ad46ecc0115273879b045f001c # v31.10.7 with: nix_path: nixpkgs=channel:nixos-unstable - uses: cachix/cachix-action@5f2d7c5294214f71b873db4b969586b980625e71 # v17 @@ -1701,14 +1701,14 @@ jobs: ZIG_LOCAL_CACHE_DIR: /zig/local-cache ZIG_GLOBAL_CACHE_DIR: /zig/global-cache steps: - - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 - name: Setup Cache - uses: namespacelabs/nscloud-cache-action@15799a6b54e5765f85b2aac25b3f0df43ed571c0 # v1.4.3 + uses: namespacelabs/nscloud-cache-action@58bf6e08898e88803c098e2b522668541cd3b2e3 # v1.6.0 with: path: | /nix /zig - - uses: cachix/install-nix-action@8aa03977d8d733052d78f4e008a241fd1dbf36b3 # v31.10.6 + - uses: cachix/install-nix-action@a49548c11d9846ad46ecc0115273879b045f001c # v31.10.7 with: nix_path: nixpkgs=channel:nixos-unstable - uses: cachix/cachix-action@5f2d7c5294214f71b873db4b969586b980625e71 # v17 @@ -1729,14 +1729,14 @@ jobs: ZIG_LOCAL_CACHE_DIR: /zig/local-cache ZIG_GLOBAL_CACHE_DIR: /zig/global-cache steps: - - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 - name: Setup Cache - uses: namespacelabs/nscloud-cache-action@15799a6b54e5765f85b2aac25b3f0df43ed571c0 # v1.4.3 + uses: namespacelabs/nscloud-cache-action@58bf6e08898e88803c098e2b522668541cd3b2e3 # v1.6.0 with: path: | /nix /zig - - uses: cachix/install-nix-action@8aa03977d8d733052d78f4e008a241fd1dbf36b3 # v31.10.6 + - uses: cachix/install-nix-action@a49548c11d9846ad46ecc0115273879b045f001c # v31.10.7 with: nix_path: nixpkgs=channel:nixos-unstable - uses: cachix/cachix-action@5f2d7c5294214f71b873db4b969586b980625e71 # v17 @@ -1763,17 +1763,17 @@ jobs: ZIG_GLOBAL_CACHE_DIR: /zig/global-cache steps: - name: Checkout code - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 - name: Setup Cache - uses: namespacelabs/nscloud-cache-action@15799a6b54e5765f85b2aac25b3f0df43ed571c0 # v1.4.3 + uses: namespacelabs/nscloud-cache-action@58bf6e08898e88803c098e2b522668541cd3b2e3 # v1.6.0 with: path: | /nix /zig # Install Nix and use that to run our tests so our environment matches exactly. - - uses: cachix/install-nix-action@8aa03977d8d733052d78f4e008a241fd1dbf36b3 # v31.10.6 + - uses: cachix/install-nix-action@a49548c11d9846ad46ecc0115273879b045f001c # v31.10.7 with: nix_path: nixpkgs=channel:nixos-unstable - uses: cachix/cachix-action@5f2d7c5294214f71b873db4b969586b980625e71 # v17 @@ -1808,7 +1808,7 @@ jobs: tar --verbose --extract --strip-components 1 --directory dist --file ghostty-source.tar.gz - name: Build and push - uses: docker/build-push-action@f9f3042f7e2789586610d6e8b85c8f03e5195baf # v7.2.0 + uses: docker/build-push-action@53b7df96c91f9c12dcc8a07bcb9ccacbed38856a # v7.3.0 with: context: dist file: dist/src/build/docker/debian/Dockerfile @@ -1825,17 +1825,17 @@ jobs: ZIG_GLOBAL_CACHE_DIR: /zig/global-cache steps: - name: Checkout code - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 - name: Setup Cache - uses: namespacelabs/nscloud-cache-action@15799a6b54e5765f85b2aac25b3f0df43ed571c0 # v1.4.3 + uses: namespacelabs/nscloud-cache-action@58bf6e08898e88803c098e2b522668541cd3b2e3 # v1.6.0 with: path: | /nix /zig # Install Nix and use that to run our tests so our environment matches exactly. - - uses: cachix/install-nix-action@8aa03977d8d733052d78f4e008a241fd1dbf36b3 # v31.10.6 + - uses: cachix/install-nix-action@a49548c11d9846ad46ecc0115273879b045f001c # v31.10.7 with: nix_path: nixpkgs=channel:nixos-unstable - uses: cachix/cachix-action@5f2d7c5294214f71b873db4b969586b980625e71 # v17 @@ -1864,7 +1864,7 @@ jobs: # timeout-minutes: 10 # steps: # - name: Checkout Ghostty - # uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + # uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 # # - name: Start SSH # run: | diff --git a/vendor/libghostty-vt/.github/workflows/update-colorschemes.yml b/vendor/libghostty-vt/.github/workflows/update-colorschemes.yml index 916575a8..911768e5 100644 --- a/vendor/libghostty-vt/.github/workflows/update-colorschemes.yml +++ b/vendor/libghostty-vt/.github/workflows/update-colorschemes.yml @@ -17,19 +17,19 @@ jobs: ZIG_GLOBAL_CACHE_DIR: /zig/global-cache steps: - name: Checkout code - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 with: fetch-depth: 0 - name: Setup Cache - uses: namespacelabs/nscloud-cache-action@15799a6b54e5765f85b2aac25b3f0df43ed571c0 # v1.4.3 + uses: namespacelabs/nscloud-cache-action@58bf6e08898e88803c098e2b522668541cd3b2e3 # v1.6.0 with: path: | /nix /zig - name: Setup Nix - uses: cachix/install-nix-action@8aa03977d8d733052d78f4e008a241fd1dbf36b3 # v31.10.6 + uses: cachix/install-nix-action@a49548c11d9846ad46ecc0115273879b045f001c # v31.10.7 with: nix_path: nixpkgs=channel:nixos-unstable - uses: cachix/cachix-action@5f2d7c5294214f71b873db4b969586b980625e71 # v17 diff --git a/vendor/libghostty-vt/.github/workflows/vouch-check-issue.yml b/vendor/libghostty-vt/.github/workflows/vouch-check-issue.yml index c91de8ae..fb36bf88 100644 --- a/vendor/libghostty-vt/.github/workflows/vouch-check-issue.yml +++ b/vendor/libghostty-vt/.github/workflows/vouch-check-issue.yml @@ -14,11 +14,11 @@ jobs: app-id: ${{ secrets.VOUCH_APP_ID }} private-key: ${{ secrets.VOUCH_APP_PRIVATE_KEY }} - - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 with: sparse-checkout: .github/issue-unvouched-message - - uses: mitchellh/vouch/action/check-issue@52aec3d64655edf2fdb58f298e02da754a056daf # unreleased main + - uses: mitchellh/vouch/action/check-issue@d66fa29a64600490892131ad87597c30c91fcac4 # unreleased main with: issue-number: ${{ github.event.issue.number }} auto-close: true diff --git a/vendor/libghostty-vt/.github/workflows/vouch-check-pr.yml b/vendor/libghostty-vt/.github/workflows/vouch-check-pr.yml index 4cc7906a..ec81e183 100644 --- a/vendor/libghostty-vt/.github/workflows/vouch-check-pr.yml +++ b/vendor/libghostty-vt/.github/workflows/vouch-check-pr.yml @@ -14,7 +14,7 @@ jobs: app-id: ${{ secrets.VOUCH_APP_ID }} private-key: ${{ secrets.VOUCH_APP_PRIVATE_KEY }} - - uses: mitchellh/vouch/action/check-pr@c6d80ead49839655b61b422700b7a3bc9d0804a9 # v1.4.2 + - uses: mitchellh/vouch/action/check-pr@d66fa29a64600490892131ad87597c30c91fcac4 # v1.5.0 with: pr-number: ${{ github.event.pull_request.number }} auto-close: true diff --git a/vendor/libghostty-vt/.github/workflows/vouch-manage-by-discussion.yml b/vendor/libghostty-vt/.github/workflows/vouch-manage-by-discussion.yml index 172c7cde..ec46fa26 100644 --- a/vendor/libghostty-vt/.github/workflows/vouch-manage-by-discussion.yml +++ b/vendor/libghostty-vt/.github/workflows/vouch-manage-by-discussion.yml @@ -18,11 +18,11 @@ jobs: app-id: ${{ secrets.VOUCH_APP_ID }} private-key: ${{ secrets.VOUCH_APP_PRIVATE_KEY }} - - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 with: token: ${{ steps.app-token.outputs.token }} - - uses: mitchellh/vouch/action/manage-by-discussion@c6d80ead49839655b61b422700b7a3bc9d0804a9 # v1.4.2 + - uses: mitchellh/vouch/action/manage-by-discussion@d66fa29a64600490892131ad87597c30c91fcac4 # v1.5.0 with: discussion-number: ${{ github.event.discussion.number }} comment-node-id: ${{ github.event.comment.node_id }} diff --git a/vendor/libghostty-vt/.github/workflows/vouch-manage-by-issue.yml b/vendor/libghostty-vt/.github/workflows/vouch-manage-by-issue.yml index e78de926..631adcea 100644 --- a/vendor/libghostty-vt/.github/workflows/vouch-manage-by-issue.yml +++ b/vendor/libghostty-vt/.github/workflows/vouch-manage-by-issue.yml @@ -18,11 +18,11 @@ jobs: app-id: ${{ secrets.VOUCH_APP_ID }} private-key: ${{ secrets.VOUCH_APP_PRIVATE_KEY }} - - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 with: token: ${{ steps.app-token.outputs.token }} - - uses: mitchellh/vouch/action/manage-by-issue@c6d80ead49839655b61b422700b7a3bc9d0804a9 # v1.4.2 + - uses: mitchellh/vouch/action/manage-by-issue@d66fa29a64600490892131ad87597c30c91fcac4 # v1.5.0 with: repo: ${{ github.repository }} issue-id: ${{ github.event.issue.number }} diff --git a/vendor/libghostty-vt/.github/workflows/vouch-sync-codeowners.yml b/vendor/libghostty-vt/.github/workflows/vouch-sync-codeowners.yml index d9b7ce74..b031f29f 100644 --- a/vendor/libghostty-vt/.github/workflows/vouch-sync-codeowners.yml +++ b/vendor/libghostty-vt/.github/workflows/vouch-sync-codeowners.yml @@ -19,11 +19,11 @@ jobs: app-id: ${{ secrets.VOUCH_APP_ID }} private-key: ${{ secrets.VOUCH_APP_PRIVATE_KEY }} - - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 with: token: ${{ steps.app-token.outputs.token }} - - uses: mitchellh/vouch/action/sync-codeowners@c6d80ead49839655b61b422700b7a3bc9d0804a9 # v1.4.2 + - uses: mitchellh/vouch/action/sync-codeowners@d66fa29a64600490892131ad87597c30c91fcac4 # v1.5.0 with: repo: ${{ github.repository }} pull-request: "true" diff --git a/vendor/libghostty-vt/VERSION b/vendor/libghostty-vt/VERSION index 23f93e75..0b50d7c6 100644 --- a/vendor/libghostty-vt/VERSION +++ b/vendor/libghostty-vt/VERSION @@ -1 +1 @@ -1.3.2-HEAD-+0f7cd84b8
\ No newline at end of file +1.3.2-HEAD-+c5a21edfc
\ No newline at end of file diff --git a/vendor/libghostty-vt/build.zig.zon b/vendor/libghostty-vt/build.zig.zon index 413c30a2..7925f0d5 100644 --- a/vendor/libghostty-vt/build.zig.zon +++ b/vendor/libghostty-vt/build.zig.zon @@ -116,8 +116,8 @@ .apple_sdk = .{ .path = "./pkg/apple-sdk" }, .android_ndk = .{ .path = "./pkg/android-ndk" }, .iterm2_themes = .{ - .url = "https://deps.files.ghostty.org/ghostty-themes-release-20260511-160054-2671288.tgz", - .hash = "N-V-__8AAPy1AwDnEoq1ww42uq58nusIeQgR16W4-5SQZFIM", + .url = "https://deps.files.ghostty.org/ghostty-themes-release-20260629-161812-8c97c3c.tgz", + .hash = "N-V-__8AAAYpBACKY0n8sQbPfzY47xFRRtjXiF766UVF5ZyD", .lazy = true, }, }, diff --git a/vendor/libghostty-vt/build.zig.zon.json b/vendor/libghostty-vt/build.zig.zon.json index ed745b36..63d5fd61 100644 --- a/vendor/libghostty-vt/build.zig.zon.json +++ b/vendor/libghostty-vt/build.zig.zon.json @@ -54,10 +54,10 @@ "url": "https://github.com/ocornut/imgui/archive/refs/tags/v1.92.5-docking.tar.gz", "hash": "sha256-yBbCDox18+Fa6Gc1DnmSVQLRpqhZOLsac7iSfl8x+cs=" }, - "N-V-__8AAPy1AwDnEoq1ww42uq58nusIeQgR16W4-5SQZFIM": { + "N-V-__8AAAYpBACKY0n8sQbPfzY47xFRRtjXiF766UVF5ZyD": { "name": "iterm2_themes", - "url": "https://deps.files.ghostty.org/ghostty-themes-release-20260511-160054-2671288.tgz", - "hash": "sha256-R2NJUKxz2LHRiCBi/MAnN3XzMyY4VWlbX0uWCbWefjQ=" + "url": "https://deps.files.ghostty.org/ghostty-themes-release-20260629-161812-8c97c3c.tgz", + "hash": "sha256-quMCPsbVIeLqzIV2okjRDZTKxA6YdkUSYuxmBHf3xMg=" }, "N-V-__8AAIC5lwAVPJJzxnCAahSvZTIlG-HhtOvnM1uh-66x": { "name": "jetbrains_mono", diff --git a/vendor/libghostty-vt/build.zig.zon.nix b/vendor/libghostty-vt/build.zig.zon.nix index 1ca2810b..2b05dd1b 100644 --- a/vendor/libghostty-vt/build.zig.zon.nix +++ b/vendor/libghostty-vt/build.zig.zon.nix @@ -193,11 +193,11 @@ in }; } { - name = "N-V-__8AAPy1AwDnEoq1ww42uq58nusIeQgR16W4-5SQZFIM"; + name = "N-V-__8AAAYpBACKY0n8sQbPfzY47xFRRtjXiF766UVF5ZyD"; path = fetchZigArtifact { name = "iterm2_themes"; - url = "https://deps.files.ghostty.org/ghostty-themes-release-20260511-160054-2671288.tgz"; - hash = "sha256-R2NJUKxz2LHRiCBi/MAnN3XzMyY4VWlbX0uWCbWefjQ="; + url = "https://deps.files.ghostty.org/ghostty-themes-release-20260629-161812-8c97c3c.tgz"; + hash = "sha256-quMCPsbVIeLqzIV2okjRDZTKxA6YdkUSYuxmBHf3xMg="; unpack = false; }; } diff --git a/vendor/libghostty-vt/build.zig.zon.txt b/vendor/libghostty-vt/build.zig.zon.txt index 108afb31..02da0630 100644 --- a/vendor/libghostty-vt/build.zig.zon.txt +++ b/vendor/libghostty-vt/build.zig.zon.txt @@ -6,7 +6,7 @@ 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-20260511-160054-2671288.tgz +https://deps.files.ghostty.org/ghostty-themes-release-20260629-161812-8c97c3c.tgz https://deps.files.ghostty.org/glslang-12201278a1a05c0ce0b6eb6026c65cd3e9247aa041b1c260324bf29cee559dd23ba1.tar.gz https://deps.files.ghostty.org/gobject-2025-11-08-23-1.tar.zst https://deps.files.ghostty.org/gtk4-layer-shell-1.1.0.tar.gz diff --git a/vendor/libghostty-vt/include/ghostty.h b/vendor/libghostty-vt/include/ghostty.h index fbfe3ee2..72bbb57a 100644 --- a/vendor/libghostty-vt/include/ghostty.h +++ b/vendor/libghostty-vt/include/ghostty.h @@ -934,6 +934,7 @@ typedef enum { GHOSTTY_ACTION_CONFIG_CHANGE, GHOSTTY_ACTION_CLOSE_WINDOW, GHOSTTY_ACTION_RING_BELL, + GHOSTTY_ACTION_SELECTION_CHANGED, GHOSTTY_ACTION_UNDO, GHOSTTY_ACTION_REDO, GHOSTTY_ACTION_CHECK_FOR_UPDATES, diff --git a/vendor/libghostty-vt/include/ghostty/vt.h b/vendor/libghostty-vt/include/ghostty/vt.h index 94a85033..5606f169 100644 --- a/vendor/libghostty-vt/include/ghostty/vt.h +++ b/vendor/libghostty-vt/include/ghostty/vt.h @@ -34,6 +34,7 @@ * - @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 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 * - @ref wasm "WebAssembly Utilities" - WebAssembly convenience functions @@ -55,6 +56,7 @@ * - @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 * */ @@ -104,6 +106,11 @@ * detect when it loses its value, and move it to a new point. */ +/** @example c-vt-compression/src/main.c + * This example demonstrates how to schedule incremental scrollback compression + * after compression-relevant terminal activity becomes idle. + */ + /** @example c-vt-selection-gesture/src/main.c * This example demonstrates how to use synthetic selection gesture events to * derive drag and deep-press selection snapshots. @@ -125,6 +132,7 @@ extern "C" { #include <ghostty/vt/allocator.h> #include <ghostty/vt/build_info.h> #include <ghostty/vt/color.h> +#include <ghostty/vt/color_scheme.h> #include <ghostty/vt/device.h> #include <ghostty/vt/focus.h> #include <ghostty/vt/formatter.h> @@ -145,6 +153,7 @@ extern "C" { #include <ghostty/vt/screen.h> #include <ghostty/vt/selection.h> #include <ghostty/vt/size_report.h> +#include <ghostty/vt/unicode.h> #include <ghostty/vt/wasm.h> #ifdef __cplusplus diff --git a/vendor/libghostty-vt/include/ghostty/vt/color.h b/vendor/libghostty-vt/include/ghostty/vt/color.h index 9dc21864..f5f7f9c4 100644 --- a/vendor/libghostty-vt/include/ghostty/vt/color.h +++ b/vendor/libghostty-vt/include/ghostty/vt/color.h @@ -7,6 +7,114 @@ #ifndef GHOSTTY_VT_COLOR_H #define GHOSTTY_VT_COLOR_H +/** @defgroup color Color Utilities + * + * Color parsing, palette generation, color math, and X11 color name + * utilities shared by libghostty-vt. + * + * These APIs expose Ghostty's color semantics directly to embedders. Use + * them when an application needs to parse the same color strings as Ghostty + * config and theme files, generate the same 256-color palette used by the + * terminal, list supported X11 color names, or make UI decisions from + * luminance and contrast values. + * + * ## Parsing Colors + * + * ghostty_color_parse() accepts the flexible syntax used by Ghostty for + * terminal colors: + * + * - X11 color names, matched ASCII case-insensitively. + * - 3- or 6-digit hex colors, with or without a leading `#`. + * - 9- or 12-digit hex colors, with a leading `#`. + * - XParseColor-style `rgb:<red>/<green>/<blue>` values. + * - XParseColor-style `rgbi:<red>/<green>/<blue>` values. + * + * Leading and trailing spaces and tabs are ignored. Use + * ghostty_color_parse_x11() when only X11 names should be accepted. + * + * @code{.c} + * GhosttyColorRgb color; + * + * if (ghostty_color_parse( + * "ForestGreen", + * sizeof("ForestGreen") - 1, + * &color) != GHOSTTY_SUCCESS) { + * // Handle invalid color input. + * } + * + * ghostty_color_parse("#abc", sizeof("#abc") - 1, &color); + * ghostty_color_parse("rgb:12/34/56", sizeof("rgb:12/34/56") - 1, &color); + * @endcode + * + * ## Palette Entries + * + * ghostty_color_parse_palette_entry() parses a single Ghostty palette + * override in `INDEX=COLOR` form. The index may be decimal or use a `0x`, + * `0o`, or `0b` prefix. The color side uses ghostty_color_parse(). + * + * @code{.c} + * GhosttyColorRgb palette[256]; + * ghostty_color_palette_default(palette); + * + * uint8_t index; + * GhosttyColorRgb rgb; + * + * if (ghostty_color_parse_palette_entry( + * "0x10=#282c34", + * sizeof("0x10=#282c34") - 1, + * &index, + * &rgb) == GHOSTTY_SUCCESS) { + * palette[index] = rgb; + * } + * @endcode + * + * ## Palette Generation + * + * ghostty_color_palette_generate() derives the 216-color cube and grayscale + * ramp from a base palette, background, and foreground. Set bits in + * GhosttyColorPaletteMask preserve specific indices from the base palette. + * The output may alias the base input. + * + * @code{.c} + * GhosttyColorRgb palette[256]; + * ghostty_color_palette_default(palette); + * + * GhosttyColorPaletteMask skip = {0}; + * GHOSTTY_COLOR_PALETTE_MASK_SET(&skip, 16); + * + * GhosttyColorRgb background = {40, 44, 52}; + * GhosttyColorRgb foreground = {220, 223, 228}; + * + * ghostty_color_palette_generate( + * palette, + * &skip, + * &background, + * &foreground, + * true, + * palette); + * @endcode + * + * ## X11 Color Names + * + * The X11 name table is static program-lifetime memory. Entries are in + * rgb.txt order and are terminated by an entry with `name == NULL`. + * ghostty_color_x11_name_count() returns the number of non-terminator + * entries. + * + * @code{.c} + * const GhosttyColorX11Entry* names = ghostty_color_x11_names(); + * size_t count = ghostty_color_x11_name_count(); + * + * for (size_t i = 0; i < count; i++) { + * // names[i].name and names[i].color are valid here. + * } + * @endcode + * + * @{ + */ + +#include <stdbool.h> +#include <stddef.h> #include <stdint.h> #include <ghostty/vt/types.h> @@ -17,7 +125,7 @@ extern "C" { /** * RGB color value. * - * @ingroup sgr + * @ingroup color */ typedef struct { uint8_t r; /**< Red component (0-255) */ @@ -28,49 +136,128 @@ typedef struct { /** * Palette color index (0-255). * - * @ingroup sgr + * @ingroup color */ typedef uint8_t GhosttyColorPaletteIndex; -/** @addtogroup sgr - * @{ +/** + * A 256-bit mask of palette indices. + * + * Index i is set iff `(bits[i >> 6] >> (i & 63)) & 1` is 1. + * The mask is typically initialized to zero and then populated with + * GHOSTTY_COLOR_PALETTE_MASK_SET(). + * + * @code{.c} + * GhosttyColorPaletteMask mask = {0}; + * GHOSTTY_COLOR_PALETTE_MASK_SET(&mask, 20); + * if (GHOSTTY_COLOR_PALETTE_MASK_IS_SET(&mask, 20)) { + * // Index 20 will be preserved. + * } + * @endcode + * + * @ingroup color */ +typedef struct { + uint64_t bits[4]; +} GhosttyColorPaletteMask; + +/** + * An entry in Ghostty's X11 color name table. + * + * @ingroup color + */ +typedef struct { + /** Null-terminated color name. NULL marks the end of the table. */ + const char* name; + /** The RGB value of the color. */ + GhosttyColorRgb color; +} GhosttyColorX11Entry; + +/** + * Return the storage word for a palette mask index. + * + * @param index The palette index (0-255) + * + * @ingroup color + */ +#define GHOSTTY_COLOR_PALETTE_MASK_WORD(index) ((index) >> 6) + +/** + * Return the storage bit for a palette mask index. + * + * @param index The palette index (0-255) + * + * @ingroup color + */ +#define GHOSTTY_COLOR_PALETTE_MASK_BIT(index) (UINT64_C(1) << ((index) & 63)) -/** Black color (0) @ingroup sgr */ +/** + * Set a palette mask index. + * + * @param mask Pointer to a GhosttyColorPaletteMask + * @param index The palette index (0-255) + * + * @ingroup color + */ +#define GHOSTTY_COLOR_PALETTE_MASK_SET(mask, index) \ + ((mask)->bits[GHOSTTY_COLOR_PALETTE_MASK_WORD(index)] |= GHOSTTY_COLOR_PALETTE_MASK_BIT(index)) + +/** + * Clear a palette mask index. + * + * @param mask Pointer to a GhosttyColorPaletteMask + * @param index The palette index (0-255) + * + * @ingroup color + */ +#define GHOSTTY_COLOR_PALETTE_MASK_UNSET(mask, index) \ + ((mask)->bits[GHOSTTY_COLOR_PALETTE_MASK_WORD(index)] &= ~GHOSTTY_COLOR_PALETTE_MASK_BIT(index)) + +/** + * Test whether a palette mask index is set. + * + * @param mask Pointer to a GhosttyColorPaletteMask + * @param index The palette index (0-255) + * @return true if the palette index is set, false otherwise + * + * @ingroup color + */ +#define GHOSTTY_COLOR_PALETTE_MASK_IS_SET(mask, index) \ + (((mask)->bits[GHOSTTY_COLOR_PALETTE_MASK_WORD(index)] & GHOSTTY_COLOR_PALETTE_MASK_BIT(index)) != 0) + +/** Black color (0) @ingroup color */ #define GHOSTTY_COLOR_NAMED_BLACK 0 -/** Red color (1) @ingroup sgr */ +/** Red color (1) @ingroup color */ #define GHOSTTY_COLOR_NAMED_RED 1 -/** Green color (2) @ingroup sgr */ +/** Green color (2) @ingroup color */ #define GHOSTTY_COLOR_NAMED_GREEN 2 -/** Yellow color (3) @ingroup sgr */ +/** Yellow color (3) @ingroup color */ #define GHOSTTY_COLOR_NAMED_YELLOW 3 -/** Blue color (4) @ingroup sgr */ +/** Blue color (4) @ingroup color */ #define GHOSTTY_COLOR_NAMED_BLUE 4 -/** Magenta color (5) @ingroup sgr */ +/** Magenta color (5) @ingroup color */ #define GHOSTTY_COLOR_NAMED_MAGENTA 5 -/** Cyan color (6) @ingroup sgr */ +/** Cyan color (6) @ingroup color */ #define GHOSTTY_COLOR_NAMED_CYAN 6 -/** White color (7) @ingroup sgr */ +/** White color (7) @ingroup color */ #define GHOSTTY_COLOR_NAMED_WHITE 7 -/** Bright black color (8) @ingroup sgr */ +/** Bright black color (8) @ingroup color */ #define GHOSTTY_COLOR_NAMED_BRIGHT_BLACK 8 -/** Bright red color (9) @ingroup sgr */ +/** Bright red color (9) @ingroup color */ #define GHOSTTY_COLOR_NAMED_BRIGHT_RED 9 -/** Bright green color (10) @ingroup sgr */ +/** Bright green color (10) @ingroup color */ #define GHOSTTY_COLOR_NAMED_BRIGHT_GREEN 10 -/** Bright yellow color (11) @ingroup sgr */ +/** Bright yellow color (11) @ingroup color */ #define GHOSTTY_COLOR_NAMED_BRIGHT_YELLOW 11 -/** Bright blue color (12) @ingroup sgr */ +/** Bright blue color (12) @ingroup color */ #define GHOSTTY_COLOR_NAMED_BRIGHT_BLUE 12 -/** Bright magenta color (13) @ingroup sgr */ +/** Bright magenta color (13) @ingroup color */ #define GHOSTTY_COLOR_NAMED_BRIGHT_MAGENTA 13 -/** Bright cyan color (14) @ingroup sgr */ +/** Bright cyan color (14) @ingroup color */ #define GHOSTTY_COLOR_NAMED_BRIGHT_CYAN 14 -/** Bright white color (15) @ingroup sgr */ +/** Bright white color (15) @ingroup color */ #define GHOSTTY_COLOR_NAMED_BRIGHT_WHITE 15 -/** @} */ - /** * Get the RGB color components. * @@ -78,20 +265,215 @@ typedef uint8_t GhosttyColorPaletteIndex; * from a GhosttyColorRgb value. Primarily useful in WebAssembly environments * where accessing struct fields directly is difficult. * - * @param color The RGB color value + * @param color Pointer to the RGB color value * @param r Pointer to store the red component (0-255) * @param g Pointer to store the green component (0-255) * @param b Pointer to store the blue component (0-255) * - * @ingroup sgr + * @ingroup color */ -GHOSTTY_API void ghostty_color_rgb_get(GhosttyColorRgb color, +GHOSTTY_API void ghostty_color_rgb_get(const GhosttyColorRgb* color, uint8_t* r, uint8_t* g, uint8_t* b); +/** + * Parse an X11 color name. + * + * The color name is resolved from Ghostty's embedded rgb.txt table. + * Leading and trailing spaces and tabs are trimmed, and matching is + * ASCII case-insensitive. Hex values are not accepted by this function. + * + * @param name The color name bytes (must not be NULL) + * @param len The length of @p name in bytes + * @param[out] out The parsed RGB color + * @return GHOSTTY_SUCCESS on success, GHOSTTY_INVALID_VALUE if no color + * matches or @p name is NULL + * + * @ingroup color + */ +GHOSTTY_API GhosttyResult ghostty_color_parse_x11( + const char* name, + size_t len, + GhosttyColorRgb* out); + +/** + * Parse a flexible Ghostty color value. + * + * Accepts Ghostty's terminal color syntax: X11 color names, hex colors + * in 3-, 6-, 9-, or 12-digit form (the leading # is optional for 3- and + * 6-digit values), and rgb:<red>/<green>/<blue> or + * rgbi:<red>/<green>/<blue> specifications. Leading and trailing spaces + * and tabs are trimmed. + * + * @param value The color value bytes (must not be NULL) + * @param len The length of @p value in bytes + * @param[out] out The parsed RGB color + * @return GHOSTTY_SUCCESS on success, GHOSTTY_INVALID_VALUE if parsing + * fails or @p value is NULL + * + * @ingroup color + */ +GHOSTTY_API GhosttyResult ghostty_color_parse( + const char* value, + size_t len, + GhosttyColorRgb* out); + +/** + * Parse a Ghostty palette entry. + * + * Accepts Ghostty palette config syntax: N=COLOR. N is a palette index + * from 0 to 255 in decimal or in 0x, 0o, or 0b-prefixed form. Spaces and + * tabs around N and COLOR are ignored. COLOR accepts the same syntax as + * ghostty_color_parse(). + * + * @param value The palette entry bytes (must not be NULL) + * @param len The length of @p value in bytes + * @param[out] out_index The parsed palette index + * @param[out] out_rgb The parsed RGB color + * @return GHOSTTY_SUCCESS on success, GHOSTTY_INVALID_VALUE on any + * failure, including index overflow + * + * @ingroup color + */ +GHOSTTY_API GhosttyResult ghostty_color_parse_palette_entry( + const char* value, + size_t len, + uint8_t* out_index, + GhosttyColorRgb* out_rgb); + +/** + * Get Ghostty's built-in default 256-color palette. + * + * Writes exactly 256 entries: Ghostty's base16 defaults, the xterm + * 6x6x6 color cube, and the grayscale ramp. + * + * @param[out] out The output palette, an array of exactly 256 + * GhosttyColorRgb values + * + * @ingroup color + */ +GHOSTTY_API void ghostty_color_palette_default(GhosttyColorRgb* out); + +/** + * Generate a 256-color palette from base colors. + * + * The base palette supplies indices 0-15, which are always preserved. + * If @p base is NULL, Ghostty's default palette is used. If @p skip is + * NULL, no extra indices are skipped. Set bits in @p skip preserve those + * indices from @p base. The 216-color cube at indices 16-231 is generated + * with trilinear CIELAB interpolation, and the grayscale ramp at indices + * 232-255 is interpolated from the background to the foreground. + * + * For light themes, @p harmonious controls whether the generated palette + * keeps the background-to-foreground orientation. When false, Ghostty + * swaps the light background and dark foreground so the cube and ramp run + * dark-to-light. The output palette may be the same pointer as @p base. + * + * @param base The base palette, an array of exactly 256 GhosttyColorRgb + * values, or NULL to use Ghostty's default palette + * @param skip The palette indices to preserve from @p base, or NULL for + * an empty mask + * @param bg The terminal background color (must not be NULL) + * @param fg The terminal foreground color (must not be NULL) + * @param harmonious Whether light themes keep background-to-foreground + * orientation + * @param[out] out The output palette, an array of exactly 256 + * GhosttyColorRgb values + * + * @ingroup color + */ +GHOSTTY_API void ghostty_color_palette_generate( + const GhosttyColorRgb* base, + const GhosttyColorPaletteMask* skip, + const GhosttyColorRgb* bg, + const GhosttyColorRgb* fg, + bool harmonious, + GhosttyColorRgb* out); + +/** + * Calculate W3C relative luminance for an RGB color. + * + * Returns a normalized value from 0.0 for black to 1.0 for white. + * See https://www.w3.org/TR/WCAG20/#relativeluminancedef. + * + * @param color The RGB color (must not be NULL) + * @return Relative luminance in the range 0.0 to 1.0 + * + * @ingroup color + */ +GHOSTTY_API double ghostty_color_luminance(const GhosttyColorRgb* color); + +/** + * Calculate perceived luminance for an RGB color. + * + * Returns a normalized value from 0.0 for black to 1.0 for white. + * Ghostty treats a background color as light when this exceeds 0.5. + * This is not the metric used internally by + * ghostty_color_palette_generate(), which uses CIELAB lightness. + * + * @param color The RGB color (must not be NULL) + * @return Perceived luminance in the range 0.0 to 1.0 + * + * @ingroup color + */ +GHOSTTY_API double ghostty_color_perceived_luminance(const GhosttyColorRgb* color); + +/** + * Calculate the WCAG contrast ratio between two RGB colors. + * + * The contrast ratio is symmetric and ranges from 1.0 for identical + * colors to 21.0 for black and white. + * + * @param a The first RGB color (must not be NULL) + * @param b The second RGB color (must not be NULL) + * @return WCAG contrast ratio in the range 1.0 to 21.0 + * + * @ingroup color + */ +GHOSTTY_API double ghostty_color_contrast(const GhosttyColorRgb* a, + const GhosttyColorRgb* b); + +/** + * Get Ghostty's X11 color name table. + * + * The returned pointer references static memory valid for the program + * lifetime and is never NULL. Entries are in rgb.txt order and are + * terminated by an entry with name == NULL. Aliases are separate entries, + * such as "medium spring green" and "MediumSpringGreen". Names are the + * exact supported spellings from rgb.txt; ghostty_color_parse_x11() also + * matches them case-insensitively. + * + * @code{.c} + * for (const GhosttyColorX11Entry* e = ghostty_color_x11_names(); + * e->name != NULL; + * e++) { + * // e->name and e->color are valid here. + * } + * @endcode + * + * @return Pointer to the first X11 color entry + * + * @ingroup color + */ +GHOSTTY_API const GhosttyColorX11Entry* ghostty_color_x11_names(void); + +/** + * Get the number of X11 color name entries. + * + * The returned count excludes the NULL terminator and is provided so + * bindings can preallocate storage before reading ghostty_color_x11_names(). + * + * @return Number of X11 color name entries + * + * @ingroup color + */ +GHOSTTY_API size_t ghostty_color_x11_name_count(void); + #ifdef __cplusplus } #endif +/** @} */ + #endif /* GHOSTTY_VT_COLOR_H */ diff --git a/vendor/libghostty-vt/include/ghostty/vt/color_scheme.h b/vendor/libghostty-vt/include/ghostty/vt/color_scheme.h new file mode 100644 index 00000000..d028ec08 --- /dev/null +++ b/vendor/libghostty-vt/include/ghostty/vt/color_scheme.h @@ -0,0 +1,73 @@ +/** + * @file color_scheme.h + * + * Color scheme report encoding - encode terminal color scheme reports into + * escape sequences. + */ + +#ifndef GHOSTTY_VT_COLOR_SCHEME_H +#define GHOSTTY_VT_COLOR_SCHEME_H + +/** @defgroup color_scheme Color Scheme Report Encoding + * + * Utilities for encoding color scheme reports into terminal escape + * sequences for color scheme reporting mode (mode 2031). + * + * ## Basic Usage + * + * Use ghostty_color_scheme_report_encode() to encode a color scheme report + * into a caller-provided buffer. If the buffer is too small, the function + * returns GHOSTTY_OUT_OF_SPACE and sets the required size in the output + * parameter. + * + * ## Example + * + * @snippet c-vt-color-scheme/src/main.c color-scheme-report-encode + * + * @{ + */ + +#include <stddef.h> +#include <ghostty/vt/types.h> +#include <ghostty/vt/device.h> + +#ifdef __cplusplus +extern "C" { +#endif + +/** + * Encode a color scheme report into an escape sequence. + * + * Encodes a color scheme report into the provided buffer. Dark color schemes + * emit ESC [ ? 997 ; 1 n, and light color schemes emit ESC [ ? 997 ; 2 n. + * The encoded bytes are identical to the terminal's internal CSI ? 996 n + * query response. + * + * Hosts should gate unsolicited sends on GHOSTTY_MODE_COLOR_SCHEME_REPORT + * (mode 2031) being set, which can be checked via the mode getters. + * + * If the buffer is too small, the function returns GHOSTTY_OUT_OF_SPACE + * and writes the required buffer size to @p out_written. The caller can + * then retry with a sufficiently sized buffer. + * + * @param scheme The color scheme to encode + * @param buf Output buffer to write the encoded sequence into (may be NULL) + * @param buf_len Size of the output buffer in bytes + * @param[out] out_written On success, the number of bytes written. On + * GHOSTTY_OUT_OF_SPACE, the required buffer size. + * @return GHOSTTY_SUCCESS on success, GHOSTTY_OUT_OF_SPACE if the buffer + * is too small + */ +GHOSTTY_API GhosttyResult ghostty_color_scheme_report_encode( + GhosttyColorScheme scheme, + char* buf, + size_t buf_len, + size_t* out_written); + +#ifdef __cplusplus +} +#endif + +/** @} */ + +#endif /* GHOSTTY_VT_COLOR_SCHEME_H */ diff --git a/vendor/libghostty-vt/include/ghostty/vt/kitty_graphics.h b/vendor/libghostty-vt/include/ghostty/vt/kitty_graphics.h index d5b50067..dda8b836 100644 --- a/vendor/libghostty-vt/include/ghostty/vt/kitty_graphics.h +++ b/vendor/libghostty-vt/include/ghostty/vt/kitty_graphics.h @@ -84,6 +84,28 @@ extern "C" { * - ghostty_kitty_graphics_placement_rect() — bounding rectangle as a * @ref GhosttySelection. * + * ## Change Detection + * + * Generation stamps allow renderers to cheaply detect whether Kitty + * graphics state changed between frames: + * + * - @ref GHOSTTY_KITTY_GRAPHICS_DATA_GENERATION is a storage-wide stamp + * updated on any transmit, placement, or delete. If unchanged, the + * placement set and all image data are identical and both placement + * snapshots and per-image staleness checks can be skipped. Placement + * geometry can still change independently (scrolling moves + * placements), so ghostty_kitty_graphics_placement_render_info() + * should still be recomputed on dirty frames. + * - @ref GHOSTTY_KITTY_IMAGE_DATA_GENERATION is a per-image stamp + * changed on every add/replace of that image ID. Texture caches + * should treat a cached texture as stale when this differs from the + * cached value; dimension/length heuristics cannot detect a + * same-sized retransmission. + * + * Stamps are unique and monotonically increasing process-wide, so + * caches keyed on a generation value never alias across screens + * (main/alternate), resets, or terminals. + * * ## Lifetime and Thread Safety * * All handles borrowed from the terminal (GhosttyKittyGraphics, @@ -119,6 +141,29 @@ typedef enum GHOSTTY_ENUM_TYPED { * Output type: GhosttyKittyGraphicsPlacementIterator * */ GHOSTTY_KITTY_GRAPHICS_DATA_PLACEMENT_ITERATOR = 1, + + /** + * Generation stamp of the last content mutation to this storage: + * any image transmit/replace, placement add, or delete. Zero means + * the storage has never been mutated (and is therefore empty). + * + * If the generation is unchanged since a previous query, the set of + * placements and all image data are identical, so placement iteration + * and image staleness checks can be skipped entirely. Note that + * placement *geometry* may still have changed (scrolling and resizing + * move placements without changing the storage contents), so rendering + * geometry such as ghostty_kitty_graphics_placement_render_info() + * must still be recomputed for frames marked dirty. + * + * Stamps are unique and monotonically increasing process-wide: a + * value observed from any storage never recurs for different content, + * even across screen switches (main vs. alternate screen have + * independent storages) or terminal resets. It is therefore safe to + * key caches on this value alone. + * + * Output type: uint64_t * + */ + GHOSTTY_KITTY_GRAPHICS_DATA_GENERATION = 2, GHOSTTY_KITTY_GRAPHICS_DATA_MAX_VALUE = GHOSTTY_ENUM_MAX_VALUE, } GhosttyKittyGraphicsData; @@ -255,6 +300,12 @@ typedef enum GHOSTTY_ENUM_TYPED { /** * Pixel format of a Kitty graphics image. * + * Note that stored images are always fully decoded: + * GHOSTTY_KITTY_IMAGE_FORMAT_PNG is never returned by + * ghostty_kitty_graphics_image_get() because PNG payloads are decoded + * to GHOSTTY_KITTY_IMAGE_FORMAT_RGBA before storage. The PNG value + * exists only for protocol-level completeness. + * * @ingroup kitty_graphics */ typedef enum GHOSTTY_ENUM_TYPED { @@ -269,6 +320,12 @@ typedef enum GHOSTTY_ENUM_TYPED { /** * Compression of a Kitty graphics image. * + * Note that stored images are always decompressed: + * GHOSTTY_KITTY_IMAGE_COMPRESSION_ZLIB_DEFLATE payloads are inflated + * before storage, so ghostty_kitty_graphics_image_get() always reports + * GHOSTTY_KITTY_IMAGE_COMPRESSION_NONE. Consumers never need to + * inflate image data themselves. + * * @ingroup kitty_graphics */ typedef enum GHOSTTY_ENUM_TYPED { @@ -315,14 +372,17 @@ typedef enum GHOSTTY_ENUM_TYPED { GHOSTTY_KITTY_IMAGE_DATA_HEIGHT = 4, /** - * Pixel format of the image. + * Pixel format of the image. Never GHOSTTY_KITTY_IMAGE_FORMAT_PNG; + * PNG payloads are decoded to RGBA before storage. * * Output type: GhosttyKittyImageFormat * */ GHOSTTY_KITTY_IMAGE_DATA_FORMAT = 5, /** - * Compression of the image. + * Compression of the image. Always + * GHOSTTY_KITTY_IMAGE_COMPRESSION_NONE; compressed payloads are + * inflated before storage. * * Output type: GhosttyKittyImageCompression * */ @@ -332,25 +392,40 @@ typedef enum GHOSTTY_ENUM_TYPED { * Borrowed pointer to the raw pixel data. Valid as long as the * underlying terminal is not mutated. * + * The data is always fully decoded, uncompressed pixels in the + * format reported by GHOSTTY_KITTY_IMAGE_DATA_FORMAT: zlib payloads + * are inflated and PNG payloads are decoded to RGBA at transmission + * time, before the image is stored. Consumers can upload this + * directly to the GPU without any decode step. + * * Output type: const uint8_t ** */ GHOSTTY_KITTY_IMAGE_DATA_DATA_PTR = 7, /** - * Length of the raw pixel data in bytes. + * Length of the raw pixel data in bytes. Always equal to + * width * height * bytes-per-pixel for the reported format. * * Output type: size_t * */ GHOSTTY_KITTY_IMAGE_DATA_DATA_LEN = 8, /** - * Timestamp of the image's most recent transmission, in nanoseconds. - * The epoch is unspecified; only equality and ordering between values - * read from the same terminal are meaningful. + * Generation stamp assigned when this image was added to (or + * replaced in) the storage. A changed generation for a given image + * ID means the pixel contents may have changed even when the + * dimensions, format, and data length are identical (e.g. a + * retransmission of the same image ID), so texture caches must key + * staleness on this value rather than on size heuristics. + * + * Stamps are unique and monotonically increasing process-wide and + * are drawn from the same sequence as + * GHOSTTY_KITTY_GRAPHICS_DATA_GENERATION. Never zero for a stored + * image, so zero can be used as an "empty" sentinel by callers. * * Output type: uint64_t * */ - GHOSTTY_KITTY_IMAGE_DATA_TRANSMIT_TIME_NS = 9, + GHOSTTY_KITTY_IMAGE_DATA_GENERATION = 9, GHOSTTY_KITTY_IMAGE_DATA_MAX_VALUE = GHOSTTY_ENUM_MAX_VALUE, } GhosttyKittyGraphicsImageData; diff --git a/vendor/libghostty-vt/include/ghostty/vt/render.h b/vendor/libghostty-vt/include/ghostty/vt/render.h index c5b1d0d4..2e3f2f71 100644 --- a/vendor/libghostty-vt/include/ghostty/vt/render.h +++ b/vendor/libghostty-vt/include/ghostty/vt/render.h @@ -39,6 +39,18 @@ extern "C" { * 2. Update it from a terminal instance whenever you need. * 3. Read from the render state to get the data needed to draw your frame. * + * ## Two-Phase Updates + * + * For callers that synchronize terminal access (e.g. a renderer thread + * sharing a lock with an IO thread), the update can be split into two + * phases to minimize the time the terminal must be held exclusively: + * ghostty_render_state_begin_update requires terminal access, while + * ghostty_render_state_end_update completes any deferred work using only + * memory owned by the render state. A typical renderer would lock, begin + * the update, unlock, and then end the update while the IO thread is free + * to continue modifying the terminal. ghostty_render_state_update is a + * convenience that performs both phases in one call. + * * ## Dirty Tracking * * Dirty tracking is a key feature of the render state that allows renderers @@ -331,6 +343,12 @@ GHOSTTY_API void ghostty_render_state_free(GhosttyRenderState state); * This consumes terminal/screen dirty state in the same way as the internal * render state update path. * + * This is a convenience function that performs a full update in one call, + * equivalent to ghostty_render_state_begin_update immediately followed by + * ghostty_render_state_end_update. Callers that hold a lock over the + * terminal state should prefer calling the two phases directly so that the + * lock is only held for the begin phase. + * * @param state The render state handle (NULL returns GHOSTTY_INVALID_VALUE) * @param terminal The terminal handle to read from (NULL returns GHOSTTY_INVALID_VALUE) * @return GHOSTTY_SUCCESS on success, GHOSTTY_INVALID_VALUE if `state` or @@ -343,6 +361,54 @@ GHOSTTY_API GhosttyResult ghostty_render_state_update(GhosttyRenderState state, GhosttyTerminal terminal); /** + * Begin an update of a render state instance from a terminal. + * + * Every begin must be completed with a ghostty_render_state_end_update call + * before the render state is read. + * + * This two-phase structure exists for callers that synchronize access to the + * terminal state (e.g. with a lock shared with an IO thread): only this + * function requires terminal access, so a caller can hold its lock for this + * call only and then call ghostty_render_state_end_update after releasing + * it. The end phase exclusively reads and writes memory owned by the render + * state, so it is safe to call while the terminal is being modified. + * + * Work that doesn't require terminal access may be deferred to the end phase + * to keep this call (and therefore lock hold time) as short as possible. + * Callers must treat the render state as incomplete until + * ghostty_render_state_end_update is called. + * + * This consumes terminal/screen dirty state in the same way as the internal + * render state update path. + * + * @param state The render state handle (NULL returns GHOSTTY_INVALID_VALUE) + * @param terminal The terminal handle to read from (NULL returns GHOSTTY_INVALID_VALUE) + * @return GHOSTTY_SUCCESS on success, GHOSTTY_INVALID_VALUE if `state` or + * `terminal` is NULL, GHOSTTY_OUT_OF_MEMORY if updating the state requires + * allocation and that allocation fails + * + * @ingroup render + */ +GHOSTTY_API GhosttyResult ghostty_render_state_begin_update(GhosttyRenderState state, + GhosttyTerminal terminal); + +/** + * Complete a prior ghostty_render_state_begin_update call by performing any + * deferred work. + * + * This only reads and writes memory owned by the render state, so it is safe + * to call while the terminal is being modified (no terminal synchronization + * is required). Calling this without a prior begin is a safe no-op. + * + * @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_end_update(GhosttyRenderState state); + +/** * Get a value from a render state. * * The `out` pointer must point to a value of the type corresponding to the diff --git a/vendor/libghostty-vt/include/ghostty/vt/sgr.h b/vendor/libghostty-vt/include/ghostty/vt/sgr.h index 8eec11dc..757c5aed 100644 --- a/vendor/libghostty-vt/include/ghostty/vt/sgr.h +++ b/vendor/libghostty-vt/include/ghostty/vt/sgr.h @@ -19,8 +19,11 @@ * The parser processes SGR parameters from CSI sequences (e.g., `ESC[1;31m`) * and returns individual text attributes like bold, italic, colors, etc. * It supports both semicolon (`;`) and colon (`:`) separators, possibly mixed, - * and handles various color formats including 8-color, 16-color, 256-color, - * X11 named colors, and RGB in multiple formats. + * and handles SGR color attributes including 8-color, 16-color, 256-color, + * direct RGB, underline color, and reset forms. Color values are returned + * using the shared @ref color types; applications that need to parse Ghostty + * config/theme color strings, generate palettes, inspect X11 color names, or + * calculate luminance and contrast should use the @ref color APIs directly. * * ## Basic Usage * diff --git a/vendor/libghostty-vt/include/ghostty/vt/terminal.h b/vendor/libghostty-vt/include/ghostty/vt/terminal.h index ddfcb9c0..26ee0c0b 100644 --- a/vendor/libghostty-vt/include/ghostty/vt/terminal.h +++ b/vendor/libghostty-vt/include/ghostty/vt/terminal.h @@ -40,6 +40,17 @@ extern "C" { * @snippet c-vt-stream/src/main.c vt-stream-init * @snippet c-vt-stream/src/main.c vt-stream-write * + * ## Scrollback Compression + * + * Scrollback compression is caller-driven. The terminal exposes an opaque + * activity token so an embedding application can restart an idle timer only + * when compression-relevant state changes. Once idle, call incremental + * compression until it no longer reports pending work. libghostty-vt does not + * create a timer or background thread. + * + * @snippet c-vt-compression/src/main.c compression-activity + * @snippet c-vt-compression/src/main.c compression-idle-step + * * ## Effects * * By default, the terminal sequence processing with ghostty_terminal_vt_write() @@ -76,11 +87,13 @@ extern "C" { * | `GHOSTTY_TERMINAL_OPT_WRITE_PTY` | `GhosttyTerminalWritePtyFn` | Query responses 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_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 | * * ### Defining a write_pty callback * @snippet c-vt-effects/src/main.c effects-write-pty @@ -91,6 +104,9 @@ extern "C" { * ### Defining a title_changed callback * @snippet c-vt-effects/src/main.c effects-title-changed * + * ### Defining a clipboard_write callback + * @snippet c-vt-effects/src/main.c effects-clipboard-write + * * ### Registering effects and processing VT data * @snippet c-vt-effects/src/main.c effects-register * @@ -177,6 +193,37 @@ typedef struct { } GhosttyTerminalOptions; /** + * Amount of compression work to perform before returning. + * + * @ingroup terminal + */ +typedef enum GHOSTTY_ENUM_TYPED { + /** Perform one bounded compression step suitable for idle scheduling. */ + GHOSTTY_TERMINAL_COMPRESSION_MODE_INCREMENTAL = 0, + + /** Synchronously inspect every currently eligible page. */ + GHOSTTY_TERMINAL_COMPRESSION_MODE_FULL = 1, + GHOSTTY_TERMINAL_COMPRESSION_MODE_MAX_VALUE = GHOSTTY_ENUM_MAX_VALUE, +} GhosttyTerminalCompressionMode; + +/** + * Scheduling result from terminal compression. + * + * @ingroup terminal + */ +typedef enum GHOSTTY_ENUM_TYPED { + /** Retained-mapping reclamation is unavailable on this target. */ + GHOSTTY_TERMINAL_COMPRESSION_RESULT_UNSUPPORTED = 0, + + /** More incremental compression work remains. */ + GHOSTTY_TERMINAL_COMPRESSION_RESULT_PENDING = 1, + + /** The pass has no continuation to schedule. */ + GHOSTTY_TERMINAL_COMPRESSION_RESULT_COMPLETE = 2, + GHOSTTY_TERMINAL_COMPRESSION_RESULT_MAX_VALUE = GHOSTTY_ENUM_MAX_VALUE, +} GhosttyTerminalCompressionResult; + +/** * Scroll viewport behavior tag. * * @ingroup terminal @@ -190,6 +237,21 @@ typedef enum GHOSTTY_ENUM_TYPED { /** Scroll by a delta amount (up is negative). */ GHOSTTY_SCROLL_VIEWPORT_DELTA, + + /** + * Scroll to an absolute row offset from the top of the scrollable + * area. Row 0 is the top of the scrollback and the requested row + * becomes the first visible row of the viewport. The value is + * clamped so the viewport never scrolls beyond the top of the + * active area. If the terminal has no scrollback (e.g. the + * alternate screen is active), the viewport always remains on the + * active area. + * + * This is the same row space as the offset field of + * GhosttyTerminalScrollbar, so a scrollbar position obtained from + * GHOSTTY_TERMINAL_DATA_SCROLLBAR round-trips cleanly. + */ + GHOSTTY_SCROLL_VIEWPORT_ROW, GHOSTTY_SCROLL_VIEWPORT_MAX_VALUE = GHOSTTY_ENUM_MAX_VALUE, } GhosttyTerminalScrollViewportTag; @@ -202,6 +264,9 @@ typedef union { /** Scroll delta (only used with GHOSTTY_SCROLL_VIEWPORT_DELTA). Up is negative. */ intptr_t delta; + /** Absolute row offset (only used with GHOSTTY_SCROLL_VIEWPORT_ROW). */ + size_t row; + /** Padding for ABI compatibility. Do not use. */ uint64_t _padding[2]; } GhosttyTerminalScrollViewportValue; @@ -233,6 +298,26 @@ typedef enum GHOSTTY_ENUM_TYPED { } GhosttyTerminalScreen; /** + * Visual style of the terminal cursor. + * + * @ingroup terminal + */ +typedef enum GHOSTTY_ENUM_TYPED { + /** Bar cursor (DECSCUSR 5, 6). */ + GHOSTTY_TERMINAL_CURSOR_STYLE_BAR = 0, + + /** Block cursor (DECSCUSR 1, 2). */ + GHOSTTY_TERMINAL_CURSOR_STYLE_BLOCK = 1, + + /** Underline cursor (DECSCUSR 3, 4). */ + GHOSTTY_TERMINAL_CURSOR_STYLE_UNDERLINE = 2, + + /** Hollow block cursor. */ + GHOSTTY_TERMINAL_CURSOR_STYLE_BLOCK_HOLLOW = 3, + GHOSTTY_TERMINAL_CURSOR_STYLE_MAX_VALUE = GHOSTTY_ENUM_MAX_VALUE, +} GhosttyTerminalCursorStyle; + +/** * Scrollbar state for the terminal viewport. * * Represents the scrollable area dimensions needed to render a scrollbar. @@ -264,6 +349,125 @@ typedef void (*GhosttyTerminalBellFn)(GhosttyTerminal terminal, void* userdata); /** + * Clipboard destination for a clipboard write. + * + * Protocol-specific destination identifiers are normalized to these values + * before the clipboard write callback is invoked. + * + * @ingroup terminal + */ +typedef enum GHOSTTY_ENUM_TYPED { + /** The standard system clipboard. */ + GHOSTTY_CLIPBOARD_LOCATION_STANDARD = 0, + + /** The selection clipboard. */ + GHOSTTY_CLIPBOARD_LOCATION_SELECTION = 1, + + /** The primary selection clipboard. */ + GHOSTTY_CLIPBOARD_LOCATION_PRIMARY = 2, + GHOSTTY_CLIPBOARD_LOCATION_MAX_VALUE = GHOSTTY_ENUM_MAX_VALUE, +} GhosttyClipboardLocation; + +/** + * One MIME representation in a clipboard write. + * + * Both strings are borrowed and valid only for the duration of the callback. + * The data is binary-safe and has already been decoded from any protocol-level + * encoding. A zero-length data string is an explicit empty representation; it + * does not clear the clipboard. + * + * This struct has a frozen layout and will not gain fields in future versions. + * + * @ingroup terminal + */ +typedef struct { + /** MIME type of the representation. */ + GhosttyString mime; + + /** Decoded, binary-safe representation data. */ + GhosttyString data; +} GhosttyClipboardContent; + +/** + * A semantic, atomic clipboard write. + * + * 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 + * data strings are all borrowed and valid only for the callback duration. + * + * All entries in `contents` are representations of the same logical value + * and must be committed atomically. A `contents_len` of zero requests that + * the destination be cleared. This is distinct from a content entry whose data + * has zero length. + * + * @ingroup terminal + */ +typedef struct { + /** Size of this struct in bytes. */ + size_t size; + + /** Clipboard destination. */ + GhosttyClipboardLocation location; + + /** Borrowed array of MIME representations. */ + const GhosttyClipboardContent* contents; + + /** Number of entries in contents; zero means clear the destination. */ + size_t contents_len; +} GhosttyClipboardWrite; + +/** + * Result of a clipboard write callback. + * + * Protocols without write acknowledgements, including OSC 52 and iTerm2 + * OSC 1337 Copy, ignore this result. + * + * @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; + +/** + * Callback function type for clipboard_write. + * + * 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. + * + * @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 + * + * @ingroup terminal + */ +typedef GhosttyClipboardWriteResult (*GhosttyTerminalClipboardWriteFn)( + GhosttyTerminal terminal, + void* userdata, + const GhosttyClipboardWrite* write); + +/** * Callback function type for color scheme queries (CSI ? 996 n). * * Called when the terminal receives a color scheme device status report @@ -353,6 +557,31 @@ typedef void (*GhosttyTerminalTitleChangedFn)(GhosttyTerminal terminal, void* userdata); /** + * Callback function type for pwd_changed. + * + * Called when the terminal pwd (current working directory) changes via + * escape sequences: OSC 7 (file:// URI), OSC 9 (ConEmu CurrentDir), or + * OSC 1337 CurrentDir (iTerm2). Use ghostty_terminal_get() with + * GHOSTTY_TERMINAL_DATA_PWD inside the callback to read the new value. + * + * The terminal stores whatever bytes the shell emitted, without parsing. + * That means for OSC 7 the value is the raw URI (typically file://...); + * for OSC 9/OSC 1337 it is typically a bare path. The embedder is + * responsible for decoding any URI scheme or host if it cares about them. + * + * The callback also fires when the shell clears the pwd (e.g. an empty + * OSC 7). In that case GHOSTTY_TERMINAL_DATA_PWD returns a zero-length + * string. + * + * @param terminal The terminal handle + * @param userdata The userdata pointer set via GHOSTTY_TERMINAL_OPT_USERDATA + * + * @ingroup terminal + */ +typedef void (*GhosttyTerminalPwdChangedFn)(GhosttyTerminal terminal, + void* userdata); + +/** * Callback function type for write_pty. * * Called when the terminal needs to write data back to the pty, for @@ -608,6 +837,56 @@ typedef enum GHOSTTY_ENUM_TYPED { * Input type: GhosttySelection* */ GHOSTTY_TERMINAL_OPT_SELECTION = 21, + + /** + * Set the default cursor style used by DECSCUSR reset (CSI 0 q). + * + * A NULL value pointer resets to the built-in default block cursor. + * + * Input type: GhosttyTerminalCursorStyle* + */ + GHOSTTY_TERMINAL_OPT_DEFAULT_CURSOR_STYLE = 22, + + /** + * Set whether the default cursor should blink when reset by DECSCUSR + * (CSI 0 q). + * + * A NULL value pointer resets to the built-in default of not blinking. + * + * Input type: bool* + */ + GHOSTTY_TERMINAL_OPT_DEFAULT_CURSOR_BLINK = 23, + + /** + * Enable or disable Glyph Protocol APC handling. + * + * When disabled, Glyph Protocol APC sequences are ignored and no + * support/query/register/clear responses are emitted. Disabling also clears + * the terminal session's glyph glossary. A NULL value pointer is a no-op. + * + * Input type: bool* + */ + GHOSTTY_TERMINAL_OPT_GLYPH_PROTOCOL = 24, + + /** + * Callback invoked when the terminal pwd changes via escape + * sequences (OSC 7, OSC 9, or OSC 1337 CurrentDir). Set to NULL + * to ignore pwd change events. + * + * Input type: GhosttyTerminalPwdChangedFn + */ + GHOSTTY_TERMINAL_OPT_PWD_CHANGED = 25, + + /** + * 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. + * + * Input type: GhosttyTerminalClipboardWriteFn + */ + GHOSTTY_TERMINAL_OPT_CLIPBOARD_WRITE = 26, GHOSTTY_TERMINAL_OPT_MAX_VALUE = GHOSTTY_ENUM_MAX_VALUE, } GhosttyTerminalOption; @@ -682,9 +961,16 @@ typedef enum GHOSTTY_ENUM_TYPED { /** * Scrollbar state for the terminal viewport. * - * This may be expensive to calculate depending on where the viewport - * is (arbitrary pins are expensive). The caller should take care to only - * call this as needed and not too frequently. + * This is amortized O(1): the total is maintained incrementally as + * the terminal is modified and the viewport offset is cached. The + * first read after the viewport moves to an arbitrary position that + * isn't an absolute row (e.g. scrolling to a selection) may cost + * O(pages) to compute the offset, after which it is cached again. + * + * There is intentionally no change notification for scroll state. + * Callers building scrollbars should poll this once per frame or + * per write batch and diff the result to detect changes; this is + * what Ghostty's own renderer does. * * Output type: GhosttyTerminalScrollbar * */ @@ -1035,7 +1321,10 @@ GHOSTTY_API void ghostty_terminal_vt_write(GhosttyTerminal terminal, * Scrolls the terminal's viewport according to the given behavior. * When using GHOSTTY_SCROLL_VIEWPORT_DELTA, set the delta field in * the value union to specify the number of rows to scroll (negative - * for up, positive for down). For other behaviors, the value is ignored. + * for up, positive for down). When using GHOSTTY_SCROLL_VIEWPORT_ROW, + * set the row field to the absolute row offset from the top of the + * scrollable area (the same row space as the offset field of + * GhosttyTerminalScrollbar). For other behaviors, the value is ignored. * * @param terminal The terminal handle (may be NULL, in which case this is a no-op) * @param behavior The scroll behavior as a tagged union @@ -1046,6 +1335,60 @@ GHOSTTY_API void ghostty_terminal_scroll_viewport(GhosttyTerminal terminal, GhosttyTerminalScrollViewport behavior); /** + * Return the current compression activity token. + * + * The token is opaque and only equality comparisons are meaningful. An + * embedding application should cache it and restart its compression idle + * delay whenever the value changes. The value may wrap and changes in either + * direction have the same meaning. + * + * This function only observes terminal state. It does not perform or schedule + * compression. + * + * @param terminal The terminal handle (NULL returns GHOSTTY_INVALID_VALUE) + * @param[out] out_activity Receives the current activity token + * @return GHOSTTY_SUCCESS on success, or GHOSTTY_INVALID_VALUE if an argument + * is NULL + * + * @ingroup terminal + */ +GHOSTTY_API GhosttyResult ghostty_terminal_compression_activity( + GhosttyTerminal terminal, + uint64_t* out_activity); + +/** + * Compress eligible terminal scrollback. + * + * Incremental mode performs bounded work suitable for an idle callback. A + * pending result means the application should invoke another step while the + * terminal remains idle. A complete result means no continuation is needed + * until ghostty_terminal_compression_activity() changes. Full mode performs + * one synchronous scan and can stall on large scrollback buffers. + * + * Compression is opportunistic. Complete means the pass has finished, not + * that every page was compressed: pages may be unprofitable or encounter an + * allocation or reclamation failure. Compression changes only the terminal's + * storage representation and never its logical contents or scrollback limit. + * Accessing compressed history restores it transparently. + * + * This function is not thread-safe with other operations on the same + * terminal. The caller must serialize it with writes, rendering, searches, + * and other terminal access. + * + * @param terminal The terminal handle (NULL returns GHOSTTY_INVALID_VALUE) + * @param mode The amount of compression work to perform + * @param[out] out_result Receives the compression scheduling result + * @return GHOSTTY_SUCCESS on success, or GHOSTTY_INVALID_VALUE if an argument + * or mode is invalid + * + * @ingroup terminal + */ +GHOSTTY_API GhosttyResult ghostty_terminal_compress( + GhosttyTerminal terminal, + GhosttyTerminalCompressionMode mode, + GhosttyTerminalCompressionResult* out_result); + +/** * Get the current value of a terminal mode. * * Returns the value of the mode identified by the given mode. diff --git a/vendor/libghostty-vt/include/ghostty/vt/unicode.h b/vendor/libghostty-vt/include/ghostty/vt/unicode.h new file mode 100644 index 00000000..e4d62c81 --- /dev/null +++ b/vendor/libghostty-vt/include/ghostty/vt/unicode.h @@ -0,0 +1,154 @@ +/** + * @file unicode.h + * + * Unicode utilities - codepoint properties matching the terminal's + * text layout semantics. + */ + +#ifndef GHOSTTY_VT_UNICODE_H +#define GHOSTTY_VT_UNICODE_H + +/** @defgroup unicode Unicode Utilities + * + * Unicode codepoint properties matching the terminal's text layout + * semantics. + * + * ## Basic Usage + * + * Use ghostty_unicode_codepoint_width() to determine how many terminal + * grid cells a codepoint occupies, using the exact same width table the + * terminal itself uses when laying out printed text. Use + * ghostty_unicode_grapheme_width() to segment and measure full grapheme + * clusters with the same rules the terminal uses when mode 2027 is + * enabled. These functions are useful for predicting column layout of + * text that has not yet been written to the terminal, such as IME + * preedit (composition) overlays. + * + * @{ + */ + +#include <stddef.h> +#include <stdint.h> +#include <ghostty/vt/types.h> + +#ifdef __cplusplus +extern "C" { +#endif + +/** + * Returns the terminal display width of a Unicode codepoint in + * terminal grid cells: 0, 1, or 2. + * + * This is the same width table the terminal itself uses when laying + * out printed text, so callers can predict column layout (e.g. IME + * preedit overlays) that exactly matches what the terminal will do + * when the text is actually written to it. + * + * Semantics: + * - Returns 0 for zero-width codepoints: C0/C1 control characters, + * nonspacing and enclosing combining marks, default-ignorable + * codepoints (ZWJ, ZWNJ, variation selectors, etc.), and + * surrogate codepoints. + * - Returns 2 for wide codepoints: East Asian Wide/Fullwidth + * (including emoji with default emoji presentation) and regional + * indicators. Width is clamped to 2 (e.g. the three-em dash). + * - Returns 1 for everything else, including invalid codepoints + * beyond U+10FFFF (this function is total; it never fails). + * + * This operates on a single codepoint only and therefore cannot account + * for grapheme-cluster-level width rules (VS16 emoji presentation, + * combining sequences, etc.). For cluster-accurate widths, use + * ghostty_unicode_grapheme_width(). Summing per-codepoint widths is only + * correct when mode 2027 (grapheme clustering) is disabled. + * + * This function is pure, allocates nothing, and is thread-safe. + * + * @param cp The Unicode codepoint to measure + * @return Display width in cells: 0, 1, or 2 + */ +GHOSTTY_API uint8_t ghostty_unicode_codepoint_width(uint32_t cp); + +/** + * Measures the terminal display width of the first grapheme cluster in a + * sequence of Unicode codepoints. + * + * This uses the exact same grapheme segmentation and cluster width rules + * the terminal itself uses when printing text with grapheme clustering + * enabled (mode 2027), so callers can predict column layout (e.g. IME + * preedit overlays) that exactly matches what the terminal will do when + * the text is actually written to it. Unlike + * ghostty_unicode_codepoint_width(), this accounts for cluster-level + * rules: emoji variation selectors, ZWJ sequences, combining marks, and + * skin tone modifiers. + * + * Reads codepoints from cps until the terminal would consider the + * grapheme cluster complete, stores the cluster's total width in cells + * (0, 1, or 2) into width (which may be NULL if only segmentation is + * desired), and returns the number of codepoints consumed. Returns 0 if + * and only if len is 0; otherwise consumes at least one codepoint. Measure + * a whole string by calling in a loop: + * + * @code + * size_t total = 0; + * for (size_t i = 0; i < len;) { + * uint8_t width; + * i += ghostty_unicode_grapheme_width(cps + i, len - i, &width); + * total += width; + * } + * @endcode + * + * This is not a streaming API. The provided sequence must contain a + * complete first grapheme cluster, or the logical end of the string. If + * input arrives in chunks, keep buffering while this function consumes all + * available codepoints (return value == len) and the stream may still + * continue; a later codepoint could still extend the cluster and change + * its width. + * + * Width semantics, matching the terminal with mode 2027 enabled: + * - The cluster starts at the width of its first codepoint, as returned by + * ghostty_unicode_codepoint_width(). + * - VS16 (U+FE0F) forces the cluster wide (2) and VS15 (U+FE0E) forces it + * narrow (1), but only when the immediately preceding codepoint in the + * cluster is a valid emoji variation sequence base (per Unicode + * emoji-variation-sequences.txt). Invalid variation selectors are + * ignored entirely. + * - Any other continuation codepoint that contributes to grapheme width + * forces the cluster wide (2). Note this means cluster width is NOT the + * maximum of per-codepoint widths: some continuation marks have narrow + * codepoint width yet still widen the cluster. + * + * Mode dependence: this models mode 2027 (grapheme clustering) enabled, + * which is Ghostty's recommended configuration. When mode 2027 is + * disabled, clusters never combine and variation selectors never change + * width; predict layout in that case by summing + * ghostty_unicode_codepoint_width() over each codepoint instead. + * + * Edge cases: + * - Codepoints beyond U+10FFFF consume one codepoint, have width 1, and + * are always cluster boundaries. This function is total; it never fails. + * - Control characters (C0/C1, CR, LF) are never printed through the + * terminal's text path; passing them here returns an unspecified (but + * stable and bounded) result. + * - A cluster whose first codepoint is zero-width (e.g. a lone combining + * mark) is malformed at a cell start; the terminal may attach it to + * earlier screen content. This function reports the fold result for the + * sequence in isolation (typically 0). + * + * This function is pure, allocates nothing, and is thread-safe. + * + * @param cps Pointer to codepoints (may be NULL only when len is 0) + * @param len Number of codepoints available + * @param width Out: cluster display width in cells (0-2); may be NULL + * @return Number of codepoints in the first grapheme cluster + */ +GHOSTTY_API size_t ghostty_unicode_grapheme_width(const uint32_t *cps, + size_t len, + uint8_t *width); + +#ifdef __cplusplus +} +#endif + +/** @} */ + +#endif /* GHOSTTY_VT_UNICODE_H */ diff --git a/vendor/libghostty-vt/src/Surface.zig b/vendor/libghostty-vt/src/Surface.zig index 410f717b..2c4edc49 100644 --- a/vendor/libghostty-vt/src/Surface.zig +++ b/vendor/libghostty-vt/src/Surface.zig @@ -1217,7 +1217,7 @@ fn selectionScrollTick(self: *Surface) !void { // We modified our viewport and selection so we need to queue // a render. - try self.io.terminal.screens.active.select(selection); + try self.setSelection(selection); try self.queueRender(); } @@ -2326,23 +2326,46 @@ fn copySelectionToClipboards( }; } -/// Set the selection contents. +/// Set the active selection and notify the apprt on a genuine state +/// transition. All selection mutations route through here rather than +/// `screen.select` directly so the notification fires consistently. To +/// also copy per `copy_on_select`, use `setSelectionAndCopy`. /// /// This must be called with the renderer mutex held. fn setSelection(self: *Surface, sel_: ?terminal.Selection) !void { + // Compute the transition before `select` below, which untracks (frees) + // the previous selection's tracked pins; reading them after would be a + // use-after-free. const prev_ = self.io.terminal.screens.active.selection; + const changed = changed: { + const prev = prev_ orelse break :changed sel_ != null; + const sel = sel_ orelse break :changed true; + break :changed !sel.eql(prev); + }; + try self.io.terminal.screens.active.select(sel_); + if (changed) { + _ = self.rt_app.performAction( + .{ .surface = self }, + .selection_changed, + {}, + ) catch |err| { + log.warn("apprt failed selection_changed notification err={}", .{err}); + }; + } +} + +/// Set a selection and, per `copy_on_select`, copy it to the clipboard. +/// For committing selection gestures (mouse release, select-all binding). +/// +/// This must be called with the renderer mutex held. +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; - // Set our selection clipboard. If the selection is cleared we do not - // clear the clipboard. If the selection is set, we only set the clipboard - // again if it changed, since setting the clipboard can be an expensive - // operation. - const sel = sel_ orelse return; - if (prev_) |prev| if (sel.eql(prev)) return; - switch (self.config.copy_on_select) { .false => unreachable, // handled above with an early exit @@ -3840,7 +3863,7 @@ pub fn mouseButtonCallback( if (self.config.copy_on_select != .false) { const prev_ = self.io.terminal.screens.active.selection; if (prev_) |prev| { - try self.setSelection(terminal.Selection.init( + try self.setSelectionAndCopy(terminal.Selection.init( prev.start(), prev.end(), prev.rectangle, @@ -3985,16 +4008,16 @@ pub fn mouseButtonCallback( else => unreachable, } - // We set the selection directly rather than use `setSelection` because - // we want to avoid copying the selection to the selection clipboard. - // For left mouse clicks we only set the clipboard on release. + // Use `setSelection` (not `setSelectionAndCopy`) here to avoid + // touching the selection clipboard: for left mouse clicks we only + // copy on release. if (press_selection) |selection| { - try self.io.terminal.screens.active.select(selection); + try self.setSelection(selection); try self.queueRender(); } else if (self.mouse.selection_gesture.left_click_count == 1 and self.io.terminal.screens.active.selection != null) { - try self.io.terminal.screens.active.select(null); + try self.setSelection(null); try self.queueRender(); } } @@ -4060,13 +4083,13 @@ pub fn mouseButtonCallback( // If there is a link at this position, we want to // select the link. Otherwise, select the word. if (try self.linkAtPos(pos)) |link| { - try self.setSelection(link.selection); + try self.setSelectionAndCopy(link.selection); } else { const sel = screen.selectWord( pin, self.config.selection_word_chars, ) orelse break :sel; - try self.setSelection(sel); + try self.setSelectionAndCopy(sel); } try self.queueRender(); @@ -4195,20 +4218,24 @@ fn maybePromptClick(self: *Surface) !bool { // Guarded at the start of this function .none => unreachable, - .click_events => { + .click_events => |v| { // For the event, we always send a left-click press event. // This matches what Kitty sends. + const key: u8, const y: u32 = switch (v) { + .absolute => .{ 1, pos_vp.y +| 1 }, + .relative => .{ 2, pos_vp.y -| prompt_pin.y +| 1 }, + }; var data: termio.Message.WriteReq.Small.Array = undefined; const resp = try std.fmt.bufPrint( &data, "\x1B[<0;{d};{d}M", - .{ pos_vp.x + 1, pos_vp.y + 1 }, + .{ pos_vp.x + 1, y }, ); // Not that noisy since this only happens on prompt clicks. log.debug( - "sending click_events=1 event=ESC{s}", - .{resp[1..]}, + "sending click_events={} event=ESC{s}", + .{ key, resp[1..] }, ); // Ask our IO thread to write the data @@ -4411,7 +4438,7 @@ fn openUrl( /// if there is no hyperlink. fn osc8URI(self: *Surface, pin: terminal.Pin) ?[]const u8 { _ = self; - const page = &pin.node.data; + const page = pin.node.page(); const cell = pin.rowAndCell().cell; const link_id = page.lookupHyperlink(cell) orelse return null; const entry = page.hyperlink_set.get(page.memory, link_id); @@ -4464,7 +4491,7 @@ pub fn mousePressureCallback( ); } - try self.io.terminal.screens.active.select(sel orelse break :select); + try self.setSelection(sel orelse break :select); try self.queueRender(); } } @@ -4669,7 +4696,7 @@ pub fn cursorPosCallback( } // Update our selection based on the gesture state - try self.io.terminal.screens.active.select(drag_selection); + try self.setSelection(drag_selection); } } @@ -5420,7 +5447,7 @@ pub fn performBindingAction(self: *Surface, action: input.Binding.Action) !bool const sel = self.io.terminal.screens.active.selectAll(); if (sel) |s| { - try self.setSelection(s); + try self.setSelectionAndCopy(s); try self.queueRender(); } }, diff --git a/vendor/libghostty-vt/src/apprt/action.zig b/vendor/libghostty-vt/src/apprt/action.zig index f6865af8..4728d73c 100644 --- a/vendor/libghostty-vt/src/apprt/action.zig +++ b/vendor/libghostty-vt/src/apprt/action.zig @@ -295,6 +295,10 @@ pub const Action = union(Key) { /// it needs to ring the bell. This is usually a sound or visual effect. ring_bell, + /// Called when the active selection changes. The apprt should read the + /// current selection itself; this carries no payload. + selection_changed, + /// Undo the last action. See the "undo" keybinding for more /// details on what can and cannot be undone. undo, @@ -396,6 +400,7 @@ pub const Action = union(Key) { config_change, close_window, ring_bell, + selection_changed, undo, redo, check_for_updates, diff --git a/vendor/libghostty-vt/src/apprt/gtk/class/application.zig b/vendor/libghostty-vt/src/apprt/gtk/class/application.zig index 107510b4..a627086e 100644 --- a/vendor/libghostty-vt/src/apprt/gtk/class/application.zig +++ b/vendor/libghostty-vt/src/apprt/gtk/class/application.zig @@ -743,6 +743,9 @@ pub const Application = extern struct { .ring_bell => Action.ringBell(target), + // GTK has no accessibility consumer for this yet. + .selection_changed => {}, + .scrollbar => Action.scrollbar(target, value), .set_title => Action.setTitle(target, value), diff --git a/vendor/libghostty-vt/src/apprt/gtk/class/global_shortcuts.zig b/vendor/libghostty-vt/src/apprt/gtk/class/global_shortcuts.zig index cf0f31a6..28987c25 100644 --- a/vendor/libghostty-vt/src/apprt/gtk/class/global_shortcuts.zig +++ b/vendor/libghostty-vt/src/apprt/gtk/class/global_shortcuts.zig @@ -108,34 +108,35 @@ pub const GlobalShortcuts = extern struct { fn close(self: *Self) void { const priv = self.private(); - const dbus = priv.dbus_connection orelse return; - if (priv.response_subscription != 0) { - dbus.signalUnsubscribe(priv.response_subscription); - priv.response_subscription = 0; - } + if (priv.dbus_connection) |dbus| { + if (priv.response_subscription != 0) { + dbus.signalUnsubscribe(priv.response_subscription); + priv.response_subscription = 0; + } - if (priv.activate_subscription != 0) { - dbus.signalUnsubscribe(priv.activate_subscription); - priv.activate_subscription = 0; - } + if (priv.activate_subscription != 0) { + dbus.signalUnsubscribe(priv.activate_subscription); + priv.activate_subscription = 0; + } - if (priv.handle) |handle| { - // Close existing session - dbus.call( - "org.freedesktop.portal.Desktop", - handle, - "org.freedesktop.portal.Session", - "Close", - null, - null, - .{}, - -1, - null, - null, - null, - ); - priv.handle = null; + if (priv.handle) |handle| { + // Close existing session + dbus.call( + "org.freedesktop.portal.Desktop", + handle, + "org.freedesktop.portal.Session", + "Close", + null, + null, + .{}, + -1, + null, + null, + null, + ); + priv.handle = null; + } } if (priv.arena) |*arena| { @@ -151,7 +152,8 @@ pub const GlobalShortcuts = extern struct { const priv = self.private(); - // We need configuration to proceed. + // We need a dbus connection and configuration to proceed. + if (priv.dbus_connection == null) return; const config = if (priv.config) |v| v.get() else return; // Setup our new arena that we'll use for memory allocations. diff --git a/vendor/libghostty-vt/src/apprt/gtk/class/split_tree.zig b/vendor/libghostty-vt/src/apprt/gtk/class/split_tree.zig index 5aef53d2..7a6b66de 100644 --- a/vendor/libghostty-vt/src/apprt/gtk/class/split_tree.zig +++ b/vendor/libghostty-vt/src/apprt/gtk/class/split_tree.zig @@ -193,6 +193,7 @@ pub const SplitTree = extern struct { .init("new-split", actionNewSplit, s_variant_type), .init("equalize", actionEqualize, null), .init("zoom", actionZoom, null), + .init("close-split", actionCloseSplit, null), }; _ = ext.actions.addAsGroup(Self, self, "split-tree", &actions); @@ -683,6 +684,15 @@ pub const SplitTree = extern struct { self.as(gobject.Object).notifyByPspec(properties.tree.impl.param_spec); } + pub fn actionCloseSplit( + _: *gio.SimpleAction, + _: ?*glib.Variant, + self: *Self, + ) callconv(.c) void { + const surface = self.getActiveSurface() orelse return; + surface.close(); + } + fn surfaceCloseRequest( surface: *Surface, self: *Self, diff --git a/vendor/libghostty-vt/src/apprt/gtk/class/surface.zig b/vendor/libghostty-vt/src/apprt/gtk/class/surface.zig index 629866dc..54825ce0 100644 --- a/vendor/libghostty-vt/src/apprt/gtk/class/surface.zig +++ b/vendor/libghostty-vt/src/apprt/gtk/class/surface.zig @@ -3030,37 +3030,56 @@ pub const Surface = extern struct { ) callconv(.c) c_int { const priv: *Private = self.private(); - switch (ec.getUnit()) { - .surface => {}, - .wheel => return @intFromBool(false), - else => return @intFromBool(false), - } + // Check if horizontal tab scrolling is enabled and this is a + // touchpad surface scroll. If not, forward to the terminal. + const tab_scroll_enabled = if (priv.config) |config| + config.get().@"gtk-horizontal-tab-scroll" + else + true; + + const is_surface_scroll = ec.getUnit() == .surface; + + if (tab_scroll_enabled and is_surface_scroll) { + priv.pending_horizontal_scroll += x; + + if (@abs(priv.pending_horizontal_scroll) < 120) { + if (priv.pending_horizontal_scroll_reset) |v| { + _ = glib.Source.remove(v); + priv.pending_horizontal_scroll_reset = null; + } + priv.pending_horizontal_scroll_reset = glib.timeoutAdd(500, ecMouseScrollHorizontalReset, self); + return @intFromBool(true); + } - priv.pending_horizontal_scroll += x; + _ = self.as(gtk.Widget).activateAction( + if (priv.pending_horizontal_scroll < 0.0) + "tab.next-page" + else + "tab.previous-page", + null, + ); - if (@abs(priv.pending_horizontal_scroll) < 120) { if (priv.pending_horizontal_scroll_reset) |v| { _ = glib.Source.remove(v); priv.pending_horizontal_scroll_reset = null; } - priv.pending_horizontal_scroll_reset = glib.timeoutAdd(500, ecMouseScrollHorizontalReset, self); - return @intFromBool(true); - } - _ = self.as(gtk.Widget).activateAction( - if (priv.pending_horizontal_scroll < 0.0) - "tab.next-page" - else - "tab.previous-page", - null, - ); + priv.pending_horizontal_scroll = 0.0; - if (priv.pending_horizontal_scroll_reset) |v| { - _ = glib.Source.remove(v); - priv.pending_horizontal_scroll_reset = null; + return @intFromBool(true); } - priv.pending_horizontal_scroll = 0.0; + // Forward horizontal scroll to the terminal (e.g. for neovim). + const surface = priv.core_surface orelse return @intFromBool(false); + const scaled = self.scaledCoordinates(x, 0); + surface.scrollCallback( + scaled.x * -1, + 0, + .{}, + ) catch |err| { + log.warn("error in scroll callback err={}", .{err}); + return @intFromBool(false); + }; return @intFromBool(true); } diff --git a/vendor/libghostty-vt/src/apprt/gtk/class/window.zig b/vendor/libghostty-vt/src/apprt/gtk/class/window.zig index 2a1a5435..c34f2cb4 100644 --- a/vendor/libghostty-vt/src/apprt/gtk/class/window.zig +++ b/vendor/libghostty-vt/src/apprt/gtk/class/window.zig @@ -1156,6 +1156,38 @@ pub const Window = extern struct { }; } + fn toplevelComputeSize( + _: *gdk.Toplevel, + size: *gdk.ToplevelSize, + self: *Self, + ) callconv(.c) void { + // The compositor/quick terminal own the size in these states. + if (self.isMaximized() or + self.isFullscreen() or + self.isQuickTerminal()) return; + + // If there's no GdkSurface yet these dimensions will be zero size which + // will make the window start out as small as possible. These checks ensure + // we don't start with a 0, 0 window size. + const gdk_surface = self.as(gtk.Native).getSurface() orelse return; + const w = gdk_surface.getWidth(); + const h = gdk_surface.getHeight(); + if (w <= 0 or h <= 0) return; + + // GTK clamps the requested size to the compositor-reported bounds, which + // go stale when the window moves to a larger monitor. Only re-assert the + // current size when it exceeds those bounds; otherwise let GTK's default + // sizing win so a freshly-mapped window isn't forced to a tiny size. + var bounds_w: c_int = undefined; + var bounds_h: c_int = undefined; + size.getBounds(&bounds_w, &bounds_h); + + if (bounds_w <= 0 or bounds_h <= 0) return; + if (w <= bounds_w and h <= bounds_h) return; + + size.setSize(w, h); + } + fn propFullscreened( _: *adw.ApplicationWindow, _: *gobject.ParamSpec, @@ -1330,6 +1362,16 @@ pub const Window = extern struct { self, .{ .detail = "height" }, ); + // Connect after GTK's compute-size handler so our size wins. + if (gobject.ext.cast(gdk.Toplevel, gdk_surface)) |toplevel| { + _ = gdk.Toplevel.signals.compute_size.connect( + toplevel, + *Self, + toplevelComputeSize, + self, + .{ .after = true }, + ); + } } // When we are realized we always setup our appearance since this diff --git a/vendor/libghostty-vt/src/apprt/gtk/ui/1.2/surface.blp b/vendor/libghostty-vt/src/apprt/gtk/ui/1.2/surface.blp index 2f01c48c..2ae0a347 100644 --- a/vendor/libghostty-vt/src/apprt/gtk/ui/1.2/surface.blp +++ b/vendor/libghostty-vt/src/apprt/gtk/ui/1.2/surface.blp @@ -318,6 +318,11 @@ menu context_menu_model { action: "split-tree.new-split"; target: "right"; } + + item { + label: _("Close Split"); + action: "split-tree.close-split"; + } } submenu { diff --git a/vendor/libghostty-vt/src/benchmark/ApcParser.zig b/vendor/libghostty-vt/src/benchmark/ApcParser.zig new file mode 100644 index 00000000..73b7c57a --- /dev/null +++ b/vendor/libghostty-vt/src/benchmark/ApcParser.zig @@ -0,0 +1,143 @@ +//! This benchmark tests the throughput of APC sequence parsing +//! through the terminal stream: VT state machine dispatch, APC +//! protocol identification, and the protocol command parsers +//! (e.g. Kitty graphics). Completed commands are parsed and then +//! discarded; command execution (image decoding, storage) is not +//! included so this isolates the parsing path. +const ApcParser = @This(); + +const std = @import("std"); +const assert = std.debug.assert; +const Allocator = std.mem.Allocator; +const terminalpkg = @import("../terminal/main.zig"); +const Benchmark = @import("Benchmark.zig"); +const options = @import("options.zig"); + +const log = std.log.scoped(.@"apc-parser-bench"); + +opts: Options, +stream: Stream, + +/// The file, opened in the setup function. +data_f: ?std.fs.File = null, + +pub const Options = struct { + /// The data to read as a filepath. If this is "-" then + /// we will read stdin. If this is unset, then we will + /// do nothing (benchmark is a noop). It'd be more unixy to + /// use stdin by default but I find that a hanging CLI command + /// with no interaction is a bit annoying. + data: ?[]const u8 = null, +}; + +const Stream = terminalpkg.Stream(Handler); + +/// A stream handler that only processes APC actions, parsing and +/// immediately discarding completed commands. +const Handler = struct { + alloc: Allocator, + apc: terminalpkg.apc.Handler = .{}, + + pub fn deinit(self: *Handler) void { + self.apc.deinit(); + } + + pub fn vt( + self: *Handler, + comptime action: Stream.Action.Tag, + value: Stream.Action.Value(action), + ) void { + switch (action) { + .apc_start => self.apc.start(), + .apc_put => self.apc.feed(self.alloc, value), + .apc_put_slice => self.apc.feedSlice(self.alloc, value.bytes), + .apc_end => if (self.apc.end()) |cmd| { + var c = cmd; + std.mem.doNotOptimizeAway(&c); + c.deinit(self.alloc); + }, + else => {}, + } + } +}; + +/// Create a new APC parser benchmark for the given arguments. +pub fn create( + alloc: Allocator, + opts: Options, +) !*ApcParser { + const ptr = try alloc.create(ApcParser); + errdefer alloc.destroy(ptr); + ptr.* = .{ + .opts = opts, + .stream = .init(.{ .alloc = alloc }), + }; + return ptr; +} + +pub fn destroy(self: *ApcParser, alloc: Allocator) void { + self.stream.deinit(); + alloc.destroy(self); +} + +pub fn benchmark(self: *ApcParser) Benchmark { + return .init(self, .{ + .stepFn = step, + .setupFn = setup, + .teardownFn = teardown, + }); +} + +fn setup(ptr: *anyopaque) Benchmark.Error!void { + const self: *ApcParser = @ptrCast(@alignCast(ptr)); + + // Open our data file to prepare for reading. We can do more + // validation here eventually. + assert(self.data_f == null); + self.data_f = options.dataFile(self.opts.data) catch |err| { + log.warn("error opening data file err={}", .{err}); + return error.BenchmarkFailed; + }; +} + +fn teardown(ptr: *anyopaque) void { + const self: *ApcParser = @ptrCast(@alignCast(ptr)); + if (self.data_f) |f| { + f.close(); + self.data_f = null; + } +} + +fn step(ptr: *anyopaque) Benchmark.Error!void { + const self: *ApcParser = @ptrCast(@alignCast(ptr)); + + const f = self.data_f orelse return; + + var read_buf: [64 * 1024]u8 align(std.atomic.cache_line) = undefined; + var f_reader = f.reader(&read_buf); + const r = &f_reader.interface; + + // This buffer size matches the read buffer size used by the + // real IO thread (see termio Exec.zig buffer_capacity) so that + // the benchmark exercises the stream with realistic chunk sizes. + var buf: [64 * 1024]u8 = undefined; + while (true) { + const n = r.readSliceShort(&buf) catch { + log.warn("error reading data file err={?}", .{f_reader.err}); + return error.BenchmarkFailed; + }; + if (n == 0) break; // EOF reached + self.stream.nextSlice(buf[0..n]); + } +} + +test ApcParser { + const testing = std.testing; + const alloc = testing.allocator; + + const impl: *ApcParser = try .create(alloc, .{}); + defer impl.destroy(alloc); + + const bench = impl.benchmark(); + _ = try bench.run(.once); +} diff --git a/vendor/libghostty-vt/src/benchmark/HyperlinkMap.zig b/vendor/libghostty-vt/src/benchmark/HyperlinkMap.zig new file mode 100644 index 00000000..166c3d4f --- /dev/null +++ b/vendor/libghostty-vt/src/benchmark/HyperlinkMap.zig @@ -0,0 +1,155 @@ +//! Benchmark hyperlink cell-map lookups and remove/insert churn. +//! +//! Hyperlink cells are stored in a fixed-capacity, open-addressed hash map. +//! The `churn` mode models terminal output that repeatedly replaces cells in +//! a page whose hyperlink map is already close to full. This is particularly +//! useful for catching probe-length cliffs at high load factors. +const HyperlinkMap = @This(); + +const std = @import("std"); +const Allocator = std.mem.Allocator; +const terminal = @import("../terminal/main.zig"); +const hyperlink = @import("../terminal/hyperlink.zig"); +const Benchmark = @import("Benchmark.zig"); + +const log = std.log.scoped(.@"hyperlink-map-bench"); + +opts: Options, +page: terminal.Page, +link_id: hyperlink.Id, +entry_count: usize, + +pub const Options = struct { + /// Requested hyperlink working-set size. Must be a power of two and at + /// least 16. The map may reserve additional probe headroom. + entries: u16 = 4096, + + /// Percentage of the map populated before the timed operation. + /// Values above 100 are treated as 100. + @"load-percent": u8 = 100, + + /// Number of complete passes over the populated cells per step. + loops: u16 = 1, + + /// Operation to perform in the timed region. + mode: Mode = .churn, +}; + +pub const Mode = enum { + /// Look up every populated hyperlink cell. + lookup, + + /// Remove and reinsert every populated hyperlink cell. + churn, +}; + +pub fn create(alloc: Allocator, opts: Options) !*HyperlinkMap { + if (opts.entries < 16 or !std.math.isPowerOfTwo(opts.entries)) { + log.err("entries must be a power of two greater than or equal to 16", .{}); + return error.InvalidEntries; + } + + const ptr = try alloc.create(HyperlinkMap); + errdefer alloc.destroy(ptr); + + // The page requests one map slot per `hyperlink_cell_multiplier` set + // entries. Keep this relationship explicit so `entries` is the working + // set size under test regardless of the map's reserved probe headroom. + const set_entries = opts.entries / 16; + var page = try terminal.Page.init(.{ + .cols = opts.entries, + .rows = 1, + .hyperlink_bytes = @intCast( + @as(usize, set_entries) * @sizeOf(hyperlink.Set.Item), + ), + }); + errdefer page.deinit(); + + if (page.hyperlinkCapacity() < opts.entries) { + log.err("insufficient map capacity expected_at_least={} actual={}", .{ + opts.entries, + page.hyperlinkCapacity(), + }); + return error.UnexpectedCapacity; + } + + const link_id = try page.insertHyperlink(.{ + .id = .{ .implicit = 1 }, + .uri = "https://example.com/benchmark", + }); + + const load = @min(opts.@"load-percent", 100); + const entry_count = @max( + 1, + @divFloor(@as(usize, opts.entries) * load, 100), + ); + for (0..entry_count) |x| { + const rac = page.getRowAndCell(x, 0); + page.hyperlink_set.use(page.memory, link_id); + try page.setHyperlink(rac.row, rac.cell, link_id); + } + + ptr.* = .{ + .opts = opts, + .page = page, + .link_id = link_id, + .entry_count = entry_count, + }; + return ptr; +} + +pub fn destroy(self: *HyperlinkMap, alloc: Allocator) void { + self.page.deinit(); + alloc.destroy(self); +} + +pub fn benchmark(self: *HyperlinkMap) Benchmark { + return .init(self, .{ + .stepFn = switch (self.opts.mode) { + .lookup => stepLookup, + .churn => stepChurn, + }, + }); +} + +fn stepLookup(ptr: *anyopaque) Benchmark.Error!void { + const self: *HyperlinkMap = @ptrCast(@alignCast(ptr)); + + for (0..self.opts.loops) |_| { + for (0..self.entry_count) |x| { + const cell = self.page.getRowAndCell(x, 0).cell; + const id = self.page.lookupHyperlink(cell) orelse + return error.BenchmarkFailed; + std.mem.doNotOptimizeAway(id); + } + } +} + +fn stepChurn(ptr: *anyopaque) Benchmark.Error!void { + const self: *HyperlinkMap = @ptrCast(@alignCast(ptr)); + + for (0..self.opts.loops) |_| { + for (0..self.entry_count) |x| { + const rac = self.page.getRowAndCell(x, 0); + self.page.clearHyperlink(rac.cell); + self.page.hyperlink_set.use(self.page.memory, self.link_id); + self.page.setHyperlink(rac.row, rac.cell, self.link_id) catch + return error.BenchmarkFailed; + } + } +} + +test HyperlinkMap { + const alloc = std.testing.allocator; + + inline for (.{ Mode.lookup, Mode.churn }) |mode| { + const impl = try HyperlinkMap.create(alloc, .{ + .entries = 64, + .mode = mode, + }); + defer impl.destroy(alloc); + + const bench = impl.benchmark(); + _ = try bench.run(.once); + } +} diff --git a/vendor/libghostty-vt/src/benchmark/PageCompression.zig b/vendor/libghostty-vt/src/benchmark/PageCompression.zig new file mode 100644 index 00000000..21153659 --- /dev/null +++ b/vendor/libghostty-vt/src/benchmark/PageCompression.zig @@ -0,0 +1,519 @@ +//! Benchmarks raw LZ4 compression and decompression on page-sized byte +//! buffers. +//! +//! This benchmark is intentionally independent of terminal page ownership and +//! lifecycle. It treats its input as opaque bytes and calls only the standalone +//! LZ4 block codec. In particular, it does not compress pages owned by a live +//! terminal or measure the scheduling and state transitions used by automatic +//! scrollback compression. +//! +//! ## Input +//! +//! `--data` names a pre-generated raw byte corpus. The corpus is divided into +//! `--page-size` byte chunks, with a final short chunk retained when the file +//! size is not an exact multiple. The default page size is 400 KiB, matching a +//! standard terminal page in ReleaseFast builds on the current target. +//! +//! A raw dump of actual page backing memory is the most representative input: +//! it includes cells, rows, styles, graphemes, hyperlinks, allocator metadata, +//! and unused capacity exactly as the codec would see them. Keep such corpora +//! outside the repository and reuse the same file when comparing branches. +//! Arbitrary files are accepted too, but their compression ratios should not be +//! interpreted as terminal scrollback ratios. +//! +//! ## Modes +//! +//! * `noop` walks the input chunks without invoking the codec. This measures the +//! benchmark loop's minimum overhead. +//! * `compress` compresses every input chunk into a reusable output buffer. +//! * `store` additionally copies each useful result into an exact-sized +//! allocation. It retains the most recent `--retained-pages` blocks so that +//! allocation, eviction, and allocator reuse resemble bounded scrollback. +//! * `decompress` prepares compressed blocks during setup, then decompresses +//! every block into a reusable output buffer. +//! * `report` compresses each chunk once and prints raw and encoded sizes. It is +//! for inspecting ratios, not timing comparisons. +//! +//! Dataset loading, output allocation, and preparation of blocks for +//! decompression happen in `setup` and are outside `Benchmark`'s timed region. +//! The `compress` and `decompress` steps perform no allocation. `store` +//! deliberately performs encoded-block allocations and frees inside the timed +//! step; only its reusable workspace and ring metadata are prepared in setup. +//! `hyperfine` still measures full process lifetime, so use `--loops` to +//! amortize setup and teardown when comparing small corpora. +//! +//! ## Examples +//! +//! Build benchmarks in ReleaseFast mode: +//! +//! zig build -Demit-bench -Doptimize=ReleaseFast -Demit-macos-app=false +//! +//! Inspect the compression ratio of a page corpus: +//! +//! ghostty-bench +page-compression --mode=report --data=/tmp/pages.raw +//! +//! Measure the cost of exact encoded storage relative to the codec alone: +//! +//! hyperfine --warmup 3 \ +//! 'ghostty-bench +page-compression --mode=compress --loops=100 --data=/tmp/pages.raw' \ +//! 'ghostty-bench +page-compression --mode=store --loops=100 --data=/tmp/pages.raw' +const PageCompression = @This(); + +const std = @import("std"); +const assert = std.debug.assert; +const Allocator = std.mem.Allocator; +const Benchmark = @import("Benchmark.zig"); +const options = @import("options.zig"); +const compress = @import("../terminal/compress.zig"); +const CompressedPage = compress.Page; +const lz4 = compress.lz4; + +const log = std.log.scoped(.@"page-compression-bench"); + +/// Prevent a malformed or accidentally enormous corpus from consuming +/// unbounded memory during benchmark setup. +const max_data_size = 64 * 1024 * 1024; + +alloc: Allocator, +opts: Options, + +/// Complete contents of the input corpus. Individual pages are slices into +/// this allocation, so it remains alive until teardown. +data: []u8 = &.{}, + +/// Compressed blocks prepared during setup for `decompress` mode. +encoded: std.ArrayList(Encoded) = .empty, + +/// Exact encoded allocations retained by `store` mode. Null entries have not +/// been reached yet; once full, `stored_next` identifies the oldest block. +stored: []?[]u8 = &.{}, +stored_next: usize = 0, + +/// Reused by compression and report modes. Its length is the compression +/// bound of the largest input chunk. +compression_output: []u8 = &.{}, + +/// Reused by decompression mode. Its length is at least one input chunk. +decompression_output: []u8 = &.{}, + +/// Fixed 16 KiB scratch table required by the compressor. +table: lz4.HashTable = undefined, + +pub const Options = struct { + /// Set by the shared CLI parser for string option ownership. + _arena: ?std.heap.ArenaAllocator = null, + + /// Select the operation performed inside the timed benchmark step. + mode: Mode = .compress, + + /// Repeat the complete corpus this many times per benchmark step. Increase + /// this when the corpus is too small for stable `hyperfine` measurements. + loops: u32 = 1, + + /// Number of bytes treated as one independent LZ4 block. Real page dumps + /// should use the exact backing-memory size of the pages being measured. + @"page-size": usize = 400 * 1024, + + /// Number of exact encoded allocations retained by `store` mode before + /// the oldest allocation is freed and replaced. The default approximates + /// a 10 MB scrollback composed of standard 400 KiB terminal pages. + @"retained-pages": usize = 25, + + /// Pre-generated input corpus. `-` reads stdin, although a regular file is + /// recommended so identical bytes can be reused across benchmark runs. + /// When unset, all modes are no-ops. + data: ?[]const u8 = null, + + pub fn deinit(self: *Options) void { + if (self._arena) |arena| arena.deinit(); + self.* = undefined; + } +}; + +pub const Mode = enum { + /// Walk page boundaries and establish the benchmark loop overhead. + noop, + + /// Compress each raw page into a reusable compression-bound buffer. + compress, + + /// Compress and retain exact-sized encoded blocks in a bounded ring. + store, + + /// Decompress blocks prepared before the timed region. + decompress, + + /// Print per-page and aggregate encoded sizes. Not a timing benchmark. + report, +}; + +const Encoded = struct { + /// Exact compressed block bytes. + bytes: []u8, + + /// Exact output length expected by the raw block decoder. + raw_len: usize, +}; + +/// Allocate benchmark state. Input data is intentionally loaded later by +/// `setup` so construction is cheap and follows the other benchmarks. +pub fn create( + alloc: Allocator, + opts: Options, +) !*PageCompression { + const ptr = try alloc.create(PageCompression); + ptr.* = .{ + .alloc = alloc, + .opts = opts, + }; + return ptr; +} + +/// Release allocations retained across benchmark steps. +pub fn destroy(self: *PageCompression, alloc: Allocator) void { + self.clearPreparedData(); + alloc.destroy(self); +} + +/// Select one operation for the benchmark harness to time. +pub fn benchmark(self: *PageCompression) Benchmark { + return .init(self, .{ + .stepFn = switch (self.opts.mode) { + .noop => stepNoop, + .compress => stepCompress, + .store => stepStore, + .decompress => stepDecompress, + .report => stepReport, + }, + .setupFn = setup, + .teardownFn = teardown, + }); +} + +/// Load and partition the input corpus. For decompression mode this also +/// creates the encoded blocks, keeping compression outside the timed region. +fn setup(ptr: *anyopaque) Benchmark.Error!void { + const self: *PageCompression = @ptrCast(@alignCast(ptr)); + assert(self.data.len == 0); + assert(self.encoded.items.len == 0); + + self.setupData() catch |err| { + log.warn("failed to prepare page compression benchmark err={}", .{err}); + return error.BenchmarkFailed; + }; +} + +fn setupData(self: *PageCompression) !void { + if (self.opts.loops == 0) return error.InvalidLoops; + if (self.opts.@"page-size" == 0) return error.InvalidPageSize; + if (self.opts.mode == .store and self.opts.@"retained-pages" == 0) + return error.InvalidRetainedPages; + + const data_file = try options.dataFile(self.opts.data) orelse return; + defer data_file.close(); + + self.data = try data_file.readToEndAlloc(self.alloc, max_data_size); + errdefer { + self.alloc.free(self.data); + self.data = &.{}; + } + if (self.data.len == 0) return; + if (self.opts.mode == .noop) return; + + const largest_page = @min(self.opts.@"page-size", self.data.len); + self.compression_output = try self.alloc.alloc( + u8, + try lz4.compressBound(largest_page), + ); + errdefer { + self.alloc.free(self.compression_output); + self.compression_output = &.{}; + } + + if (self.opts.mode == .store) try self.prepareStored(); + if (self.opts.mode == .decompress) try self.prepareEncoded(); +} + +/// Allocate only the ring metadata for `store` mode. The encoded blocks which +/// populate it are intentionally allocated by the timed benchmark step. +fn prepareStored(self: *PageCompression) !void { + self.stored = try self.alloc.alloc(?[]u8, self.opts.@"retained-pages"); + @memset(self.stored, null); + self.stored_next = 0; +} + +/// Precompress every input page and verify one decode before benchmarking. +/// This catches corpus or codec problems before the timer starts. +fn prepareEncoded(self: *PageCompression) !void { + self.decompression_output = try self.alloc.alloc( + u8, + @min(self.opts.@"page-size", self.data.len), + ); + errdefer { + self.alloc.free(self.decompression_output); + self.decompression_output = &.{}; + } + + var it = self.pages(); + while (it.next()) |page| { + const encoded_len = try lz4.compress( + page, + self.compression_output, + &self.table, + ); + const encoded = try self.alloc.dupe( + u8, + self.compression_output[0..encoded_len], + ); + self.encoded.append(self.alloc, .{ + .bytes = encoded, + .raw_len = page.len, + }) catch |err| { + self.alloc.free(encoded); + return err; + }; + } + + var page_it = self.pages(); + for (self.encoded.items) |block| { + const page = page_it.next().?; + const output = self.decompression_output[0..block.raw_len]; + _ = try lz4.decompress(block.bytes, output); + if (!std.mem.eql(u8, page, output)) return error.RoundTripMismatch; + } +} + +/// Release everything created by setup. This is shared by teardown and +/// destroy so errors and direct unit-test use remain leak-free. +fn clearPreparedData(self: *PageCompression) void { + for (self.encoded.items) |block| self.alloc.free(block.bytes); + self.encoded.deinit(self.alloc); + self.encoded = .empty; + + for (self.stored) |block| if (block) |bytes| self.alloc.free(bytes); + if (self.stored.len > 0) self.alloc.free(self.stored); + self.stored = &.{}; + self.stored_next = 0; + + if (self.compression_output.len > 0) + self.alloc.free(self.compression_output); + self.compression_output = &.{}; + + if (self.decompression_output.len > 0) + self.alloc.free(self.decompression_output); + self.decompression_output = &.{}; + + if (self.data.len > 0) self.alloc.free(self.data); + self.data = &.{}; +} + +fn teardown(ptr: *anyopaque) void { + const self: *PageCompression = @ptrCast(@alignCast(ptr)); + self.clearPreparedData(); +} + +/// Baseline mode: traverse exactly the same page boundaries as compression +/// without invoking the codec. +fn stepNoop(ptr: *anyopaque) Benchmark.Error!void { + const self: *PageCompression = @ptrCast(@alignCast(ptr)); + for (0..self.opts.loops) |_| { + var it = self.pages(); + while (it.next()) |page| std.mem.doNotOptimizeAway(page); + } +} + +/// Compress all pages into one reusable output buffer. Only the returned +/// encoded length is consumed because retaining output pages would measure +/// allocation and ownership rather than codec throughput. +fn stepCompress(ptr: *anyopaque) Benchmark.Error!void { + const self: *PageCompression = @ptrCast(@alignCast(ptr)); + for (0..self.opts.loops) |_| { + var it = self.pages(); + while (it.next()) |page| { + const encoded_len = lz4.compress( + page, + self.compression_output, + &self.table, + ) catch |err| { + log.warn("page compression failed err={}", .{err}); + return error.BenchmarkFailed; + }; + std.mem.doNotOptimizeAway(encoded_len); + } + } +} + +/// Compress pages and retain their exact encoded allocations in a bounded +/// ring. This models the part of `compress.Page.init` which differs from the +/// allocation-free codec benchmark: copying the result, allocating its +/// persistent storage, and freeing an old block when scrollback is full. +fn stepStore(ptr: *anyopaque) Benchmark.Error!void { + const self: *PageCompression = @ptrCast(@alignCast(ptr)); + if (self.data.len == 0) return; + assert(self.stored.len > 0); + + for (0..self.opts.loops) |_| { + var it = self.pages(); + while (it.next()) |page| { + const output_len = CompressedPage.requiredScratch(page.len) catch |err| { + log.warn("failed to size stored page output err={}", .{err}); + return error.BenchmarkFailed; + }; + if (output_len == 0) continue; + + const encoded_len = lz4.compress( + page, + self.compression_output[0..output_len], + &self.table, + ) catch |err| switch (err) { + // This is the same profitability outcome as + // compress.Page.init: a block which exceeds the useful output + // limit remains resident and consumes no encoded allocation. + error.OutputTooSmall => continue, + error.InputTooLarge => { + log.warn("stored page compression failed err={}", .{err}); + return error.BenchmarkFailed; + }, + }; + + const encoded = self.alloc.dupe( + u8, + self.compression_output[0..encoded_len], + ) catch |err| { + log.warn("stored page allocation failed err={}", .{err}); + return error.BenchmarkFailed; + }; + + const slot = &self.stored[self.stored_next]; + if (slot.*) |previous| self.alloc.free(previous); + slot.* = encoded; + self.stored_next = (self.stored_next + 1) % self.stored.len; + std.mem.doNotOptimizeAway(encoded); + } + } +} + +/// Decompress blocks prepared by setup. The output allocation is reused so +/// this measures only decoding and the required memory writes. +fn stepDecompress(ptr: *anyopaque) Benchmark.Error!void { + const self: *PageCompression = @ptrCast(@alignCast(ptr)); + for (0..self.opts.loops) |_| { + for (self.encoded.items) |block| { + const output = self.decompression_output[0..block.raw_len]; + _ = lz4.decompress(block.bytes, output) catch |err| { + log.warn("page decompression failed err={}", .{err}); + return error.BenchmarkFailed; + }; + std.mem.doNotOptimizeAway(output); + } + } +} + +/// Print size information for evaluating compression ratio. This shares the +/// input and codec paths with compression mode but deliberately makes no +/// timing claims. +fn stepReport(ptr: *anyopaque) Benchmark.Error!void { + const self: *PageCompression = @ptrCast(@alignCast(ptr)); + if (self.data.len == 0) return; + + var page_index: usize = 0; + var raw_total: usize = 0; + var encoded_total: usize = 0; + var it = self.pages(); + while (it.next()) |page| : (page_index += 1) { + const encoded_len = lz4.compress( + page, + self.compression_output, + &self.table, + ) catch |err| { + log.warn("page compression report failed err={}", .{err}); + return error.BenchmarkFailed; + }; + raw_total += page.len; + encoded_total += encoded_len; + std.debug.print( + "page-compression page={d} raw={d} encoded={d} ratio={d:.2}%\n", + .{ page_index, page.len, encoded_len, percentage(encoded_len, page.len) }, + ); + } + + std.debug.print( + "page-compression total pages={d} raw={d} encoded={d} ratio={d:.2}% " ++ + "workspace={d} output_bound={d}\n", + .{ + page_index, + raw_total, + encoded_total, + percentage(encoded_total, raw_total), + @sizeOf(lz4.HashTable), + self.compression_output.len, + }, + ); +} + +/// Iterate fixed-size page chunks without allocating an index table. +fn pages(self: *const PageCompression) PageIterator { + return .{ + .data = self.data, + .page_size = self.opts.@"page-size", + }; +} + +const PageIterator = struct { + data: []const u8, + page_size: usize, + offset: usize = 0, + + fn next(self: *PageIterator) ?[]const u8 { + if (self.offset >= self.data.len) return null; + const len = @min(self.page_size, self.data.len - self.offset); + const end = self.offset + len; + defer self.offset = end; + return self.data[self.offset..end]; + } +}; + +fn percentage(part: usize, whole: usize) f64 { + if (whole == 0) return 0; + return @as(f64, @floatFromInt(part)) * 100 / + @as(f64, @floatFromInt(whole)); +} + +test PageCompression { + const testing = std.testing; + const impl: *PageCompression = try .create(testing.allocator, .{}); + defer impl.destroy(testing.allocator); + + const bench = impl.benchmark(); + _ = try bench.run(.once); +} + +test "PageCompression store retains exact encoded allocations" { + const testing = std.testing; + const page_size = 1024; + const impl: *PageCompression = try .create(testing.allocator, .{ + .mode = .store, + .@"page-size" = page_size, + .@"retained-pages" = 2, + }); + defer impl.destroy(testing.allocator); + + impl.data = try testing.allocator.alloc(u8, 3 * page_size); + @memset(impl.data, 0); + impl.compression_output = try testing.allocator.alloc( + u8, + try lz4.compressBound(page_size), + ); + try impl.prepareStored(); + + try stepStore(impl); + try testing.expectEqual(@as(usize, 1), impl.stored_next); + for (impl.stored) |block| { + const encoded = block.?; + try testing.expect(encoded.len < page_size); + + var decoded: [page_size]u8 = undefined; + _ = try lz4.decompress(encoded, &decoded); + try testing.expect(std.mem.allEqual(u8, &decoded, 0)); + } +} diff --git a/vendor/libghostty-vt/src/benchmark/ScreenClone.zig b/vendor/libghostty-vt/src/benchmark/ScreenClone.zig index 108eaa0c..5176ce26 100644 --- a/vendor/libghostty-vt/src/benchmark/ScreenClone.zig +++ b/vendor/libghostty-vt/src/benchmark/ScreenClone.zig @@ -21,6 +21,10 @@ pub const Options = struct { /// The type of codepoint width calculation to use. mode: Mode = .clone, + /// Multiplier on the number of iterations each step runs. This is + /// useful to make a benchmark run long enough for profiling. + loops: u32 = 1, + /// The size of the terminal. This affects benchmarking when /// dealing with soft line wrapping and the memory impact /// of page sizes. @@ -48,6 +52,22 @@ pub const Mode = enum { /// RenderState rather than a screen clone. render, + + /// Like render, but only the portion of the render state update + /// that requires holding a terminal lock (beginUpdate). The + /// deferred work (endUpdate) is excluded since it happens outside + /// of any locks. + @"render-locked", + + /// RenderState update with no changes to the terminal. This is + /// the common case for a renderer that is redrawing frames (e.g. + /// cursor blink, mouse movement) without terminal changes. + @"render-clean", + + /// RenderState update where a single row is dirty. This models the + /// common case of a shell prompt or TUI updating a small portion + /// of the screen between frames. + @"render-partial", }; pub fn create( @@ -79,6 +99,9 @@ pub fn benchmark(self: *ScreenClone) Benchmark { .noop => stepNoop, .clone => stepClone, .render => stepRender, + .@"render-locked" => stepRenderLocked, + .@"render-clean" => stepRenderClean, + .@"render-partial" => stepRenderPartial, }, .setupFn = setup, .teardownFn = teardown, @@ -178,16 +201,102 @@ fn stepRender(ptr: *anyopaque) Benchmark.Error!void { // We loop because its so fast that a single benchmark run doesn't // properly capture our speeds. - for (0..1000) |_| { + for (0..50_000 * @as(u64, self.opts.loops)) |_| { // Forces a full rebuild because it thinks our screen changed state.screen = .alternate; state.update(alloc, &self.terminal) catch |err| { log.warn("error cloning screen err={}", .{err}); return error.BenchmarkFailed; }; - std.mem.doNotOptimizeAway(state); + std.mem.doNotOptimizeAway(&state); + + // Note: we purposely do not free memory because we don't want + // to benchmark that. We'll free when the benchmark exits. + } +} + +fn stepRenderLocked(ptr: *anyopaque) Benchmark.Error!void { + const self: *ScreenClone = @ptrCast(@alignCast(ptr)); + + // We do this once out of the loop because a significant slowdown + // on the first run is allocation. After that first run, even with + // a full rebuild, it is much faster. Let's ignore that first run + // slowdown. + const alloc = self.terminal.screens.active.alloc; + var state: terminalpkg.RenderState = .empty; + state.update(alloc, &self.terminal) catch |err| { + log.warn("error cloning screen err={}", .{err}); + return error.BenchmarkFailed; + }; + + // We loop because its so fast that a single benchmark run doesn't + // properly capture our speeds. + for (0..50_000 * @as(u64, self.opts.loops)) |_| { + // Forces a full rebuild because it thinks our screen changed + state.screen = .alternate; + state.beginUpdate(alloc, &self.terminal) catch |err| { + log.warn("error cloning screen err={}", .{err}); + return error.BenchmarkFailed; + }; + std.mem.doNotOptimizeAway(&state); // Note: we purposely do not free memory because we don't want // to benchmark that. We'll free when the benchmark exits. } } + +fn stepRenderClean(ptr: *anyopaque) Benchmark.Error!void { + const self: *ScreenClone = @ptrCast(@alignCast(ptr)); + + // Initial update so that subsequent updates are clean (nothing + // dirty, no rebuilds). + const alloc = self.terminal.screens.active.alloc; + var state: terminalpkg.RenderState = .empty; + state.update(alloc, &self.terminal) catch |err| { + log.warn("error cloning screen err={}", .{err}); + return error.BenchmarkFailed; + }; + + // We loop because its so fast that a single benchmark run doesn't + // properly capture our speeds. + for (0..3_000_000 * @as(u64, self.opts.loops)) |_| { + state.update(alloc, &self.terminal) catch |err| { + log.warn("error cloning screen err={}", .{err}); + return error.BenchmarkFailed; + }; + std.mem.doNotOptimizeAway(&state); + } +} + +fn stepRenderPartial(ptr: *anyopaque) Benchmark.Error!void { + const self: *ScreenClone = @ptrCast(@alignCast(ptr)); + + // Initial update so that subsequent updates are incremental. + const alloc = self.terminal.screens.active.alloc; + var state: terminalpkg.RenderState = .empty; + state.update(alloc, &self.terminal) catch |err| { + log.warn("error cloning screen err={}", .{err}); + return error.BenchmarkFailed; + }; + + // Grab a pin roughly in the middle of the active area that we + // dirty on every iteration to simulate a small screen update. + const pages = &self.terminal.screens.active.pages; + const pin = pages.pin(.{ .active = .{ + .x = 0, + .y = self.terminal.rows / 2, + } }).?; + + // We loop because its so fast that a single benchmark run doesn't + // properly capture our speeds. + for (0..2_000_000 * @as(u64, self.opts.loops)) |_| { + // Mark a single row dirty. `update` clears this so each + // iteration rebuilds exactly one row. + pin.markDirty(); + state.update(alloc, &self.terminal) catch |err| { + log.warn("error cloning screen err={}", .{err}); + return error.BenchmarkFailed; + }; + std.mem.doNotOptimizeAway(&state); + } +} diff --git a/vendor/libghostty-vt/src/benchmark/ScrollbackCompression.zig b/vendor/libghostty-vt/src/benchmark/ScrollbackCompression.zig new file mode 100644 index 00000000..40ec20f4 --- /dev/null +++ b/vendor/libghostty-vt/src/benchmark/ScrollbackCompression.zig @@ -0,0 +1,343 @@ +//! Benchmarks cold-history compression and restoration on pages owned by a +//! live terminal. +//! +//! Unlike `page-compression`, which measures the standalone codec against raw +//! byte chunks, this benchmark first parses a VT corpus into a real `Terminal`. +//! Its timed operations therefore include the PageList state transition, +//! exact-sized encoded allocation, retained-mapping reclamation, and transparent +//! restoration through `PageList.Node.page`. +//! +//! ## Input +//! +//! `--data` names a pre-generated VT byte stream. Parsing happens during setup +//! and is not part of the benchmark's timed region. Use the same saved corpus, +//! terminal dimensions, and scrollback limit when comparing revisions. In +//! particular, do not pipe a generator into this benchmark: generation and +//! pipe scheduling add noise which can overwhelm the operation being measured. +//! +//! The benchmark always operates on the primary screen's PageList. This keeps +//! the result focused on scrollback even if malformed or incomplete input +//! leaves the terminal in its alternate screen at end of file. `--max-scrollback` +//! is expressed in bytes and defaults to 10 MB. +//! +//! ## Modes +//! +//! * `noop` parses the corpus but performs no timed PageList operation. This is +//! the common process and setup baseline for the other modes. +//! * `compress` times one complete `compress` invocation. +//! * `incremental` reaches the same final representation through +//! `compress(.drain)`. It drains the candidate-bounded steps and final +//! no-work verification pass in the timed region, making cursor and repeated +//! traversal overhead directly comparable with `compress`. +//! * `restore` compresses cold history during setup, outside the timed region, +//! then visits every fully historical node through `Node.page`. That public +//! content-access boundary transparently restores compressed nodes. +//! * `report` performs one compression pass and prints compressed page count, +//! encoded ratio, and estimated resident-byte savings. It is intended for +//! inspecting a corpus rather than timing comparisons. +//! +//! A fully historical page is a node strictly before the node containing the +//! top of the active area. The boundary node is deliberately excluded because +//! it can contain both history and active rows. Pages intersecting the current +//! viewport are also excluded so visible contents remain resident. Normal +//! terminal execution compresses eligible pages incrementally after activity +//! becomes idle. The benchmark invokes PageList operations directly so its +//! timed regions exclude the production scheduler's idle delay and +//! renderer-thread coordination. +//! +//! ## Examples +//! +//! Build the benchmark in ReleaseFast mode: +//! +//! zig build -Demit-bench -Doptimize=ReleaseFast -Demit-macos-app=false +//! +//! Inspect the memory reduction for a saved VT corpus: +//! +//! ghostty-bench +scrollback-compression --mode=report \ +//! --data=/tmp/scrollback.vt --terminal-cols=120 --terminal-rows=80 +//! +//! Compare PageList compression and restoration cost. Setup still contributes +//! to full process time, so use a sufficiently large corpus and compare against +//! `noop` with identical arguments: +//! +//! hyperfine --warmup 3 \ +//! 'ghostty-bench +scrollback-compression --mode=noop --data=/tmp/scrollback.vt' \ +//! 'ghostty-bench +scrollback-compression --mode=compress --data=/tmp/scrollback.vt' \ +//! 'ghostty-bench +scrollback-compression --mode=incremental --data=/tmp/scrollback.vt' \ +//! 'ghostty-bench +scrollback-compression --mode=restore --data=/tmp/scrollback.vt' +const ScrollbackCompression = @This(); + +const std = @import("std"); +const Allocator = std.mem.Allocator; +const terminalpkg = @import("../terminal/main.zig"); +const Benchmark = @import("Benchmark.zig"); +const options = @import("options.zig"); +const PageList = terminalpkg.PageList; +const Terminal = terminalpkg.Terminal; + +const log = std.log.scoped(.@"scrollback-compression-bench"); + +opts: Options, +terminal: Terminal, + +pub const Options = struct { + /// Set by the shared CLI parser so the `data` string remains valid for the + /// lifetime of the benchmark implementation. + _arena: ?std.heap.ArenaAllocator = null, + + /// Select the PageList operation performed inside the timed benchmark. + mode: Mode = .compress, + + /// Dimensions used to construct the terminal which consumes the corpus. + /// These affect wrapping and therefore the number and contents of pages. + @"terminal-rows": u16 = 80, + @"terminal-cols": u16 = 120, + + /// Maximum primary-screen scrollback allocation in bytes. PageList rounds + /// this as required by its page allocation policy. + @"max-scrollback": usize = 10_000_000, + + /// Pre-generated VT corpus. `-` reads stdin, although a regular file is + /// strongly recommended so comparisons can reuse identical input bytes. + /// When unset, every mode operates on the initial empty terminal. + data: ?[]const u8 = null, + + pub fn deinit(self: *Options) void { + if (self._arena) |arena| arena.deinit(); + self.* = undefined; + } +}; + +pub const Mode = enum { + /// Establish process and setup overhead without a PageList operation. + noop, + + /// Compress every eligible fully historical page once. + compress, + + /// Compress every eligible page through bounded resumable steps. + incremental, + + /// Restore pages compressed outside the timed region. + restore, + + /// Compress once and print aggregate memory statistics. + report, +}; + +pub fn create( + alloc: Allocator, + opts: Options, +) !*ScrollbackCompression { + const ptr = try alloc.create(ScrollbackCompression); + errdefer alloc.destroy(ptr); + + ptr.* = .{ + .opts = opts, + .terminal = try .init(alloc, .{ + .rows = opts.@"terminal-rows", + .cols = opts.@"terminal-cols", + .max_scrollback = opts.@"max-scrollback", + }), + }; + return ptr; +} + +pub fn destroy(self: *ScrollbackCompression, alloc: Allocator) void { + self.terminal.deinit(alloc); + alloc.destroy(self); +} + +pub fn benchmark(self: *ScrollbackCompression) Benchmark { + return .init(self, .{ + .stepFn = switch (self.opts.mode) { + .noop => stepNoop, + .compress => stepCompress, + .incremental => stepIncremental, + .restore => stepRestore, + .report => stepReport, + }, + .setupFn = setup, + }); +} + +/// Reset the terminal and consume the complete VT corpus before timing starts. +/// Restore mode also prepares its compressed representation here so its timed +/// step contains decoding and mapping writes, but not encoding. +fn setup(ptr: *anyopaque) Benchmark.Error!void { + const self: *ScrollbackCompression = @ptrCast(@alignCast(ptr)); + self.terminal.fullReset(); + + self.loadCorpus() catch |err| { + log.warn("failed to prepare scrollback compression benchmark err={}", .{err}); + return error.BenchmarkFailed; + }; + + if (self.opts.mode == .restore) { + _ = self.pages().compress(.full); + } +} + +/// Feed the corpus in the same 64 KiB chunks used by the real IO thread and +/// terminal-stream benchmark. Parser and file IO costs remain in setup. +fn loadCorpus(self: *ScrollbackCompression) !void { + const data_file = try options.dataFile(self.opts.data) orelse return; + defer data_file.close(); + + var stream = self.terminal.vtStream(); + defer stream.deinit(); + + var read_buf: [64 * 1024]u8 align(std.atomic.cache_line) = undefined; + var file_reader = data_file.reader(&read_buf); + const reader = &file_reader.interface; + + var buf: [64 * 1024]u8 = undefined; + while (true) { + const n = reader.readSliceShort(&buf) catch + return file_reader.err orelse error.ReadFailed; + if (n == 0) return; + stream.nextSlice(buf[0..n]); + } +} + +/// Return the primary screen because it is the terminal screen which owns +/// scrollback. A corpus ending in the alternate screen must not turn this into +/// an alternate-screen allocation benchmark. +fn pages(self: *ScrollbackCompression) *PageList { + return &self.terminal.screens.get(.primary).?.pages; +} + +fn stepNoop(ptr: *anyopaque) Benchmark.Error!void { + const self: *ScrollbackCompression = @ptrCast(@alignCast(ptr)); + std.mem.doNotOptimizeAway(&self.terminal); +} + +fn stepCompress(ptr: *anyopaque) Benchmark.Error!void { + const self: *ScrollbackCompression = @ptrCast(@alignCast(ptr)); + _ = self.pages().compress(.full); + std.mem.doNotOptimizeAway(&self.terminal); +} + +fn stepIncremental(ptr: *anyopaque) Benchmark.Error!void { + const self: *ScrollbackCompression = @ptrCast(@alignCast(ptr)); + _ = self.pages().compress(.drain); + std.mem.doNotOptimizeAway(&self.terminal); +} + +fn stepRestore(ptr: *anyopaque) Benchmark.Error!void { + const self: *ScrollbackCompression = @ptrCast(@alignCast(ptr)); + std.mem.doNotOptimizeAway(self.visitColdPages()); +} + +/// Visit all nodes which were eligible for the setup compression pass. +/// +/// `page` is intentionally used instead of inspecting the union payload so the +/// benchmark follows the same transparent restoration boundary as consumers. +/// Compressible nodes restore here; resident candidates which failed the +/// opportunistic pass are harmlessly visited through the same boundary. +fn visitColdPages(self: *ScrollbackCompression) usize { + const page_list = self.pages(); + const active_node = page_list.getTopLeft(.active).node; + var visited: usize = 0; + var node_ = page_list.pages.first; + while (node_) |node| : (node_ = node.next) { + if (node == active_node) break; + std.mem.doNotOptimizeAway(node.page()); + visited += 1; + } + return visited; +} + +fn stepReport(ptr: *anyopaque) Benchmark.Error!void { + const self: *ScrollbackCompression = @ptrCast(@alignCast(ptr)); + _ = self.pages().compress(.full); + const memory = self.pages().memoryStats(); + + std.debug.print( + "scrollback-compression compressed={d} raw={d} " ++ + "encoded={d} ratio={d:.2}% savings={d}\n", + .{ + memory.compressed_pages, + memory.decommitted_raw_bytes, + memory.encoded_bytes, + percentage( + memory.encoded_bytes, + memory.decommitted_raw_bytes, + ), + memory.estimatedSavings(), + }, + ); +} + +fn percentage(part: usize, whole: usize) f64 { + if (whole == 0) return 0; + return @as(f64, @floatFromInt(part)) * 100 / + @as(f64, @floatFromInt(whole)); +} + +test ScrollbackCompression { + const testing = std.testing; + const impl: *ScrollbackCompression = try .create(testing.allocator, .{}); + defer impl.destroy(testing.allocator); + + const bench = impl.benchmark(); + _ = try bench.run(.once); +} + +test "ScrollbackCompression restores cold terminal pages" { + const testing = std.testing; + const impl: *ScrollbackCompression = try .create(testing.allocator, .{ + .mode = .restore, + .@"terminal-rows" = 4, + // Standard-width pages hold 215 rows. Keeping that row capacity here + // makes this test corpus small while still producing cold history. + .@"terminal-cols" = 215, + .@"max-scrollback" = 1_000_000, + }); + defer impl.destroy(testing.allocator); + + var stream = impl.terminal.vtStream(); + defer stream.deinit(); + for (0..256) |_| stream.nextSlice("aaaa\r\n"); + + _ = impl.pages().compress(.full); + const compressed = impl.pages().memoryStats(); + try testing.expect(compressed.compressed_pages > 0); + try testing.expect(impl.visitColdPages() >= compressed.compressed_pages); + + // Restored historical pages are resident and therefore eligible for a + // later explicit pass. This also verifies that the benchmark traversal + // went through Node.page rather than merely inspecting page metadata. + _ = impl.pages().compress(.full); + const recompressed = impl.pages().memoryStats(); + try testing.expectEqual( + compressed.compressed_pages, + recompressed.compressed_pages, + ); +} + +test "ScrollbackCompression drains incremental compression steps" { + const testing = std.testing; + const impl: *ScrollbackCompression = try .create(testing.allocator, .{ + .mode = .incremental, + .@"terminal-rows" = 4, + .@"terminal-cols" = 215, + .@"max-scrollback" = 1_000_000, + }); + defer impl.destroy(testing.allocator); + + var stream = impl.terminal.vtStream(); + defer stream.deinit(); + for (0..256) |_| stream.nextSlice("aaaa\r\n"); + + _ = impl.pages().compress(.drain); + const incremental = impl.pages().memoryStats(); + try testing.expect(incremental.compressed_pages > 0); + + // Restore the same pages and compare against the monolithic operation. + // Both paths should produce the same final storage representation. + _ = impl.visitColdPages(); + _ = impl.pages().compress(.full); + const monolithic = impl.pages().memoryStats(); + try testing.expectEqual(monolithic, incremental); +} diff --git a/vendor/libghostty-vt/src/benchmark/TerminalStream.zig b/vendor/libghostty-vt/src/benchmark/TerminalStream.zig index 1cac656e..a5302005 100644 --- a/vendor/libghostty-vt/src/benchmark/TerminalStream.zig +++ b/vendor/libghostty-vt/src/benchmark/TerminalStream.zig @@ -2,15 +2,13 @@ //! handler from input to terminal state update. This is useful to //! test general throughput of VT parsing and handling. //! -//! Note that the handler used for this benchmark isn't the full -//! terminal handler, since that requires a significant amount of -//! state. This is a simplified version that only handles specific -//! terminal operations like printing characters. We should expand -//! this to include more operations to improve the accuracy of the -//! benchmark. +//! This uses the full readonly terminal stream handler +//! (terminal.TerminalStream) so every escape sequence updates real +//! terminal state (styles, cursor movement, erases, modes, etc.). +//! This closely mirrors the work done by the real IO thread. //! -//! It is a fairly broad benchmark that can be used to determine -//! if we need to optimize something more specific (e.g. the parser). +//! For more isolated measurements see the terminal-parser and +//! osc-parser benchmarks. const TerminalStream = @This(); const std = @import("std"); @@ -20,13 +18,12 @@ const terminalpkg = @import("../terminal/main.zig"); const Benchmark = @import("Benchmark.zig"); const options = @import("options.zig"); const Terminal = terminalpkg.Terminal; -const Stream = terminalpkg.Stream(*Handler); +const Stream = terminalpkg.TerminalStream; const log = std.log.scoped(.@"terminal-stream-bench"); opts: Options, terminal: Terminal, -handler: Handler, stream: Stream, /// The file, opened in the setup function. @@ -61,14 +58,15 @@ pub fn create( .rows = opts.@"terminal-rows", .cols = opts.@"terminal-cols", }), - .handler = .{ .t = &ptr.terminal }, - .stream = .init(&ptr.handler), + .stream = undefined, }; + ptr.stream = .initAlloc(alloc, .init(&ptr.terminal)); return ptr; } pub fn destroy(self: *TerminalStream, alloc: Allocator) void { + self.stream.deinit(); self.terminal.deinit(alloc); alloc.destroy(self); } @@ -114,11 +112,14 @@ fn step(ptr: *anyopaque) Benchmark.Error!void { // aren't currently IO bound. const f = self.data_f orelse return; - var read_buf: [4096]u8 align(std.atomic.cache_line) = undefined; + var read_buf: [64 * 1024]u8 align(std.atomic.cache_line) = undefined; var f_reader = f.reader(&read_buf); const r = &f_reader.interface; - var buf: [4096]u8 = undefined; + // This buffer size matches the read buffer size used by the + // real IO thread (see termio Exec.zig buffer_capacity) so that + // the benchmark exercises the stream with realistic chunk sizes. + var buf: [64 * 1024]u8 = undefined; while (true) { const n = r.readSliceShort(&buf) catch { log.warn("error reading data file err={?}", .{f_reader.err}); @@ -129,26 +130,6 @@ fn step(ptr: *anyopaque) Benchmark.Error!void { } } -/// Implements the handler interface for the terminal.Stream. -/// We should expand this to include more operations to make -/// our benchmark more realistic. -const Handler = struct { - t: *Terminal, - - pub fn vt( - self: *Handler, - comptime action: Stream.Action.Tag, - value: Stream.Action.Value(action), - ) void { - switch (action) { - .print => self.t.print(value.cp) catch |err| { - log.warn("error processing benchmark print err={}", .{err}); - }, - else => {}, - } - } -}; - test TerminalStream { const testing = std.testing; const alloc = testing.allocator; diff --git a/vendor/libghostty-vt/src/benchmark/cli.zig b/vendor/libghostty-vt/src/benchmark/cli.zig index 13f07077..dc4d7b8f 100644 --- a/vendor/libghostty-vt/src/benchmark/cli.zig +++ b/vendor/libghostty-vt/src/benchmark/cli.zig @@ -6,8 +6,12 @@ const cli = @import("../cli.zig"); /// benchmarks. View docs for each individual one in the predictably /// named files. pub const Action = enum { + @"apc-parser", @"codepoint-width", @"grapheme-break", + @"hyperlink-map", + @"page-compression", + @"scrollback-compression", @"screen-clone", @"terminal-parser", @"terminal-stream", @@ -24,7 +28,11 @@ pub const Action = enum { /// See TerminalStream for an example. pub fn Struct(comptime action: Action) type { return switch (action) { + .@"apc-parser" => @import("ApcParser.zig"), + .@"hyperlink-map" => @import("HyperlinkMap.zig"), .@"screen-clone" => @import("ScreenClone.zig"), + .@"page-compression" => @import("PageCompression.zig"), + .@"scrollback-compression" => @import("ScrollbackCompression.zig"), .@"terminal-stream" => @import("TerminalStream.zig"), .@"codepoint-width" => @import("CodepointWidth.zig"), .@"grapheme-break" => @import("GraphemeBreak.zig"), diff --git a/vendor/libghostty-vt/src/benchmark/main.zig b/vendor/libghostty-vt/src/benchmark/main.zig index 5673044f..f22891c7 100644 --- a/vendor/libghostty-vt/src/benchmark/main.zig +++ b/vendor/libghostty-vt/src/benchmark/main.zig @@ -4,9 +4,12 @@ pub const CApi = @import("CApi.zig"); pub const TerminalStream = @import("TerminalStream.zig"); pub const CodepointWidth = @import("CodepointWidth.zig"); pub const GraphemeBreak = @import("GraphemeBreak.zig"); +pub const HyperlinkMap = @import("HyperlinkMap.zig"); pub const ScreenClone = @import("ScreenClone.zig"); pub const TerminalParser = @import("TerminalParser.zig"); pub const IsSymbol = @import("IsSymbol.zig"); +pub const PageCompression = @import("PageCompression.zig"); +pub const ScrollbackCompression = @import("ScrollbackCompression.zig"); test { @import("std").testing.refAllDecls(@This()); diff --git a/vendor/libghostty-vt/src/config/Config.zig b/vendor/libghostty-vt/src/config/Config.zig index 66b8c605..d6a038d9 100644 --- a/vendor/libghostty-vt/src/config/Config.zig +++ b/vendor/libghostty-vt/src/config/Config.zig @@ -51,7 +51,6 @@ const terminal = struct { const color = @import("../terminal/color.zig"); const selection_codepoints = @import("../terminal/selection_codepoints.zig"); const style = @import("../terminal/style.zig"); - const x11_color = @import("../terminal/x11_color.zig"); }; const log = std.log.scoped(.config); @@ -1374,10 +1373,17 @@ input: RepeatableReadableIO = .{}, /// When this limit is reached, the oldest lines are removed from the /// scrollback. /// -/// Scrollback currently exists completely in memory. This means that the -/// larger this value, the larger potential memory usage. Scrollback is -/// allocated lazily up to this limit, so if you set this to a very large -/// value, it will not immediately consume a lot of memory. +/// Scrollback is stored in memory and allocated lazily up to this limit, so +/// setting a very large limit does not immediately consume that amount of +/// memory. On supported systems with scrollback compression enabled, Ghostty +/// attempts to compress fully historical pages which are not currently visible +/// while the terminal is idle. This can reduce physical memory usage, depending +/// on the contents of the scrollback. +/// +/// This limit always measures the uncompressed logical size of the terminal +/// pages. Compression does not allow Ghostty to retain more history than the +/// configured limit. Accessing compressed history restores it transparently +/// and may increase the terminal's physical memory usage again. /// /// This size is per terminal surface, not for the entire application. /// @@ -1385,7 +1391,31 @@ input: RepeatableReadableIO = .{}, /// This is a future planned feature. /// /// This can be changed at runtime but will only affect new terminal surfaces. -@"scrollback-limit": usize = 10_000_000, // 10MB +@"scrollback-limit": usize = 50_000_000, // 50MB + +/// Whether to compress scrollback pages while the terminal is idle. +/// +/// Ghostty does its best to only compress when idle and decompress +/// as needed. This means that compression doesn't lower IO throughput. +/// We recommend you keep it on. +/// +/// The scrollback limit remains an uncompressed logical limit regardless of +/// this setting, so disabling compression can increase physical memory usage +/// but does not change how much history is retained. +/// +/// Text-heavy terminal history generally compresses to approximately 10% to +/// 30% of its uncompressed page memory, corresponding to a 70% to 90% reduction +/// in physical memory for pages which are compressed. Compression savings are +/// content-dependent. +/// +/// Note that the way Ghostty works is that we compress and discard the +/// physical/resident memory but we retain virtual mappings. You will not +/// see a decrease in virtual memory usage, but you will see a decrease +/// in physical/memory usage. +/// +/// Changing this at runtime affects future compression work. Pages which are +/// already compressed remain compressed until their contents are accessed. +@"scrollback-compression": bool = true, /// Control when the scrollbar is shown to scroll the scrollback buffer. /// @@ -3657,6 +3687,14 @@ else /// which is the old style. @"gtk-wide-tabs": bool = true, +/// If `true` (default), then two-finger horizontal scrolling on a touchpad +/// will switch between tabs. Scrolling left goes to the next tab and +/// scrolling right goes to the previous tab. Set this to `false` to +/// disable this behavior. +/// +/// Available since 1.4.0. +@"gtk-horizontal-tab-scroll": bool = true, + /// Custom CSS files to be loaded. /// /// GTK CSS documentation can be found at the following links: @@ -5433,16 +5471,8 @@ pub const Color = struct { pub fn parseCLI(input_: ?[]const u8) !Color { const input = input_ orelse return error.ValueRequired; - // Trim any whitespace before processing - const trimmed = std.mem.trim(u8, input, " \t"); - - if (terminal.x11_color.map.get(trimmed)) |rgb| return .{ - .r = rgb.r, - .g = rgb.g, - .b = rgb.b, - }; - - return fromHex(trimmed); + const rgb: terminal.color.RGB = terminal.color.RGB.parse(input) catch return error.InvalidValue; + return .{ .r = rgb.r, .g = rgb.g, .b = rgb.b }; } /// Deep copy of the struct. Required by Config. @@ -5473,48 +5503,15 @@ pub const Color = struct { ) catch error.OutOfMemory; } - /// fromHex parses a color from a hex value such as #RRGGBB. The "#" - /// is optional. - pub fn fromHex(input: []const u8) !Color { - // Trim the beginning '#' if it exists - const trimmed = if (input.len != 0 and input[0] == '#') input[1..] else input; - if (trimmed.len != 6 and trimmed.len != 3) return error.InvalidValue; - - // Expand short hex values to full hex values - const rgb: []const u8 = if (trimmed.len == 3) &.{ - trimmed[0], trimmed[0], - trimmed[1], trimmed[1], - trimmed[2], trimmed[2], - } else trimmed; - - // Parse the colors two at a time. - var result: Color = undefined; - comptime var i: usize = 0; - inline while (i < 6) : (i += 2) { - const v: u8 = - ((try std.fmt.charToDigit(rgb[i], 16)) * 16) + - try std.fmt.charToDigit(rgb[i + 1], 16); - - @field(result, switch (i) { - 0 => "r", - 2 => "g", - 4 => "b", - else => unreachable, - }) = v; - } - - return result; - } - - test "fromHex" { + test "parseCLI hex" { const testing = std.testing; - try testing.expectEqual(Color{ .r = 0, .g = 0, .b = 0 }, try Color.fromHex("#000000")); - try testing.expectEqual(Color{ .r = 10, .g = 11, .b = 12 }, try Color.fromHex("#0A0B0C")); - try testing.expectEqual(Color{ .r = 10, .g = 11, .b = 12 }, try Color.fromHex("0A0B0C")); - try testing.expectEqual(Color{ .r = 255, .g = 255, .b = 255 }, try Color.fromHex("FFFFFF")); - try testing.expectEqual(Color{ .r = 255, .g = 255, .b = 255 }, try Color.fromHex("FFF")); - try testing.expectEqual(Color{ .r = 51, .g = 68, .b = 85 }, try Color.fromHex("#345")); + try testing.expectEqual(Color{ .r = 0, .g = 0, .b = 0 }, try Color.parseCLI("#000000")); + try testing.expectEqual(Color{ .r = 10, .g = 11, .b = 12 }, try Color.parseCLI("#0A0B0C")); + try testing.expectEqual(Color{ .r = 10, .g = 11, .b = 12 }, try Color.parseCLI("0A0B0C")); + try testing.expectEqual(Color{ .r = 255, .g = 255, .b = 255 }, try Color.parseCLI("FFFFFF")); + try testing.expectEqual(Color{ .r = 255, .g = 255, .b = 255 }, try Color.parseCLI("FFF")); + try testing.expectEqual(Color{ .r = 51, .g = 68, .b = 85 }, try Color.parseCLI("#345")); } test "parseCLI from name" { @@ -5860,20 +5857,12 @@ pub const Palette = struct { input: ?[]const u8, ) !void { const value = input orelse return error.ValueRequired; - const eqlIdx = std.mem.indexOf(u8, value, "=") orelse - return error.InvalidValue; - - // Parse the key part (trim whitespace) - const key = try std.fmt.parseInt( - u8, - std.mem.trim(u8, value[0..eqlIdx], " \t"), - 0, - ); - - // Parse the color part (Color.parseCLI will handle whitespace) - const rgb = try Color.parseCLI(value[eqlIdx + 1 ..]); - self.value[key] = .{ .r = rgb.r, .g = rgb.g, .b = rgb.b }; - self.mask.set(key); + const entry = terminal.color.parsePaletteEntry(value) catch |err| switch (err) { + error.Overflow => return error.Overflow, + error.InvalidFormat => return error.InvalidValue, + }; + self.value[entry.index] = entry.color; + self.mask.set(entry.index); } /// Deep copy of the struct. Required by Config. diff --git a/vendor/libghostty-vt/src/font/CodepointResolver.zig b/vendor/libghostty-vt/src/font/CodepointResolver.zig index a4f13c29..90fe2491 100644 --- a/vendor/libghostty-vt/src/font/CodepointResolver.zig +++ b/vendor/libghostty-vt/src/font/CodepointResolver.zig @@ -24,7 +24,7 @@ const Face = font.Face; const Glyph = font.Glyph; const Library = font.Library; const Presentation = font.Presentation; -const RenderOptions = font.face.RenderOptions; +const RenderOptions = font.Glyph.RenderOptions; const SpriteFace = font.SpriteFace; const Style = font.Style; diff --git a/vendor/libghostty-vt/src/font/Glyph.zig b/vendor/libghostty-vt/src/font/Glyph.zig index f9937027..62c44e22 100644 --- a/vendor/libghostty-vt/src/font/Glyph.zig +++ b/vendor/libghostty-vt/src/font/Glyph.zig @@ -1,6 +1,9 @@ //! Glyph is a single loaded glyph for a face. const Glyph = @This(); +const std = @import("std"); +const Metrics = @import("Metrics.zig"); + /// width of glyph in pixels width: u32, @@ -17,3 +20,611 @@ offset_y: i32, /// be normalized to be between 0 and 1 prior to use in shaders. atlas_x: u32, atlas_y: u32, + +/// The size and position of a glyph. +pub const Size = struct { + width: f64, + height: f64, + x: f64, + y: f64, +}; + +/// Metrics describing the authored glyph coordinate space. +pub const DesignMetrics = struct { + /// Units-per-em for outline/design coordinates. + units_per_em: u32, + + /// Authored advance width in design units. + advance_width: u32, + + /// Authored line height in design units. + line_height: u32, +}; + +/// Additional options for rendering glyphs. +pub const RenderOptions = struct { + /// The metrics that are defining the grid layout. These are usually + /// the metrics of the primary font face. The grid metrics are used + /// by the font face to better layout the glyph in situations where + /// the font is not exactly the same size as the grid. + grid_metrics: Metrics, + + /// The number of grid cells this glyph will take up. This can be used + /// optionally by the rasterizer to better layout the glyph. + cell_width: ?u2 = null, + + /// Constraint and alignment properties for the glyph. The rasterizer + /// should call the `constrain` function on this with the original size + /// and bearings of the glyph to get remapped values that the glyph + /// should be scaled/moved to. + constraint: Constraint = .none, + + /// The number of cells, horizontally that the glyph is free to take up + /// when resized and aligned by `constraint`. This is usually 1, but if + /// there's whitespace to the right of the cell then it can be 2. + constraint_width: u2 = 1, + + /// Thicken the glyph. This draws the glyph with a thicker stroke width. + /// This is purely an aesthetic setting. + /// + /// This only works with CoreText currently. + thicken: bool = false, + + /// "Strength" of the thickening, between `0` and `255`. + /// Only has an effect when `thicken` is enabled. + /// + /// `0` does not correspond to *no* thickening, + /// just the *lightest* thickening available. + /// + /// CoreText only. + thicken_strength: u8 = 255, + + /// See the `constraint` field. + pub const Constraint = struct { + /// Don't constrain the glyph in any way. + pub const none: Constraint = .{}; + + /// Sizing rule. + size: Constraint.Size = .none, + + /// Vertical alignment rule. + align_vertical: Align = .none, + /// Horizontal alignment rule. + align_horizontal: Align = .none, + + /// Top padding when resizing. + pad_top: f64 = 0.0, + /// Left padding when resizing. + pad_left: f64 = 0.0, + /// Right padding when resizing. + pad_right: f64 = 0.0, + /// Bottom padding when resizing. + pad_bottom: f64 = 0.0, + + // Size and bearings of the glyph relative + // to the bounding box of its scale group. + relative_width: f64 = 1.0, + relative_height: f64 = 1.0, + relative_x: f64 = 0.0, + relative_y: f64 = 0.0, + + /// Maximum aspect ratio (width/height) to allow when stretching. + max_xy_ratio: ?f64 = null, + + /// Maximum number of cells horizontally to use. + max_constraint_width: u2 = 2, + + /// What to use as the height metric when constraining the glyph and + /// the constraint width is 1, + height: Height = .cell, + + pub const Size = enum { + /// Don't change the size of this glyph. + none, + /// Scale the glyph down if needed to fit within the bounds, + /// preserving aspect ratio. + fit, + /// Scale the glyph up or down to exactly match the bounds, + /// preserving aspect ratio. + cover, + /// Scale the glyph down if needed to fit within the bounds, + /// preserving aspect ratio. If the glyph doesn't cover a + /// single cell, scale up. If the glyph exceeds a single + /// cell but is within the bounds, do nothing. + /// (Nerd Font specific rule.) + fit_cover1, + /// Stretch the glyph to exactly fit the bounds in both + /// directions, disregarding aspect ratio. + stretch, + }; + + pub const Align = enum { + /// Don't move the glyph on this axis. + none, + /// Move the glyph so that its leading (bottom/left) + /// edge aligns with the leading edge of the axis. + start, + /// Move the glyph so that its trailing (top/right) + /// edge aligns with the trailing edge of the axis. + end, + /// Move the glyph so that it is centered on this axis. + center, + /// Move the glyph so that it is centered on this axis, + /// but always with respect to the first cell even for + /// multi-cell constraints. (Nerd Font specific rule.) + center1, + }; + + pub const Height = enum { + /// Use the full line height of the primary face for + /// constraining this glyph. + cell, + /// Use the icon height from the grid metrics for + /// constraining this glyph. Unlike `cell`, the value of + /// this height depends on both the constraint width and the + /// affected by the `adjust-icon-height` config option. + icon, + }; + + /// Returns true if the constraint does anything. If it doesn't, + /// because it neither sizes nor positions the glyph, then this + /// returns false. + pub inline fn doesAnything(self: Constraint) bool { + return self.size != .none or + self.align_horizontal != .none or + self.align_vertical != .none; + } + + /// Apply this constraint to the provided glyph + /// size, given the available width and height. + pub fn constrain( + self: Constraint, + glyph: Glyph.Size, + metrics: Metrics, + /// Number of cells horizontally available for this glyph. + constraint_width: u2, + ) Glyph.Size { + if (!self.doesAnything()) return glyph; + + switch (self.size) { + .stretch => { + // Stretched glyphs are usually meant to align across cell + // boundaries, which works best if they're scaled and + // aligned to the grid rather than the face. This is most + // easily done by inserting this little fib in the metrics. + var m = metrics; + m.face_width = @floatFromInt(m.cell_width); + m.face_height = @floatFromInt(m.cell_height); + m.face_y = 0.0; + + // Negative padding for stretched glyphs is a band-aid to + // avoid gaps due to pixel rounding, but at the cost of + // unsightly overlap artifacts. Since we scale and align to + // the grid rather than the face, we don't need it. + var c = self; + c.pad_bottom = @max(0, c.pad_bottom); + c.pad_top = @max(0, c.pad_top); + c.pad_left = @max(0, c.pad_left); + c.pad_right = @max(0, c.pad_right); + + return c.constrainInner(glyph, m, constraint_width); + }, + else => return self.constrainInner(glyph, metrics, constraint_width), + } + } + + fn constrainInner( + self: Constraint, + glyph: Glyph.Size, + metrics: Metrics, + constraint_width: u2, + ) Glyph.Size { + // For extra wide font faces, never stretch glyphs across two cells. + // This mirrors font_patcher. + const min_constraint_width: u2 = if ((self.size == .stretch) and (metrics.face_width > 0.9 * metrics.face_height)) + 1 + else + @min(self.max_constraint_width, constraint_width); + + // The bounding box for the glyph's scale group. + // Scaling and alignment rules are calculated for + // this box and then applied to the glyph. + var group: Glyph.Size = group: { + const group_width = glyph.width / self.relative_width; + const group_height = glyph.height / self.relative_height; + break :group .{ + .width = group_width, + .height = group_height, + .x = glyph.x - (group_width * self.relative_x), + .y = glyph.y - (group_height * self.relative_y), + }; + }; + + // Apply prescribed scaling, preserving the + // center bearings of the group bounding box + const width_factor, const height_factor = self.scale_factors(group, metrics, min_constraint_width); + const center_x = group.x + (group.width / 2); + const center_y = group.y + (group.height / 2); + group.width *= width_factor; + group.height *= height_factor; + group.x = center_x - (group.width / 2); + group.y = center_y - (group.height / 2); + + // NOTE: font_patcher jumps through a lot of hoops at this + // point to ensure that the glyph remains within the target + // bounding box after rounding to font definition units. + // This is irrelevant here as we're not rounding, we're + // staying in f64 and heading straight to rendering. + + // Apply prescribed alignment + group.y = self.aligned_y(group, metrics); + group.x = self.aligned_x(group, metrics, min_constraint_width); + + // Transfer the scaling and alignment back to the glyph and return. + return .{ + .width = width_factor * glyph.width, + .height = height_factor * glyph.height, + .x = group.x + (group.width * self.relative_x), + .y = group.y + (group.height * self.relative_y), + }; + } + + /// Return width and height scaling factors for this scaling group. + fn scale_factors( + self: Constraint, + group: Glyph.Size, + metrics: Metrics, + min_constraint_width: u2, + ) struct { f64, f64 } { + if (self.size == .none) { + return .{ 1.0, 1.0 }; + } + + const multi_cell = (min_constraint_width > 1); + + const pad_width_factor = @as(f64, @floatFromInt(min_constraint_width)) - (self.pad_left + self.pad_right); + const pad_height_factor = 1 - (self.pad_bottom + self.pad_top); + + const target_width = pad_width_factor * metrics.face_width; + const target_height = pad_height_factor * switch (self.height) { + .cell => metrics.face_height, + // Like font-patcher, the icon constraint height depends on the + // constraint width. Unlike font-patcher, the multi-cell + // icon_height may be different from face_height due to the + // `adjust-icon-height` config option. + .icon => if (multi_cell) + metrics.icon_height + else + metrics.icon_height_single, + }; + + var width_factor = target_width / group.width; + var height_factor = target_height / group.height; + + switch (self.size) { + .none => unreachable, + .fit => { + // Scale down to fit if needed + height_factor = @min(1, width_factor, height_factor); + width_factor = height_factor; + }, + .cover => { + // Scale to cover + height_factor = @min(width_factor, height_factor); + width_factor = height_factor; + }, + .fit_cover1 => { + // Scale down to fit or up to cover at least one cell + // NOTE: This is similar to font_patcher's "pa" mode, + // however, font_patcher will only do the upscaling + // part if the constraint width is 1, resulting in + // some icons becoming smaller when the constraint + // width increases. You'd see icons shrinking when + // opening up a space after them. This makes no + // sense, so we've fixed the rule such that these + // icons are scaled to the same size for multi-cell + // constraints as they would be for single-cell. + height_factor = @min(width_factor, height_factor); + if (multi_cell and (height_factor > 1)) { + // Call back into this function with + // constraint width 1 to get single-cell scale + // factors. We use the height factor as width + // could have been modified by max_xy_ratio. + _, const single_height_factor = self.scale_factors(group, metrics, 1); + height_factor = @max(1, single_height_factor); + } + width_factor = height_factor; + }, + .stretch => {}, + } + + // Reduce aspect ratio if required + if (self.max_xy_ratio) |ratio| { + if (group.width * width_factor > group.height * height_factor * ratio) { + width_factor = group.height * height_factor * ratio / group.width; + } + } + + return .{ width_factor, height_factor }; + } + + /// Return vertical bearing for aligning this group + fn aligned_y( + self: Constraint, + group: Glyph.Size, + metrics: Metrics, + ) f64 { + if ((self.size == .none) and (self.align_vertical == .none)) { + // If we don't have any constraints affecting the vertical axis, + // we don't touch vertical alignment. + return group.y; + } + // We use face_height and offset by face_y, rather than + // using cell_height directly, to account for the asymmetry + // of the pixel cell around the face (a consequence of + // aligning the baseline with a pixel boundary rather than + // vertically centering the face). + const pad_bottom_dy = self.pad_bottom * metrics.face_height; + const pad_top_dy = self.pad_top * metrics.face_height; + const start_y = metrics.face_y + pad_bottom_dy; + const end_y = metrics.face_y + (metrics.face_height - group.height - pad_top_dy); + const center_y = (start_y + end_y) / 2; + return switch (self.align_vertical) { + // NOTE: Even if there is no prescribed alignment, we ensure + // that the group doesn't protrude outside the padded cell, + // since this is implied by every available size constraint. If + // the group is too high we fall back to centering, though if we + // hit the .none prong we always have self.size != .none, so + // this should never happen. + .none => if (end_y < start_y) + center_y + else + @max(start_y, @min(group.y, end_y)), + .start => start_y, + .end => end_y, + .center, .center1 => center_y, + }; + } + + /// Return horizontal bearing for aligning this group + fn aligned_x( + self: Constraint, + group: Glyph.Size, + metrics: Metrics, + min_constraint_width: u2, + ) f64 { + if ((self.size == .none) and (self.align_horizontal == .none)) { + // If we don't have any constraints affecting the horizontal + // axis, we don't touch horizontal alignment. + return group.x; + } + // For multi-cell constraints, we align relative to the span + // from the left edge of the first cell to the right edge of + // the last face cell assuming it's left-aligned within the + // rounded and adjusted pixel cell. Any horizontal offset to + // center the face within the grid cell is the responsibility + // of the backend-specific rendering code, and should be done + // after applying constraints. + const full_face_span = metrics.face_width + @as(f64, @floatFromInt((min_constraint_width - 1) * metrics.cell_width)); + const pad_left_dx = self.pad_left * metrics.face_width; + const pad_right_dx = self.pad_right * metrics.face_width; + const start_x = pad_left_dx; + const end_x = full_face_span - group.width - pad_right_dx; + return switch (self.align_horizontal) { + // NOTE: Even if there is no prescribed alignment, we ensure + // that the glyph doesn't protrude outside the padded cell, + // since this is implied by every available size constraint. The + // left-side bound has priority if the group is too wide, though + // if we hit the .none prong we always have self.size != .none, + // so this should never happen. + .none => @max(start_x, @min(group.x, end_x)), + .start => start_x, + .end => @max(start_x, end_x), + .center => @max(start_x, (start_x + end_x) / 2), + // NOTE: .center1 implements the font_patcher rule of centering + // in the first cell even for multi-cell constraints. Since glyphs + // are not allowed to protrude to the left, this results in the + // left-alignment like .start when the glyph is wider than a cell. + .center1 => center1: { + const end1_x = metrics.face_width - group.width - pad_right_dx; + break :center1 @max(start_x, (start_x + end1_x) / 2); + }, + }; + } + }; +}; + +test "Constraints" { + const comparison = @import("../datastruct/comparison.zig"); + const getConstraint = @import("nerd_font_attributes.zig").getConstraint; + const GlyphSize = Size; + + // Hardcoded data matches metrics from CoreText at size 12 and DPI 96. + + // Define grid metrics (matches font-family = JetBrains Mono) + const metrics: Metrics = .{ + .cell_width = 10, + .cell_height = 22, + .cell_baseline = 5, + .underline_position = 19, + .underline_thickness = 1, + .strikethrough_position = 12, + .strikethrough_thickness = 1, + .overline_position = 0, + .overline_thickness = 1, + .box_thickness = 1, + .cursor_thickness = 1, + .cursor_height = 22, + .icon_height = 21.12, + .icon_height_single = 44.48 / 3.0, + .face_width = 9.6, + .face_height = 21.12, + .face_y = 0.2, + }; + + // ASCII (no constraint). + { + const constraint: RenderOptions.Constraint = .none; + + // BBox of 'x' from JetBrains Mono. + const glyph_x: GlyphSize = .{ + .width = 6.784, + .height = 15.28, + .x = 1.408, + .y = 4.84, + }; + + // Any constraint width: do nothing. + inline for (.{ 1, 2 }) |constraint_width| { + try comparison.expectApproxEqual( + glyph_x, + constraint.constrain(glyph_x, metrics, constraint_width), + ); + } + } + + // Symbol (same constraint as hardcoded in Renderer.addGlyph). + { + const constraint: RenderOptions.Constraint = .{ .size = .fit }; + + // BBox of '■' (0x25A0 black square) from Iosevka. + // NOTE: This glyph is designed to span two cells. + const glyph_25A0: GlyphSize = .{ + .width = 10.272, + .height = 10.272, + .x = 2.864, + .y = 5.304, + }; + + // Constraint width 1: scale down and shift to fit a single cell. + try comparison.expectApproxEqual( + GlyphSize{ + .width = metrics.face_width, + .height = metrics.face_width, + .x = 0, + .y = 5.64, + }, + constraint.constrain(glyph_25A0, metrics, 1), + ); + + // Constraint width 2: do nothing. + try comparison.expectApproxEqual( + glyph_25A0, + constraint.constrain(glyph_25A0, metrics, 2), + ); + } + + // Emoji (same constraint as hardcoded in SharedGrid.renderGlyph). + { + const constraint: RenderOptions.Constraint = .{ + .size = .cover, + .align_horizontal = .center, + .align_vertical = .center, + .pad_left = 0.025, + .pad_right = 0.025, + }; + + // BBox of '🥸' (0x1F978) from Apple Color Emoji. + const glyph_1F978: GlyphSize = .{ + .width = 20, + .height = 20, + .x = 0.46, + .y = 1, + }; + + // Constraint width 2: scale to cover two cells with padding, center; + try comparison.expectApproxEqual( + GlyphSize{ + .width = 18.72, + .height = 18.72, + .x = 0.44, + .y = 1.4, + }, + constraint.constrain(glyph_1F978, metrics, 2), + ); + } + + // Nerd Font default. + { + const constraint = getConstraint(0xea61).?; + + // Verify that this is the constraint we expect. + try std.testing.expectEqual(.fit_cover1, constraint.size); + try std.testing.expectEqual(.icon, constraint.height); + try std.testing.expectEqual(.center1, constraint.align_horizontal); + try std.testing.expectEqual(.center1, constraint.align_vertical); + + // BBox of '' (0xEA61 nf-cod-lightbulb) from Symbols Only. + // NOTE: This icon is part of a group, so the + // constraint applies to a larger bounding box. + const glyph_EA61: GlyphSize = .{ + .width = 9.015625, + .height = 13.015625, + .x = 3.015625, + .y = 3.76525, + }; + + // Constraint width 1: scale and shift group to fit a single cell. + try comparison.expectApproxEqual( + GlyphSize{ + .width = 7.2125, + .height = 10.4125, + .x = 0.8125, + .y = 5.950695224719102, + }, + constraint.constrain(glyph_EA61, metrics, 1), + ); + + // Constraint width 2: no scaling; left-align and vertically center group. + try comparison.expectApproxEqual( + GlyphSize{ + .width = glyph_EA61.width, + .height = glyph_EA61.height, + .x = 1.015625, + .y = 4.7483690308988775, + }, + constraint.constrain(glyph_EA61, metrics, 2), + ); + } + + // Nerd Font stretch. + { + const constraint = getConstraint(0xe0c0).?; + + // Verify that this is the constraint we expect. + try std.testing.expectEqual(.stretch, constraint.size); + try std.testing.expectEqual(.cell, constraint.height); + try std.testing.expectEqual(.start, constraint.align_horizontal); + try std.testing.expectEqual(.center1, constraint.align_vertical); + + // BBox of ' ' (0xE0C0 nf-ple-flame_thick) from Symbols Only. + const glyph_E0C0: GlyphSize = .{ + .width = 16.796875, + .height = 16.46875, + .x = -0.796875, + .y = 1.7109375, + }; + + // Constraint width 1: stretch and position to exactly cover one cell. + try comparison.expectApproxEqual( + GlyphSize{ + .width = @floatFromInt(metrics.cell_width), + .height = @floatFromInt(metrics.cell_height), + .x = 0, + .y = 0, + }, + constraint.constrain(glyph_E0C0, metrics, 1), + ); + + // Constraint width 1: stretch and position to exactly cover two cells. + try comparison.expectApproxEqual( + GlyphSize{ + .width = @floatFromInt(2 * metrics.cell_width), + .height = @floatFromInt(metrics.cell_height), + .x = 0, + .y = 0, + }, + constraint.constrain(glyph_E0C0, metrics, 2), + ); + } +} diff --git a/vendor/libghostty-vt/src/font/Metrics.zig b/vendor/libghostty-vt/src/font/Metrics.zig index a72cb7be..60a300b0 100644 --- a/vendor/libghostty-vt/src/font/Metrics.zig +++ b/vendor/libghostty-vt/src/font/Metrics.zig @@ -560,6 +560,8 @@ pub const Modifier = union(enum) { } test "formatConfig percent" { + if (comptime @import("terminal_options").artifact == .lib) return; + const configpkg = @import("../config.zig"); const testing = std.testing; var buf: std.Io.Writer.Allocating = .init(testing.allocator); @@ -571,6 +573,8 @@ pub const Modifier = union(enum) { } test "formatConfig absolute" { + if (comptime @import("terminal_options").artifact == .lib) return; + const configpkg = @import("../config.zig"); const testing = std.testing; var buf: std.Io.Writer.Allocating = .init(testing.allocator); diff --git a/vendor/libghostty-vt/src/font/SharedGrid.zig b/vendor/libghostty-vt/src/font/SharedGrid.zig index 5fd729b3..18870aab 100644 --- a/vendor/libghostty-vt/src/font/SharedGrid.zig +++ b/vendor/libghostty-vt/src/font/SharedGrid.zig @@ -33,7 +33,7 @@ const Library = font.Library; const Metrics = font.Metrics; const Presentation = font.Presentation; const Style = font.Style; -const RenderOptions = font.face.RenderOptions; +const RenderOptions = font.Glyph.RenderOptions; const log = std.log.scoped(.font_shared_grid); diff --git a/vendor/libghostty-vt/src/font/face.zig b/vendor/libghostty-vt/src/font/face.zig index d77253ad..aeb7280c 100644 --- a/vendor/libghostty-vt/src/font/face.zig +++ b/vendor/libghostty-vt/src/font/face.zig @@ -2,7 +2,6 @@ const std = @import("std"); const builtin = @import("builtin"); const build_config = @import("../build_config.zig"); const options = @import("main.zig").options; -const Metrics = @import("main.zig").Metrics; const config = @import("../config.zig"); const freetype = @import("face/freetype.zig"); const coretext = @import("face/coretext.zig"); @@ -94,407 +93,6 @@ pub const Variation = struct { }; }; -/// The size and position of a glyph. -pub const GlyphSize = struct { - width: f64, - height: f64, - x: f64, - y: f64, -}; - -/// Additional options for rendering glyphs. -pub const RenderOptions = struct { - /// The metrics that are defining the grid layout. These are usually - /// the metrics of the primary font face. The grid metrics are used - /// by the font face to better layout the glyph in situations where - /// the font is not exactly the same size as the grid. - grid_metrics: Metrics, - - /// The number of grid cells this glyph will take up. This can be used - /// optionally by the rasterizer to better layout the glyph. - cell_width: ?u2 = null, - - /// Constraint and alignment properties for the glyph. The rasterizer - /// should call the `constrain` function on this with the original size - /// and bearings of the glyph to get remapped values that the glyph - /// should be scaled/moved to. - constraint: Constraint = .none, - - /// The number of cells, horizontally that the glyph is free to take up - /// when resized and aligned by `constraint`. This is usually 1, but if - /// there's whitespace to the right of the cell then it can be 2. - constraint_width: u2 = 1, - - /// Thicken the glyph. This draws the glyph with a thicker stroke width. - /// This is purely an aesthetic setting. - /// - /// This only works with CoreText currently. - thicken: bool = false, - - /// "Strength" of the thickening, between `0` and `255`. - /// Only has an effect when `thicken` is enabled. - /// - /// `0` does not correspond to *no* thickening, - /// just the *lightest* thickening available. - /// - /// CoreText only. - thicken_strength: u8 = 255, - - /// See the `constraint` field. - pub const Constraint = struct { - /// Don't constrain the glyph in any way. - pub const none: Constraint = .{}; - - /// Sizing rule. - size: Size = .none, - - /// Vertical alignment rule. - align_vertical: Align = .none, - /// Horizontal alignment rule. - align_horizontal: Align = .none, - - /// Top padding when resizing. - pad_top: f64 = 0.0, - /// Left padding when resizing. - pad_left: f64 = 0.0, - /// Right padding when resizing. - pad_right: f64 = 0.0, - /// Bottom padding when resizing. - pad_bottom: f64 = 0.0, - - // Size and bearings of the glyph relative - // to the bounding box of its scale group. - relative_width: f64 = 1.0, - relative_height: f64 = 1.0, - relative_x: f64 = 0.0, - relative_y: f64 = 0.0, - - /// Maximum aspect ratio (width/height) to allow when stretching. - max_xy_ratio: ?f64 = null, - - /// Maximum number of cells horizontally to use. - max_constraint_width: u2 = 2, - - /// What to use as the height metric when constraining the glyph and - /// the constraint width is 1, - height: Height = .cell, - - pub const Size = enum { - /// Don't change the size of this glyph. - none, - /// Scale the glyph down if needed to fit within the bounds, - /// preserving aspect ratio. - fit, - /// Scale the glyph up or down to exactly match the bounds, - /// preserving aspect ratio. - cover, - /// Scale the glyph down if needed to fit within the bounds, - /// preserving aspect ratio. If the glyph doesn't cover a - /// single cell, scale up. If the glyph exceeds a single - /// cell but is within the bounds, do nothing. - /// (Nerd Font specific rule.) - fit_cover1, - /// Stretch the glyph to exactly fit the bounds in both - /// directions, disregarding aspect ratio. - stretch, - }; - - pub const Align = enum { - /// Don't move the glyph on this axis. - none, - /// Move the glyph so that its leading (bottom/left) - /// edge aligns with the leading edge of the axis. - start, - /// Move the glyph so that its trailing (top/right) - /// edge aligns with the trailing edge of the axis. - end, - /// Move the glyph so that it is centered on this axis. - center, - /// Move the glyph so that it is centered on this axis, - /// but always with respect to the first cell even for - /// multi-cell constraints. (Nerd Font specific rule.) - center1, - }; - - pub const Height = enum { - /// Use the full line height of the primary face for - /// constraining this glyph. - cell, - /// Use the icon height from the grid metrics for - /// constraining this glyph. Unlike `cell`, the value of - /// this height depends on both the constraint width and the - /// affected by the `adjust-icon-height` config option. - icon, - }; - - /// Returns true if the constraint does anything. If it doesn't, - /// because it neither sizes nor positions the glyph, then this - /// returns false. - pub inline fn doesAnything(self: Constraint) bool { - return self.size != .none or - self.align_horizontal != .none or - self.align_vertical != .none; - } - - /// Apply this constraint to the provided glyph - /// size, given the available width and height. - pub fn constrain( - self: Constraint, - glyph: GlyphSize, - metrics: Metrics, - /// Number of cells horizontally available for this glyph. - constraint_width: u2, - ) GlyphSize { - if (!self.doesAnything()) return glyph; - - switch (self.size) { - .stretch => { - // Stretched glyphs are usually meant to align across cell - // boundaries, which works best if they're scaled and - // aligned to the grid rather than the face. This is most - // easily done by inserting this little fib in the metrics. - var m = metrics; - m.face_width = @floatFromInt(m.cell_width); - m.face_height = @floatFromInt(m.cell_height); - m.face_y = 0.0; - - // Negative padding for stretched glyphs is a band-aid to - // avoid gaps due to pixel rounding, but at the cost of - // unsightly overlap artifacts. Since we scale and align to - // the grid rather than the face, we don't need it. - var c = self; - c.pad_bottom = @max(0, c.pad_bottom); - c.pad_top = @max(0, c.pad_top); - c.pad_left = @max(0, c.pad_left); - c.pad_right = @max(0, c.pad_right); - - return c.constrainInner(glyph, m, constraint_width); - }, - else => return self.constrainInner(glyph, metrics, constraint_width), - } - } - - fn constrainInner( - self: Constraint, - glyph: GlyphSize, - metrics: Metrics, - constraint_width: u2, - ) GlyphSize { - // For extra wide font faces, never stretch glyphs across two cells. - // This mirrors font_patcher. - const min_constraint_width: u2 = if ((self.size == .stretch) and (metrics.face_width > 0.9 * metrics.face_height)) - 1 - else - @min(self.max_constraint_width, constraint_width); - - // The bounding box for the glyph's scale group. - // Scaling and alignment rules are calculated for - // this box and then applied to the glyph. - var group: GlyphSize = group: { - const group_width = glyph.width / self.relative_width; - const group_height = glyph.height / self.relative_height; - break :group .{ - .width = group_width, - .height = group_height, - .x = glyph.x - (group_width * self.relative_x), - .y = glyph.y - (group_height * self.relative_y), - }; - }; - - // Apply prescribed scaling, preserving the - // center bearings of the group bounding box - const width_factor, const height_factor = self.scale_factors(group, metrics, min_constraint_width); - const center_x = group.x + (group.width / 2); - const center_y = group.y + (group.height / 2); - group.width *= width_factor; - group.height *= height_factor; - group.x = center_x - (group.width / 2); - group.y = center_y - (group.height / 2); - - // NOTE: font_patcher jumps through a lot of hoops at this - // point to ensure that the glyph remains within the target - // bounding box after rounding to font definition units. - // This is irrelevant here as we're not rounding, we're - // staying in f64 and heading straight to rendering. - - // Apply prescribed alignment - group.y = self.aligned_y(group, metrics); - group.x = self.aligned_x(group, metrics, min_constraint_width); - - // Transfer the scaling and alignment back to the glyph and return. - return .{ - .width = width_factor * glyph.width, - .height = height_factor * glyph.height, - .x = group.x + (group.width * self.relative_x), - .y = group.y + (group.height * self.relative_y), - }; - } - - /// Return width and height scaling factors for this scaling group. - fn scale_factors( - self: Constraint, - group: GlyphSize, - metrics: Metrics, - min_constraint_width: u2, - ) struct { f64, f64 } { - if (self.size == .none) { - return .{ 1.0, 1.0 }; - } - - const multi_cell = (min_constraint_width > 1); - - const pad_width_factor = @as(f64, @floatFromInt(min_constraint_width)) - (self.pad_left + self.pad_right); - const pad_height_factor = 1 - (self.pad_bottom + self.pad_top); - - const target_width = pad_width_factor * metrics.face_width; - const target_height = pad_height_factor * switch (self.height) { - .cell => metrics.face_height, - // Like font-patcher, the icon constraint height depends on the - // constraint width. Unlike font-patcher, the multi-cell - // icon_height may be different from face_height due to the - // `adjust-icon-height` config option. - .icon => if (multi_cell) - metrics.icon_height - else - metrics.icon_height_single, - }; - - var width_factor = target_width / group.width; - var height_factor = target_height / group.height; - - switch (self.size) { - .none => unreachable, - .fit => { - // Scale down to fit if needed - height_factor = @min(1, width_factor, height_factor); - width_factor = height_factor; - }, - .cover => { - // Scale to cover - height_factor = @min(width_factor, height_factor); - width_factor = height_factor; - }, - .fit_cover1 => { - // Scale down to fit or up to cover at least one cell - // NOTE: This is similar to font_patcher's "pa" mode, - // however, font_patcher will only do the upscaling - // part if the constraint width is 1, resulting in - // some icons becoming smaller when the constraint - // width increases. You'd see icons shrinking when - // opening up a space after them. This makes no - // sense, so we've fixed the rule such that these - // icons are scaled to the same size for multi-cell - // constraints as they would be for single-cell. - height_factor = @min(width_factor, height_factor); - if (multi_cell and (height_factor > 1)) { - // Call back into this function with - // constraint width 1 to get single-cell scale - // factors. We use the height factor as width - // could have been modified by max_xy_ratio. - _, const single_height_factor = self.scale_factors(group, metrics, 1); - height_factor = @max(1, single_height_factor); - } - width_factor = height_factor; - }, - .stretch => {}, - } - - // Reduce aspect ratio if required - if (self.max_xy_ratio) |ratio| { - if (group.width * width_factor > group.height * height_factor * ratio) { - width_factor = group.height * height_factor * ratio / group.width; - } - } - - return .{ width_factor, height_factor }; - } - - /// Return vertical bearing for aligning this group - fn aligned_y( - self: Constraint, - group: GlyphSize, - metrics: Metrics, - ) f64 { - if ((self.size == .none) and (self.align_vertical == .none)) { - // If we don't have any constraints affecting the vertical axis, - // we don't touch vertical alignment. - return group.y; - } - // We use face_height and offset by face_y, rather than - // using cell_height directly, to account for the asymmetry - // of the pixel cell around the face (a consequence of - // aligning the baseline with a pixel boundary rather than - // vertically centering the face). - const pad_bottom_dy = self.pad_bottom * metrics.face_height; - const pad_top_dy = self.pad_top * metrics.face_height; - const start_y = metrics.face_y + pad_bottom_dy; - const end_y = metrics.face_y + (metrics.face_height - group.height - pad_top_dy); - const center_y = (start_y + end_y) / 2; - return switch (self.align_vertical) { - // NOTE: Even if there is no prescribed alignment, we ensure - // that the group doesn't protrude outside the padded cell, - // since this is implied by every available size constraint. If - // the group is too high we fall back to centering, though if we - // hit the .none prong we always have self.size != .none, so - // this should never happen. - .none => if (end_y < start_y) - center_y - else - @max(start_y, @min(group.y, end_y)), - .start => start_y, - .end => end_y, - .center, .center1 => center_y, - }; - } - - /// Return horizontal bearing for aligning this group - fn aligned_x( - self: Constraint, - group: GlyphSize, - metrics: Metrics, - min_constraint_width: u2, - ) f64 { - if ((self.size == .none) and (self.align_horizontal == .none)) { - // If we don't have any constraints affecting the horizontal - // axis, we don't touch horizontal alignment. - return group.x; - } - // For multi-cell constraints, we align relative to the span - // from the left edge of the first cell to the right edge of - // the last face cell assuming it's left-aligned within the - // rounded and adjusted pixel cell. Any horizontal offset to - // center the face within the grid cell is the responsibility - // of the backend-specific rendering code, and should be done - // after applying constraints. - const full_face_span = metrics.face_width + @as(f64, @floatFromInt((min_constraint_width - 1) * metrics.cell_width)); - const pad_left_dx = self.pad_left * metrics.face_width; - const pad_right_dx = self.pad_right * metrics.face_width; - const start_x = pad_left_dx; - const end_x = full_face_span - group.width - pad_right_dx; - return switch (self.align_horizontal) { - // NOTE: Even if there is no prescribed alignment, we ensure - // that the glyph doesn't protrude outside the padded cell, - // since this is implied by every available size constraint. The - // left-side bound has priority if the group is too wide, though - // if we hit the .none prong we always have self.size != .none, - // so this should never happen. - .none => @max(start_x, @min(group.x, end_x)), - .start => start_x, - .end => @max(start_x, end_x), - .center => @max(start_x, (start_x + end_x) / 2), - // NOTE: .center1 implements the font_patcher rule of centering - // in the first cell even for multi-cell constraints. Since glyphs - // are not allowed to protrude to the left, this results in the - // left-alignment like .start when the glyph is wider than a cell. - .center1 => center1: { - const end1_x = metrics.face_width - group.width - pad_right_dx; - break :center1 @max(start_x, (start_x + end1_x) / 2); - }, - }; - } - }; -}; - test { @import("std").testing.refAllDecls(@This()); } @@ -512,197 +110,3 @@ test "Variation.Id: slnt should be 1936486004" { try testing.expectEqual(@as(u32, 1936486004), @as(u32, @bitCast(id))); try testing.expectEqualStrings("slnt", &(id.str())); } - -test "Constraints" { - const comparison = @import("../datastruct/comparison.zig"); - const getConstraint = @import("nerd_font_attributes.zig").getConstraint; - - // Hardcoded data matches metrics from CoreText at size 12 and DPI 96. - - // Define grid metrics (matches font-family = JetBrains Mono) - const metrics: Metrics = .{ - .cell_width = 10, - .cell_height = 22, - .cell_baseline = 5, - .underline_position = 19, - .underline_thickness = 1, - .strikethrough_position = 12, - .strikethrough_thickness = 1, - .overline_position = 0, - .overline_thickness = 1, - .box_thickness = 1, - .cursor_thickness = 1, - .cursor_height = 22, - .icon_height = 21.12, - .icon_height_single = 44.48 / 3.0, - .face_width = 9.6, - .face_height = 21.12, - .face_y = 0.2, - }; - - // ASCII (no constraint). - { - const constraint: RenderOptions.Constraint = .none; - - // BBox of 'x' from JetBrains Mono. - const glyph_x: GlyphSize = .{ - .width = 6.784, - .height = 15.28, - .x = 1.408, - .y = 4.84, - }; - - // Any constraint width: do nothing. - inline for (.{ 1, 2 }) |constraint_width| { - try comparison.expectApproxEqual( - glyph_x, - constraint.constrain(glyph_x, metrics, constraint_width), - ); - } - } - - // Symbol (same constraint as hardcoded in Renderer.addGlyph). - { - const constraint: RenderOptions.Constraint = .{ .size = .fit }; - - // BBox of '■' (0x25A0 black square) from Iosevka. - // NOTE: This glyph is designed to span two cells. - const glyph_25A0: GlyphSize = .{ - .width = 10.272, - .height = 10.272, - .x = 2.864, - .y = 5.304, - }; - - // Constraint width 1: scale down and shift to fit a single cell. - try comparison.expectApproxEqual( - GlyphSize{ - .width = metrics.face_width, - .height = metrics.face_width, - .x = 0, - .y = 5.64, - }, - constraint.constrain(glyph_25A0, metrics, 1), - ); - - // Constraint width 2: do nothing. - try comparison.expectApproxEqual( - glyph_25A0, - constraint.constrain(glyph_25A0, metrics, 2), - ); - } - - // Emoji (same constraint as hardcoded in SharedGrid.renderGlyph). - { - const constraint: RenderOptions.Constraint = .{ - .size = .cover, - .align_horizontal = .center, - .align_vertical = .center, - .pad_left = 0.025, - .pad_right = 0.025, - }; - - // BBox of '🥸' (0x1F978) from Apple Color Emoji. - const glyph_1F978: GlyphSize = .{ - .width = 20, - .height = 20, - .x = 0.46, - .y = 1, - }; - - // Constraint width 2: scale to cover two cells with padding, center; - try comparison.expectApproxEqual( - GlyphSize{ - .width = 18.72, - .height = 18.72, - .x = 0.44, - .y = 1.4, - }, - constraint.constrain(glyph_1F978, metrics, 2), - ); - } - - // Nerd Font default. - { - const constraint = getConstraint(0xea61).?; - - // Verify that this is the constraint we expect. - try std.testing.expectEqual(.fit_cover1, constraint.size); - try std.testing.expectEqual(.icon, constraint.height); - try std.testing.expectEqual(.center1, constraint.align_horizontal); - try std.testing.expectEqual(.center1, constraint.align_vertical); - - // BBox of '' (0xEA61 nf-cod-lightbulb) from Symbols Only. - // NOTE: This icon is part of a group, so the - // constraint applies to a larger bounding box. - const glyph_EA61: GlyphSize = .{ - .width = 9.015625, - .height = 13.015625, - .x = 3.015625, - .y = 3.76525, - }; - - // Constraint width 1: scale and shift group to fit a single cell. - try comparison.expectApproxEqual( - GlyphSize{ - .width = 7.2125, - .height = 10.4125, - .x = 0.8125, - .y = 5.950695224719102, - }, - constraint.constrain(glyph_EA61, metrics, 1), - ); - - // Constraint width 2: no scaling; left-align and vertically center group. - try comparison.expectApproxEqual( - GlyphSize{ - .width = glyph_EA61.width, - .height = glyph_EA61.height, - .x = 1.015625, - .y = 4.7483690308988775, - }, - constraint.constrain(glyph_EA61, metrics, 2), - ); - } - - // Nerd Font stretch. - { - const constraint = getConstraint(0xe0c0).?; - - // Verify that this is the constraint we expect. - try std.testing.expectEqual(.stretch, constraint.size); - try std.testing.expectEqual(.cell, constraint.height); - try std.testing.expectEqual(.start, constraint.align_horizontal); - try std.testing.expectEqual(.center1, constraint.align_vertical); - - // BBox of ' ' (0xE0C0 nf-ple-flame_thick) from Symbols Only. - const glyph_E0C0: GlyphSize = .{ - .width = 16.796875, - .height = 16.46875, - .x = -0.796875, - .y = 1.7109375, - }; - - // Constraint width 1: stretch and position to exactly cover one cell. - try comparison.expectApproxEqual( - GlyphSize{ - .width = @floatFromInt(metrics.cell_width), - .height = @floatFromInt(metrics.cell_height), - .x = 0, - .y = 0, - }, - constraint.constrain(glyph_E0C0, metrics, 1), - ); - - // Constraint width 1: stretch and position to exactly cover two cells. - try comparison.expectApproxEqual( - GlyphSize{ - .width = @floatFromInt(2 * metrics.cell_width), - .height = @floatFromInt(metrics.cell_height), - .x = 0, - .y = 0, - }, - constraint.constrain(glyph_E0C0, metrics, 2), - ); - } -} diff --git a/vendor/libghostty-vt/src/font/face/coretext.zig b/vendor/libghostty-vt/src/font/face/coretext.zig index 1d133388..cd8bc7d8 100644 --- a/vendor/libghostty-vt/src/font/face/coretext.zig +++ b/vendor/libghostty-vt/src/font/face/coretext.zig @@ -291,7 +291,7 @@ pub const Face = struct { alloc: Allocator, atlas: *font.Atlas, glyph_index: u32, - opts: font.face.RenderOptions, + opts: font.Glyph.RenderOptions, ) !font.Glyph { var glyphs = [_]macos.graphics.Glyph{@intCast(glyph_index)}; diff --git a/vendor/libghostty-vt/src/font/face/freetype.zig b/vendor/libghostty-vt/src/font/face/freetype.zig index 528f72d5..d04fe180 100644 --- a/vendor/libghostty-vt/src/font/face/freetype.zig +++ b/vendor/libghostty-vt/src/font/face/freetype.zig @@ -393,7 +393,7 @@ pub const Face = struct { } /// Get a rect that represents the position and size of the loaded glyph. - fn getGlyphSize(glyph: freetype.c.FT_GlyphSlot) font.face.GlyphSize { + fn getGlyphSize(glyph: freetype.c.FT_GlyphSlot) font.Glyph.Size { // If we're dealing with an outline glyph then we get the // outline's bounding box instead of using the built-in // metrics, since that's more precise and allows better @@ -427,7 +427,7 @@ pub const Face = struct { alloc: Allocator, atlas: *font.Atlas, glyph_index: u32, - opts: font.face.RenderOptions, + opts: font.Glyph.RenderOptions, ) !Glyph { self.ft_mutex.lock(); defer self.ft_mutex.unlock(); diff --git a/vendor/libghostty-vt/src/font/face/web_canvas.zig b/vendor/libghostty-vt/src/font/face/web_canvas.zig index b4f9f5d5..a36a482d 100644 --- a/vendor/libghostty-vt/src/font/face/web_canvas.zig +++ b/vendor/libghostty-vt/src/font/face/web_canvas.zig @@ -189,7 +189,7 @@ pub const Face = struct { alloc: Allocator, atlas: *font.Atlas, glyph_index: u32, - opts: font.face.RenderOptions, + opts: font.Glyph.RenderOptions, ) !font.Glyph { _ = opts; diff --git a/vendor/libghostty-vt/src/font/glyf_rasterize.zig b/vendor/libghostty-vt/src/font/glyf_rasterize.zig new file mode 100644 index 00000000..f321214b --- /dev/null +++ b/vendor/libghostty-vt/src/font/glyf_rasterize.zig @@ -0,0 +1,825 @@ +//! Rasterization for OpenType glyf outlines. +//! +//! This module intentionally lives in `font` rather than `font/opentype` +//! because I wanted to keep `font/opentype` dependency free on the font +//! package. + +const std = @import("std"); +const assert = std.debug.assert; +const Allocator = std.mem.Allocator; +const z2d = @import("z2d"); + +const Glyph = @import("Glyph.zig"); +const glyf = @import("opentype/glyf.zig"); + +const DesignMetrics = Glyph.DesignMetrics; + +/// An owned, tightly packed alpha8 bitmap. +pub const Bitmap = struct { + width: u32, + height: u32, + data: []u8, + + // An empty 0x0 bitmap. + pub const empty: Bitmap = .{ .width = 0, .height = 0, .data = "" }; + + pub fn initEmpty(alloc: Allocator, width: u32, height: u32) Allocator.Error!Bitmap { + const data = try alloc.alloc(u8, @as(usize, width) * @as(usize, height)); + @memset(data, 0); + return .{ .width = width, .height = height, .data = data }; + } + + pub fn deinit(self: *Bitmap, alloc: Allocator) void { + alloc.free(self.data); + self.* = undefined; + } +}; + +pub const Error = Allocator.Error || z2d.Path.Error || z2d.painter.FillError; + +/// Rasterize a decoded glyf outline to a full-cell alpha bitmap. +/// +/// The returned bitmap is always `grid_metrics.cell_width * cell_width` by +/// `grid_metrics.cell_height`. `opts.constraint` is applied using the same +/// `RenderOptions.Constraint` machinery used by the platform font +/// backends. +/// +/// The caller owns the returned bitmap. +pub fn rasterize( + alloc: Allocator, + outline: glyf.Glyf.Outline, + design: DesignMetrics, + opts: Glyph.RenderOptions, +) Error!Bitmap { + assert(design.units_per_em > 0); + assert(design.advance_width > 0); + assert(design.line_height > 0); + + // Calculate our final width/height. + const width: u32 = std.math.mul( + u32, + opts.grid_metrics.cell_width, + opts.cell_width orelse 1, + ) catch std.math.maxInt(u32); + const height = opts.grid_metrics.cell_height; + assert(width > 0 and height > 0); + + // If we have no contours or points then we have no drawable shape, but the + // caller still asked for a cell-sized bitmap. Return that full bitmap with + // zero coverage so downstream atlas/upload code doesn't need a separate + // size contract for empty glyphs. + if (outline.contours.len == 0 or outline.points.len == 0) return Bitmap.initEmpty(alloc, width, height); + + // Glyf entries have a header bounding box, but this rasterizer operates on + // the decoded Outline only. Recompute bounds from the decoded coordinate + // data so placement and scaling follow the geometry we actually draw. If a + // source glyf header disagrees with its points, the point data is the safer + // source of truth for rasterization; the header belongs in decode-time + // validation/metadata, not this font-level drawing API. + const bounds: Bounds = bounds: { + var bounds: Bounds = .{ + .x_min = @floatFromInt(outline.points[0].x), + .y_min = @floatFromInt(outline.points[0].y), + .x_max = @floatFromInt(outline.points[0].x), + .y_max = @floatFromInt(outline.points[0].y), + }; + for (outline.points[1..]) |p| { + const x: f64 = @floatFromInt(p.x); + const y: f64 = @floatFromInt(p.y); + bounds.x_min = @min(bounds.x_min, x); + bounds.y_min = @min(bounds.y_min, y); + bounds.x_max = @max(bounds.x_max, x); + bounds.y_max = @max(bounds.y_max, y); + } + break :bounds bounds; + }; + + // Degenerate point bounds can't produce filled area and would make the + // point-to-bitmap transform divide by zero, so return a full transparent + // 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( + .image_surface_alpha8, + alloc, + @intCast(width), + @intCast(height), + ); + defer sfc.deinit(alloc); + + var path: z2d.Path = .empty; + defer path.deinit(alloc); + + const placement: Placement = .init(bounds, design, opts); + for (0..outline.contours.len) |i| try appendContourPath( + alloc, + &path, + outline.contour(i), + bounds, + placement, + ); + + try z2d.painter.fill( + alloc, + &sfc, + &.{ .opaque_pattern = .{ + .pixel = .{ .alpha8 = .{ .a = 255 } }, + } }, + path.nodes.items, + .{}, + ); + + return .{ + .width = width, + .height = height, + .data = try alloc.dupe(u8, std.mem.sliceAsBytes(sfc.image_surface_alpha8.buf)), + }; +} + +const Bounds = struct { + x_min: f64, + y_min: f64, + x_max: f64, + y_max: f64, + + fn width(self: Bounds) f64 { + return self.x_max - self.x_min; + } + + fn height(self: Bounds) f64 { + return self.y_max - self.y_min; + } +}; + +/// Cell-relative pixel rectangle where the decoded outline bounds should be +/// rasterized within the output bitmap. +/// +/// This is deliberately the placement of the outline's computed point bounds, +/// not the full declared advance/line-height box. `advance_width` and +/// `line_height` describe the design-space layout box the outline was drawn +/// within: they include intentional bearings and whitespace around the visible +/// points. We use that declared box when applying `RenderOptions.Constraint` so +/// sizing and alignment preserve those bearings consistently with other font +/// backends; once that is resolved, we rasterize only the actual outline bounds +/// into this rectangle. +/// +/// ```text +/// output bitmap / terminal cell +/// ╭────────────────────────────────────────────────────────────────────────╮ top +/// │ │ +/// │ declared advance/line-height box │ +/// │ (outer layout box used for constraints) │ +/// │ ╭────────────────────────────────────────────────────────────────╮ │ +/// │ │ │ │ +/// │◀──────── x ────────▶╭────────── width ──────────╮ │ │ +/// │ │ │ Placement │ ▲ height │ │ +/// │ │ │ outline point bounds │ │ │ │ +/// │ │ │ pixels to draw │ │ │ │ +/// │ │ │ │ │ │ │ +/// │ │ ╰───────────────────────────╯ ▼ │ │ +/// │ ╰─────────────────▲──────────────────────────────────────────────╯ │ +/// │ │ y │ +/// ╰────────────────────────────────────────────────────────────────────────╯ bottom +/// x is measured from the bitmap left to the Placement left. +/// y is measured from the bitmap bottom to the Placement bottom. +/// bitmap_height is the full top-to-bottom bitmap height. +/// ``` +/// +/// Constraints are applied to the outer box so the whitespace remains part of +/// alignment decisions. `Placement` is the inner rectangle after that outer box +/// has been constrained. +const Placement = struct { + /// Left edge of the rasterized outline bounds in bitmap pixels, measured + /// from the bitmap's left edge. + x: f64, + + /// Bottom edge of the rasterized outline bounds in bitmap pixels, measured + /// from the bitmap's bottom edge. This matches the cell-relative y axis + /// used by font.Glyph.Size and is converted to z2d's y-down axis when + /// points are transformed. + y: f64, + + /// Width of the rasterized outline bounds in bitmap pixels after applying + /// font.Glyph.RenderOptions.Constraint. + width: f64, + + /// Height of the rasterized outline bounds in bitmap pixels after applying + /// font.Glyph.RenderOptions.Constraint. + height: f64, + + /// Full bitmap height in pixels, used to convert cell-relative y-up-ish + /// placement into the y-down coordinate system used by z2d surfaces. + bitmap_height: f64, + + /// Calculate where the decoded point bounds should land in the output + /// bitmap. + /// + /// The glyf protocol supplies declared metrics (`units_per_em`, + /// `advance_width`, and `line_height`) in design units, while Ghostty's + /// font constraint code works in cell-relative pixels. We first map the em + /// square to one cell height, matching the linked glyph rasterizer's + /// baseline model where design-space `y=0` is the bottom/baseline of the em + /// and `y=units_per_em` is its top. Then we describe the actual outline + /// bounds as a relative sub-rectangle of the declared advance/line-height + /// box. That declared box includes any intentional side bearings or + /// vertical whitespace around the outline; constraints should apply to that + /// layout box rather than to the tight point bounds alone. This returns the + /// final pixel rectangle for only the outline bounds that we will rasterize. + fn init( + bounds: Bounds, + design: DesignMetrics, + opts: Glyph.RenderOptions, + ) Placement { + // Start with protocol-like design units mapped so that the em square + // occupies one cell. This makes units_per_em the scale reference and + // preserves the linked rasterizer's y=0 baseline/bottom behavior. + // Callers can then use RenderOptions.Constraint to fit/cover/stretch/ + // align the declared advance/line-height box using existing font logic. + const scale = @as(f64, @floatFromInt(opts.grid_metrics.cell_height)) / + @as(f64, @floatFromInt(design.units_per_em)); + + // Convert the decoded point bounds into the same pixel coordinate space + // expected by RenderOptions.Constraint. This rectangle is the visible + // outline bounds, not the full advance/line-height layout box. + const glyph: Glyph.Size = .{ + .width = bounds.width() * scale, + .height = bounds.height() * scale, + .x = bounds.x_min * scale, + .y = bounds.y_min * scale, + }; + + // Convert the declared layout box to pixels. This is the box that + // carries intentional bearings/whitespace and should be constrained. + const group_width = @as(f64, @floatFromInt(design.advance_width)) * scale; + const group_height = @as(f64, @floatFromInt(design.line_height)) * scale; + + // Apply the same fit/cover/stretch/alignment/padding rules used by + // normal font rendering. The result is still the outline bounds, but + // placed as if its containing advance/line-height box was constrained. + const constraint: Glyph.RenderOptions.Constraint = constraint: { + var constraint = opts.constraint; + if (group_width > 0 and group_height > 0) { + // Tell Constraint that `glyph` is a sub-rectangle of the + // declared layout box. Constraint will size/align the outer box + // and then return the corresponding transformed inner box. + constraint.relative_width = glyph.width / group_width; + constraint.relative_height = glyph.height / group_height; + constraint.relative_x = glyph.x / group_width; + constraint.relative_y = glyph.y / group_height; + } + break :constraint constraint; + }; + const constrained = constraint.constrain( + glyph, + opts.grid_metrics, + opts.constraint_width, + ); + + // Store the final outline placement plus the full bitmap height needed + // later to flip from cell-relative y to z2d's y-down surface space. + return .{ + .x = constrained.x, + .y = constrained.y, + .width = constrained.width, + .height = constrained.height, + .bitmap_height = @floatFromInt(opts.grid_metrics.cell_height), + }; + } +}; + +const Point = struct { + x: f64, + y: f64, +}; + +/// Append one contour to a z2d path. +/// +/// Glyf contours are quadratic outlines with explicit on-curve points and +/// off-curve control points. Consecutive off-curve points imply an on-curve +/// point halfway between them, and a contour may begin with an off-curve point. +/// This normalizes those cases while walking the closed contour and emits z2d +/// line/cubic-curve operations in bitmap coordinates. +fn appendContourPath( + alloc: Allocator, + path: *z2d.Path, + contour: []const glyf.Glyf.Outline.Point, + bounds: Bounds, + placement: Placement, +) Error!void { + if (contour.len == 0) return; + + const first = contour[0]; + const last = contour[contour.len - 1]; + + var current: Point = undefined; + var i: usize = 0; + + // Choose the starting on-curve point for this closed contour. If the first + // point is off-curve then the contour logically starts either at the final + // on-curve point, or at the implied midpoint between the final and first + // off-curve points. + if (first.on_curve) { + i = 1; + current = transformPoint( + first, + bounds, + placement, + ); + } else if (last.on_curve) { + current = transformPoint( + last, + bounds, + placement, + ); + } else { + current = midpoint( + transformPoint(last, bounds, placement), + transformPoint(first, bounds, placement), + ); + } + + // Move to the beginning + try path.moveTo(alloc, current.x, current.y); + + // Go through the points and connect em! + while (i < contour.len) { + const p = contour[i]; + + // On-curve points connect to the current point with a straight line. + if (p.on_curve) { + current = transformPoint(p, bounds, placement); + try path.lineTo(alloc, current.x, current.y); + i += 1; + continue; + } + + // Off-curve points are quadratic control points. The following point is + // either the curve endpoint or, if it is also off-curve, contributes an + // implied on-curve endpoint halfway between the two controls. + const control = transformPoint(p, bounds, placement); + const next = contour[(i + 1) % contour.len]; + const end = if (next.on_curve) transformPoint( + next, + bounds, + placement, + ) else midpoint( + control, + transformPoint(next, bounds, placement), + ); + + // z2d paths only expose cubic curves, so convert the TrueType + // quadratic segment to an equivalent cubic segment before appending it. + const c1 = Point{ + .x = current.x + ((2.0 / 3.0) * (control.x - current.x)), + .y = current.y + ((2.0 / 3.0) * (control.y - current.y)), + }; + const c2 = Point{ + .x = end.x + ((2.0 / 3.0) * (control.x - end.x)), + .y = end.y + ((2.0 / 3.0) * (control.y - end.y)), + }; + try path.curveTo( + alloc, + c1.x, + c1.y, + c2.x, + c2.y, + end.x, + end.y, + ); + + current = end; + + // If we consumed an explicit on-curve endpoint then skip it; otherwise + // the next off-curve point still needs to be used as the control point + // for the following quadratic segment. + i += if (next.on_curve) 2 else 1; + } + + try path.close(alloc); +} + +/// Convert a decoded glyf point from design-space coordinates to z2d bitmap +/// coordinates. +/// +/// `bounds` describes the decoded outline's point/control bounds in glyf +/// design units. `placement` describes where those bounds should land in the +/// output bitmap after constraints are applied. Glyf coordinates are y-up; z2d +/// surfaces are y-down, so this also flips the y axis using +/// `placement.bitmap_height`. +fn transformPoint( + p: glyf.Glyf.Outline.Point, + bounds: Bounds, + placement: Placement, +) Point { + const scale_x = placement.width / bounds.width(); + const scale_y = placement.height / bounds.height(); + const x_design: f64 = @floatFromInt(p.x); + const y_design: f64 = @floatFromInt(p.y); + return .{ + .x = placement.x + ((x_design - bounds.x_min) * scale_x), + .y = placement.bitmap_height - placement.y - + ((y_design - bounds.y_min) * scale_y), + }; +} + +/// Return the implied on-curve point between two off-curve TrueType control +/// points. +fn midpoint(a: Point, b: Point) Point { + return .{ + .x = (a.x + b.x) / 2.0, + .y = (a.y + b.y) / 2.0, + }; +} + +fn testMetrics(width: u32, height: u32) @import("Metrics.zig") { + return .{ + .cell_width = width, + .cell_height = height, + .cell_baseline = 0, + .underline_position = height, + .underline_thickness = 1, + .strikethrough_position = height / 2, + .strikethrough_thickness = 1, + .overline_position = 0, + .overline_thickness = 1, + .box_thickness = 1, + .cursor_thickness = 1, + .cursor_height = height, + .icon_height = @floatFromInt(height), + .icon_height_single = @floatFromInt(height), + .face_width = @floatFromInt(width), + .face_height = @floatFromInt(height), + .face_y = 0, + }; +} + +test { + _ = @import("glyf_rasterize_png_test.zig"); +} + +test "glyf_rasterize: empty outline returns empty bitmap" { + const testing = std.testing; + const alloc = testing.allocator; + + var bm = try rasterize(alloc, .{ .points = &.{}, .contours = &.{} }, .{ + .units_per_em = 1000, + .advance_width = 1000, + .line_height = 1000, + }, .{ + .grid_metrics = testMetrics(20, 20), + }); + defer bm.deinit(alloc); + + try testing.expectEqual(@as(u32, 20), bm.width); + try testing.expectEqual(@as(u32, 20), bm.height); + try testing.expectEqual(@as(usize, 20 * 20), bm.data.len); + for (bm.data) |v| try testing.expectEqual(@as(u8, 0), v); +} + +test "glyf_rasterize: square fills bitmap center" { + const testing = std.testing; + const alloc = testing.allocator; + + const outline: glyf.Glyf.Outline = .{ + .points = &.{ + .{ .x = 0, .y = 0, .on_curve = true }, + .{ .x = 1000, .y = 0, .on_curve = true }, + .{ .x = 1000, .y = 1000, .on_curve = true }, + .{ .x = 0, .y = 1000, .on_curve = true }, + }, + .contours = &.{3}, + }; + + var bm = try rasterize(alloc, outline, .{ + .units_per_em = 1000, + .advance_width = 1000, + .line_height = 1000, + }, .{ + .grid_metrics = testMetrics(20, 20), + }); + defer bm.deinit(alloc); + + try testing.expect(bm.data[10 * bm.width + 10] > 200); +} + +test "glyf_rasterize: quadratic contour renders" { + const testing = std.testing; + const alloc = testing.allocator; + + const outline: glyf.Glyf.Outline = .{ + .points = &.{ + .{ .x = 0, .y = 0, .on_curve = true }, + .{ .x = 500, .y = 1000, .on_curve = false }, + .{ .x = 1000, .y = 0, .on_curve = true }, + }, + .contours = &.{2}, + }; + + var bm = try rasterize(alloc, outline, .{ + .units_per_em = 1000, + .advance_width = 1000, + .line_height = 1000, + }, .{ + .grid_metrics = testMetrics(20, 20), + }); + defer bm.deinit(alloc); + + var nonzero = false; + for (bm.data) |v| nonzero = nonzero or v != 0; + try testing.expect(nonzero); +} + +test "glyf_rasterize: consecutive off-curve points render" { + const testing = std.testing; + const alloc = testing.allocator; + + const outline: glyf.Glyf.Outline = .{ + .points = &.{ + .{ .x = 0, .y = 0, .on_curve = true }, + .{ .x = 250, .y = 1000, .on_curve = false }, + .{ .x = 750, .y = 1000, .on_curve = false }, + .{ .x = 1000, .y = 0, .on_curve = true }, + }, + .contours = &.{3}, + }; + + var bm = try rasterize(alloc, outline, .{ + .units_per_em = 1000, + .advance_width = 1000, + .line_height = 1000, + }, .{ + .grid_metrics = testMetrics(20, 20), + }); + defer bm.deinit(alloc); + + var nonzero = false; + for (bm.data) |v| nonzero = nonzero or v != 0; + try testing.expect(nonzero); +} + +test "glyf_rasterize: units per em controls baseline scale" { + const testing = std.testing; + const alloc = testing.allocator; + + const outline: glyf.Glyf.Outline = .{ + .points = &.{ + .{ .x = 0, .y = 0, .on_curve = true }, + .{ .x = 1000, .y = 0, .on_curve = true }, + .{ .x = 1000, .y = 1000, .on_curve = true }, + .{ .x = 0, .y = 1000, .on_curve = true }, + }, + .contours = &.{3}, + }; + + var bm = try rasterize(alloc, outline, .{ + .units_per_em = 2000, + .advance_width = 1000, + .line_height = 1000, + }, .{ + .grid_metrics = testMetrics(20, 20), + }); + defer bm.deinit(alloc); + + // With a 2000-unit em in a 20px cell, this 1000-unit square occupies the + // bottom half of the cell. This matches the linked rasterizer's y=0 + // baseline/bottom behavior and proves units_per_em is the scale reference. + try testing.expect(bm.data[15 * bm.width + 5] > 200); + try testing.expectEqual(@as(u8, 0), bm.data[5 * bm.width + 5]); +} + +test "glyf_rasterize: degenerate outline returns full empty bitmap" { + const testing = std.testing; + const alloc = testing.allocator; + + const outline: glyf.Glyf.Outline = .{ + .points = &.{ + .{ .x = 0, .y = 0, .on_curve = true }, + .{ .x = 1000, .y = 0, .on_curve = true }, + .{ .x = 500, .y = 0, .on_curve = true }, + }, + .contours = &.{2}, + }; + + var bm = try rasterize(alloc, outline, .{ + .units_per_em = 1000, + .advance_width = 1000, + .line_height = 1000, + }, .{ + .grid_metrics = testMetrics(20, 20), + }); + defer bm.deinit(alloc); + + try testing.expectEqual(@as(u32, 20), bm.width); + try testing.expectEqual(@as(u32, 20), bm.height); + try testing.expectEqual(@as(usize, 20 * 20), bm.data.len); + for (bm.data) |v| try testing.expectEqual(@as(u8, 0), v); +} + +test "glyf_rasterize: contour can start off curve with final on curve point" { + const testing = std.testing; + const alloc = testing.allocator; + + const outline: glyf.Glyf.Outline = .{ + .points = &.{ + .{ .x = 500, .y = 1000, .on_curve = false }, + .{ .x = 1000, .y = 0, .on_curve = true }, + .{ .x = 0, .y = 0, .on_curve = true }, + }, + .contours = &.{2}, + }; + + var bm = try rasterize(alloc, outline, .{ + .units_per_em = 1000, + .advance_width = 1000, + .line_height = 1000, + }, .{ + .grid_metrics = testMetrics(20, 20), + }); + defer bm.deinit(alloc); + + var nonzero = false; + for (bm.data) |v| nonzero = nonzero or v != 0; + try testing.expect(nonzero); +} + +test "glyf_rasterize: contour can start with implied midpoint" { + const testing = std.testing; + const alloc = testing.allocator; + + const outline: glyf.Glyf.Outline = .{ + .points = &.{ + .{ .x = 250, .y = 1000, .on_curve = false }, + .{ .x = 1000, .y = 0, .on_curve = true }, + .{ .x = 750, .y = 1000, .on_curve = false }, + .{ .x = 0, .y = 0, .on_curve = true }, + }, + .contours = &.{3}, + }; + + var bm = try rasterize(alloc, outline, .{ + .units_per_em = 1000, + .advance_width = 1000, + .line_height = 1000, + }, .{ + .grid_metrics = testMetrics(20, 20), + }); + defer bm.deinit(alloc); + + var nonzero = false; + for (bm.data) |v| nonzero = nonzero or v != 0; + try testing.expect(nonzero); +} + +test "glyf_rasterize: multiple contours render independently" { + const testing = std.testing; + const alloc = testing.allocator; + + const outline: glyf.Glyf.Outline = .{ + .points = &.{ + .{ .x = 0, .y = 0, .on_curve = true }, + .{ .x = 400, .y = 0, .on_curve = true }, + .{ .x = 400, .y = 400, .on_curve = true }, + .{ .x = 0, .y = 400, .on_curve = true }, + .{ .x = 600, .y = 600, .on_curve = true }, + .{ .x = 1000, .y = 600, .on_curve = true }, + .{ .x = 1000, .y = 1000, .on_curve = true }, + .{ .x = 600, .y = 1000, .on_curve = true }, + }, + .contours = &.{ 3, 7 }, + }; + + var bm = try rasterize(alloc, outline, .{ + .units_per_em = 1000, + .advance_width = 1000, + .line_height = 1000, + }, .{ + .grid_metrics = testMetrics(20, 20), + }); + defer bm.deinit(alloc); + + try testing.expect(bm.data[16 * bm.width + 4] > 200); + try testing.expect(bm.data[4 * bm.width + 16] > 200); + try testing.expectEqual(@as(u8, 0), bm.data[10 * bm.width + 10]); +} + +test "glyf_rasterize: non-zero bearings preserve declared whitespace" { + const testing = std.testing; + const alloc = testing.allocator; + + const outline: glyf.Glyf.Outline = .{ + .points = &.{ + .{ .x = 250, .y = 0, .on_curve = true }, + .{ .x = 750, .y = 0, .on_curve = true }, + .{ .x = 750, .y = 1000, .on_curve = true }, + .{ .x = 250, .y = 1000, .on_curve = true }, + }, + .contours = &.{3}, + }; + + var bm = try rasterize(alloc, outline, .{ + .units_per_em = 1000, + .advance_width = 1000, + .line_height = 1000, + }, .{ + .grid_metrics = testMetrics(20, 20), + }); + defer bm.deinit(alloc); + + try testing.expectEqual(@as(u8, 0), bm.data[10 * bm.width + 2]); + try testing.expect(bm.data[10 * bm.width + 10] > 200); + try testing.expectEqual(@as(u8, 0), bm.data[10 * bm.width + 17]); +} + +test "glyf_rasterize: negative y coordinates descend below baseline" { + const testing = std.testing; + const alloc = testing.allocator; + + const outline: glyf.Glyf.Outline = .{ + .points = &.{ + .{ .x = 0, .y = -250, .on_curve = true }, + .{ .x = 1000, .y = -250, .on_curve = true }, + .{ .x = 1000, .y = 750, .on_curve = true }, + .{ .x = 0, .y = 750, .on_curve = true }, + }, + .contours = &.{3}, + }; + + var bm = try rasterize(alloc, outline, .{ + .units_per_em = 1000, + .advance_width = 1000, + .line_height = 1000, + }, .{ + .grid_metrics = testMetrics(20, 20), + }); + defer bm.deinit(alloc); + + try testing.expectEqual(@as(u8, 0), bm.data[2 * bm.width + 10]); + try testing.expect(bm.data[10 * bm.width + 10] > 200); + try testing.expect(bm.data[18 * bm.width + 10] > 200); +} + +test "glyf_rasterize: two-cell bitmap and constraint render within width" { + const testing = std.testing; + const alloc = testing.allocator; + + const outline: glyf.Glyf.Outline = .{ + .points = &.{ + .{ .x = 0, .y = 0, .on_curve = true }, + .{ .x = 1000, .y = 0, .on_curve = true }, + .{ .x = 1000, .y = 1000, .on_curve = true }, + .{ .x = 0, .y = 1000, .on_curve = true }, + }, + .contours = &.{3}, + }; + + var bm = try rasterize(alloc, outline, .{ + .units_per_em = 1000, + .advance_width = 1000, + .line_height = 1000, + }, .{ + .grid_metrics = testMetrics(20, 20), + .cell_width = 2, + .constraint_width = 2, + .constraint = .{ + .size = .cover, + .align_horizontal = .center, + .align_vertical = .center, + }, + }); + defer bm.deinit(alloc); + + try testing.expectEqual(@as(u32, 40), bm.width); + try testing.expectEqual(@as(u32, 20), bm.height); + try testing.expectEqual(@as(u8, 0), bm.data[10 * bm.width + 2]); + try testing.expect(bm.data[10 * bm.width + 20] > 200); + try testing.expectEqual(@as(u8, 0), bm.data[10 * bm.width + 37]); +} + +test "glyf_rasterize: line height does not change unconstrained em scale" { + const testing = std.testing; + const alloc = testing.allocator; + + const outline: glyf.Glyf.Outline = .{ + .points = &.{ + .{ .x = 0, .y = 0, .on_curve = true }, + .{ .x = 1000, .y = 0, .on_curve = true }, + .{ .x = 1000, .y = 1000, .on_curve = true }, + .{ .x = 0, .y = 1000, .on_curve = true }, + }, + .contours = &.{3}, + }; + + var bm = try rasterize(alloc, outline, .{ + .units_per_em = 1000, + .advance_width = 1000, + .line_height = 2000, + }, .{ + .grid_metrics = testMetrics(20, 20), + }); + defer bm.deinit(alloc); + + try testing.expect(bm.data[10 * bm.width + 10] > 200); + try testing.expect(bm.data[2 * bm.width + 10] > 200); + try testing.expect(bm.data[17 * bm.width + 10] > 200); +} diff --git a/vendor/libghostty-vt/src/font/glyf_rasterize_png_test.zig b/vendor/libghostty-vt/src/font/glyf_rasterize_png_test.zig new file mode 100644 index 00000000..e8ad70fd --- /dev/null +++ b/vendor/libghostty-vt/src/font/glyf_rasterize_png_test.zig @@ -0,0 +1,301 @@ +const std = @import("std"); +const Allocator = std.mem.Allocator; +const wuffs = @import("wuffs"); +const z2d = @import("z2d"); + +const glyf_rasterize = @import("glyf_rasterize.zig"); +const glyf = @import("opentype/glyf.zig"); + +const log = std.log.scoped(.glyf_rasterize); + +const test_glyf_payloads = [_][]const u8{ + // Nerd Font branch, folder, home, heart, and Rust cog outlines from: + // https://github.com/raphamorim/glyph-protocol-examples/blob/main/bubbletea/main.go + "AAIARv8zAhIDnQAZAB0AABcjNTQ3Njc3Njc2NTUjNxcjFRQGBwcGBwYVEQcRM82HJxs3SyoUE2aPjmY0NCUvDxSHh83rVzgoIzAbKSZAoaenvF5kIhkfHSM9AVdXAn8=", + "AAEAAP/UA5wC/AAVAAAXIiY1ETQ2MzMyFxcWMyEyFgcRFgYjcy9ERC/nOCQjER0BITBEAQFEMCxELwJBMEQvLhdEL/4yL0Q=", + "AAEAAP+aBBEDNgA+AAABFAYjIxMUBxUUBisFIiY9AjQmIyMiBh0CFAYrAiIiJwYiIyMiJjc1MjQ1NSMiJjQ3ATYzMhcBFgQOJBY5AQEqHh0GBzsrHioiGHMYIioeLDkBBAMBBAIcHiwBAToYIhIBzg4aFw8BzBcBahgi/t4KBB4eKioeLHQYIiIYdCweKgICKh7KBAJ+IDISAZQODP5qFA==", + "AAEAAP/dA5sC+QAZAAATJjU1NDY3NhYXFzc2NhcWFhUVFAcBBiMiJ1ZWel09eCwWFSx4PV16Vv66FB0eFAEhUHULXpAQCiYsFhYsJgoQkF4LdVD+zxMT", + "AAoAAP/YAyEC+AENARcBWgFlAW8BfQGIAbsBxAHNAAAAMhYXFhYyNjc2MzIXFhcWFxY3NjMyFhcWFxY3NjMyFxcHBxcWNzYXFgcGFxYzMhcWBw4CFAcUFQcUFxYWFRQGFhcWFgcGFBcWFxYGBwYUFxYGBw4CFhYXFgcGBwYVFBcWBwYjIgYXFgYnJgcGFxcHBiInJgYHBiMiJyYHBgcGBiMiJyYiBwYiJyYmBwYGJyYmJyYHBiImJyYmBwYiJjc3JyYHBicmNzYmIwYmNzY2Nzc0JyYmNTQ3NiYnJiY3NjQnJiY0Njc2NjQnJicmJjY3NjYnJjc3Mjc2NScmJyYnJjYXMjY1NCYmJyc3NhcWNzcnJjYzMhcWNjc2NjMyFxY3Njc2NjMyFxYyNzY3FyIHBhYzMjYnJgczBwYHBhUUMzIXFhYHBgcOAhUGFQciFhcWFxYXFhcWFjc2NzY3NjMzNzYvAiYnJjU0NzcnJicmJicnBwYGJyYnBwYGFxYyNzYmJyYFIgcGFBcWNicmBRcWBwYPAgYXFzM1NRcVMzY3NjU0JyYjBxUzMhcWFQcjIhUGFjMyNzYyFxYXFhUWFhcWNzY/AjY2Fxc2NjQjIiYnJicmJyYnJiMGIgcGFjMyNiclIgcGFjc2JyYBjQQICgcECAgIEQMJBgUCAwUGEBMDBgQEAwUDFBIFAwQEAQEEBRIZBQUGBQMCFxUFBwwBAgICARgSChoEFBcEExEUDwQECBATERMEGAoGCAQEBhEHAxUZCAsGAxcYBAUGChoVAgMBAQQEBhQSCgMECgQRFAUEBgcGBAUQEAgLDQwOCwoODQgFBg4CBRUPDAQEBAgTFAYIAQEEBREaBAYGBQQYGQYKAQQCARgSCg0NBBQXBBMQERIEBg4KCAIDCg0ECBEUBA0QBwQFEBcBAQICAgsIGRgCAgIBBAMFGhIFBAEBCAMFEhMIBAQEBgUREQQGCAcEBgQQDwoLCQQFCQcMDBARCg0HPgEPQjEfoJ8NJzACAykCBAQCAQEEAgIDGAgEAwggCg0BAQMCDBABAgIBHRsDBw4PAx8xFkQTCRQSEAkHEvoMDgcHGAgEBAcFAjAHBA0OFBQTBf3wBAsIAh0cAQMKBFOENjcIERsLLzEkJAECAXl5ARgBBBQZEAYEBQYBRCEnKiYSBgYGECAcARg/NhQLDgkLBQkQBimQEAQPChESCA4BVRAGCigLChEFAvgEEQsIBgkREA8FCAIBDA0KERYCAgkIBAQWFwIDBQYFBBkVAwIDBhkDBAQEAQEBAQcEAwQHBSQICAgMEREICwoFBggKDAgQEQwJBAQCCAgHGAYDAwQIAxAXBwMGFBkKBgQCAxYWBAQJCAQVHAwOAwQQEwYREBMVFBMCEA0GAgQmAgQPDAoSFgQJCQgXFgICBAYEBhgVBgEMFgQKAwMGBAQEBgQTEQgJCQwQEAgLCwQMBAkHBAoDAwkLDAgGCAgSFwcEAwQHAgIGBQQXDAEEBQEGCgQTBAcGBAICFxYICAkEFhEKDA0BARcSBBEPEw8ERQcKHiAKBScEESgaBAIDCjghJh4BBAIBAQEBBAECAhUjEQMJAgcIGBMBAQURFgcNDAMHCgYgIQY0IxAcBAETEwgDBBObARgMCwwIFAQEAgIIHAYKKAsDAwkYDQQNDRAjLg9eXgE4AQQHDhQHA4g0AgQqKwICGgUECAkVHAEHFAMDCAcKAx4iCgcGARwCBAwPJjAHDgQCwgMJIiIJAg0UFBIRDgQ=", +}; + +/// Return deterministic font metrics for the PNG reference test. +/// +/// These are intentionally minimal: the rasterizer only needs cell geometry, +/// face geometry, and icon heights for constraint calculations. +fn testMetrics(width: u32, height: u32) @import("Metrics.zig") { + return .{ + .cell_width = width, + .cell_height = height, + .cell_baseline = 0, + .underline_position = height, + .underline_thickness = 1, + .strikethrough_position = height / 2, + .strikethrough_thickness = 1, + .overline_position = 0, + .overline_thickness = 1, + .box_thickness = 1, + .cursor_thickness = 1, + .cursor_height = height, + .icon_height = @floatFromInt(height), + .icon_height_single = @floatFromInt(height), + .face_width = @floatFromInt(width), + .face_height = @floatFromInt(height), + .face_y = 0, + }; +} + +/// Decode a base64-encoded glyf protocol payload into an owned outline. +/// +/// The payload is a complete simple-glyph `glyf` table entry. The returned +/// outline owns decoded point and contour storage and must be deinitialized by +/// the caller. +fn decodeGlyfPayload(alloc: Allocator, payload: []const u8) !glyf.Glyf.Outline { + const decoder = std.base64.standard.Decoder; + const size = try decoder.calcSizeForSlice(payload); + const data = try alloc.alloc(u8, size); + defer alloc.free(data); + + try decoder.decode(data, payload); + const entry = try glyf.Glyf.Entry.init(data); + return try entry.decode(alloc); +} + +/// Copy a tightly packed alpha bitmap into the alpha atlas at `dst_x`, `dst_y`. +/// +/// The destination rectangle must fit inside the atlas. This is a test helper, +/// so it trusts the hardcoded atlas layout rather than clipping. +fn blitBitmap(atlas: *z2d.Surface, bm: glyf_rasterize.Bitmap, dst_x: usize, dst_y: usize) void { + const dst_width: usize = @intCast(atlas.getWidth()); + const dst = std.mem.sliceAsBytes(atlas.image_surface_alpha8.buf); + for (0..bm.height) |y| { + const src_start = y * bm.width; + const src_end = src_start + bm.width; + const dst_start = (dst_y + y) * dst_width + dst_x; + @memcpy(dst[dst_start .. dst_start + bm.width], bm.data[src_start..src_end]); + } +} + +/// Draw faint terminal-cell outlines into one row of the alpha atlas. +/// +/// The boxes make cell advance and placement behavior visible in the reference +/// PNG without overpowering the rendered glyph coverage. +fn drawCellBoxes(atlas: *z2d.Surface, y: usize, cell_width: usize, cell_height: usize) void { + const width: usize = @intCast(atlas.getWidth()); + const dst = std.mem.sliceAsBytes(atlas.image_surface_alpha8.buf); + const alpha = 64; + + var x: usize = 0; + while (x < width) : (x += cell_width) { + const right = @min(x + cell_width - 1, width - 1); + const bottom = y + cell_height - 1; + + for (x..right + 1) |px| { + dst[y * width + px] = @max(dst[y * width + px], alpha); + dst[bottom * width + px] = @max(dst[bottom * width + px], alpha); + } + for (y..bottom + 1) |py| { + dst[py * width + x] = @max(dst[py * width + x], alpha); + dst[py * width + right] = @max(dst[py * width + right], alpha); + } + } +} + +/// Compare a generated atlas PNG against the checked-in reference image. +/// +/// On missing reference or mismatch, copy the generated PNG into the workspace +/// as `glyf_rasterize_test.png`. On pixel mismatch, also write +/// `glyf_rasterize_diff.png`, where red is reference-only coverage and green is +/// newly generated coverage. Returns true when a difference was found. +fn diffAtlas( + alloc: Allocator, + atlas: *z2d.Surface, + generated_path: []const u8, +) !bool { + const ref_path = "src/font/testdata/glyf_rasterize.png"; + + const generated_file = try std.fs.openFileAbsolute(generated_path, .{ .mode = .read_only }); + defer generated_file.close(); + const generated_bytes = try generated_file.readToEndAlloc(alloc, std.math.maxInt(usize)); + defer alloc.free(generated_bytes); + + const cwd_absolute = try std.fs.cwd().realpathAlloc(alloc, "."); + defer alloc.free(cwd_absolute); + + const ref_file = std.fs.cwd().openFile(ref_path, .{ .mode = .read_only }) catch |err| { + log.err("Can't open reference file {s}: {}", .{ ref_path, err }); + + const test_path = try std.fmt.allocPrint(alloc, "{s}/glyf_rasterize_test.png", .{cwd_absolute}); + defer alloc.free(test_path); + try std.fs.copyFileAbsolute(generated_path, test_path, .{}); + return true; + }; + defer ref_file.close(); + const ref_bytes = try ref_file.readToEndAlloc(alloc, std.math.maxInt(usize)); + defer alloc.free(ref_bytes); + + if (std.mem.eql(u8, generated_bytes, ref_bytes)) return false; + + const test_path = try std.fmt.allocPrint(alloc, "{s}/glyf_rasterize_test.png", .{cwd_absolute}); + defer alloc.free(test_path); + try std.fs.copyFileAbsolute(generated_path, test_path, .{}); + + const ref_rgba = try wuffs.png.decode(alloc, ref_bytes); + defer alloc.free(ref_rgba.data); + + if (ref_rgba.width != atlas.getWidth() or ref_rgba.height != atlas.getHeight()) { + log.err( + "glyf rasterize visual output dimensions differ from reference: " ++ + "test={s} ({d}x{d}), reference={s} ({d}x{d})", + .{ test_path, atlas.getWidth(), atlas.getHeight(), ref_path, ref_rgba.width, ref_rgba.height }, + ); + return true; + } + + var diff = try z2d.Surface.init( + .image_surface_rgb, + alloc, + atlas.getWidth(), + atlas.getHeight(), + ); + defer diff.deinit(alloc); + + const test_gray = std.mem.sliceAsBytes(atlas.image_surface_alpha8.buf); + const diff_pix = diff.image_surface_rgb.buf; + var differs = false; + for (test_gray, 0..) |t, i| { + const r = ref_rgba.data[i * 4]; + if (t == r) { + diff_pix[i].r = t / 3; + diff_pix[i].g = t / 3; + diff_pix[i].b = t / 3; + } else { + differs = true; + diff_pix[i].r = r; + diff_pix[i].g = t; + } + } + + if (!differs) { + log.err( + "generated glyf rasterize PNG bytes differ from reference but pixels match; " ++ + "test={s}, reference={s}", + .{ test_path, ref_path }, + ); + return true; + } + + const diff_path = "./glyf_rasterize_diff.png"; + try z2d.png_exporter.writeToPNGFile(diff, diff_path, .{}); + log.err( + "glyf rasterize visual output differs from reference: test={s}, reference={s}, diff={s}", + .{ test_path, ref_path, diff_path }, + ); + + return true; +} + +test "glyf_rasterize: bubbletea glyph protocol examples match reference image" { + const testing = std.testing; + const alloc = testing.allocator; + + // The generated PNG is a visual atlas for reading placement behavior. + // Each column below is one terminal cell. The five payloads are rendered in + // order: branch, folder, home, heart, rust. + // + // ```text + // columns: 0 1 2 3 4 5 6 7 8 9 + // ┌─┬─┬─┬─┬─┬─┬─┬─┬─┬─┐ + // row 0 │B│F│H│♥│R│ │ │ │ │ │ narrow/default: one-cell bitmap stride + // ├─┼─┼─┼─┼─┼─┼─┼─┼─┼─┤ + // row 1 │B │F │H │♥ │R │ width=2: same glyphs, two-cell bitmaps + // ├─┼─┼─┼─┼─┼─┼─┼─┼─┼─┤ + // row 2 │ B│ F│ H│ ♥│ R│ width=2 + horizontal center alignment + // ├─┼─┼─┼─┼─┼─┼─┼─┼─┼─┤ + // row 3 │B │F │H │♥ │R │ advance_width=2000: wider design box + // └─┴─┴─┴─┴─┴─┴─┴─┴─┴─┘ + // ``` + // + // The faint grid lines in the PNG are these cell boundaries. Rows 2 and 3 + // are the design-metric/placement regression checks: row 2 centers a + // one-cell-wide design box inside two cells, while row 3 centers a two-cell + // design box so the visible glyph returns to the start of each span. + const cell_width = 20; + const cell_height = 20; + const columns = test_glyf_payloads.len; + const narrow_stride_x = cell_width; + const wide_stride_x = cell_width * 2; + const row_count = 4; + + var atlas = try z2d.Surface.init( + .image_surface_alpha8, + alloc, + @intCast(wide_stride_x * columns), + cell_height * row_count, + ); + defer atlas.deinit(alloc); + + for (test_glyf_payloads, 0..) |payload, i| { + var outline = try decodeGlyfPayload(alloc, payload); + defer outline.deinit(alloc); + + var narrow = try glyf_rasterize.rasterize(alloc, outline, .{ + .units_per_em = 1000, + .advance_width = 1000, + .line_height = 1000, + }, .{ + .grid_metrics = testMetrics(cell_width, cell_height), + }); + defer narrow.deinit(alloc); + blitBitmap(&atlas, narrow, i * narrow_stride_x, 0); + + var wide = try glyf_rasterize.rasterize(alloc, outline, .{ + .units_per_em = 1000, + .advance_width = 1000, + .line_height = 1000, + }, .{ + .grid_metrics = testMetrics(cell_width, cell_height), + .cell_width = 2, + }); + defer wide.deinit(alloc); + blitBitmap(&atlas, wide, i * wide_stride_x, cell_height); + + var centered = try glyf_rasterize.rasterize(alloc, outline, .{ + .units_per_em = 1000, + .advance_width = 1000, + .line_height = 1000, + }, .{ + .grid_metrics = testMetrics(cell_width, cell_height), + .cell_width = 2, + .constraint_width = 2, + .constraint = .{ .align_horizontal = .center }, + }); + defer centered.deinit(alloc); + blitBitmap(&atlas, centered, i * wide_stride_x, cell_height * 2); + + var designed_wide = try glyf_rasterize.rasterize(alloc, outline, .{ + .units_per_em = 1000, + .advance_width = 2000, + .line_height = 1000, + }, .{ + .grid_metrics = testMetrics(cell_width, cell_height), + .cell_width = 2, + .constraint_width = 2, + .constraint = .{ .align_horizontal = .center }, + }); + defer designed_wide.deinit(alloc); + blitBitmap(&atlas, designed_wide, i * wide_stride_x, cell_height * 3); + } + + for (0..row_count) |row| drawCellBoxes(&atlas, row * cell_height, cell_width, cell_height); + + var dir = testing.tmpDir(.{}); + defer dir.cleanup(); + const tmp_dir = try dir.dir.realpathAlloc(alloc, "."); + defer alloc.free(tmp_dir); + + const generated_path = try std.fmt.allocPrint(alloc, "{s}/glyf_rasterize.png", .{tmp_dir}); + defer alloc.free(generated_path); + try z2d.png_exporter.writeToPNGFile(atlas, generated_path, .{}); + + try testing.expect(!try diffAtlas(alloc, &atlas, generated_path)); +} diff --git a/vendor/libghostty-vt/src/font/main.zig b/vendor/libghostty-vt/src/font/main.zig index a8522afe..0c218bbd 100644 --- a/vendor/libghostty-vt/src/font/main.zig +++ b/vendor/libghostty-vt/src/font/main.zig @@ -15,6 +15,7 @@ pub const Collection = @import("Collection.zig"); pub const DeferredFace = @import("DeferredFace.zig"); pub const Face = face.Face; pub const Glyph = @import("Glyph.zig"); +pub const glyf_rasterize = @import("glyf_rasterize.zig"); pub const Metrics = @import("Metrics.zig"); pub const opentype = @import("opentype.zig"); pub const shape = @import("shape.zig"); diff --git a/vendor/libghostty-vt/src/font/nerd_font_attributes.zig b/vendor/libghostty-vt/src/font/nerd_font_attributes.zig index f4a19d96..fc988496 100644 --- a/vendor/libghostty-vt/src/font/nerd_font_attributes.zig +++ b/vendor/libghostty-vt/src/font/nerd_font_attributes.zig @@ -4,7 +4,7 @@ //! This file provides info extracted from the nerd fonts patcher script, //! specifying the scaling/positioning attributes of various glyphs. -const Constraint = @import("face.zig").RenderOptions.Constraint; +const Constraint = @import("Glyph.zig").RenderOptions.Constraint; /// Get the constraints for the provided codepoint. pub fn getConstraint(cp: u21) ?Constraint { diff --git a/vendor/libghostty-vt/src/font/opentype/glyf.zig b/vendor/libghostty-vt/src/font/opentype/glyf.zig index 7392729a..912b5fe8 100644 --- a/vendor/libghostty-vt/src/font/opentype/glyf.zig +++ b/vendor/libghostty-vt/src/font/opentype/glyf.zig @@ -1,4 +1,5 @@ const std = @import("std"); +const Allocator = std.mem.Allocator; const sfnt = @import("sfnt.zig"); /// Glyph Data Table @@ -15,6 +16,49 @@ const sfnt = @import("sfnt.zig"); pub const Glyf = struct { data: []const u8, + /// A decoded glyph outline. + /// + /// The `contours` slice is the list of end point indices and + /// `points` owns all the points. Glyf guarantees that contour + /// points are sequential so we can just store the end and calculate + /// the points that way. Use the helpers to make it ergonomic. + pub const Outline = struct { + /// List of contour end points. Calculate the full list of + /// points using points[prev...this+1] + contours: []const sfnt.uint16, + + /// The backing storage of all points in the entry. + points: []const Point, + + /// A single decoded point in a simple glyph contour. + pub const Point = struct { + x: i32, + y: i32, + on_curve: bool, + }; + + /// Return the point slice for the contour at `index`. + /// + /// The returned slice references `points` and is invalidated when + /// this outline is deinitialized. + pub fn contour(self: Outline, index: usize) []const Point { + const start = if (index == 0) + 0 + else + @as(usize, self.contours[index - 1]) + 1; + const end = @as(usize, self.contours[index]) + 1; + return self.points[start..end]; + } + + /// Free all memory owned by this outline. Pass in the same + /// allocator used for decoding. + pub fn deinit(self: *Outline, alloc: Allocator) void { + alloc.free(self.contours); + alloc.free(self.points); + self.* = undefined; + } + }; + /// https://learn.microsoft.com/en-us/typography/opentype/spec/glyf#table-organization pub const Entry = struct { header: Header, @@ -241,6 +285,12 @@ pub const Glyf = struct { TooManyPoints, }; + /// Errors that can be returned from `Entry.decode()`. + pub const DecodeError = SizeError || Allocator.Error || error{ + /// Coordinate delta accumulation overflowed. + CoordinateOverflow, + }; + /// Determines the size (in bytes) of this entry. /// /// If the entry is valid, returns the number of bytes @@ -393,6 +443,149 @@ pub const Glyf = struct { // No issues found, the glyf entry is valid, return its length. return @sizeOf(Header) + fbs.pos; } + + /// Decode this simple glyph entry into an owned outline. + /// + /// NOTE: Currently produces errors when given composite glyphs + /// or any glyphs that have hinting instructions included. + pub fn decode(self: Entry, alloc: Allocator) DecodeError!Glyf.Outline { + // We only support simple glyphs. + switch (self.entryType()) { + .simple => {}, + .composite => return error.CompositeNotSupported, + } + + var fbs = std.io.fixedBufferStream(self.data); + const reader = fbs.reader(); + + // A zero-contour glyph may be header-only. See size for the + // reason for the hardcoded 2 here. + const num_contours: usize = @intCast(self.header.numberOfContours); + if (num_contours == 0 and self.data.len < 2) return .{ + .points = &.{}, + .contours = &.{}, + }; + + // We now know our full amount of contour ending points. + const end_points = try alloc.alloc(sfnt.uint16, num_contours); + errdefer alloc.free(end_points); + + // If we have no contours, then the only possible remaining + // field is instructionLength. Instructions are not supported. + if (num_contours == 0) { + const instructions_length = try reader.readInt(sfnt.uint16, .big); + if (instructions_length > 0) return error.InstructionsNotSupported; + return .{ .points = &.{}, .contours = end_points }; + } + + // The number of points is determined by the final end point + // entry since the entries have to be monotonic (something + // we verify below). + const point_count: usize = point_count: { + var prev_end_point: isize = -1; + + // Go through the end points array and update our end_points + // with the valid index. The final endpoint tells us our point + // count, since endpoints are stored as inclusive point indices. + for (0..end_points.len) |i| { + const index = try reader.readInt(sfnt.uint16, .big); + if (index <= prev_end_point) return error.EndPointsOutOfOrder; + prev_end_point = index; + end_points[i] = index; + } + + // The final point tells us our point count. + break :point_count @as(usize, end_points[end_points.len - 1]) + 1; + }; + + // Instructions are not supported. + const instructions_length = try reader.readInt(sfnt.uint16, .big); + if (instructions_length > 0) return error.InstructionsNotSupported; + + // Allocate our points right away even though the next entries + // are flags. We want to do this so that if the allocator is + // a bump allocator, the flags free will actually free it. + const points = try alloc.alloc(Glyf.Outline.Point, point_count); + errdefer alloc.free(points); + + // This is EXTREMELY annoying but all the flags are separate + // from the points so we have to do some allocation here since + // its a dynamic amount and we need to save the values for later. + // + // Typical glyphs have small point counts, so use stack storage + // first while still falling back to the caller's allocator for + // unusually large outlines. + var flags_stack = std.heap.stackFallback(4096, alloc); + const flags_alloc = flags_stack.get(); + const flags = try flags_alloc.alloc(SimpleFlags, point_count); + defer flags_alloc.free(flags); + { + var point_i: usize = 0; + while (point_i < point_count) { + const flag: SimpleFlags = @bitCast(try reader.readByte()); + flags[point_i] = flag; + point_i += 1; + + if (flag.repeat) { + const repeat_count: usize = try reader.readByte(); + if (point_i + repeat_count > point_count) return error.TooManyPoints; + + for (0..repeat_count) |_| { + flags[point_i] = flag; + point_i += 1; + } + } + } + } + + // Go through x coordinate deltas + var x: i32 = 0; + for (flags, points) |flag, *point| { + const dx: i32 = if (flag.x_short) short: { + break :short if (flag.x_repeat_or_sign) + @as(i32, try reader.readByte()) + else + -@as(i32, try reader.readByte()); + } else if (!flag.x_repeat_or_sign) + @as(i32, try reader.readInt(sfnt.int16, .big)) + else + 0; + + x = std.math.add( + i32, + x, + dx, + ) catch return error.CoordinateOverflow; + point.x = x; + } + + // Go through y coordinate deltas + var y: i32 = 0; + for (flags, points) |flag, *point| { + const dy: i32 = if (flag.y_short) short: { + break :short if (flag.y_repeat_or_sign) + @as(i32, try reader.readByte()) + else + -@as(i32, try reader.readByte()); + } else if (!flag.y_repeat_or_sign) + @as(i32, try reader.readInt(sfnt.int16, .big)) + else + 0; + + y = std.math.add( + i32, + y, + dy, + ) catch return error.CoordinateOverflow; + point.y = y; + point.on_curve = flag.on_curve; + } + + return .{ + .points = points, + .contours = end_points, + }; + } }; /// Initialize the table from the provided data. @@ -451,7 +644,37 @@ pub fn getGlyph(font: sfnt.SFNT, index: usize) !struct { usize, Glyf.Entry } { return .{ end_offset - start_offset, try glyf.entry(start_offset) }; } +fn testAppendInt( + buf: *std.ArrayList(u8), + alloc: Allocator, + comptime T: type, + value: T, +) !void { + var bytes: [@sizeOf(T)]u8 = undefined; + std.mem.writeInt(T, &bytes, value, .big); + try buf.appendSlice(alloc, &bytes); +} + +fn testAppendHeader( + buf: *std.ArrayList(u8), + alloc: Allocator, + number_of_contours: i16, + x_min: i16, + y_min: i16, + x_max: i16, + y_max: i16, +) !void { + try testAppendInt(buf, alloc, i16, number_of_contours); + try testAppendInt(buf, alloc, i16, x_min); + try testAppendInt(buf, alloc, i16, y_min); + try testAppendInt(buf, alloc, i16, x_max); + try testAppendInt(buf, alloc, i16, y_max); +} + test "glyf" { + // lib-vt source archives intentionally exclude full Ghostty font fixtures. + if (comptime @import("terminal_options").artifact == .lib) return error.SkipZigTest; + const testing = std.testing; const alloc = testing.allocator; // Cozette because it doesn't have any hinting. @@ -475,13 +698,188 @@ test "glyf" { try testing.expect(glyph_A.entryType() == .simple); try testing.expect(len_A >= try glyph_A.size()); + var outline_A = try glyph_A.decode(alloc); + defer outline_A.deinit(alloc); + try testing.expectEqual(@as(usize, @intCast(glyph_A.header.numberOfContours)), outline_A.contours.len); + try testing.expect(outline_A.points.len > 0); + // Glyph "Ĩ" is at index 265. const len_Itilde, const glyph_Itilde = try getGlyph(font, 265); try testing.expect(glyph_Itilde.entryType() == .simple); try testing.expect(len_Itilde >= try glyph_Itilde.size()); } +test "glyf: decode triangle" { + const testing = std.testing; + const alloc = testing.allocator; + + var buf: std.ArrayList(u8) = .empty; + defer buf.deinit(alloc); + + try testAppendHeader(&buf, alloc, 1, 100, 100, 900, 900); + try testAppendInt(&buf, alloc, u16, 2); // endPtsOfContours[0] + try testAppendInt(&buf, alloc, u16, 0); // instructionLength + try buf.append(alloc, 0x01); // on curve + try buf.append(alloc, 0x01); // on curve + try buf.append(alloc, 0x01); // on curve + try testAppendInt(&buf, alloc, i16, 500); + try testAppendInt(&buf, alloc, i16, -400); + try testAppendInt(&buf, alloc, i16, 800); + try testAppendInt(&buf, alloc, i16, 900); + try testAppendInt(&buf, alloc, i16, -800); + try testAppendInt(&buf, alloc, i16, 0); + + const glyph = try Glyf.Entry.init(buf.items); + var outline = try glyph.decode(alloc); + defer outline.deinit(alloc); + + try testing.expectEqual(@as(i16, 100), glyph.header.xMin); + try testing.expectEqual(@as(i16, 900), glyph.header.xMax); + try testing.expectEqual(@as(usize, 1), outline.contours.len); + try testing.expectEqual(@as(usize, 3), outline.points.len); + const contour = outline.contour(0); + try testing.expectEqual(@as(usize, 3), contour.len); + try testing.expectEqual(Glyf.Outline.Point{ .x = 500, .y = 900, .on_curve = true }, contour[0]); + try testing.expectEqual(Glyf.Outline.Point{ .x = 100, .y = 100, .on_curve = true }, contour[1]); + try testing.expectEqual(Glyf.Outline.Point{ .x = 900, .y = 100, .on_curve = true }, contour[2]); +} + +test "glyf: decode multiple contours" { + const testing = std.testing; + const alloc = testing.allocator; + + var buf: std.ArrayList(u8) = .empty; + defer buf.deinit(alloc); + + try testAppendHeader(&buf, alloc, 2, 0, 0, 30, 10); + try testAppendInt(&buf, alloc, u16, 1); // first contour ends at point 1 + try testAppendInt(&buf, alloc, u16, 3); // second contour ends at point 3 + try testAppendInt(&buf, alloc, u16, 0); // instructionLength + for (0..4) |_| try buf.append(alloc, 0x01); // on curve + for ([_]i16{ 0, 10, 10, 10 }) |dx| try testAppendInt(&buf, alloc, i16, dx); + for ([_]i16{ 0, 0, 10, 0 }) |dy| try testAppendInt(&buf, alloc, i16, dy); + + const glyph = try Glyf.Entry.init(buf.items); + var outline = try glyph.decode(alloc); + defer outline.deinit(alloc); + + try testing.expectEqual(@as(usize, 2), outline.contours.len); + try testing.expectEqual(@as(usize, 4), outline.points.len); + try testing.expectEqual(@as(u16, 1), outline.contours[0]); + try testing.expectEqual(@as(u16, 3), outline.contours[1]); + try testing.expectEqual(@as(usize, 2), outline.contour(0).len); + try testing.expectEqual(@as(usize, 2), outline.contour(1).len); + try testing.expectEqual(outline.points[0..2].ptr, outline.contour(0).ptr); + try testing.expectEqual(outline.points[2..4].ptr, outline.contour(1).ptr); +} + +test "glyf: decode repeat and short vector flags" { + const testing = std.testing; + const alloc = testing.allocator; + + var buf: std.ArrayList(u8) = .empty; + defer buf.deinit(alloc); + + try testAppendHeader(&buf, alloc, 1, 0, -16, 16, 0); + try testAppendInt(&buf, alloc, u16, 3); // four points + try testAppendInt(&buf, alloc, u16, 0); // instructionLength + try buf.append(alloc, 0x01 | 0x02 | 0x04 | 0x08 | 0x10); // on, x short positive, y short negative, repeat + try buf.append(alloc, 3); // repeat for the next three points + for ([_]u8{ 1, 2, 4, 8 }) |dx| try buf.append(alloc, dx); + for ([_]u8{ 1, 2, 4, 8 }) |dy| try buf.append(alloc, dy); + + const glyph = try Glyf.Entry.init(buf.items); + var outline = try glyph.decode(alloc); + defer outline.deinit(alloc); + + try testing.expectEqual(@as(usize, 4), outline.points.len); + try testing.expectEqual(Glyf.Outline.Point{ .x = 1, .y = -1, .on_curve = true }, outline.points[0]); + try testing.expectEqual(Glyf.Outline.Point{ .x = 3, .y = -3, .on_curve = true }, outline.points[1]); + try testing.expectEqual(Glyf.Outline.Point{ .x = 7, .y = -7, .on_curve = true }, outline.points[2]); + try testing.expectEqual(Glyf.Outline.Point{ .x = 15, .y = -15, .on_curve = true }, outline.points[3]); +} + +test "glyf: decode off curve and same coordinate flags" { + const testing = std.testing; + const alloc = testing.allocator; + + var buf: std.ArrayList(u8) = .empty; + defer buf.deinit(alloc); + + try testAppendHeader(&buf, alloc, 1, 0, 0, 7, 9); + try testAppendInt(&buf, alloc, u16, 1); // two points + try testAppendInt(&buf, alloc, u16, 0); // instructionLength + try buf.append(alloc, 0x10 | 0x20); // off curve, x same, y same + try buf.append(alloc, 0x01 | 0x02 | 0x04 | 0x10 | 0x20); // on curve, short positive x/y + try buf.append(alloc, 7); // x delta + try buf.append(alloc, 9); // y delta + + const glyph = try Glyf.Entry.init(buf.items); + var outline = try glyph.decode(alloc); + defer outline.deinit(alloc); + + try testing.expectEqual(Glyf.Outline.Point{ .x = 0, .y = 0, .on_curve = false }, outline.points[0]); + try testing.expectEqual(Glyf.Outline.Point{ .x = 7, .y = 9, .on_curve = true }, outline.points[1]); +} + +test "glyf: decode one-point contour" { + const testing = std.testing; + const alloc = testing.allocator; + + var buf: std.ArrayList(u8) = .empty; + defer buf.deinit(alloc); + + try testAppendHeader(&buf, alloc, 1, 0, 0, 0, 0); + try testAppendInt(&buf, alloc, u16, 0); // endPtsOfContours[0] + try testAppendInt(&buf, alloc, u16, 0); // instructionLength + try buf.append(alloc, 0x01 | 0x10 | 0x20); // on curve, x same, y same + + const glyph = try Glyf.Entry.init(buf.items); + var outline = try glyph.decode(alloc); + defer outline.deinit(alloc); + + try testing.expectEqual(@as(usize, 1), outline.points.len); + try testing.expectEqual(@as(usize, 1), outline.contours.len); + try testing.expectEqual(@as(u16, 0), outline.contours[0]); + try testing.expectEqual(Glyf.Outline.Point{ .x = 0, .y = 0, .on_curve = true }, outline.contour(0)[0]); +} + +test "glyf: decode contour ending at max point index" { + const testing = std.testing; + const alloc = testing.allocator; + + var buf: std.ArrayList(u8) = .empty; + defer buf.deinit(alloc); + + try testAppendHeader(&buf, alloc, 1, 0, 0, 0, 0); + try testAppendInt(&buf, alloc, u16, std.math.maxInt(u16)); // 65536 points + try testAppendInt(&buf, alloc, u16, 0); // instructionLength + + const flag = 0x01 | 0x10 | 0x20; // on curve, x same, y same + var remaining: usize = @as(usize, std.math.maxInt(u16)) + 1; + while (remaining > 0) { + const run = @min(remaining, 256); + if (run == 1) { + try buf.append(alloc, flag); + } else { + try buf.append(alloc, flag | 0x08); // repeat + try buf.append(alloc, @intCast(run - 1)); + } + remaining -= run; + } + + const glyph = try Glyf.Entry.init(buf.items); + var outline = try glyph.decode(alloc); + defer outline.deinit(alloc); + + try testing.expectEqual(@as(usize, 65536), outline.points.len); + try testing.expectEqual(@as(usize, 65536), outline.contour(0).len); +} + test "glyf: reject glyphs with instructions and composite glyphs" { + // lib-vt source archives intentionally exclude full Ghostty font fixtures. + if (comptime @import("terminal_options").artifact == .lib) return error.SkipZigTest; + const testing = std.testing; const alloc = testing.allocator; const test_font = @import("../embedded.zig").jetbrains_mono; @@ -496,6 +894,10 @@ test "glyf: reject glyphs with instructions and composite glyphs" { Glyf.Entry.SizeError.InstructionsNotSupported, glyph_notdef.size(), ); + try testing.expectError( + Glyf.Entry.DecodeError.InstructionsNotSupported, + glyph_notdef.decode(alloc), + ); // Glyph "Á" is at index 2. const len_Aacute, const glyph_Aacute = try getGlyph(font, 2); @@ -505,9 +907,16 @@ test "glyf: reject glyphs with instructions and composite glyphs" { Glyf.Entry.SizeError.CompositeNotSupported, glyph_Aacute.size(), ); + try testing.expectError( + Glyf.Entry.DecodeError.CompositeNotSupported, + glyph_Aacute.decode(alloc), + ); } test "glyf: reject truncated" { + // lib-vt source archives intentionally exclude full Ghostty font fixtures. + if (comptime @import("terminal_options").artifact == .lib) return error.SkipZigTest; + const testing = std.testing; const alloc = testing.allocator; // Cozette because it doesn't have any hinting. @@ -522,9 +931,13 @@ test "glyf: reject truncated" { // it before the full length (which is 228 bytes). glyph_nul.data = glyph_nul.data[0 .. 227 - @sizeOf(Glyf.Entry.Header)]; try testing.expectError(Glyf.Entry.SizeError.EndOfStream, glyph_nul.size()); + try testing.expectError(Glyf.Entry.DecodeError.EndOfStream, glyph_nul.decode(alloc)); } test "glyf: reject endpoints out of order" { + // lib-vt source archives intentionally exclude full Ghostty font fixtures. + if (comptime @import("terminal_options").artifact == .lib) return error.SkipZigTest; + const testing = std.testing; const alloc = testing.allocator; // Cozette because it doesn't have any hinting. @@ -544,9 +957,16 @@ test "glyf: reject endpoints out of order" { // copied, we can just const cast it back to mutable lol. std.mem.bytesAsSlice(u16, @as([]u8, @constCast(glyph_nul.data)))[3] = 0; try testing.expectError(Glyf.Entry.SizeError.EndPointsOutOfOrder, glyph_nul.size()); + try testing.expectError( + Glyf.Entry.DecodeError.EndPointsOutOfOrder, + glyph_nul.decode(alloc), + ); } test "glyf: reject too many points" { + // lib-vt source archives intentionally exclude full Ghostty font fixtures. + if (comptime @import("terminal_options").artifact == .lib) return error.SkipZigTest; + const testing = std.testing; const alloc = testing.allocator; // Cozette because it doesn't have any hinting. @@ -568,10 +988,12 @@ test "glyf: reject too many points" { @as([]u8, @constCast(glyph_nul.data))[107] |= 0x08; @as([]u8, @constCast(glyph_nul.data))[108] = 0xFF; try testing.expectError(Glyf.Entry.SizeError.TooManyPoints, glyph_nul.size()); + try testing.expectError(Glyf.Entry.DecodeError.TooManyPoints, glyph_nul.decode(alloc)); } test "glyf: zero-contour glyph can be header-only" { const testing = std.testing; + const alloc = testing.allocator; const header: Glyf.Entry.Header = .{ .numberOfContours = 0, @@ -582,4 +1004,43 @@ test "glyf: zero-contour glyph can be header-only" { }; const glyph = try Glyf.Entry.init(std.mem.asBytes(&header)); try testing.expectEqual(@sizeOf(Glyf.Entry.Header), try glyph.size()); + + var outline = try glyph.decode(alloc); + defer outline.deinit(alloc); + try testing.expectEqual(@as(usize, 0), outline.points.len); + try testing.expectEqual(@as(usize, 0), outline.contours.len); +} + +test "glyf: zero-contour glyph can include instruction length" { + const testing = std.testing; + const alloc = testing.allocator; + + var buf: std.ArrayList(u8) = .empty; + defer buf.deinit(alloc); + + try testAppendHeader(&buf, alloc, 0, 0, 0, 0, 0); + try testAppendInt(&buf, alloc, u16, 0); // instructionLength + + const glyph = try Glyf.Entry.init(buf.items); + try testing.expectEqual(@sizeOf(Glyf.Entry.Header) + 2, try glyph.size()); + + var outline = try glyph.decode(alloc); + defer outline.deinit(alloc); + try testing.expectEqual(@as(usize, 0), outline.points.len); + try testing.expectEqual(@as(usize, 0), outline.contours.len); +} + +test "glyf: zero-contour glyph rejects instructions" { + const testing = std.testing; + const alloc = testing.allocator; + + var buf: std.ArrayList(u8) = .empty; + defer buf.deinit(alloc); + + try testAppendHeader(&buf, alloc, 0, 0, 0, 0, 0); + try testAppendInt(&buf, alloc, u16, 1); // instructionLength + + const glyph = try Glyf.Entry.init(buf.items); + try testing.expectError(Glyf.Entry.SizeError.InstructionsNotSupported, glyph.size()); + try testing.expectError(Glyf.Entry.DecodeError.InstructionsNotSupported, glyph.decode(alloc)); } diff --git a/vendor/libghostty-vt/src/font/opentype/sfnt.zig b/vendor/libghostty-vt/src/font/opentype/sfnt.zig index 9373cda0..55a9a0bd 100644 --- a/vendor/libghostty-vt/src/font/opentype/sfnt.zig +++ b/vendor/libghostty-vt/src/font/opentype/sfnt.zig @@ -285,6 +285,9 @@ pub const SFNT = struct { const native_endian = @import("builtin").target.cpu.arch.endian(); test "parse font" { + // lib-vt source archives intentionally exclude full Ghostty font fixtures. + if (comptime @import("terminal_options").artifact == .lib) return error.SkipZigTest; + const testing = std.testing; const alloc = testing.allocator; @@ -298,6 +301,9 @@ test "parse font" { } test "get table" { + // lib-vt source archives intentionally exclude full Ghostty font fixtures. + if (comptime @import("terminal_options").artifact == .lib) return error.SkipZigTest; + const testing = std.testing; const alloc = testing.allocator; diff --git a/vendor/libghostty-vt/src/font/shaper/coretext.zig b/vendor/libghostty-vt/src/font/shaper/coretext.zig index 3f69af6d..8a9374bd 100644 --- a/vendor/libghostty-vt/src/font/shaper/coretext.zig +++ b/vendor/libghostty-vt/src/font/shaper/coretext.zig @@ -1333,7 +1333,7 @@ test "shape emoji width long" { var t = try terminal.Terminal.init(alloc, .{ .cols = 30, .rows = 3 }); defer t.deinit(alloc); - var page = t.screens.active.pages.pages.first.?.data; + var page = t.screens.active.pages.pages.first.?.page(); var row = page.getRow(1); const cell = &row.cells.ptr(page.memory)[0]; cell.* = .{ diff --git a/vendor/libghostty-vt/src/font/shaper/harfbuzz.zig b/vendor/libghostty-vt/src/font/shaper/harfbuzz.zig index a400ecaf..996aabff 100644 --- a/vendor/libghostty-vt/src/font/shaper/harfbuzz.zig +++ b/vendor/libghostty-vt/src/font/shaper/harfbuzz.zig @@ -811,7 +811,7 @@ test "shape emoji width long" { ); defer t.deinit(alloc); - var page = t.screens.active.pages.pages.first.?.data; + var page = t.screens.active.pages.pages.first.?.page(); var row = page.getRow(1); const cell = &row.cells.ptr(page.memory)[0]; cell.* = .{ diff --git a/vendor/libghostty-vt/src/font/sprite/Face.zig b/vendor/libghostty-vt/src/font/sprite/Face.zig index 596a9204..fd029d13 100644 --- a/vendor/libghostty-vt/src/font/sprite/Face.zig +++ b/vendor/libghostty-vt/src/font/sprite/Face.zig @@ -176,7 +176,7 @@ pub fn renderGlyph( alloc: Allocator, atlas: *font.Atlas, cp: u32, - opts: font.face.RenderOptions, + opts: font.Glyph.RenderOptions, ) !font.Glyph { if (std.debug.runtime_safety) { if (!self.hasCodepoint(cp, null)) { @@ -205,7 +205,16 @@ pub fn renderGlyph( else => |width| metrics.cell_width * width, }; - const height = metrics.cell_height; + // Sprite glyphs generally get the full cell height, but cursor glyphs need + // to be affected by `adjust-cursor-height`, so we use `cursor_height` for + // the height if it's one of the full-height cursors. + const height = switch (cp) { + @intFromEnum(Sprite.cursor_rect), + @intFromEnum(Sprite.cursor_hollow_rect), + @intFromEnum(Sprite.cursor_bar), + => metrics.cursor_height, + else => metrics.cell_height, + }; const padding_x = width / 4; const padding_y = height / 4; @@ -219,11 +228,32 @@ pub fn renderGlyph( // Write the drawing to the atlas const region = try canvas.writeAtlas(alloc, atlas); + // The X offset is the displacement from the left edge of the cell that our + // drawn sprite will be drawn in the grid. That's the same as the distance + // we have calculated from the left of the canvas, minus the padding, since + // the padding represents extra pixels to the left of the cell. + const offset_x: i32 = + @as(i32, @intCast(canvas.clip_left)) - + @as(i32, @intCast(padding_x)); + // Similar logic applies for the Y offset, but with the additional factor + // that we want to re-center cursor glyphs in the cell if they were drawn + // taller or shorter than a normal cell is; this only applies to cursors + // currently, but could conceivably apply to other things in the future. + const offset_y: i32 = + @as(i32, @intCast(region.height +| canvas.clip_bottom)) - + @as(i32, @intCast(padding_y)) + + @divTrunc( + // By adding half the difference between the cell height and the + // height we passed to the draw function, we center it in the cell. + @as(i32, @intCast(metrics.cell_height)) - @as(i32, @intCast(height)), + 2, + ); + return .{ .width = region.width, .height = region.height, - .offset_x = @as(i32, @intCast(canvas.clip_left)) - @as(i32, @intCast(padding_x)), - .offset_y = @as(i32, @intCast(region.height +| canvas.clip_bottom)) - @as(i32, @intCast(padding_y)), + .offset_x = offset_x, + .offset_y = offset_y, .atlas_x = region.x, .atlas_y = region.y, }; @@ -535,6 +565,92 @@ test "sprite face render all sprites" { try std.testing.expect(!diff); // There should be no diffs from reference. } +test "full height cursor sprites respect cursor height metric" { + const testing = std.testing; + const alloc = testing.allocator; + + var atlas: font.Atlas = try .init(alloc, 128, .grayscale); + defer atlas.deinit(alloc); + + // face with a 8x16 cell dimension. + var face: Face = .{ + .metrics = .calc(.{ + // Fudged number, not used in anything we care about here. + .px_per_em = 16, + + .cell_width = 8.0, + .ascent = 12.0, + .descent = -4.0, + .line_gap = 0.0, + }), + }; + + try testing.expectEqual(16, face.metrics.cell_height); + + // --- smaller than cell height --- + face.metrics.cursor_height = 12; + // bar + { + const glyph = try face.renderGlyph(alloc, &atlas, @intFromEnum(Sprite.cursor_bar), .{ .grid_metrics = face.metrics }); + try testing.expectEqual(12, glyph.height); + try testing.expectEqual(14, glyph.offset_y); + } + // rect + { + const glyph = try face.renderGlyph(alloc, &atlas, @intFromEnum(Sprite.cursor_rect), .{ .grid_metrics = face.metrics }); + try testing.expectEqual(12, glyph.height); + try testing.expectEqual(14, glyph.offset_y); + } + // hollow rect + { + const glyph = try face.renderGlyph(alloc, &atlas, @intFromEnum(Sprite.cursor_hollow_rect), .{ .grid_metrics = face.metrics }); + try testing.expectEqual(12, glyph.height); + try testing.expectEqual(14, glyph.offset_y); + } + + // --- equal to the cell height --- + face.metrics.cursor_height = 16; + // bar + { + const glyph = try face.renderGlyph(alloc, &atlas, @intFromEnum(Sprite.cursor_bar), .{ .grid_metrics = face.metrics }); + try testing.expectEqual(16, glyph.height); + try testing.expectEqual(16, glyph.offset_y); + } + // rect + { + const glyph = try face.renderGlyph(alloc, &atlas, @intFromEnum(Sprite.cursor_rect), .{ .grid_metrics = face.metrics }); + try testing.expectEqual(16, glyph.height); + try testing.expectEqual(16, glyph.offset_y); + } + // hollow rect + { + const glyph = try face.renderGlyph(alloc, &atlas, @intFromEnum(Sprite.cursor_hollow_rect), .{ .grid_metrics = face.metrics }); + try testing.expectEqual(16, glyph.height); + try testing.expectEqual(16, glyph.offset_y); + } + + // --- greater than the cell height --- + face.metrics.cursor_height = 20; + // bar + { + const glyph = try face.renderGlyph(alloc, &atlas, @intFromEnum(Sprite.cursor_bar), .{ .grid_metrics = face.metrics }); + try testing.expectEqual(20, glyph.height); + try testing.expectEqual(18, glyph.offset_y); + } + // rect + { + const glyph = try face.renderGlyph(alloc, &atlas, @intFromEnum(Sprite.cursor_rect), .{ .grid_metrics = face.metrics }); + try testing.expectEqual(20, glyph.height); + try testing.expectEqual(18, glyph.offset_y); + } + // hollow rect + { + const glyph = try face.renderGlyph(alloc, &atlas, @intFromEnum(Sprite.cursor_hollow_rect), .{ .grid_metrics = face.metrics }); + try testing.expectEqual(20, glyph.height); + try testing.expectEqual(18, glyph.offset_y); + } +} + // test "sprite face print all sprites" { // std.debug.print("\n\n", .{}); // inline for (ranges) |range| { diff --git a/vendor/libghostty-vt/src/font/testdata/glyf_rasterize.png b/vendor/libghostty-vt/src/font/testdata/glyf_rasterize.png Binary files differnew file mode 100644 index 00000000..f5f8fb81 --- /dev/null +++ b/vendor/libghostty-vt/src/font/testdata/glyf_rasterize.png diff --git a/vendor/libghostty-vt/src/inspector/widgets/page.zig b/vendor/libghostty-vt/src/inspector/widgets/page.zig index 844abc35..e332ab3c 100644 --- a/vendor/libghostty-vt/src/inspector/widgets/page.zig +++ b/vendor/libghostty-vt/src/inspector/widgets/page.zig @@ -25,8 +25,9 @@ pub fn inspector(page: *const terminal.Page) void { /// the tree node is open or not. If it is open you must close it with /// TreePop. pub fn treeNode(state: struct { - /// The page - page: *const terminal.Page, + /// Page dimensions available without reading its backing memory. + cols: terminal.size.CellCountInt, + rows: terminal.size.CellCountInt, /// The index of the page in a page list, used for headers. index: usize, /// The range of rows this page covers, inclusive. @@ -34,6 +35,10 @@ pub fn treeNode(state: struct { /// Whether this page is the active or viewport node. active: bool, viewport: bool, + /// Whether the page backing memory is currently compressed. + compressed: bool, + /// Dirty state is unavailable without restoring a compressed page. + dirty: ?bool, }) bool { // Setup our node. const open = open: { @@ -61,8 +66,8 @@ pub fn treeNode(state: struct { // Metadata cimgui.c.ImGui_TextDisabled( "%dc x %dr", - state.page.size.cols, - state.page.size.rows, + state.cols, + state.rows, ); cimgui.c.ImGui_SameLine(); cimgui.c.ImGui_Text("rows %d..%d", state.row_range[0], state.row_range[1]); @@ -76,7 +81,11 @@ pub fn treeNode(state: struct { cimgui.c.ImGui_SameLine(); cimgui.c.ImGui_TextColored(.{ .x = 0.4, .y = 0.8, .z = 1.0, .w = 1.0 }, "viewport"); } - if (state.page.isDirty()) { + if (state.compressed) { + cimgui.c.ImGui_SameLine(); + cimgui.c.ImGui_TextColored(.{ .x = 0.8, .y = 0.6, .z = 1.0, .w = 1.0 }, "compressed"); + } + if (state.dirty orelse false) { cimgui.c.ImGui_SameLine(); cimgui.c.ImGui_TextColored(.{ .x = 1.0, .y = 0.4, .z = 0.4, .w = 1.0 }, "dirty"); } diff --git a/vendor/libghostty-vt/src/inspector/widgets/pagelist.zig b/vendor/libghostty-vt/src/inspector/widgets/pagelist.zig index bedbfb59..7e001289 100644 --- a/vendor/libghostty-vt/src/inspector/widgets/pagelist.zig +++ b/vendor/libghostty-vt/src/inspector/widgets/pagelist.zig @@ -1,12 +1,23 @@ const std = @import("std"); const cimgui = @import("dcimgui"); const terminal = @import("../../terminal/main.zig"); +const pagepkg = @import("../../terminal/page.zig"); const stylepkg = @import("../../terminal/style.zig"); const widgets = @import("../widgets.zig"); -const units = @import("../units.zig"); const PageList = terminal.PageList; +/// Cell pointers resolved against a Node.PreservedPage rather than the +/// PageList node. +/// Omitting the node makes it impossible for inspector helpers to +/// accidentally restore the original compressed page. +const InspectedCell = struct { + row: *pagepkg.Row, + cell: *pagepkg.Cell, + row_idx: terminal.size.CellCountInt, + col_idx: terminal.size.CellCountInt, +}; + /// PageList inspector widget. pub const Inspector = struct { pub const empty: Inspector = .{}; @@ -26,6 +37,13 @@ pub const Inspector = struct { } if (cimgui.c.ImGui_CollapsingHeader( + "Page Compression", + cimgui.c.ImGuiTreeNodeFlags_DefaultOpen, + )) { + compressionTable(pages); + } + + if (cimgui.c.ImGui_CollapsingHeader( "Scrollbar & Regions", cimgui.c.ImGuiTreeNodeFlags_DefaultOpen, )) { @@ -58,8 +76,10 @@ pub const Inspector = struct { var index: usize = pages.totalPages(); var node = pages.pages.last; while (node) |page_node| : (node = page_node.prev) { - const page = &page_node.data; - row_offset -= page.size.rows; + const rows = page_node.rows(); + const resident = page_node.pageIfResident(); + const compressed = page_node.storage() == .compressed; + row_offset -= rows; index -= 1; // We use our location as the ID so that even if reallocations @@ -69,14 +89,29 @@ pub const Inspector = struct { // Open up the tree node. if (!widgets.page.treeNode(.{ - .page = page, + .cols = page_node.cols(), + .rows = rows, .index = index, - .row_range = .{ row_offset, row_offset + page.size.rows - 1 }, + .row_range = .{ row_offset, row_offset + rows - 1 }, .active = node == active_pin.node, .viewport = node == viewport_pin.node, + .compressed = compressed, + .dirty = if (resident) |page| page.isDirty() else null, })) continue; defer cimgui.c.ImGui_TreePop(); - widgets.page.inspector(page); + + // Decode compressed contents into a temporary page. The + // original node stays compressed while its entry is open. + var preserved = page_node.pagePreservingState( + std.heap.page_allocator, + ) catch { + cimgui.c.ImGui_TextDisabled( + "(unable to copy compressed page)", + ); + continue; + }; + defer preserved.deinit(); + widgets.page.inspector(preserved.page()); } } } @@ -106,7 +141,7 @@ fn summaryTable(pages: *const PageList) void { _ = cimgui.c.ImGui_TableSetColumnIndex(1); widgets.helpMarker("Total number of pages in the linked list."); _ = cimgui.c.ImGui_TableSetColumnIndex(2); - cimgui.c.ImGui_Text("%d", pages.totalPages()); + cimgui.c.ImGui_Text("%zu", pages.totalPages()); cimgui.c.ImGui_TableNextRow(); _ = cimgui.c.ImGui_TableSetColumnIndex(0); @@ -114,34 +149,21 @@ fn summaryTable(pages: *const PageList) void { _ = cimgui.c.ImGui_TableSetColumnIndex(1); widgets.helpMarker("Total rows represented by scrollback + active area."); _ = cimgui.c.ImGui_TableSetColumnIndex(2); - cimgui.c.ImGui_Text("%d", pages.total_rows); - - cimgui.c.ImGui_TableNextRow(); - _ = cimgui.c.ImGui_TableSetColumnIndex(0); - cimgui.c.ImGui_Text("Page Bytes"); - _ = cimgui.c.ImGui_TableSetColumnIndex(1); - widgets.helpMarker("Total bytes allocated for active pages."); - _ = cimgui.c.ImGui_TableSetColumnIndex(2); - cimgui.c.ImGui_Text( - "%d KiB", - units.toKibiBytes(pages.page_size), - ); + cimgui.c.ImGui_Text("%zu", pages.total_rows); cimgui.c.ImGui_TableNextRow(); _ = cimgui.c.ImGui_TableSetColumnIndex(0); - cimgui.c.ImGui_Text("Max Size"); + cimgui.c.ImGui_Text("Scrollback Limit"); _ = cimgui.c.ImGui_TableSetColumnIndex(1); widgets.helpMarker( - \\Maximum bytes before pages must be evicated. The total - \\used bytes may be higher due to minimum individual page - \\sizes but the next allocation that would exceed this limit - \\will evict pages from the front of the list to free up space. + \\Maximum uncompressed logical page memory before the oldest + \\history is evicted. Minimum page allocation sizes can make + \\current usage temporarily exceed this value. ); _ = cimgui.c.ImGui_TableSetColumnIndex(2); - cimgui.c.ImGui_Text( - "%d KiB", - units.toKibiBytes(pages.maxSize()), - ); + var limit_buf: [64]u8 = undefined; + const limit = formatBytes(&limit_buf, pages.maxSize()); + cimgui.c.ImGui_TextUnformatted(limit.ptr); cimgui.c.ImGui_TableNextRow(); _ = cimgui.c.ImGui_TableSetColumnIndex(0); @@ -150,14 +172,142 @@ fn summaryTable(pages: *const PageList) void { widgets.helpMarker("Current viewport anchoring mode."); _ = cimgui.c.ImGui_TableSetColumnIndex(2); cimgui.c.ImGui_Text("%s", @tagName(pages.viewport).ptr); +} + +fn compressionTable(pages: *const PageList) void { + const memory = pages.memoryStats(); + + if (!cimgui.c.ImGui_BeginTable( + "pagelist_compression", + 3, + cimgui.c.ImGuiTableFlags_BordersInnerV | + cimgui.c.ImGuiTableFlags_RowBg | + cimgui.c.ImGuiTableFlags_SizingFixedFit, + )) return; + defer cimgui.c.ImGui_EndTable(); + + compressionTextRow( + "Platform Support", + "Whether this target can discard physical page memory while retaining " ++ + "its virtual address range. The scrollback-compression setting may " ++ + "still disable automatic compression on a supported platform.", + if (terminal.compression_enabled) "supported" else "unsupported", + ); + var state_buf: [96]u8 = undefined; + const state = std.fmt.bufPrintZ( + &state_buf, + "{d} compressed, {d} resident", + .{ memory.compressed_pages, memory.resident_pages }, + ) catch unreachable; + compressionTextRow( + "Page States", + "Compressed pages retain an encoded allocation while their raw mapping " ++ + "is decommitted. Resident pages still have physical raw backing. " ++ + "Active, visible, and recently changed pages are expected to be resident.", + state, + ); + + var raw_buf: [64]u8 = undefined; + compressionTextRow( + "Uncompressed Size", + "Total size of all raw page mappings. This is the approximate page " ++ + "backing memory required if no pages were compressed and is also " ++ + "the virtual address space retained across compression.", + formatBytes(&raw_buf, memory.raw_bytes), + ); + + var encoded_buf: [64]u8 = undefined; + var compressed_raw_buf: [64]u8 = undefined; + var storage_buf: [160]u8 = undefined; + const storage = if (memory.decommitted_raw_bytes > 0) storage: { + const ratio = percentage( + memory.encoded_bytes, + memory.decommitted_raw_bytes, + ); + break :storage std.fmt.bufPrintZ( + &storage_buf, + "{s} encoded / {s} raw ({d:.1}%)", + .{ + formatBytes(&encoded_buf, memory.encoded_bytes), + formatBytes( + &compressed_raw_buf, + memory.decommitted_raw_bytes, + ), + ratio, + }, + ) catch unreachable; + } else "none"; + compressionTextRow( + "Compressed Storage", + "Encoded allocation size compared with the original raw size of only " ++ + "the compressed pages. The percentage is the compression ratio, " ++ + "so smaller is better. Raw physical pages have been discarded.", + storage, + ); + + var resident_buf: [64]u8 = undefined; + compressionTextRow( + "Estimated Resident", + "Physical page backing estimated to remain after compression: resident " ++ + "raw allocations, including unused pool tails, plus encoded storage. " ++ + "Node and allocator metadata and unrelated terminal memory are excluded.", + formatBytes(&resident_buf, memory.estimatedResidentBytes()), + ); + + var savings_bytes_buf: [64]u8 = undefined; + var savings_buf: [128]u8 = undefined; + const savings = memory.estimatedSavings(); + const savings_text = std.fmt.bufPrintZ( + &savings_buf, + "{s} ({d:.1}% of raw page memory)", + .{ + formatBytes(&savings_bytes_buf, savings), + percentage(savings, memory.raw_bytes), + }, + ) catch unreachable; + compressionTextRow( + "Estimated Savings", + "Physical page backing avoided across the complete PageList. This is " ++ + "decommitted raw memory minus replacement encoded storage and is " ++ + "not a measurement of total process RSS.", + savings_text, + ); +} + +fn compressionTextRow( + label: [:0]const u8, + help: [:0]const u8, + value: [:0]const u8, +) void { cimgui.c.ImGui_TableNextRow(); _ = cimgui.c.ImGui_TableSetColumnIndex(0); - cimgui.c.ImGui_Text("Tracked Pins"); + cimgui.c.ImGui_Text("%s", label.ptr); _ = cimgui.c.ImGui_TableSetColumnIndex(1); - widgets.helpMarker("Number of pins tracked for automatic updates."); + widgets.helpMarker(help); _ = cimgui.c.ImGui_TableSetColumnIndex(2); - cimgui.c.ImGui_Text("%d", pages.countTrackedPins()); + cimgui.c.ImGui_TextUnformatted(value.ptr); +} + +fn formatBytes(buf: []u8, bytes: usize) [:0]const u8 { + if (bytes >= 1024 * 1024) { + const value: f64 = @as(f64, @floatFromInt(bytes)) / (1024 * 1024); + return std.fmt.bufPrintZ(buf, "{d:.2} MiB", .{value}) catch unreachable; + } + + if (bytes >= 1024) { + const value: f64 = @as(f64, @floatFromInt(bytes)) / 1024; + return std.fmt.bufPrintZ(buf, "{d:.1} KiB", .{value}) catch unreachable; + } + + return std.fmt.bufPrintZ(buf, "{d} B", .{bytes}) catch unreachable; +} + +fn percentage(numerator: usize, denominator: usize) f64 { + if (denominator == 0) return 0; + return 100 * + @as(f64, @floatFromInt(numerator)) / + @as(f64, @floatFromInt(denominator)); } fn scrollbarInfo(pages: *PageList) void { @@ -321,11 +471,18 @@ fn trackedPinsTable(pages: *const PageList) void { } _ = cimgui.c.ImGui_TableSetColumnIndex(3); - const dirty = pin.isDirty(); - if (dirty) { - cimgui.c.ImGui_TextColored(.{ .x = 1.0, .y = 0.4, .z = 0.4, .w = 1.0 }, "dirty"); + if (pin.node.pageIfResident()) |page| { + const dirty = page.dirty or + page.getRowAndCell(pin.x, pin.y).row.dirty; + if (dirty) { + cimgui.c.ImGui_TextColored(.{ .x = 1.0, .y = 0.4, .z = 0.4, .w = 1.0 }, "dirty"); + } else { + cimgui.c.ImGui_TextDisabled("clean"); + } } else { - cimgui.c.ImGui_TextDisabled("clean"); + // Dirty state lives in the discarded mapping. Keep inspector + // traversal metadata-only rather than restoring this page. + cimgui.c.ImGui_TextDisabled("compressed"); } _ = cimgui.c.ImGui_TableSetColumnIndex(4); @@ -540,17 +697,37 @@ pub const CellChooser = struct { .history => terminal.Point{ .history = self.lookup_coord }, }; - const cell = pages.getCell(pt) orelse { + const pin = pages.pin(pt) orelse { cimgui.c.ImGui_TextDisabled("(cell out of range)"); return; }; - self.cell_info.draw(cell, pt); + // Cell pointers must come from the same page whose auxiliary tables + // we inspect. For compressed nodes this is an independent preserved + // page, leaving the PageList node compressed across inspector frames. + var preserved = pin.node.pagePreservingState( + std.heap.page_allocator, + ) catch { + cimgui.c.ImGui_TextDisabled("(unable to copy compressed page)"); + return; + }; + defer preserved.deinit(); + + const page = preserved.page(); + const rac = page.getRowAndCell(pin.x, pin.y); + const cell: InspectedCell = .{ + .row = rac.row, + .cell = rac.cell, + .row_idx = pin.y, + .col_idx = pin.x, + }; + + self.cell_info.draw(cell, pt, page); if (cell.cell.style_id != stylepkg.default_id) { cimgui.c.ImGui_SeparatorText("Style"); - const style = cell.node.data.styles.get( - cell.node.data.memory, + const style = page.styles.get( + page.memory, cell.cell.style_id, ).*; widgets.style.table(style, null); @@ -558,12 +735,12 @@ pub const CellChooser = struct { if (cell.cell.hyperlink) { cimgui.c.ImGui_SeparatorText("Hyperlink"); - hyperlinkTable(cell); + hyperlinkTable(cell, page); } if (cell.cell.hasGrapheme()) { cimgui.c.ImGui_SeparatorText("Grapheme"); - graphemeTable(cell); + graphemeTable(cell, page); } } }; @@ -577,7 +754,7 @@ fn maxCoord( return br_point.coord(); } -fn hyperlinkTable(cell: PageList.Cell) void { +fn hyperlinkTable(cell: InspectedCell, page: *const terminal.Page) void { if (!cimgui.c.ImGui_BeginTable( "cell_hyperlink", 2, @@ -585,7 +762,6 @@ fn hyperlinkTable(cell: PageList.Cell) void { )) return; defer cimgui.c.ImGui_EndTable(); - const page = &cell.node.data; const link_id = page.lookupHyperlink(cell.cell) orelse { cimgui.c.ImGui_TableNextRow(); _ = cimgui.c.ImGui_TableSetColumnIndex(0); @@ -632,7 +808,7 @@ fn hyperlinkTable(cell: PageList.Cell) void { cimgui.c.ImGui_Text("%d", refs); } -fn graphemeTable(cell: PageList.Cell) void { +fn graphemeTable(cell: InspectedCell, page: *const terminal.Page) void { if (!cimgui.c.ImGui_BeginTable( "cell_grapheme", 2, @@ -640,7 +816,6 @@ fn graphemeTable(cell: PageList.Cell) void { )) return; defer cimgui.c.ImGui_EndTable(); - const page = &cell.node.data; const cps = page.lookupGrapheme(cell.cell) orelse { cimgui.c.ImGui_TableNextRow(); _ = cimgui.c.ImGui_TableSetColumnIndex(0); @@ -680,8 +855,9 @@ pub const CellInfo = struct { pub fn draw( _: *const CellInfo, - cell: PageList.Cell, + cell: InspectedCell, point: terminal.Point, + page: *const terminal.Page, ) void { if (!cimgui.c.ImGui_BeginTable( "cell_info", @@ -747,7 +923,7 @@ pub const CellInfo = struct { _ = cimgui.c.ImGui_TableSetColumnIndex(2); if (cimgui.c.ImGui_BeginListBox("##cell_grapheme", .{ .x = 0, .y = 0 })) { defer cimgui.c.ImGui_EndListBox(); - if (cell.node.data.lookupGrapheme(cell.cell)) |cps| { + if (page.lookupGrapheme(cell.cell)) |cps| { var buf: [96]u8 = undefined; for (cps) |cp| { const label = std.fmt.bufPrintZ(&buf, "U+{X}", .{cp}) catch "U+?"; @@ -845,7 +1021,7 @@ pub const CellInfo = struct { widgets.helpMarker("OSC8 hyperlink ID associated with this cell."); _ = cimgui.c.ImGui_TableSetColumnIndex(2); - const link_id = cell.node.data.lookupHyperlink(cell.cell) orelse 0; + const link_id = page.lookupHyperlink(cell.cell) orelse 0; cimgui.c.ImGui_Text("id=%d", link_id); } } diff --git a/vendor/libghostty-vt/src/inspector/widgets/screen.zig b/vendor/libghostty-vt/src/inspector/widgets/screen.zig index bfe961f4..41c113e7 100644 --- a/vendor/libghostty-vt/src/inspector/widgets/screen.zig +++ b/vendor/libghostty-vt/src/inspector/widgets/screen.zig @@ -393,7 +393,7 @@ pub fn semanticPromptTable( _ = cimgui.c.ImGui_TableSetColumnIndex(1); switch (semantic_prompt.click) { .none => cimgui.c.ImGui_TextDisabled("(none)"), - .click_events => cimgui.c.ImGui_Text("click_events"), + .click_events => |click_events| cimgui.c.ImGui_Text("click_events=%s", @tagName(click_events).ptr), .cl => |cl| cimgui.c.ImGui_Text("cl=%s", @tagName(cl).ptr), } } diff --git a/vendor/libghostty-vt/src/lib_vt.zig b/vendor/libghostty-vt/src/lib_vt.zig index 6d4406e8..e01cdbb8 100644 --- a/vendor/libghostty-vt/src/lib_vt.zig +++ b/vendor/libghostty-vt/src/lib_vt.zig @@ -71,6 +71,7 @@ pub const RenderState = terminal.RenderState; pub const Screen = terminal.Screen; pub const ScreenSet = terminal.ScreenSet; pub const Selection = terminal.Selection; +pub const SelectionGesture = terminal.SelectionGesture; pub const size_report = terminal.size_report; pub const SizeReportStyle = terminal.SizeReportStyle; pub const StringMap = terminal.StringMap; @@ -132,6 +133,14 @@ pub const input = struct { pub const encodeMouse = mouse_encode.encode; }; +/// Unicode utilities that match the terminal's text layout semantics. +pub const unicode = struct { + const unicode_pkg = @import("unicode/main.zig"); + + pub const codepointWidth = unicode_pkg.codepointWidth; + pub const graphemeWidth = unicode_pkg.graphemeWidth; +}; + comptime { // If we're building the C library (vs. the Zig module) then // we want to reference the C API so that it gets exported. @@ -182,10 +191,13 @@ comptime { @export(&c.osc_end, .{ .name = "ghostty_osc_end" }); @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" }); @export(&c.style_default, .{ .name = "ghostty_style_default" }); @export(&c.style_is_default, .{ .name = "ghostty_style_is_default" }); @@ -196,6 +208,16 @@ comptime { @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" }); @export(&c.sgr_new, .{ .name = "ghostty_sgr_new" }); @export(&c.sgr_free, .{ .name = "ghostty_sgr_free" }); @export(&c.sgr_reset, .{ .name = "ghostty_sgr_reset" }); @@ -213,6 +235,8 @@ comptime { @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" }); @@ -237,6 +261,8 @@ comptime { @export(&c.terminal_set, .{ .name = "ghostty_terminal_set" }); @export(&c.terminal_vt_write, .{ .name = "ghostty_terminal_vt_write" }); @export(&c.terminal_scroll_viewport, .{ .name = "ghostty_terminal_scroll_viewport" }); + @export(&c.terminal_compression_activity, .{ .name = "ghostty_terminal_compression_activity" }); + @export(&c.terminal_compress, .{ .name = "ghostty_terminal_compress" }); @export(&c.terminal_mode_get, .{ .name = "ghostty_terminal_mode_get" }); @export(&c.terminal_mode_set, .{ .name = "ghostty_terminal_mode_set" }); @export(&c.terminal_get, .{ .name = "ghostty_terminal_get" }); @@ -340,6 +366,7 @@ test { _ = terminal; _ = @import("lib/main.zig"); @import("std").testing.refAllDecls(input); + @import("std").testing.refAllDecls(unicode); if (comptime terminal.options.c_abi) { _ = terminal.c_api; } diff --git a/vendor/libghostty-vt/src/renderer/State.zig b/vendor/libghostty-vt/src/renderer/State.zig index 22cae8fe..8693458f 100644 --- a/vendor/libghostty-vt/src/renderer/State.zig +++ b/vendor/libghostty-vt/src/renderer/State.zig @@ -1,6 +1,9 @@ //! This is the render state that is given to a renderer. +const State = @This(); + const std = @import("std"); +const assert = std.debug.assert; const Allocator = std.mem.Allocator; const Inspector = @import("../inspector/main.zig").Inspector; const terminalpkg = @import("../terminal/main.zig"); @@ -30,6 +33,76 @@ preedit: ?Preedit = null, /// need about the mouse. mouse: Mouse = .{}, +/// The number of threads currently waiting to acquire `mutex` via +/// `lockDemand`. This is not protected by the mutex; it is read by +/// hot lock/unlock loops (the IO parse thread) in `yieldToDemand` to +/// decide whether to hand the mutex off before relocking it. +demand: std.atomic.Value(u32) = .init(0), + +/// Handoff generation counter. Incremented (with a futex wake) by +/// `unlockDemand` after a demanding waiter releases the mutex, so that +/// `yieldToDemand` knows the waiter had its turn. +handoff_gen: std.atomic.Value(u32) = .init(0), + +/// How long `yieldToDemand` sleeps waiting for a demanding waiter to +/// take its turn before giving up. This bounds how long the IO parse +/// thread can stall if a wake is lost or the waiter is descheduled; a +/// demanding critical section (the renderer's frame snapshot) is +/// microseconds, so one millisecond is generous. +const handoff_timeout_ns = 1 * std.time.ns_per_ms; + +/// Acquire `mutex` while signaling demand for it. Use this instead of +/// locking the mutex directly on threads that must not be starved by +/// a hot lock/unlock loop (the renderer's frame snapshot). Must be +/// released with `unlockDemand`; releasing with `mutex.unlock` keeps +/// the data safe but makes parked `yieldToDemand` callers wait out +/// their full timeout. +/// +/// Both `std.Thread.Mutex` and os_unfair_lock are unfair: a running +/// thread that unlocks and immediately relocks beats a sleeping +/// waiter every time, because the waiter must first be woken and +/// scheduled. Under sustained pty output the IO parse thread is +/// exactly such a loop, so without this signal the renderer can +/// starve for as long as the output lasts. +pub fn lockDemand(self: *State) void { + _ = self.demand.fetchAdd(1, .monotonic); + self.mutex.lock(); + const prev = self.demand.fetchSub(1, .monotonic); + assert(prev > 0); +} + +/// Release `mutex` acquired via `lockDemand` and notify hot loops +/// parked in `yieldToDemand` that the demanding waiter had its turn. +pub fn unlockDemand(self: *State) void { + self.mutex.unlock(); + _ = self.handoff_gen.fetchAdd(1, .monotonic); + std.Thread.Futex.wake(&self.handoff_gen, 1); +} + +/// Called by hot lock/unlock loops between critical sections, with +/// `mutex` NOT held: if a `lockDemand` waiter exists, sleep until it +/// has acquired and released the mutex (or the timeout passes). This +/// is the handoff that unfair mutexes never do on their own. +/// +/// The orderings here are all monotonic because these atomics are a +/// scheduling heuristic, not a synchronization boundary: the mutex +/// itself orders the protected data, and the timeout bounds any +/// staleness. +pub fn yieldToDemand(self: *State) void { + if (self.demand.load(.monotonic) == 0) return; + + // Snapshot the generation before rechecking demand: if the waiter + // acquires and releases between our check and the wait below, the + // generation no longer matches and timedWait returns immediately. + const gen = self.handoff_gen.load(.monotonic); + if (self.demand.load(.monotonic) == 0) return; + std.Thread.Futex.timedWait( + &self.handoff_gen, + gen, + handoff_timeout_ns, + ) catch {}; +} + pub const Mouse = struct { /// The point on the viewport where the mouse currently is. We use /// viewport points to avoid the complexity of mapping the mouse to diff --git a/vendor/libghostty-vt/src/renderer/Thread.zig b/vendor/libghostty-vt/src/renderer/Thread.zig index 48864219..e397a55a 100644 --- a/vendor/libghostty-vt/src/renderer/Thread.zig +++ b/vendor/libghostty-vt/src/renderer/Thread.zig @@ -10,6 +10,7 @@ const internal_os = @import("../os/main.zig"); const rendererpkg = @import("../renderer.zig"); const apprt = @import("../apprt.zig"); const configpkg = @import("../config.zig"); +const terminalpkg = @import("../terminal/main.zig"); const BlockingQueue = @import("../datastruct/main.zig").BlockingQueue; const App = @import("../App.zig"); @@ -72,6 +73,9 @@ cursor_h: xev.Timer, cursor_c: xev.Completion = .{}, cursor_c_cancel: xev.Completion = .{}, +/// Incremental scrollback compression scheduling. +compression: Compression = undefined, + /// The surface we're rendering to. surface: *apprt.Surface, @@ -111,10 +115,12 @@ 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", }; } }; @@ -162,7 +168,7 @@ pub fn init( var mailbox = try Mailbox.create(alloc); errdefer mailbox.destroy(alloc); - return .{ + var result: Thread = .{ .alloc = alloc, .config = .init(config), .loop = loop, @@ -178,6 +184,14 @@ pub fn init( .mailbox = mailbox, .app_mailbox = app_mailbox, }; + + // Only enable compression if we have it enabled... save some + // minor resources. + if (comptime terminalpkg.compression_enabled) { + result.compression = try .init(); + } + + return result; } /// Clean up the thread. This is only safe to call once the thread @@ -189,6 +203,8 @@ pub fn deinit(self: *Thread) void { self.draw_h.deinit(); self.draw_now.deinit(); self.cursor_h.deinit(); + if (comptime terminalpkg.compression_enabled) + self.compression.deinit(); self.loop.deinit(); // Nothing can possibly access the mailbox anymore, destroy it. @@ -492,6 +508,16 @@ fn drainMailbox(self: *Thread) !void { } fn changeConfig(self: *Thread, config: *const DerivedConfig) !void { + // A newly enabled scheduler must reconsider existing history even when no + // terminal activity occurred while compression was disabled. + if (comptime terminalpkg.compression_enabled) { + if (!self.config.scrollback_compression and + config.scrollback_compression) + { + self.compression.activity = null; + } + } + self.config = config.*; } @@ -537,6 +563,10 @@ fn wakeupCallback( // Render immediately _ = renderCallback(t, undefined, undefined, {}); + // PageList mutations maintain their own compression dirty state. Checking + // it here covers output, resize, and viewport scrolling uniformly. + t.compression.wake(t); + // The below is not used anymore but if we ever want to introduce // a configuration to introduce a delay to coalesce renders, we can // use this. @@ -723,3 +753,113 @@ fn cursorBlinkInterval() u64 { return CURSOR_BLINK_INTERVAL; } + +/// Schedules incremental terminal compression after renderer activity stops. +/// +/// This owns all renderer-specific compression state. The terminal decides +/// when compression-relevant activity changes and performs the actual work; +/// the renderer only provides idle scheduling and avoids waiting for the +/// terminal lock. +const Compression = struct { + const idle_interval = 250; + const step_interval = 1; + + timer: xev.Timer, + completion: xev.Completion = .{}, + reset_completion: xev.Completion = .{}, + activity: ?u64 = null, + + fn init() !Compression { + return .{ .timer = try xev.Timer.init() }; + } + + fn deinit(self: *Compression) void { + self.timer.deinit(); + } + + /// Start or postpone compression after a renderer wake. + fn wake(self: *Compression, thread: *Thread) void { + // If we have no compression then don't do anything. + if (comptime !terminalpkg.compression_enabled) return; + if (!thread.config.scrollback_compression) return; + + // PageList activity, rather than a generic renderer wake, restarts the + // idle interval. In particular, the inspector wakes the renderer every + // frame without changing terminal contents and must not starve this + // timer indefinitely. + if (thread.state.mutex.tryLock()) { + defer thread.state.mutex.unlock(); + const activity = thread.state.terminal.compressionActivity(); + if (self.activity == activity) return; + self.activity = activity; + } else if (self.completion.state() == .active) { + // Contention doesn't prove that compression-relevant activity + // changed. Keep an existing deadline so frequent inspector frames + // cannot postpone compression forever. The timer rechecks both the + // activity token and lock availability before doing any work. + return; + } + + // Contention may mean parsing is active. Scheduling is a harmless + // false positive when no compression work is actually pending, but is + // necessary when no timer is already active. + self.schedule(thread, idle_interval); + } + + /// Start the one-shot timer, or move its deadline if it is already active. + fn schedule(self: *Compression, thread: *Thread, delay_ms: u64) void { + self.timer.reset( + &thread.loop, + &self.completion, + &self.reset_completion, + delay_ms, + Thread, + thread, + timerCallback, + ); + } + + fn timerCallback( + thread_: ?*Thread, + _: *xev.Loop, + _: *xev.Completion, + result: xev.Timer.RunError!void, + ) xev.CallbackAction { + _ = result catch |err| switch (err) { + error.Canceled => return .disarm, + else => { + log.warn("error in compression timer err={}", .{err}); + return .disarm; + }, + }; + + const thread = thread_ orelse return .disarm; + const self = &thread.compression; + + if (self.step(thread)) |delay| self.schedule(thread, delay); + return .disarm; + } + + /// Try one bounded step without waiting for the terminal lock. The return + /// value is the delay before another attempt, or null when work is done. + fn step(self: *Compression, thread: *Thread) ?u64 { + if (!thread.config.scrollback_compression) return null; + + const state = thread.state; + if (!state.mutex.tryLock()) return idle_interval; + defer state.mutex.unlock(); + + const activity = state.terminal.compressionActivity(); + if (self.activity != activity) { + self.activity = activity; + return idle_interval; + } + + return switch (state.terminal.compress(.incremental)) { + .pending => step_interval, + .unsupported, + .complete, + => null, + }; + } +}; diff --git a/vendor/libghostty-vt/src/renderer/generic.zig b/vendor/libghostty-vt/src/renderer/generic.zig index 0f4a294b..14b79036 100644 --- a/vendor/libghostty-vt/src/renderer/generic.zig +++ b/vendor/libghostty-vt/src/renderer/generic.zig @@ -1170,8 +1170,10 @@ pub fn Renderer(comptime GraphicsAPI: type) type { // std.log.err("[updateFrame critical time] start={}\tduration={} us", .{ start_micro, end.since(start) / std.time.ns_per_us }); // } - state.mutex.lock(); - defer state.mutex.unlock(); + // Lock while signaling demand so the IO parse thread + // can't starve us. See renderer.State.lockDemand. + state.lockDemand(); + defer state.unlockDemand(); // If we're in a synchronized output state, we pause all rendering. if (state.terminal.modes.get(.synchronized_output)) { @@ -1199,8 +1201,15 @@ pub fn Renderer(comptime GraphicsAPI: type) type { state.terminal.scrollViewport(.bottom); } - // Update our terminal state - try self.terminal_state.update(self.alloc, state.terminal); + // Begin the update of our terminal state. Work that + // doesn't require terminal access (e.g. style + // denormalization) is deferred to the endUpdate call + // outside of this critical section, keeping our lock + // hold time as short as possible. + try self.terminal_state.beginUpdate( + self.alloc, + state.terminal, + ); // If our terminal state is dirty at all we need to redo // the viewport search. @@ -1279,6 +1288,11 @@ pub fn Renderer(comptime GraphicsAPI: type) type { }; }; + // Outside the critical area, complete the update we began + // within it. This must be done before anything reads the + // render state (e.g. rebuildCells). + self.terminal_state.endUpdate(); + // Outside the critical area we can update our links to contain // our regex results. self.config.links.renderCellMap( diff --git a/vendor/libghostty-vt/src/renderer/image.zig b/vendor/libghostty-vt/src/renderer/image.zig index 442b7543..f8269354 100644 --- a/vendor/libghostty-vt/src/renderer/image.zig +++ b/vendor/libghostty-vt/src/renderer/image.zig @@ -192,9 +192,9 @@ pub const State = struct { return; }; - // For transmit time we always just use the current time - // and overwrite the overlay. - const transmit_time = try std.time.Instant.now(); + // Overlays are always considered new content, so we take a + // fresh generation stamp to force replacing any existing one. + const generation = terminal.kitty.graphics.nextGeneration(); // Ensure we have space for our overlay placement. Do this before // we upload our image so we don't have to deal with cleaning @@ -207,7 +207,7 @@ pub const State = struct { try self.prepImage( alloc, .overlay, - transmit_time, + generation, pending, ); errdefer comptime unreachable; @@ -525,14 +525,14 @@ pub const State = struct { self: *State, alloc: Allocator, id: Id, - transmit_time: std.time.Instant, + generation: u64, pending: Image.Pending, ) PrepImageError!void { - // If this image exists and its transmit time is the same we assume - // it is the identical image so we don't need to send it to the GPU. + // If this image exists and its generation is the same it is the + // identical image so we don't need to send it to the GPU. const gop = try self.images.getOrPut(alloc, id); if (gop.found_existing and - gop.value_ptr.transmit_time.order(transmit_time) == .eq) + gop.value_ptr.generation == generation) { return; } @@ -570,7 +570,7 @@ pub const State = struct { if (!gop.found_existing) { gop.value_ptr.* = .{ .image = new_image, - .transmit_time = undefined, + .generation = 0, }; } else { gop.value_ptr.image.markForReplace( @@ -586,7 +586,7 @@ pub const State = struct { log.warn("error preparing image for upload err={}", .{err}); return error.ImageConversionError; }; - gop.value_ptr.transmit_time = transmit_time; + gop.value_ptr.generation = generation; } /// Prepare the provided Kitty image for upload to the GPU by copying its @@ -599,7 +599,7 @@ pub const State = struct { try self.prepImage( alloc, .{ .kitty = image.id }, - image.transmit_time, + image.generation, .{ .width = image.width, .height = image.height, @@ -688,7 +688,13 @@ pub const Id = union(enum) { /// The map used for storing images. pub const ImageMap = std.AutoHashMapUnmanaged(Id, struct { image: Image, - transmit_time: std.time.Instant, + + /// The generation of the terminal image this was created from + /// (see terminal.kitty.graphics.Image.generation). Used to detect + /// staleness: a differing generation for the same ID means the + /// contents changed and the texture must be replaced. Zero is + /// never a valid stored generation so it marks "not yet uploaded". + generation: u64, }); /// The state for a single image that is to be rendered. diff --git a/vendor/libghostty-vt/src/renderer/link.zig b/vendor/libghostty-vt/src/renderer/link.zig index c5de6157..11ecc87a 100644 --- a/vendor/libghostty-vt/src/renderer/link.zig +++ b/vendor/libghostty-vt/src/renderer/link.zig @@ -21,6 +21,20 @@ pub const Link = struct { pub fn deinit(self: *Link) void { self.regex.deinit(); } + + /// Returns true if this link's highlight condition matches the given mouse state. + fn active( + self: *const Link, + mouse_viewport: ?point.Coordinate, + mouse_mods: inputpkg.Mods, + ) bool { + return switch (self.highlight) { + .always => true, + .always_mods => |v| mouse_mods.equal(v), + .hover => mouse_viewport != null, + .hover_mods => |v| mouse_viewport != null and mouse_mods.equal(v), + }; + } }; /// A set of links. This provides a higher level API for renderers @@ -66,6 +80,14 @@ pub const Set = struct { // Fast path, not very likely since we have default links. if (self.links.len == 0) return; + // Determine if any links are active before building the string and + // byte-to-cell map. Those buffers scale with viewport size and this + // function runs during frame updates, so avoid allocating them when + // the current mouse/modifier state can't highlight any regex links. + for (self.links) |*link| { + if (link.active(mouse_viewport, mouse_mods)) break; + } else return; + // Convert our render state to a string + byte map. var builder: std.Io.Writer.Allocating = .init(alloc); defer builder.deinit(); @@ -80,20 +102,7 @@ pub const Set = struct { // Go through each link and see if we have any matches. for (self.links) |*link| { - // Determine if our highlight conditions are met. We use a - // switch here instead of an if so that we can get a compile - // error if any other conditions are added. - switch (link.highlight) { - .always => {}, - .always_mods => |v| if (!mouse_mods.equal(v)) continue, - - // We check the hover points later. - .hover => if (mouse_viewport == null) continue, - .hover_mods => |v| { - if (mouse_viewport == null) continue; - if (!mouse_mods.equal(v)) continue; - }, - } + if (!link.active(mouse_viewport, mouse_mods)) continue; var offset: usize = 0; while (offset < str.len) { @@ -266,6 +275,65 @@ test "renderCellMap hover links" { } } +test "renderCellMap inactive links don't allocate" { + const testing = std.testing; + const alloc = testing.allocator; + + var t: terminal.Terminal = try .init(alloc, .{ + .cols = 5, + .rows = 3, + }); + defer t.deinit(alloc); + + var s = t.vtStream(); + defer s.deinit(); + const str = "1ABCD2EFGH\r\n3IJKL"; + s.nextSlice(str); + + var state: terminal.RenderState = .empty; + defer state.deinit(alloc); + try state.update(alloc, &t); + + var set = try Set.fromConfig(alloc, &.{ + .{ + .regex = "AB", + .action = .{ .open = {} }, + .highlight = .{ .hover = {} }, + }, + + .{ + .regex = "EF", + .action = .{ .open = {} }, + .highlight = .{ .always_mods = .{ .ctrl = true } }, + }, + + .{ + .regex = "IJ", + .action = .{ .open = {} }, + .highlight = .{ .hover_mods = .{ .shift = true } }, + }, + }); + defer set.deinit(alloc); + + var failing = std.testing.FailingAllocator.init( + alloc, + .{ .fail_index = 0 }, + ); + const failing_alloc = failing.allocator(); + + var result: terminal.RenderState.CellSet = .empty; + defer result.deinit(failing_alloc); + try set.renderCellMap( + failing_alloc, + &result, + &state, + null, + .{}, + ); + + try testing.expectEqual(@as(usize, 0), result.count()); +} + test "renderCellMap mods no match" { const testing = std.testing; const alloc = testing.allocator; diff --git a/vendor/libghostty-vt/src/shell-integration/nushell/vendor/autoload/ghostty.nu b/vendor/libghostty-vt/src/shell-integration/nushell/vendor/autoload/ghostty.nu index b2731f8a..1f6716f9 100644 --- a/vendor/libghostty-vt/src/shell-integration/nushell/vendor/autoload/ghostty.nu +++ b/vendor/libghostty-vt/src/shell-integration/nushell/vendor/autoload/ghostty.nu @@ -6,6 +6,7 @@ export module ghostty { # Wrap `ssh` with `ghostty +ssh` and translate the shell-integration # feature flags into command options. + @complete external export def --wrapped ssh [...args] { if not ((has_feature "ssh-env") or (has_feature "ssh-terminfo")) { ^ssh ...$args @@ -24,6 +25,7 @@ export module ghostty { } # Wrap `sudo` to preserve Ghostty's TERMINFO environment variable + @complete external export def --wrapped sudo [...args] { mut sudo_args = $args diff --git a/vendor/libghostty-vt/src/simd/vt.cpp b/vendor/libghostty-vt/src/simd/vt.cpp index 5bf4147d..15d46966 100644 --- a/vendor/libghostty-vt/src/simd/vt.cpp +++ b/vendor/libghostty-vt/src/simd/vt.cpp @@ -198,35 +198,55 @@ size_t DecodeUTF8(const uint8_t* HWY_RESTRICT input, return static_cast<size_t>(out - output); } -/// Decode the UTF-8 text in input into output until an escape -/// character is found. This returns the number of bytes consumed -/// from input and writes the number of decoded characters into -/// output_count. -/// -/// This may return a value less than count even with no escape -/// character if the input ends with an incomplete UTF-8 sequence. -/// The caller should check the next byte manually to determine -/// if it is incomplete. +// Widen the N uint8 lanes of v into N uint32 values stored at out. +// This is the UTF-8 to UTF-32 "decode" for ASCII bytes. template <class D> -size_t DecodeUTF8UntilControlSeqImpl(D d, - const T* HWY_RESTRICT input, - size_t count, - char32_t* output, - size_t* output_count) { - const size_t N = hn::Lanes(d); +static HWY_INLINE void WidenAsciiStore(D d, + hn::Vec<D> v, + char32_t* HWY_RESTRICT out) { + uint32_t* HWY_RESTRICT out32 = reinterpret_cast<uint32_t*>(out); +#if HWY_TARGET == HWY_SCALAR + // The scalar fallback target has single-lane vectors, which cannot + // be halved; widen the one lane directly. + (void)d; + out32[0] = hn::GetLane(v); +#else + const hn::Half<D> dh; + const hn::Half<hn::Half<D>> dq; + const hn::Rebind<uint32_t, decltype(dq)> d32; + const size_t N4 = hn::Lanes(dq); + const auto lo = hn::LowerHalf(dh, v); + const auto hi = hn::UpperHalf(dh, v); + hn::StoreU(hn::PromoteTo(d32, hn::LowerHalf(dq, lo)), d32, out32 + 0 * N4); + hn::StoreU(hn::PromoteTo(d32, hn::UpperHalf(dq, lo)), d32, out32 + 1 * N4); + hn::StoreU(hn::PromoteTo(d32, hn::LowerHalf(dq, hi)), d32, out32 + 2 * N4); + hn::StoreU(hn::PromoteTo(d32, hn::UpperHalf(dq, hi)), d32, out32 + 3 * N4); +#endif +} - // Create a vector containing ESC since that denotes a control sequence. +// The general (non-ASCII) portion of DecodeUTF8UntilControlSeqImpl. +// Continues scanning for ESC starting at byte offset `base` and decodes +// input[base..stop) via simdutf. The caller must have already decoded +// input[0..base) as ASCII into output[0..base) (one codepoint per byte). +template <class D> +static HWY_NOINLINE size_t DecodeNonAsciiUntilControlSeq( + D d, + const T* HWY_RESTRICT input, + size_t count, + size_t base, + char32_t* output, + size_t* output_count) { + const size_t N = hn::Lanes(d); const hn::Vec<D> esc_vec = Set(d, 0x1B); // Compare N elements at a time. - size_t i = 0; + size_t i = base; for (; i + N <= count; i += N) { // Load the N elements from our input into a vector. const hn::Vec<D> input_vec = hn::LoadU(d, input + i); // If we don't have any escapes we keep going. We want to accumulate // the largest possible valid UTF-8 sequence before decoding. - // TODO(mitchellh): benchmark this vs decoding every time const size_t esc_idx = IndexOfChunk(d, esc_vec, input_vec); if (esc_idx == kNotFound) { continue; @@ -235,25 +255,17 @@ size_t DecodeUTF8UntilControlSeqImpl(D d, // We have an ESC char, decode up to this point. We start by assuming // a valid UTF-8 sequence and slow-path into error handling if we find // an invalid sequence. - *output_count = DecodeUTF8(input, i + esc_idx, output); + *output_count = base + DecodeUTF8(input + base, i + esc_idx - base, + output + base); return i + esc_idx; } - // If we have leftover input then we decode it one byte at a time (slow!) + // If we have leftover input then we scan it one byte at a time (slow!) // using pretty much the same logic as above. - if (i != count) { - const hn::CappedTag<T, 1> d1; - using D1 = decltype(d1); - const hn::Vec<D1> esc1 = Set(d1, hn::GetLane(esc_vec)); - for (; i < count; ++i) { - const hn::Vec<D1> input_vec = hn::LoadU(d1, input + i); - const size_t esc_idx = IndexOfChunk(d1, esc1, input_vec); - if (esc_idx == kNotFound) { - continue; - } - - *output_count = DecodeUTF8(input, i + esc_idx, output); - return i + esc_idx; + for (; i < count; ++i) { + if (input[i] == 0x1B) { + *output_count = base + DecodeUTF8(input + base, i - base, output + base); + return i; } } @@ -267,9 +279,86 @@ size_t DecodeUTF8UntilControlSeqImpl(D d, // like C0, C1, F5-FF should NOT be trimmed — they should be passed // through to DecodeUTF8 which will replace them with U+FFFD per the // maximal subpart algorithm. - const size_t trimmed_len = TrimValidPartialUTF8(input, i); - *output_count = DecodeUTF8(input, trimmed_len, output); - return trimmed_len; + const size_t trimmed_len = TrimValidPartialUTF8(input + base, count - base); + *output_count = base + DecodeUTF8(input + base, trimmed_len, output + base); + return base + trimmed_len; +} + +/// Decode the UTF-8 text in input into output until an escape +/// character is found. This returns the number of bytes consumed +/// from input and writes the number of decoded characters into +/// output_count. +/// +/// This may return a value less than count even with no escape +/// character if the input ends with an incomplete UTF-8 sequence. +/// The caller should check the next byte manually to determine +/// if it is incomplete. +template <class D> +size_t DecodeUTF8UntilControlSeqImpl(D d, + const T* HWY_RESTRICT input, + size_t count, + char32_t* output, + size_t* output_count) { + const size_t N = hn::Lanes(d); + + // Create a vector containing ESC since that denotes a control sequence. + const hn::Vec<D> esc_vec = Set(d, 0x1B); + // Any byte >= 0x80 is part of a multi-byte UTF-8 sequence. + const hn::Vec<D> high_vec = Set(d, 0x80); + + // ASCII fast path: terminal input is overwhelmingly ASCII, for which + // UTF-8 decoding is a simple widening of each byte to 32 bits. We + // fuse the ESC scan with the decode, one chunk at a time, and only + // fall back to the full UTF-8 decoder (simdutf) when we encounter a + // non-ASCII byte. This avoids a second pass over the input and, for + // the common short runs between escape sequences, avoids the fixed + // overhead of the general-purpose decoder. + size_t i = 0; + for (; i + N <= count; i += N) { + const hn::Vec<D> input_vec = hn::LoadU(d, input + i); + + // Find the first byte that stops the ASCII fast path: an ESC or + // any non-ASCII byte. + const hn::Mask<D> stop_mask = + hn::Or(hn::Eq(input_vec, esc_vec), hn::Ge(input_vec, high_vec)); + const intptr_t stop = hn::FindFirstTrue(d, stop_mask); + + // Widen the whole chunk unconditionally: output is guaranteed to + // be at least as large as input, and if we stop mid-chunk only + // the prefix is reported (the rest is scratch that the caller + // never reads). + WidenAsciiStore(d, input_vec, output + i); + if (stop < 0) continue; + + const size_t stop_idx = i + static_cast<size_t>(stop); + if (input[stop_idx] == 0x1B) { + // ESC: everything before it was ASCII, one codepoint per byte. + *output_count = stop_idx; + return stop_idx; + } + + // Non-ASCII: decode the rest (up to an ESC) with the full decoder. + return DecodeNonAsciiUntilControlSeq(d, input, count, stop_idx, output, + output_count); + } + + // Leftover input (< N bytes): process one byte at a time. + for (; i < count; ++i) { + const T b = input[i]; + if (b == 0x1B) { + *output_count = i; + return i; + } + if (b >= 0x80) { + return DecodeNonAsciiUntilControlSeq(d, input, count, i, output, + output_count); + } + output[i] = b; + } + + // The entire input was ASCII (no ESC, no partial sequences possible). + *output_count = count; + return count; } size_t DecodeUTF8UntilControlSeq(const uint8_t* HWY_RESTRICT input, diff --git a/vendor/libghostty-vt/src/simd/vt.zig b/vendor/libghostty-vt/src/simd/vt.zig index 4230665f..807285f7 100644 --- a/vendor/libghostty-vt/src/simd/vt.zig +++ b/vendor/libghostty-vt/src/simd/vt.zig @@ -80,13 +80,18 @@ fn utf8DecodeUntilControlSeqScalar( var valid: usize = 1; // lead byte is valid for (0..seq.len - 1) |ci| { if (decode_offset + valid >= decode.len) { - // Truncated at end of buffer: treat as incomplete - // input that may be completed later. Stop decoding - // without consuming these bytes. - return .{ + // The sequence is cut off by the end of the decode + // region. If the region ends at the true end of the + // input then it may be completed by future input, so + // stop without consuming these bytes. If the region + // was bounded by an ESC then the sequence can never + // be completed; the valid-so-far prefix is a maximal + // subpart which maps to a single U+FFFD below. + if (decode.len == input.len) return .{ .consumed = decode_offset, .decoded = decode_count, }; + break; } const cb = decode[decode_offset + valid]; if (cb < seq.ranges[ci][0] or cb > seq.ranges[ci][1]) { @@ -148,6 +153,58 @@ fn utf8SeqInfo(lead: u8) Utf8SeqInfo { }; } +// 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 +// lengths (including chunk-boundary straddling cases). +test "decode simd matches scalar" { + if (comptime !options.simd) return error.SkipZigTest; + + const testing = std.testing; + var prng = std.Random.DefaultPrng.init(0xf00dface); + const rand = prng.random(); + + var input: [257]u8 = undefined; + var out_simd: [input.len]u32 = undefined; + var out_scalar: [input.len]u32 = undefined; + + for (0..10_000) |_| { + const len = rand.intRangeAtMost(usize, 0, input.len); + const style = rand.intRangeAtMost(u8, 0, 2); + for (input[0..len]) |*b| { + b.* = switch (style) { + // Mostly ASCII with occasional specials. + 0 => switch (rand.intRangeAtMost(u8, 0, 20)) { + 0 => 0x1B, + 1 => rand.intRangeAtMost(u8, 0, 0x1F), + 2 => rand.int(u8), + else => rand.intRangeAtMost(u8, 0x20, 0x7E), + }, + // Heavy multi-byte/invalid UTF-8. + 1 => switch (rand.intRangeAtMost(u8, 0, 3)) { + 0 => rand.intRangeAtMost(u8, 0x80, 0xBF), + 1 => rand.intRangeAtMost(u8, 0xC0, 0xFF), + 2 => 0x1B, + else => rand.intRangeAtMost(u8, 0x20, 0x7E), + }, + // Fully random bytes. + else => rand.int(u8), + }; + } + + const res_simd = utf8DecodeUntilControlSeq(input[0..len], &out_simd); + const res_scalar = utf8DecodeUntilControlSeqScalar(input[0..len], &out_scalar); + errdefer std.debug.print("input={x}\n", .{input[0..len]}); + try testing.expectEqual(res_scalar.consumed, res_simd.consumed); + try testing.expectEqual(res_scalar.decoded, res_simd.decoded); + try testing.expectEqualSlices( + u32, + out_scalar[0..res_scalar.decoded], + out_simd[0..res_simd.decoded], + ); + } +} + test "decode no escape" { const testing = std.testing; @@ -399,6 +456,35 @@ test "decode valid multibyte surrounded by invalid" { } } +test "decode partial UTF-8 before escape" { + const testing = std.testing; + + // A valid-so-far but incomplete sequence cut off by an ESC can + // never be completed, so it is consumed and replaced by a single + // U+FFFD (maximal subpart) rather than left pending. Only + // sequences cut off by the true end of input are left pending. + var output: [64]u32 = undefined; + + // 2-byte lead cut off by ESC. + { + const str = "hi\xc2\x1b[0m"; + const result = utf8DecodeUntilControlSeq(str, &output); + try testing.expectEqual(@as(usize, 3), result.consumed); + try testing.expectEqual(@as(usize, 3), result.decoded); + try testing.expectEqual(@as(u32, 0xFFFD), output[2]); + } + + // 3-byte lead plus one valid continuation cut off by ESC: + // the whole prefix is one maximal subpart, one U+FFFD. + { + const str = "\xe0\xa0\x1bX"; + const result = utf8DecodeUntilControlSeq(str, &output); + try testing.expectEqual(@as(usize, 2), result.consumed); + try testing.expectEqual(@as(usize, 1), result.decoded); + try testing.expectEqual(@as(u32, 0xFFFD), output[0]); + } +} + test "decode invalid byte before escape" { const testing = std.testing; diff --git a/vendor/libghostty-vt/src/synthetic/Kitty.zig b/vendor/libghostty-vt/src/synthetic/Kitty.zig new file mode 100644 index 00000000..c2c649d7 --- /dev/null +++ b/vendor/libghostty-vt/src/synthetic/Kitty.zig @@ -0,0 +1,88 @@ +//! Generates random Kitty graphics protocol APC sequences. +//! +//! The payload is random base64, NOT a valid image: decoding it as +//! PNG will fail. This corpus is meant for benchmarking the stream, +//! APC, and Kitty command parsing paths, which never decode the +//! image data. It does not exercise successful image loading. +const Kitty = @This(); + +const std = @import("std"); +const assert = std.debug.assert; +const Generator = @import("Generator.zig"); +const Bytes = @import("Bytes.zig"); + +/// Random number generator. +rand: std.Random, + +/// The base64 payload length for each generated command. This is +/// rounded down to a multiple of four so the payload is always valid +/// base64 without requiring padding. Kitty clients typically chunk +/// payloads at 4096 bytes. +data_len: usize = 4096, + +fn checkBase64Alphabet(c: u8) bool { + return switch (c) { + 'A'...'Z', 'a'...'z', '0'...'9', '+', '/' => true, + else => false, + }; +} + +/// The base64 alphabet, without the padding character. +pub const base64_alphabet = Bytes.generateAlphabet(checkBase64Alphabet); + +pub fn generator(self: *Kitty) Generator { + return .init(self, next); +} + +const prefix = "\x1b_G"; +const st = "\x1b\\"; + +/// Get the next Kitty graphics APC sequence: a well-formed transmit +/// command with a random base64 payload (not a valid image; see the +/// module comment), including the APC prefix and the ST terminator. +pub fn next( + self: *Kitty, + writer: *std.Io.Writer, + max_len: usize, +) Generator.Error!void { + var control_buf: [64]u8 = undefined; + const control = std.fmt.bufPrint( + &control_buf, + "a=t,f=100,i={d};", + .{self.rand.intRangeAtMost(u32, 1, 1_000_000)}, + ) catch unreachable; + + const overhead = prefix.len + control.len + st.len; + assert(max_len > overhead); + + const avail = @min(self.data_len, max_len - overhead); + const payload_len = avail - (avail % 4); + + try writer.writeAll(prefix); + try writer.writeAll(control); + if (payload_len > 0) { + const bytes: Bytes = .{ + .rand = self.rand, + .alphabet = base64_alphabet, + .min_len = payload_len, + .max_len = payload_len, + }; + _ = try bytes.write(writer); + } + try writer.writeAll(st); +} + +test "kitty" { + const testing = std.testing; + var prng = std.Random.DefaultPrng.init(0); + + var buf: [8192]u8 = undefined; + var writer: std.Io.Writer = .fixed(&buf); + var v: Kitty = .{ .rand = prng.random() }; + const gen = v.generator(); + try gen.next(&writer, buf.len); + + const data = writer.buffered(); + try testing.expect(std.mem.startsWith(u8, data, "\x1b_Ga=t,f=100,i=")); + try testing.expect(std.mem.endsWith(u8, data, "\x1b\\")); +} diff --git a/vendor/libghostty-vt/src/synthetic/cli.zig b/vendor/libghostty-vt/src/synthetic/cli.zig index d9b6a659..d6ec028f 100644 --- a/vendor/libghostty-vt/src/synthetic/cli.zig +++ b/vendor/libghostty-vt/src/synthetic/cli.zig @@ -7,6 +7,7 @@ const cli = @import("../cli.zig"); /// predictably named files under `cli/`. pub const Action = enum { ascii, + kitty, osc, utf8, @@ -21,6 +22,7 @@ pub const Action = enum { pub fn Struct(comptime action: Action) type { return switch (action) { .ascii => @import("cli/Ascii.zig"), + .kitty => @import("cli/Kitty.zig"), .osc => @import("cli/Osc.zig"), .utf8 => @import("cli/Utf8.zig"), }; diff --git a/vendor/libghostty-vt/src/synthetic/cli/Kitty.zig b/vendor/libghostty-vt/src/synthetic/cli/Kitty.zig new file mode 100644 index 00000000..99a92798 --- /dev/null +++ b/vendor/libghostty-vt/src/synthetic/cli/Kitty.zig @@ -0,0 +1,70 @@ +const Kitty = @This(); + +const std = @import("std"); +const Allocator = std.mem.Allocator; +const synthetic = @import("../main.zig"); + +const log = std.log.scoped(.@"kitty-gen"); + +pub const Options = struct { + /// The base64 payload length of each command in bytes, rounded + /// down to a multiple of four. Kitty clients typically chunk + /// payloads at 4096 bytes. + @"data-len": usize = 4096, +}; + +opts: Options, + +/// The buffer a single generated sequence is written into. Sized to +/// the payload length plus room for the control data and terminator. +buf: []u8, + +/// Create a new Kitty graphics sequence generator for the given arguments. +pub fn create( + alloc: Allocator, + opts: Options, +) !*Kitty { + const ptr = try alloc.create(Kitty); + errdefer alloc.destroy(ptr); + + const buf = try alloc.alloc(u8, opts.@"data-len" + 128); + errdefer alloc.free(buf); + + ptr.* = .{ .opts = opts, .buf = buf }; + return ptr; +} + +pub fn destroy(self: *Kitty, alloc: Allocator) void { + alloc.free(self.buf); + alloc.destroy(self); +} + +pub fn run(self: *Kitty, writer: *std.Io.Writer, rand: std.Random) !void { + var gen: synthetic.Kitty = .{ + .rand = rand, + .data_len = self.opts.@"data-len", + }; + + while (true) { + var fixed: std.Io.Writer = .fixed(self.buf); + try gen.next(&fixed, self.buf.len); + writer.writeAll(fixed.buffered()) catch |err| switch (err) { + error.WriteFailed => return, + }; + } +} + +test Kitty { + const testing = std.testing; + const alloc = testing.allocator; + + const impl: *Kitty = try .create(alloc, .{}); + defer impl.destroy(alloc); + + var prng = std.Random.DefaultPrng.init(1); + const rand = prng.random(); + + var buf: [8192]u8 = undefined; + var writer: std.Io.Writer = .fixed(&buf); + try impl.run(&writer, rand); +} diff --git a/vendor/libghostty-vt/src/synthetic/main.zig b/vendor/libghostty-vt/src/synthetic/main.zig index 85f9f7d3..1c26090d 100644 --- a/vendor/libghostty-vt/src/synthetic/main.zig +++ b/vendor/libghostty-vt/src/synthetic/main.zig @@ -18,6 +18,7 @@ pub const cli = @import("cli.zig"); pub const Generator = @import("Generator.zig"); pub const Bytes = @import("Bytes.zig"); pub const Utf8 = @import("Utf8.zig"); +pub const Kitty = @import("Kitty.zig"); pub const Osc = @import("Osc.zig"); test { diff --git a/vendor/libghostty-vt/src/terminal/PageList.zig b/vendor/libghostty-vt/src/terminal/PageList.zig index 63155962..79aa96f1 100644 --- a/vendor/libghostty-vt/src/terminal/PageList.zig +++ b/vendor/libghostty-vt/src/terminal/PageList.zig @@ -12,8 +12,10 @@ const fastmem = @import("../fastmem.zig"); const tripwire = @import("../tripwire.zig"); const DoublyLinkedList = @import("../datastruct/main.zig").IntrusiveDoublyLinkedList; const color = @import("color.zig"); +const compression = @import("compress.zig"); const highlight = @import("highlight.zig"); const kitty = @import("kitty.zig"); +const terminal_mem = @import("mem.zig"); const point = @import("point.zig"); const pagepkg = @import("page.zig"); const stylepkg = @import("style.zig"); @@ -44,8 +46,251 @@ pub const List = DoublyLinkedList(Node); const Node = struct { prev: ?*Node = null, next: ?*Node = null, - data: Page, + data: Data, serial: u64, + + /// How the backing memory of the embedded Page was allocated. Pool-owned + /// memory is always a full standard-size item from the memory + /// pool, regardless of the page layout size. Heap-owned memory + /// is allocated directly with the page allocator and is exactly + /// `Page.memory.len` bytes. + /// + /// This must never be inferred from the memory length: heap-owned + /// pages can be smaller than the standard size (e.g. compacted + /// pages), and returning one to the pool would corrupt it. + /// + /// This has no default on purpose so that every construction site + /// is forced to make an explicit decision. + owned: Owned, + + /// The physical representation of the page contents. + /// + /// Both states retain the same `Page.memory` virtual mapping and the same + /// `Page` metadata. A resident page can access the mapping directly since + /// it is, well, resident. + /// + /// A compressed page owns an encoded copy while the physical memory + /// behind the raw mapping is discarded. HOWEVER, we retain the + /// virtual allocation so that restoration is infallible. + const Data = union(enum) { + resident: Page, + compressed: compression.Page, + }; + + const Owned = enum { pool, heap }; + + /// The backing-memory representation currently stored by this node. + pub const Storage = enum { resident, compressed }; + + /// Return the terminal page stored in this node. + /// + /// WARNING: This will DECOMPRESS compressed pages! Only use this if + /// you need access to the underlying memory. If you only need access to + /// metadata (row, col counts etc) then use the other metadata functions. + pub inline fn page(self: *Node) *Page { + return switch (self.data) { + .resident => |*page_| page_, + .compressed => self.restore(.preserve), + }; + } + + /// Return the terminal page only when its raw memory is resident. + /// + /// Unlike `page`, this never restores a compressed node. This is useful + /// for diagnostics which can display node metadata without touching the + /// discarded mapping, but may inspect page contents when they are already + /// available. + pub inline fn pageIfResident(self: *Node) ?*Page { + return switch (self.data) { + .resident => |*page_| page_, + .compressed => null, + }; + } + + /// Read-only page with full memory access that doesn't change + /// this node's storage state: if a node is compressed it remains + /// compressed. + /// + /// Resident pages are borrowed directly. So they're basically free. + /// + /// Compressed pages are decoded into an independently owned Page + /// by the caller so callers can inspect their contents. Because resident + /// pages are still borrowed, you have to continue to have exclusive + /// access to the PageList during this operation. + pub const PreservedPage = union(enum) { + borrowed: *const Page, + owned: struct { + page: Page, + alloc: Allocator, + }, + + /// Return the read-only page represented by this value. + pub fn page(self: *const PreservedPage) *const Page { + return switch (self.*) { + .borrowed => |page_| page_, + .owned => |*owned| &owned.page, + }; + } + + /// Release storage owned by this preserved page. + pub fn deinit(self: *PreservedPage) void { + switch (self.*) { + .borrowed => {}, + // The clone buffer came from the caller's allocator rather + // than Page's OS allocator, so it must not use Page.deinit. + .owned => |*owned| owned.alloc.free(owned.page.memory), + } + self.* = undefined; + } + }; + + /// Return read-only page contents without changing this node's storage. + /// + /// `alloc` is unused for a resident node. A compressed node uses it for an + /// exact-sized, page-aligned decode buffer owned by the returned value. + /// + /// The caller must call `PreservedPage.deinit` when finished. See + /// `PreservedPage` for the synchronization required by its borrowed + /// resident representation. + pub fn pagePreservingState( + self: *const Node, + alloc: Allocator, + ) Allocator.Error!PreservedPage { + return switch (self.data) { + .resident => |*page_| .{ .borrowed = page_ }, + .compressed => |*compressed| owned: { + const memory = try alloc.alignedAlloc( + u8, + .fromByteUnits(std.heap.page_size_min), + compressed.page.memory.len, + ); + + const page_ = compressed.cloneBuf(memory) catch |err| { + // The encoded data was produced by our codec and remains + // immutable while compressed. Failure is internal + // corruption, matching the normal restoration boundary. + switch (err) { + error.TruncatedInput, + error.InvalidOffset, + error.OutputTooSmall, + error.OutputSizeMismatch, + => {}, + } + + alloc.free(memory); + log.err("failed to clone compressed page err={}", .{err}); + @panic("failed to clone compressed terminal page"); + }; + + break :owned .{ .owned = .{ + .page = page_, + .alloc = alloc, + } }; + }, + }; + } + + /// Return the node's backing-memory representation without restoring it. + pub inline fn storage(self: *const Node) Storage { + return switch (self.data) { + .resident => .resident, + .compressed => .compressed, + }; + } + + /// Return a page which the caller knows is already resident. + /// + /// This avoids the representation check in hot paths which already hold + /// live pointers into the page mapping. Such pointers are only valid while + /// the page is resident. Prefer `page` unless the caller can establish + /// that invariant independently. + pub inline fn pageAssumeResident(self: *Node) *Page { + return &self.data.resident; + } + + /// Return the number of populated rows without accessing page memory. + pub inline fn rows(self: *const Node) size.CellCountInt { + return self.metadata().size.rows; + } + + /// Return the current column count without accessing page memory. + pub inline fn cols(self: *const Node) size.CellCountInt { + return self.metadata().size.cols; + } + + /// Return the page capacity without accessing page memory. + pub inline fn capacity(self: *const Node) Capacity { + return self.metadata().capacity; + } + + /// Return the embedded Page metadata without restoring its memory. + inline fn metadata(self: *const Node) *const Page { + return switch (self.data) { + .resident => |*page_| page_, + .compressed => |*page_| &page_.page, + }; + } + + const RestoreMode = enum { + /// Decode the compressed representation back into the raw mapping. + preserve, + + /// Discard the compressed representation without decoding it. The + /// caller must not read the page contents before overwriting them. + discard, + }; + + /// Restore this node to the resident representation. + /// + /// Preserve mode reconstructs the page contents and is used by `page`. + /// Discard mode skips decoding for callers which will overwrite or destroy + /// the page. Both modes recommit the retained mapping, free the encoded + /// allocation, and leave the node in a valid resident state. + noinline fn restore(self: *Node, comptime mode: RestoreMode) *Page { + const compressed = switch (self.data) { + .resident => |*page_| return page_, + .compressed => |*page_| page_, + }; + + // Decommit only discarded the physical pages. Recommit prepares the + // still-reserved mapping for decoding or reuse by the caller. + terminal_mem.recommit(compressed.page.memory); + + const restored = switch (mode) { + .preserve => compressed.restore() catch |err| { + // Compressed nodes retain the immutable encoding and exact + // output mapping used to create them. Any decode error is an + // internal codec bug or memory corruption. Keep this switch + // exhaustive so new decoder errors require classification. + switch (err) { + error.TruncatedInput, + error.InvalidOffset, + error.OutputTooSmall, + error.OutputSizeMismatch, + => {}, + } + + log.err("failed to restore compressed page err={}", .{err}); + @panic("failed to restore compressed terminal page"); + }, + + .discard => compressed.page, + }; + + // Remove our compressed data + compressed.deinit(); + + // We're now a resident, non-compressed node. + self.data = .{ .resident = restored }; + return &self.data.resident; + } + + inline fn isCompressed(self: *const Node) bool { + return switch (self.data) { + .resident => false, + .compressed => true, + }; + } }; /// The memory pool we get page nodes from. @@ -120,29 +365,42 @@ pool: MemoryPool, /// The list of pages in the screen. pages: List, -/// A monotonically increasing serial number that is incremented each -/// time a page is allocated or reused as new. The serial is assigned to -/// the Node. +/// The next globally unique reference generation for this PageList. A +/// generation is assigned whenever a page is allocated, reused as new, or +/// changed in place such that existing page coordinates are no longer stable. /// /// The serial number can be used to detect whether the page is identical /// to the page that was originally referenced by a pointer. Since we reuse /// and pool memory, pointer stability is not guaranteed, but the serial -/// will always be different for different allocations. +/// will always be different for different page generations. /// /// Developer note: we never do overflow checking on this. If we created /// a new page every second it'd take 584 billion years to overflow. We're /// going to risk it. page_serial: u64, -/// The lowest still valid serial number that could exist. This allows -/// for quick comparisons to find invalid pages in references. -page_serial_min: u64, - -/// Byte size of the total amount of allocated pages. Note this does -/// not include the total allocated amount in the pool which may be more -/// than this due to preheating. +/// The first serial in the current whole-list validity epoch. Only `reset` +/// advances this value, immediately before rebuilding every page. It is not +/// the generation of the first page or the exact minimum live generation. +/// Page generations are not monotonic in list order: replacement and split +/// operations can put a fresh generation before older live pages. +/// +/// A generation below this epoch is definitely invalid, allowing O(1) +/// rejection in `nodeIsValid` and bulk removal of pre-reset search results. +/// A generation at or above it is only potentially valid and must still be +/// checked against the live list before its coordinates are used. +page_serial_epoch: u64, + +/// Byte size of the raw backing mappings owned by active page nodes. This is +/// logical scrollback accounting and does not change while a mapping is +/// decommitted. It excludes encoded storage and unused preheated pool items. page_size: usize, +/// Continuation state for incremental page compression. This allows +/// compress(.incremental) to work. More details on all that there and +/// in the state struct. +page_compression: IncrementalCompressionState = .{}, + /// Maximum size of the page allocation in bytes. This only includes pages /// that are used ONLY for scrollback. If the active area is still partially /// in a page that also includes scrollback, then that page is not included. @@ -403,7 +661,7 @@ pub fn init( .pool = pool, .pages = page_list, .page_serial = page_serial, - .page_serial_min = 0, + .page_serial_epoch = 0, .page_size = page_size, .explicit_max_size = max_size orelse std.math.maxInt(usize), .min_max_size = min_max_size, @@ -444,13 +702,14 @@ 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 non-standard pages + // If we have an error, we need to clean up our heap-owned pages // since they're not in the pool. errdefer { var it = page_list.first; while (it) |node| : (it = node.next) { - if (node.data.memory.len > std_size) { - page_alloc.free(node.data.memory); + switch (node.owned) { + .pool => {}, + .heap => page_alloc.free(node.page().memory), } } } @@ -463,7 +722,9 @@ fn initPages( const page_buf = if (pooled) buf: { try tw.check(.page_buf_std); - break :buf try pool.pages.create(); + const buf = try pool.pages.create(); + terminal_mem.recommit(buf); + break :buf buf; } else buf: { try tw.check(.page_buf_non_std); break :buf try page_alloc.alignedAlloc( @@ -488,11 +749,12 @@ fn initPages( // Initialize the first set of pages to contain our viewport so that // the top of the first page is always the active area. node.* = .{ - .data = .initBuf(.init(page_buf), layout), + .data = .{ .resident = .initBuf(.init(page_buf), layout) }, .serial = serial.*, + .owned = if (pooled) .pool else .heap, }; - node.data.size.rows = @min(rem, node.data.capacity.rows); - rem -= node.data.size.rows; + node.page().size.rows = @min(rem, node.capacity().rows); + rem -= node.rows(); // Add the page to the list page_list.append(node); @@ -553,15 +815,14 @@ fn verifyIntegrity(self: *const PageList) IntegrityError!void { { var node_ = self.pages.first; while (node_) |node| { - actual_total += node.data.size.rows; + actual_total += node.rows(); node_ = node.next; - // While doing this traversal, verify no node has a serial - // number lower than our min. - if (node.serial < self.page_serial_min) { + // Every live node must belong to the current validity epoch. + if (node.serial < self.page_serial_epoch) { log.warn( - "PageList integrity violation: page serial too low serial={} min={}", - .{ node.serial, self.page_serial_min }, + "PageList integrity violation: page serial predates epoch serial={} epoch={}", + .{ node.serial, self.page_serial_epoch }, ); return IntegrityError.PageSerialInvalid; } @@ -588,7 +849,7 @@ fn verifyIntegrity(self: *const PageList) IntegrityError!void { var offset: usize = 0; var node = self.pages.last; while (node) |n| : (node = n.prev) { - offset += n.data.size.rows; + offset += n.rows(); if (n == self.viewport_pin.node) { offset -= self.viewport_pin.y; break :offset self.total_rows - offset; @@ -624,8 +885,7 @@ fn verifyIntegrity(self: *const PageList) IntegrityError!void { } } -/// Deinit the pagelist. If you own the memory pool (used clonePool) then -/// this will reset the pool and retain capacity. +/// Deinit the pagelist, freeing all page memory and the memory pool. pub fn deinit(self: *PageList) void { // Verify integrity before cleanup self.assertIntegrity(); @@ -634,12 +894,14 @@ pub fn deinit(self: *PageList) void { self.tracked_pins.deinit(self.pool.alloc); // Go through our linked list and deallocate all pages that are - // not standard size. + // heap-owned (not in the pool). const page_alloc = self.pool.pages.arena.child_allocator; var it = self.pages.first; while (it) |node| : (it = node.next) { - if (node.data.memory.len > std_size) { - page_alloc.free(node.data.memory); + const page = node.restore(.discard); + switch (node.owned) { + .pool => {}, + .heap => page_alloc.free(page.memory), } } @@ -658,10 +920,13 @@ pub fn deinit(self: *PageList) void { pub fn reset(self: *PageList) void { defer self.assertIntegrity(); - // Invalidate all external page refs to the previous list. The reset below - // rebuilds the page list from the pools, so old untracked refs must be - // rejected before any validation attempts to inspect their node pointers. - self.page_serial_min = self.page_serial; + // Reset discards all scrollback, so there is nothing left to compress. + self.page_compression.reset(); + + // Begin a new whole-list validity epoch before rebuilding from the pools. + // Every old reference now has a serial below the epoch and can be rejected + // in O(1), even if the node pool later reuses its pointer address. + self.page_serial_epoch = self.page_serial; // We need enough pages/nodes to keep our active area. This should // never fail since we by definition have allocated a page already @@ -678,14 +943,15 @@ pub fn reset(self: *PageList) void { ) catch unreachable; // Before resetting our pools we need to free any pages that - // are non-standard size since those were allocated outside - // the pool. + // are heap-owned since those were allocated outside the pool. { const page_alloc = self.pool.pages.arena.child_allocator; var it = self.pages.first; while (it) |node| : (it = node.next) { - if (node.data.memory.len > std_size) { - page_alloc.free(node.data.memory); + const page = node.restore(.discard); + switch (node.owned) { + .pool => {}, + .heap => page_alloc.free(page.memory), } } } @@ -825,8 +1091,9 @@ pub fn clone( const page_alloc = pool.pages.arena.child_allocator; var page_it = page_list.first; while (page_it) |node| : (page_it = node.next) { - if (node.data.memory.len > std_size) { - page_alloc.free(node.data.memory); + switch (node.owned) { + .pool => {}, + .heap => page_alloc.free(node.page().memory), } } } @@ -840,25 +1107,27 @@ pub fn clone( // we don't know if the source page has a standard size. const node = try createPageExt( &pool, - chunk.node.data.capacity, + .{ .cap = chunk.node.capacity() }, &page_serial, &page_size, ); - assert(node.data.capacity.rows >= chunk.end - chunk.start); - defer node.data.assertIntegrity(); - node.data.size.rows = chunk.end - chunk.start; - node.data.size.cols = chunk.node.data.size.cols; - try node.data.cloneFrom( - &chunk.node.data, + const dst_page = node.page(); + const src_page = chunk.node.page(); + assert(node.capacity().rows >= chunk.end - chunk.start); + defer dst_page.assertIntegrity(); + dst_page.size.rows = chunk.end - chunk.start; + dst_page.size.cols = chunk.node.cols(); + try dst_page.cloneFrom( + src_page, chunk.start, chunk.end, ); - node.data.dirty = chunk.node.data.dirty; + dst_page.dirty = src_page.dirty; page_list.append(node); - total_rows += node.data.size.rows; + total_rows += node.rows(); // Remap our tracked pins by changing the page and // offsetting the Y position based on the chunk start. @@ -887,7 +1156,7 @@ pub fn clone( .pool = pool, .pages = page_list, .page_serial = page_serial, - .page_serial_min = 0, + .page_serial_epoch = 0, .page_size = page_size, .explicit_max_size = self.explicit_max_size, .min_max_size = self.min_max_size, @@ -911,8 +1180,9 @@ pub fn clone( // now we rarely clone less than the active area and if we do // the area is by definition very small. const last = result.pages.last.?; - const row = &last.data.rows.ptr(last.data.memory)[last.data.size.rows - 1]; - last.data.clearCells(row, 0, result.cols); + const page = last.page(); + const row = &page.rows.ptr(page.memory)[last.rows() - 1]; + page.clearCells(row, 0, result.cols); } // Update our total rows to be our row size. @@ -952,6 +1222,12 @@ pub const Resize = struct { pub fn resize(self: *PageList, opts: Resize) Allocator.Error!void { defer self.assertIntegrity(); + // Resizing forces all nodes to be decompressed today so we need to + // reschedule compression. + // TODO(mitchellh): Deferred reflow on non-viewport/non-active pages. + self.page_compression.reset(); + self.page_compression.markActivity(); + if (comptime std.debug.runtime_safety) { // Resize does not work with 0 values, this should be protected // upstream @@ -1047,6 +1323,16 @@ fn resizeCols( const wrapped = wrapped: { var wrapped: usize = 0; + // If shrinking rows (in the .lt branch of resize, rows shrink + // before we get here) pushed the cursor pin above the new active + // area, there are no rows to count and iterating .left_up toward + // the active-area top would be an invalid (reversed) range. The + // preserved-cursor growth below already no-ops for a cursor that + // isn't in the active area, so we just count zero here. + if (active_pin) |ap| { + if (p.before(ap)) break :wrapped 0; + } + var row_it = p.rowIterator(.left_up, active_pin); while (row_it.next()) |next| { const row = next.rowAndCell().row; @@ -1075,7 +1361,7 @@ fn resizeCols( // Create the first node that contains our reflow. const first_rewritten_node = node: { - const page = &self.pages.first.?.data; + const page = self.pages.first.?.page(); const cap = page.capacity.adjust( .{ .cols = cols }, ) catch |err| err: { @@ -1092,8 +1378,8 @@ fn resizeCols( break :err cap; }; - const node = try self.createPage(cap); - node.data.size.rows = 1; + const node = try self.createPage(.{ .cap = cap }); + node.page().size.rows = 1; break :node node; }; @@ -1134,7 +1420,7 @@ fn resizeCols( // Once we're done reflowing a page, destroy it immediately. // This frees memory and makes it more likely in memory // constrained environments that the next reflow will work. - if (row.y == row.node.data.size.rows - 1) { + if (row.y == row.node.rows() - 1) { self.destroyNode(row.node); } } @@ -1150,7 +1436,7 @@ fn resizeCols( var node_it = self.pages.first; var total: usize = 0; while (node_it) |node| : (node_it = node.next) { - total += node.data.size.rows; + total += node.rows(); if (total >= self.rows) break; } else { for (total..self.rows) |_| _ = try self.grow(); @@ -1223,7 +1509,7 @@ const ReflowCursor = struct { total_rows: usize, fn init(node: *List.Node) ReflowCursor { - const page = &node.data; + const page = node.page(); const rows = page.rows.ptr(page.memory); return .{ .x = 0, @@ -1236,7 +1522,7 @@ const ReflowCursor = struct { .new_rows = 0, // Initially whatever size our input node is. - .total_rows = node.data.size.rows, + .total_rows = node.rows(), }; } @@ -1247,7 +1533,7 @@ const ReflowCursor = struct { row: Pin, cursor_pin: ?*Pin, ) Allocator.Error!void { - const src_page: *Page = &row.node.data; + const src_page: *Page = row.node.page(); const src_row = row.rowAndCell().row; const src_y = row.y; const cells = src_row.cells.ptr(src_page.memory)[0..src_page.size.cols]; @@ -1270,7 +1556,7 @@ const ReflowCursor = struct { { const pin_keys = list.tracked_pins.keys(); for (pin_keys) |p| { - if (&p.node.data != src_page or + if (p.node.page() != src_page or p.y != src_y) continue; if (cursor_pin != null and p == cursor_pin.?) continue; @@ -1293,7 +1579,7 @@ const ReflowCursor = struct { // If the cursor is after blanks on the right, those cells are still // before the next write and must reflow with it. if (cursor_pin) |p| { - if (&p.node.data == src_page and p.y == src_y) { + if (p.node.page() == src_page and p.y == src_y) { cols_len = @max(cols_len, p.x + 1); } } @@ -1344,7 +1630,7 @@ const ReflowCursor = struct { { const pin_keys = list.tracked_pins.keys(); for (pin_keys) |p| { - if (&p.node.data != src_page or + if (p.node.page() != src_page or p.y != src_y or p.x != x) continue; @@ -1369,7 +1655,7 @@ const ReflowCursor = struct { // since we won't process that cell in the loop. const pin_keys = list.tracked_pins.keys(); for (pin_keys) |p| { - if (&p.node.data != src_page or + if (p.node.page() != src_page or p.y != src_y or p.x != x + 1) continue; @@ -1853,7 +2139,7 @@ const ReflowCursor = struct { { const pin_keys = list.tracked_pins.keys(); for (pin_keys) |p| { - if (&p.node.data != old_page or + if (p.node.page() != old_page or p.y != old_page.size.rows - 1) continue; p.node = self.node; @@ -1954,9 +2240,9 @@ const ReflowCursor = struct { // after reinitializing our cursor on the new page. const new_rows = self.new_rows; - const node = try list.createPage(cap); + const node = try list.createPage(.{ .cap = cap }); errdefer comptime unreachable; - node.data.size.rows = 1; + node.page().size.rows = 1; list.pages.insertAfter(self.node, node); self.* = .init(node); @@ -2056,7 +2342,7 @@ fn resizeWithoutReflow(self: *PageList, opts: Resize) Allocator.Error!void { .lt => { var it = self.pageIterator(.right_down, .{ .screen = .{} }, null); while (it.next()) |chunk| { - const page = &chunk.node.data; + const page = chunk.node.page(); defer page.assertIntegrity(); const rows = page.rows.ptr(page.memory); for (0..page.size.rows) |i| { @@ -2153,12 +2439,17 @@ fn resizeWithoutReflow(self: *PageList, opts: Resize) Allocator.Error!void { // just update our rows and we're done. This effectively // "pulls down" scrollback. // + // This traversal intentionally reads only node metadata. A + // compressed history page pulled into the active area remains + // compressed until a renderer or other content consumer goes + // through `Node.page`, which restores it transparently. + // // If we don't have enough scrollback, we add the difference, // to the active area. var count: usize = 0; var page = self.pages.first; while (page) |p| : (page = p.next) { - count += p.data.size.rows; + count += p.rows(); if (count >= rows) break; } else { assert(count < rows); @@ -2189,7 +2480,7 @@ fn resizeWithoutReflowGrowCols( chunk: PageIterator.Chunk, ) Allocator.Error!void { assert(cols > self.cols); - const page = &chunk.node.data; + const page = chunk.node.page(); // Update our col count const old_cols = self.cols; @@ -2263,7 +2554,7 @@ fn resizeWithoutReflowGrowCols( // This can fail for many reasons (can't fit styles/graphemes, etc.) so // if it fails then we give up and drop back into creating new pages. if (prev) |prev_node| prev: { - const prev_page = &prev_node.data; + const prev_page = prev_node.page(); // We only want scenarios where we have excess capacity. if (prev_page.size.rows >= prev_page.capacity.rows) break :prev; @@ -2308,7 +2599,7 @@ fn resizeWithoutReflowGrowCols( // If we have an error, we clear the rows we just added to our prev page. const prev_copied = copied; errdefer if (prev_copied > 0) { - const prev_page = &prev.?.data; + 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( @@ -2333,8 +2624,9 @@ fn resizeWithoutReflowGrowCols( // We need to loop because our col growth may force us // to split pages. while (copied < page.size.rows) { - const new_node = try self.createPage(cap); - defer new_node.data.assertIntegrity(); + const new_node = try self.createPage(.{ .cap = cap }); + const new_page = new_node.page(); + defer new_page.assertIntegrity(); // The length we can copy into the new page is at most the number // of rows in our cap. But if we can finish our source page we use that. @@ -2343,10 +2635,10 @@ fn resizeWithoutReflowGrowCols( // Perform the copy const y_start = copied; const src_rows = page.rows.ptr(page.memory)[y_start .. copied + len]; - const dst_rows = new_node.data.rows.ptr(new_node.data.memory)[0..len]; + const dst_rows = new_page.rows.ptr(new_page.memory)[0..len]; for (dst_rows, src_rows) |*dst_row, *src_row| { - new_node.data.size.rows += 1; - if (new_node.data.cloneRowFrom( + new_page.size.rows += 1; + if (new_page.cloneRowFrom( page, dst_row, src_row, @@ -2364,7 +2656,7 @@ fn resizeWithoutReflowGrowCols( // We can actually safely handle this though by exiting // this loop early and cutting our copy short. - new_node.data.size.rows -= 1; + new_page.size.rows -= 1; break; } } @@ -2405,12 +2697,13 @@ fn trailingBlankLines( // Go through our pages backwards since we're counting trailing blanks. var it = self.pages.last; - while (it) |page| : (it = page.prev) { - const len = page.data.size.rows; - const rows = page.data.rows.ptr(page.data.memory)[0..len]; + while (it) |node| : (it = node.prev) { + const page = node.page(); + const len = node.rows(); + const rows = page.rows.ptr(page.memory)[0..len]; for (0..len) |i| { const rev_i = len - i - 1; - const cells = rows[rev_i].cells.ptr(page.data.memory)[0..page.data.size.cols]; + const cells = rows[rev_i].cells.ptr(page.memory)[0..node.cols()]; // If the row has any text then we're done. if (pagepkg.Cell.hasTextAny(cells)) return count; @@ -2436,6 +2729,7 @@ fn trimTrailingBlankRows( max: size.CellCountInt, ) size.CellCountInt { var trimmed: size.CellCountInt = 0; + var invalidated_node: ?*List.Node = null; const bl_pin = self.getBottomRight(.screen).?; var it = bl_pin.rowIterator(.left_up, null); while (it.next()) |row_pin| { @@ -2457,11 +2751,16 @@ fn trimTrailingBlankRows( // No text, we can trim this row. Because it has // no text we can also be sure it has no styling // so we don't need to worry about memory. - row_pin.node.data.size.rows -= 1; - if (row_pin.node.data.size.rows == 0) { + if (row_pin.node.rows() > 1 and invalidated_node != row_pin.node) { + // Shrinking a retained page changes its valid row-coordinate range. + self.invalidateNodeLayout(row_pin.node); + invalidated_node = row_pin.node; + } + row_pin.node.page().size.rows -= 1; + if (row_pin.node.page().size.rows == 0) { self.erasePage(row_pin.node); } else { - row_pin.node.data.assertIntegrity(); + row_pin.node.page().assertIntegrity(); } trimmed += 1; @@ -2514,6 +2813,14 @@ pub fn scroll(self: *PageList, behavior: Scroll) void { return; } + // Moving the viewport changes which historical pages are visible. Restart + // traversal so pages which leave the viewport are reconsidered after the + // renderer's idle delay. False positives from clamped scrolling are cheap. + defer { + self.page_compression.reset(); + self.page_compression.markActivity(); + } + switch (behavior) { .active => self.viewport = .active, .top => self.viewport = .top, @@ -2572,44 +2879,38 @@ pub fn scroll(self: *PageList, behavior: Scroll) void { if (n < midpoint) { // Iterate forward from the first node. var node_it = self.pages.first; - var rem: size.CellCountInt = std.math.cast( - size.CellCountInt, - n, - ) orelse { - self.viewport = .active; - break :row; - }; + var rem: usize = n; while (node_it) |node| : (node_it = node.next) { - if (rem < node.data.size.rows) { + if (rem < node.rows()) { self.viewport_pin.* = .{ .node = node, - .y = rem, + .y = std.math.cast(size.CellCountInt, rem) orelse { + self.viewport = .active; + break :row; + }, }; break :row; } - rem -= node.data.size.rows; + rem -= node.rows(); } } else { // Iterate backwards from the last node. var node_it = self.pages.last; - var rem: size.CellCountInt = std.math.cast( - size.CellCountInt, - self.total_rows - n, - ) orelse { - self.viewport = .active; - break :row; - }; + var rem: usize = self.total_rows - n; while (node_it) |node| : (node_it = node.prev) { - if (rem <= node.data.size.rows) { + if (rem <= node.rows()) { self.viewport_pin.* = .{ .node = node, - .y = node.data.size.rows - rem, + .y = std.math.cast(size.CellCountInt, node.rows() - rem) orelse { + self.viewport = .active; + break :row; + }, }; break :row; } - rem -= node.data.size.rows; + rem -= node.rows(); } } @@ -2619,6 +2920,8 @@ pub fn scroll(self: *PageList, behavior: Scroll) void { }, .delta_prompt => |n| self.scrollPrompt(n), .delta_row => |n| delta_row: { + const amount: usize = @abs(n); + switch (self.viewport) { // If we're at the top and we're scrolling backwards, // we don't have to do anything, because there's nowhere to go. @@ -2635,11 +2938,11 @@ pub fn scroll(self: *PageList, behavior: Scroll) void { .pin => switch (std.math.order(n, 0)) { .eq => break :delta_row, - .lt => switch (self.viewport_pin.upOverflow(@intCast(-n))) { + .lt => switch (self.viewport_pin.upOverflow(amount)) { .offset => |new_pin| { self.viewport_pin.* = new_pin; if (self.viewport_pin_row_offset) |*v| { - v.* -= @as(usize, @intCast(-n)); + v.* -= amount; } break :delta_row; }, @@ -2651,7 +2954,7 @@ pub fn scroll(self: *PageList, behavior: Scroll) void { }, }, - .gt => switch (self.viewport_pin.downOverflow(@intCast(n))) { + .gt => switch (self.viewport_pin.downOverflow(amount)) { // If we offset its a valid pin but we still have to // check if we're in the active area. .offset => |new_pin| { @@ -2660,7 +2963,7 @@ pub fn scroll(self: *PageList, behavior: Scroll) void { } else { self.viewport_pin.* = new_pin; if (self.viewport_pin_row_offset) |*v| { - v.* += @intCast(n); + v.* += amount; } } break :delta_row; @@ -2678,10 +2981,10 @@ pub fn scroll(self: *PageList, behavior: Scroll) void { // Slow path: we have to calculate the new pin by moving // from our viewport. const top = self.getTopLeft(.viewport); - const p: Pin = if (n < 0) switch (top.upOverflow(@intCast(-n))) { + const p: Pin = if (n < 0) switch (top.upOverflow(amount)) { .offset => |v| v, .overflow => |v| v.end, - } else switch (top.downOverflow(@intCast(n))) { + } else switch (top.downOverflow(amount)) { .offset => |v| v, .overflow => |v| v.end, }; @@ -2719,7 +3022,7 @@ pub fn scroll(self: *PageList, behavior: Scroll) void { fn scrollPrompt(self: *PageList, delta: isize) void { // If we aren't jumping any prompts then we don't need to do anything. if (delta == 0) return; - const delta_start: usize = @intCast(if (delta > 0) delta else -delta); + const delta_start: usize = @abs(delta); var delta_rem: usize = delta_start; // We start at the row before or after our viewport depending on the @@ -2781,11 +3084,12 @@ pub fn scrollClear(self: *PageList) Allocator.Error!void { var page = self.pages.last.?; var n: usize = 0; while (true) { - const rows: [*]Row = page.data.rows.ptr(page.data.memory); - for (0..page.data.size.rows) |i| { - const rev_i = page.data.size.rows - i - 1; + const current_page = page.page(); + const rows: [*]Row = current_page.rows.ptr(current_page.memory); + for (0..page.rows()) |i| { + const rev_i = page.rows() - i - 1; const row = rows[rev_i]; - const cells = row.cells.ptr(page.data.memory)[0..self.cols]; + const cells = row.cells.ptr(current_page.memory)[0..self.cols]; for (cells) |cell| { if (!cell.isEmpty()) break :non_empty self.rows - n; } @@ -2802,40 +3106,90 @@ pub fn scrollClear(self: *PageList) Allocator.Error!void { for (0..non_empty) |_| _ = try self.grow(); } +/// Give a live node a new generation before changing its coordinate layout in +/// place. +/// +/// This must be called when a node remains at the same address and stays in the +/// list, but a mutation changes which logical row a `(node, y)` coordinate +/// identifies or whether that coordinate is still in range. Examples include +/// rotating rows after an erase, truncating a page's row range, reinitializing +/// the sole remaining page, or shortening the source page of a split. Without +/// a new generation, a cached pointer, serial, and coordinate could still pass +/// `nodeIsValid` while referring to a different row than it originally did. +/// Screen and Terminal fast paths which manipulate Page rows directly must use +/// this because they bypass PageList's own row-mutation helpers. +/// +/// The caller must pass a node which is currently live in this PageList. Call +/// this at the point the operation commits to changing the layout and before +/// the first such change. When an operation is intended to be atomic, finish +/// its failable preparation first so a failed operation does not needlessly +/// invalidate references. One call per affected node is sufficient when an +/// operation performs several layout changes without exposing intermediate +/// references. +/// +/// This helper is only needed for in-place changes. Removing or replacing a +/// node already invalidates old references through the live-list check in +/// `nodeIsValid`, and newly allocated or reused nodes receive a fresh serial as +/// part of their initialization. Ordinary cell or style changes which preserve +/// the meaning of page coordinates do not require a new generation solely for +/// that reason. +/// +/// The only state changed here is `node.serial` and the next-generation counter +/// `page_serial`. Consequently, every previously captured pointer-plus-serial +/// pair for this node becomes invalid while new references can capture the new +/// generation. This does not advance `page_serial_epoch`: only `reset` starts a +/// new whole-list validity epoch. +/// +/// This also does not mutate the page, update tracked pins or viewport state, +/// adjust row or memory accounting, mark cells dirty, or notify incremental +/// compression. The surrounding operation remains responsible for all such +/// bookkeeping required by its layout change. +pub fn invalidateNodeLayout(self: *PageList, node: *List.Node) void { + node.serial = self.page_serial; + self.page_serial += 1; +} + /// Compact a page to use the minimum required memory for the contents /// it stores. Returns the new node pointer if compaction occurred, or null -/// if the page was already compact or compaction would not provide meaningful +/// if the page was already compact or compaction would not provide any /// savings. /// -/// The current design of PageList at the time of writing this doesn't -/// allow for smaller than `std_size` nodes so if the current node's backing -/// page is standard size or smaller, no compaction will occur. In the -/// future we should fix this up. +/// The compacted page is always an exact-size heap allocation, never +/// a pool item, since a pool item always retains a full std_size +/// buffer regardless of the page layout. Note that this means that +/// when compacting a pool-owned node, the freed pool item is returned +/// to the pool free list, so the memory savings are only fully +/// realized once the pool itself is reset or freed. /// /// If this returns OOM, the PageList is left unchanged and no dangling /// memory references exist. It is safe to ignore the error and continue using /// the uncompacted page. pub fn compact(self: *PageList, node: *List.Node) Allocator.Error!?*List.Node { defer self.assertIntegrity(); - const page: *Page = &node.data; + const page: *Page = node.page(); // We should never have empty rows in our pagelist anyways... assert(page.size.rows > 0); - // We never compact standard size or smaller pages because changing - // the capacity to something smaller won't save memory. - if (page.memory.len <= std_size) return null; - // Compute the minimum capacity required for this page's content const req_cap = page.exactRowCapacity(0, page.size.rows); const new_size = Page.layout(req_cap).total_size; - const old_size = page.memory.len; + + // The memory this node currently retains. A pool-owned node always + // retains a full pool item no matter its layout size. + const old_size: usize = switch (node.owned) { + .pool => PagePool.item_size, + .heap => page.memory.len, + }; if (new_size >= old_size) return null; // Create the new smaller page - const new_node = try self.createPage(req_cap); + const new_node = try self.createPage(.{ + .cap = req_cap, + .exact_size = true, + }); errdefer self.destroyNode(new_node); - const new_page: *Page = &new_node.data; + const new_page: *Page = new_node.page(); new_page.size = page.size; new_page.dirty = page.dirty; new_page.cloneFrom( @@ -2866,6 +3220,7 @@ pub fn compact(self: *PageList, node: *List.Node) Allocator.Error!?*List.Node { self.pages.insertBefore(node, new_node); self.pages.remove(node); self.destroyNode(node); + self.page_compression.markActivity(); new_page.assertIntegrity(); return new_node; @@ -2896,7 +3251,7 @@ pub fn split( // when we modify it later it updates our p here. Copying the node // fixes this. const original_node = p.node; - const page: *Page = &original_node.data; + const page: *Page = original_node.page(); // A page that is already 1 row can't be split. In the future we can // theoretically maybe split by soft-wrapping multiple pages but that @@ -2912,18 +3267,18 @@ pub fn split( defer self.assertIntegrity(); // Create a new node with the same capacity of managed memory. - const target = try self.createPage(page.capacity); + const target = try self.createPage(.{ .cap = page.capacity }); errdefer self.destroyNode(target); // Determine how many rows we're copying const y_start = p.y; const y_end = page.size.rows; - target.data.size.rows = y_end - y_start; - assert(target.data.size.rows <= target.data.capacity.rows); + target.page().size.rows = y_end - y_start; + assert(target.rows() <= target.capacity().rows); // Copy our old data. This should NOT fail because we have the // capacity of the old page which already fits the data we requested. - target.data.cloneFrom(page, y_start, y_end) catch |err| { + target.page().cloneFrom(page, y_start, y_end) catch |err| { log.err( "error cloning rows for split err={}", .{err}, @@ -2939,9 +3294,13 @@ pub fn split( // error handling. It is possible but we haven't written it. errdefer comptime unreachable; + // Failable split work is complete; shortening the source changes its row range. + self.invalidateNodeLayout(original_node); + self.page_compression.markActivity(); + // Move any tracked pins from the copied rows for (self.tracked_pins.keys()) |tracked| { - if (&tracked.node.data != page or + if (tracked.node.page() != page or tracked.y < p.y) continue; tracked.node = target; @@ -3008,9 +3367,11 @@ pub const Scrollbar = struct { /// Return the scrollbar state for this PageList. /// -/// This may be expensive to calculate depending on where the viewport -/// is (arbitrary pins are expensive). The caller should take care to only -/// call this as needed and not too frequently. +/// This is amortized O(1): the total is maintained incrementally and +/// the viewport offset is cached. The first call after the viewport +/// moves to an arbitrary pin (e.g. scrolling to a selection) may cost +/// O(pages) to compute the offset, after which it is cached again. +/// See viewportRowOffset for more details. pub fn scrollbar(self: *PageList) Scrollbar { // If we have no scrollback, special case no scrollbar. // We need to do this because the way PageList works is that @@ -3057,9 +3418,9 @@ fn viewportRowOffset(self: *PageList) usize { var offset: usize = 0; var node = self.pages.last; while (node) |n| : (node = n.prev) { - offset += n.data.size.rows; + offset += n.rows(); if (n == self.viewport_pin.node) { - assert(n.data.size.rows > self.viewport_pin.y); + assert(n.rows() > self.viewport_pin.y); offset -= self.viewport_pin.y; break :offset self.total_rows - offset; } @@ -3134,11 +3495,15 @@ pub fn maxSize(self: *const PageList) usize { pub fn grow(self: *PageList) Allocator.Error!?*List.Node { defer self.assertIntegrity(); + // Growing can move a complete page behind the active boundary. + self.page_compression.markActivity(); + const last = self.pages.last.?; - if (last.data.capacity.rows > last.data.size.rows) { + if (last.capacity().rows > last.rows()) { // Fast path: we have capacity in the last page. - last.data.size.rows += 1; - last.data.assertIntegrity(); + const page = last.page(); + page.size.rows += 1; + page.assertIntegrity(); // Increase our total rows by one self.total_rows += 1; @@ -3168,7 +3533,7 @@ pub fn grow(self: *PageList) Allocator.Error!?*List.Node { assert(first != last); // Decrease our total row count from the pruned page - self.total_rows -= first.data.size.rows; + self.total_rows -= first.rows(); // If our total row count is now less than our required // rows then we can't prune. The "+ 1" is because we'll add one @@ -3176,7 +3541,7 @@ pub fn grow(self: *PageList) Allocator.Error!?*List.Node { if (self.total_rows + 1 < self.rows) { self.pages.prepend(first); assert(self.pages.first == first); - self.total_rows += first.data.size.rows; + self.total_rows += first.rows(); break :prune; } @@ -3185,14 +3550,14 @@ pub fn grow(self: *PageList) Allocator.Error!?*List.Node { if (self.viewport_pin_row_offset) |*v| { // If our offset is less than the number of rows in the // pruned page, then we are now at the top. - if (v.* < first.data.size.rows) { + if (v.* < first.rows()) { self.viewport = .top; break :viewport; } // Otherwise, our viewport pin is below what we pruned // so we just decrement our offset. - v.* -= first.data.size.rows; + v.* -= first.rows(); } } @@ -3208,48 +3573,56 @@ pub fn grow(self: *PageList) Allocator.Error!?*List.Node { } self.viewport_pin.garbage = false; - // Non-standard pages can't be reused, just destroy them. - if (first.data.memory.len > std_size) { - self.destroyNode(first); - break :prune; + switch (first.owned) { + // Pool-owned pages are reused below. + .pool => {}, + + // Heap-owned pages can't be reused because they may be + // any size (larger or smaller than a standard page), so + // just destroy them. + .heap => { + self.destroyNode(first); + break :prune; + }, } // Reset our memory - const buf = first.data.memory; + const buf = first.restore(.discard).memory; @memset(buf, 0); assert(buf.len <= std_size); // Initialize our new page and reinsert it as the last - first.data = .initBuf(.init(buf), Page.layout(cap)); - first.data.size.rows = 1; + first.data = .{ .resident = .initBuf(.init(buf), Page.layout(cap)) }; + const page = first.page(); + page.size.rows = 1; self.pages.insertAfter(last, first); self.total_rows += 1; - // We also need to reset the serial number. Since this is the only - // place we ever reuse a serial number, we also can safely set - // page_serial_min to be one more than the old serial because we - // only ever prune the oldest pages. - self.page_serial_min = first.serial + 1; + // Reusing the node gives it a fresh generation. Do not begin a new + // page_serial_epoch here: generations are not monotonic in list order, + // so older live successors may have lower generations. The epoch only + // advances when reset invalidates the entire list. first.serial = self.page_serial; self.page_serial += 1; // In this case we do NOT need to update page_size because // we're reusing an existing page so nothing has changed. - first.data.assertIntegrity(); + page.assertIntegrity(); return first; } // We need to allocate a new memory buffer. - const next_node = try self.createPage(cap); + const next_node = try self.createPage(.{ .cap = cap }); // we don't errdefer this because we've added it to the linked // list and its fine to have dangling unused pages. self.pages.append(next_node); - next_node.data.size.rows = 1; + const page = next_node.page(); + page.size.rows = 1; // We should never be more than our max size here because we've // verified the case above. - next_node.data.assertIntegrity(); + page.assertIntegrity(); // Record the increased row count self.total_rows += 1; @@ -3296,7 +3669,7 @@ pub fn increaseCapacity( adjustment: ?IncreaseCapacity, ) IncreaseCapacityError!*List.Node { defer self.assertIntegrity(); - const page: *Page = &node.data; + const page: *Page = node.page(); // Apply our adjustment var cap = page.capacity; @@ -3306,21 +3679,36 @@ pub fn increaseCapacity( const Int = @FieldType(Capacity, field_name); const old = @field(cap, field_name); - // We use checked math to prevent overflow. If there is an - // overflow it means we're out of space in this dimension, - // since pages can take up to their maxInt capacity in any - // category. - const new = std.math.mul( - Int, - old, - 2, - ) catch |err| overflow: { - comptime assert(@TypeOf(err) == error{Overflow}); - // Our final doubling would overflow since maxInt is - // 2^N - 1 for an unsignged int of N bits. So, if we overflow - // and we haven't used all the bits, use all the bits. - if (old < std.math.maxInt(Int)) break :overflow std.math.maxInt(Int); - return error.OutOfSpace; + const new: Int = new: { + // A dimension can be zero for pages with exact + // capacities (see compact). Doubling zero stays zero, + // which would break our guarantee that we always + // increase by at least one unit and turn caller retry + // loops into infinite loops. Jump straight to the + // standard default instead: it is what all standard + // pages start with, so retrying callers are guaranteed + // enough room for their pending allocation. + if (old == 0) { + const default: Capacity = .{ .cols = 0, .rows = 0 }; + break :new @field(default, field_name); + } + + // We use checked math to prevent overflow. If there is + // an overflow it means we're out of space in this + // dimension, since pages can take up to their maxInt + // capacity in any category. + break :new std.math.mul( + Int, + old, + 2, + ) catch |err| overflow: { + comptime assert(@TypeOf(err) == error{Overflow}); + // Our final doubling would overflow since maxInt is + // 2^N - 1 for an unsignged int of N bits. So, if we overflow + // and we haven't used all the bits, use all the bits. + if (old < std.math.maxInt(Int)) break :overflow std.math.maxInt(Int); + return error.OutOfSpace; + }; }; @field(cap, field_name) = new; @@ -3337,9 +3725,9 @@ pub fn increaseCapacity( log.info("adjusting page capacity={}", .{cap}); // Create our new page and clone the old page into it. - const new_node = try self.createPage(cap); + const new_node = try self.createPage(.{ .cap = cap }); errdefer self.destroyNode(new_node); - const new_page: *Page = &new_node.data; + const new_page: *Page = new_node.page(); assert(new_page.capacity.rows >= page.capacity.rows); assert(new_page.capacity.cols >= page.capacity.cols); new_page.size.rows = page.size.rows; @@ -3375,21 +3763,35 @@ pub fn increaseCapacity( self.pages.insertBefore(node, new_node); self.pages.remove(node); self.destroyNode(node); + self.page_compression.markActivity(); new_page.assertIntegrity(); return new_node; } +/// Options for createPage and createPageExt. +const CreatePage = struct { + /// The capacity to allocate the page with. + cap: Capacity, + + /// Force the page backing memory to be an exact-size heap + /// allocation even if it would fit within a standard-size pool + /// item. This is used when compacting pages to their minimum + /// size, since a pool item always retains a full std_size buffer + /// regardless of the page layout. + exact_size: bool = false, +}; + /// Create a new page node. This does not add it to the list and this /// does not do any memory size accounting with max_size/page_size. inline fn createPage( self: *PageList, - cap: Capacity, + opts: CreatePage, ) Allocator.Error!*List.Node { - // log.debug("create page cap={}", .{cap}); + // log.debug("create page cap={}", .{opts.cap}); return try createPageExt( &self.pool, - cap, + opts, &self.page_serial, &self.page_size, ); @@ -3397,15 +3799,15 @@ inline fn createPage( inline fn createPageExt( pool: *MemoryPool, - cap: Capacity, + opts: CreatePage, serial: *u64, total_size: ?*usize, ) Allocator.Error!*List.Node { var page = try pool.nodes.create(); errdefer pool.nodes.destroy(page); - const layout = Page.layout(cap); - const pooled = layout.total_size <= std_size; + const layout = Page.layout(opts.cap); + const pooled = !opts.exact_size and layout.total_size <= std_size; const page_alloc = pool.pages.arena.child_allocator; // It would be better to encode this into the Zig error handling @@ -3416,14 +3818,15 @@ inline fn createPageExt( // Our page buffer comes from our standard memory pool if it // is within our standard size since this is what the pool // dispenses. Otherwise, we use the heap allocator to allocate. - const page_buf = if (pooled) - try pool.pages.create() - else - try page_alloc.alignedAlloc( - u8, - .fromByteUnits(std.heap.page_size_min), - layout.total_size, - ); + const page_buf = if (pooled) buf: { + const buf = try pool.pages.create(); + terminal_mem.recommit(buf); + break :buf buf; + } else try page_alloc.alignedAlloc( + u8, + .fromByteUnits(std.heap.page_size_min), + layout.total_size, + ); errdefer if (pooled) pool.pages.destroy(page_buf) else @@ -3431,14 +3834,23 @@ inline fn createPageExt( // In runtime safety modes, allocators fill with 0xAA. On freestanding // (WASM), the WasmAllocator reuses freed slots without zeroing. + // + // Otherwise, we rely on pool item buffers being zeroed: fresh items + // come from the OS page allocator (zeroed pages) and destroyNodeExt + // zeroes buffers before returning them to the pool. The one + // exception is the first pointer-size bytes, which hold the pool's + // free list node while a buffer is in the free list; initBuf below + // always overwrites those since the page rows start at offset 0 + // (comptime-asserted in Page). if (comptime std.debug.runtime_safety or builtin.os.tag == .freestanding) @memset(page_buf, 0); page.* = .{ - .data = .initBuf(.init(page_buf), layout), + .data = .{ .resident = .initBuf(.init(page_buf), layout) }, .serial = serial.*, + .owned = if (pooled) .pool else .heap, }; - page.data.size.rows = 0; + page.page().size.rows = 0; serial.* += 1; if (total_size) |v| { @@ -3451,6 +3863,410 @@ inline fn createPageExt( return page; } +/// Temporary output memory used while creating a compressed page. +/// +/// Standard-sized output borrows a page-pool item so repeated compression can +/// reuse the same virtual mapping. Oversized pages use a temporary allocation +/// from the page allocator and release it immediately after compression. +const CompressionScratch = union(enum) { + pooled: *align(std.heap.page_size_min) [std_size]u8, + allocated: []align(std.heap.page_size_min) u8, + + fn init( + pool: *MemoryPool, + required: usize, + raw_len: usize, + ) Allocator.Error!CompressionScratch { + assert(required <= raw_len); + + if (required <= std_size) { + const memory = try pool.pages.create(); + terminal_mem.recommit(memory); + return .{ .pooled = memory }; + } + + const page_alloc = pool.pages.arena.child_allocator; + return .{ .allocated = try page_alloc.alignedAlloc( + u8, + .fromByteUnits(std.heap.page_size_min), + raw_len, + ) }; + } + + fn bytes(self: *CompressionScratch) []u8 { + return switch (self.*) { + .pooled => |memory| memory, + .allocated => |memory| memory, + }; + } + + fn deinit(self: *CompressionScratch, pool: *MemoryPool) void { + switch (self.*) { + .pooled => |memory| { + _ = terminal_mem.decommit(.zero, memory, memory.len); + pool.pages.destroy(memory); + }, + .allocated => |memory| { + const page_alloc = pool.pages.arena.child_allocator; + page_alloc.free(memory); + }, + } + } +}; + +/// PageList-owned state for incremental compression. +/// +/// The position is stored as a page serial rather than a node pointer so it +/// remains safe when PageList operations destroy, replace, or reuse nodes +/// between steps. It also records the first serial which was unallocated at +/// the prior step so new nodes before a valid marker restart safely. If the +/// exact serial no longer exists in the cold prefix, the next step also +/// restarts at the first page. +/// +/// A completed pass is followed by another pass from the oldest cold page. +/// Compression becomes idle only when that verification pass compresses no +/// pages. This converges after pages are restored between incremental steps +/// without requiring consumers to maintain a separate attempt cursor. +const IncrementalCompressionState = struct { + flags: packed struct { + /// Set after any page is compressed during the current traversal. + /// We always run incremental compression until we get a fully + /// no-compression pass (the verification pass). This lets us + /// recompress decompressed pages due to search, scrolling, etc. + did_compress: bool = false, + + /// Set after a traversal first reaches the active boundary. When this + /// verification traversal reaches the boundary, `did_compress` + /// determines whether to restart once more or finish the pass. + verifying: bool = false, + } = .{}, + + /// Changes whenever PageList activity may affect compression work. + /// Callers use this value (via Terminal.compressionActivity) to + /// determine whether to recompress. + /// + /// The directionality of this doesn't matter. We overflow and + /// wrap when maxxed. The comparison of this value to your saved + /// value is all that matters. There is a possible edge case where you + /// don't compress, a full wraparound happens, and you get the same value, + /// but its so unlikely. + /// + /// This is 48-bits so that 16-bits can be reserved for Terminal to + /// add extra state. + activity_serial: u48 = 0, + + /// Serial of the last page inspected by the traversal. This is + /// intentionally an implementation detail and is reset by PageList + /// operations which restart traversal progress. + last_serial: ?u64 = null, + + /// First node serial which had not been allocated at the prior step. A + /// node at or above this value before the saved marker requires a restart. + next_serial: u64 = 0, + + /// Record activity without disturbing valid traversal progress. + fn markActivity(self: *IncrementalCompressionState) void { + self.activity_serial +%= 1; + } + + /// Discard traversal progress without changing the activity token. + fn reset(self: *IncrementalCompressionState) void { + const activity_serial: u48 = self.activity_serial; + self.* = .{ .activity_serial = activity_serial }; + } +}; + +/// Result of one incremental compression step. +pub const IncrementalCompressionResult = enum { + /// Strict retained-mapping reclamation is unavailable on this target. + unsupported, + + /// More cold pages or a verification pass remain after this invocation's + /// candidate-bounded work. + pending, + + /// A complete verification pass compressed zero pages. + complete, +}; + +/// Iterate complete historical pages which do not intersect the viewport. +/// +/// All boundaries come from PageList pins and Page metadata. Advancing this +/// iterator never restores a compressed page or reads its backing memory. +const CompressionIterator = struct { + current: *List.Node, + active: *List.Node, + viewport_first: *List.Node, + viewport_last: *List.Node, + + fn init(self: *const PageList) CompressionIterator { + return .{ + .current = self.pages.first.?, + .active = self.getTopLeft(.active).node, + .viewport_first = self.getTopLeft(.viewport).node, + .viewport_last = self.getBottomRight(.viewport).?.node, + }; + } + + fn next(self: *CompressionIterator) ?*List.Node { + while (self.current != self.active) { + // The viewport is a contiguous node range. Once traversal reaches + // its first node, advance through the complete visible range and + // resume at the next offscreen page. + if (self.current == self.viewport_first) { + while (self.current != self.active and + self.current != self.viewport_last) + { + self.current = self.current.next.?; + } + + if (self.current == self.active) return null; + self.current = self.current.next.?; + continue; + } + + const node = self.current; + self.current = node.next.?; + return node; + } + + return null; + } + + fn done(self: *const CompressionIterator) bool { + return self.current == self.active; + } +}; + +/// Bound candidate inspection independently from compression work. Skipping +/// an already-compressed page is cheap, but still counts toward this limit. +const incremental_compression_max_inspected = 8; + +/// Failure injection for the final reclamation step. The operating-system +/// failure path cannot otherwise be exercised by tests because terminal_mem +/// deliberately simulates successful decommit in test builds. +const compressPage_tw = tripwire.module( + enum { decommit }, + error{DecommitFailed}, +); + +/// Compress eligible nodes, saving a significant amount of memory. +/// +/// Eligible nodes are complete pages before the active boundary which do not +/// intersect the viewport. The boundary page is excluded because it may +/// contain both scrollback and active rows; visible pages remain resident for +/// immediate redraw and scrolling. +/// +/// Compression requires a system that supports reclaiming physical memory for +/// virtual allocations while retaining their address ranges. +/// +/// Compression is SLOW (relatively), so incremental compression during idle +/// periods is recommended. Incremental mode performs one bounded step and its +/// result specifies whether to continue immediately. Drain mode performs +/// incremental steps until the pass and its verification pass finish. Full +/// mode visits every currently eligible node once without using incremental +/// state. +/// +/// PageList tracks mutations which require a later incremental pass in its +/// compression state. On supported targets, full compression returns +/// `complete`, indicating that it has no continuation to schedule rather than +/// that every page was compressed. +pub fn compress( + self: *PageList, + mode: enum { incremental, drain, full }, +) IncrementalCompressionResult { + return switch (mode) { + .incremental => self.compressIncremental(), + .drain => while (true) switch (self.compressIncremental()) { + .pending => continue, + .complete => break .complete, + .unsupported => break .unsupported, + }, + .full => full: { + // Match incremental mode's unsupported result. Full compression + // has no useful work to perform without strict reclamation. + if (!terminal_mem.canReclaim(.strict)) { + self.page_compression.reset(); + break :full .unsupported; + } + + self.compressFull(); + + // Full compression has no continuation. Discard any partial + // incremental cursor so later activity starts at the oldest page. + self.page_compression.reset(); + + break :full .complete; + }, + }; +} + +/// Perform one candidate-bounded incremental cold-history compression step. +fn compressIncremental(self: *PageList) IncrementalCompressionResult { + const state = &self.page_compression; + + // If we can't reclaim virtual memory, compression is unsupported. + if (!terminal_mem.canReclaim(.strict)) { + state.reset(); + return .unsupported; + } + + // Find the node following the exact continuation marker within the cold + // prefix. A missing marker means the list changed between steps, so begin + // again at the current first page. This lookup does not touch page memory. + var it: CompressionIterator = .init(self); + if (state.last_serial) |last_serial| continuation: { + while (it.next()) |node| { + // A newly allocated or replacement node appeared before the + // marker. Restart immediately so that node cannot be skipped. + if (node.serial >= state.next_serial) break; + + // Not a match? Keep looking + if (node.serial != last_serial) continue; + + // Match! The iterator already points to the next offscreen node. + break :continuation; + } + + // Not found or otherwise invalid. Reset + state.last_serial = null; + it = .init(self); + } + + // Keep track of our next_serial + state.next_serial = self.page_serial; + + // We cap the number of pages we look at to do our best to + // time-bound the incremental compression. + var inspected_pages: usize = 0; + while (inspected_pages < incremental_compression_max_inspected) { + const node = it.next() orelse break; + + state.last_serial = node.serial; + inspected_pages += 1; + + // If this page is already compressed, ignore it. + if (node.isCompressed()) continue; + + // Compression is substantially more expensive even if it fails. + // So we just try it. + if (self.compressPage(node)) state.flags.did_compress = true; + break; + } + + // If we didn't reach our active node, then we still have work to do. + if (!it.done()) return .pending; + + // We reached our active node. So we're done, except that we always + // do one pass after the first success so we can recompress nodes that + // were possibly decompressed (e.g. by search, inspector, whatever). + if (!state.flags.verifying or state.flags.did_compress) { + const activity_serial: u48 = state.activity_serial; + state.* = .{ + .flags = .{ .verifying = true }, + .activity_serial = activity_serial, + }; + return .pending; + } + + // Leave the state fresh while idle so later activity naturally begins at + // the oldest cold page, including pages restored after this pass. + state.reset(); + return .complete; +} + +/// Compress every fully historical resident page which is currently cold. +fn compressFull(self: *PageList) void { + var it: CompressionIterator = .init(self); + while (it.next()) |node| { + + // Don't restore an already-compressed page just to recompress it. + if (node.isCompressed()) continue; + + // Failure leaves this node resident and unchanged. + _ = self.compressPage(node); + } +} + +/// Attempt to compress one resident page while retaining its raw mapping. +/// +/// Compression is opportunistic: every failure leaves the page resident and +/// usable. Candidate selection and retry policy belong to `compress`; this +/// primitive only performs one state transition. +fn compressPage(self: *PageList, node: *List.Node) bool { + // Recompression requires first restoring the raw page and is a policy + // decision, so this primitive only accepts resident nodes. + if (node.isCompressed()) return false; + + const page = node.page(); + + // The scratch size is capped just below the representation's break-even + // point. Codec limits and pages too small to cover the compressed-state + // overhead simply make this page ineligible for compression. + const required = compression.Page.requiredScratch(page.memory.len) catch |err| + switch (err) { + error.InputTooLarge, + error.OutputTooSmall, + => return false, + }; + if (required == 0) return false; + + // Build the compressed candidate without changing the node. This scope is + // intentional: its defer releases the borrowed or temporary scratch before + // we attempt to discard the source mapping below. Only the candidate's + // exact-sized encoded allocation survives the scope. + const candidate = candidate: { + var scratch = CompressionScratch.init( + &self.pool, + required, + page.memory.len, + ) catch |err| switch (err) { + error.OutOfMemory => return false, + }; + defer scratch.deinit(&self.pool); + + var table: compression.lz4.HashTable = undefined; + break :candidate compression.Page.init( + self.pool.alloc, + page, + scratch.bytes()[0..required], + &table, + ) catch |err| switch (err) { + error.OutOfMemory, + error.InputTooLarge, + error.OutputTooSmall, + => return false, + }; + }; + + // Null means compression crossed the break-even point. The node and its + // resident mapping are still untouched in this case. + var compressed = candidate orelse return false; + + // Strict decommit is the final fallible step. It either discards the whole + // raw mapping or leaves it untouched, so failure can safely free the + // candidate and preserve the resident node exactly as it was. + const decommit_allowed: bool = allowed: { + compressPage_tw.check(.decommit) catch |err| switch (err) { + error.DecommitFailed => break :allowed false, + }; + break :allowed true; + }; + if (!decommit_allowed or !terminal_mem.decommit( + .strict, + compressed.page.memory, + compressed.page.memory.len, + )) { + compressed.deinit(); + return false; + } + + // Publish the new state only after both the encoded allocation and the + // retained-mapping decommit have succeeded. + node.data = .{ .compressed = compressed }; + return true; +} + /// Destroy the memory of the given node in the PageList linked list /// and return it to the pool. The node is assumed to already be removed /// from the linked list. @@ -3467,18 +4283,33 @@ fn destroyNodeExt( node: *List.Node, total_size: ?*usize, ) void { - const page: *Page = &node.data; + const page = node.restore(.discard); + + // Update our accounting for page size. This must mirror what was + // added at creation time: a pool-owned page always accounts for a + // full pool item even if its layout is smaller, while a heap-owned + // page accounts for its exact memory length. + if (total_size) |v| v.* -= switch (node.owned) { + .pool => PagePool.item_size, + .heap => page.memory.len, + }; - // Update our accounting for page size - if (total_size) |v| v.* -= page.memory.len; + switch (node.owned) { + .pool => { + assert(page.memory.len <= std_size); - if (page.memory.len <= std_size) { - // Reset the memory to zero so it can be reused - @memset(page.memory, 0); - pool.pages.destroy(@ptrCast(page.memory.ptr)); - } else { - const page_alloc = pool.pages.arena.child_allocator; - page_alloc.free(page.memory); + // Reset the memory to zero (and decommit it, where + // supported) so it can be reused. + const item: *align(std.heap.page_size_min) [std_size]u8 = + @ptrCast(@alignCast(page.memory.ptr)); + _ = terminal_mem.decommit(.zero, item, page.memory.len); + pool.pages.destroy(item); + }, + + .heap => { + const page_alloc = pool.pages.arena.child_allocator; + page_alloc.free(page.memory); + }, } pool.nodes.destroy(node); @@ -3499,12 +4330,20 @@ pub fn eraseRow( const pn = self.pin(pt).?; var node = pn.node; - var rows = node.data.rows.ptr(node.data.memory.ptr); + var page = node.page(); + var rows = page.rows.ptr(page.memory.ptr); + + // Erasing history may restore compressed pages. Mark unconditionally + // because incrementing the activity token is cheaper than locating the + // active boundary. + self.page_compression.markActivity(); // In order to move the following rows up we rotate the rows array by 1. // The rotate operation turns e.g. [ 0 1 2 3 ] in to [ 1 2 3 0 ], which // works perfectly to move all of our elements where they belong. - fastmem.rotateOnce(Row, rows[pn.y..node.data.size.rows]); + // Rotating rows changes which logical row cached coordinates identify. + self.invalidateNodeLayout(node); + fastmem.rotateOnce(Row, rows[pn.y..node.rows()]); // We adjust the tracked pins in this page, moving up any that were below // the removed row. @@ -3522,12 +4361,13 @@ pub fn eraseRow( // // Technically we only need to mark rows from the erased row to the end // of the page as dirty, but that's slower and this is a hot function. - node.data.dirty = true; + page.dirty = true; // We iterate through all of the following pages in order to move their // rows up by 1 as well. while (node.next) |next| { - const next_rows = next.data.rows.ptr(next.data.memory.ptr); + const next_page = next.page(); + const next_rows = next_page.rows.ptr(next_page.memory.ptr); // We take the top row of the page and clone it in to the bottom // row of the previous page, which gets rid of the top row that was @@ -3544,19 +4384,22 @@ pub fn eraseRow( // 5 5 5 | 6 // 6 6 6 | 7 // 7 7 7 <' 4 - try node.data.cloneRowFrom( - &next.data, - &rows[node.data.size.rows - 1], + try page.cloneRowFrom( + next_page, + &rows[node.rows() - 1], &next_rows[0], ); node = next; + page = next_page; rows = next_rows; - fastmem.rotateOnce(Row, rows[0..node.data.size.rows]); + // Rotating this page moves every cached row coordinate up by one. + self.invalidateNodeLayout(node); + fastmem.rotateOnce(Row, rows[0..node.rows()]); // Mark the whole page as dirty. - node.data.dirty = true; + page.dirty = true; // Our tracked pins for this page need to be updated. // If the pin is in row 0 that means the corresponding row has @@ -3566,7 +4409,7 @@ pub fn eraseRow( if (p.node != node) continue; if (p.y == 0) { p.node = node.prev.?; - p.y = p.node.data.size.rows - 1; + p.y = p.node.rows() - 1; continue; } p.y -= 1; @@ -3574,7 +4417,7 @@ pub fn eraseRow( } // Clear the final row which was rotated from the top of the page. - node.data.clearCells(&rows[node.data.size.rows - 1], 0, node.data.size.cols); + page.clearCells(&rows[node.rows() - 1], 0, node.cols()); } /// A variant of eraseRow that shifts only a bounded number of following @@ -3598,20 +4441,28 @@ pub fn eraseRowBounded( const pn = self.pin(pt).?; var node: *List.Node = pn.node; - var rows = node.data.rows.ptr(node.data.memory.ptr); + var page = node.page(); + var rows = page.rows.ptr(page.memory.ptr); + + // Erasing history may restore compressed pages. Mark unconditionally + // because incrementing the activity token is cheaper than locating the + // active boundary. + self.page_compression.markActivity(); // If the row limit is less than the remaining rows before the end of the // page, then we clear the row, rotate it to the end of the boundary limit // and update our pins. - if (node.data.size.rows - pn.y > limit) { - node.data.clearCells(&rows[pn.y], 0, node.data.size.cols); + 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()); fastmem.rotateOnce(Row, rows[pn.y..][0 .. limit + 1]); // Mark the whole page as dirty. // // Technically we only need to mark from the erased row to the // limit but this is a hot function, so we want to minimize work. - node.data.dirty = true; + page.dirty = true; // If our viewport is a pin and our pin is within the erased // region we need to maybe shift our cache up. We do this here instead @@ -3646,18 +4497,20 @@ pub fn eraseRowBounded( return; } - fastmem.rotateOnce(Row, rows[pn.y..node.data.size.rows]); + // Rotating this suffix changes which logical row its coordinates identify. + self.invalidateNodeLayout(node); + fastmem.rotateOnce(Row, rows[pn.y..node.rows()]); // Mark the whole page as dirty. // // Technically we only need to mark rows from the erased row to the end // of the page as dirty, but that's slower and this is a hot function. - node.data.dirty = true; + page.dirty = true; // We need to keep track of how many rows we've shifted so that we can // determine at what point we need to do a partial shift on subsequent // pages. - var shifted: usize = node.data.size.rows - pn.y; + var shifted: usize = node.rows() - pn.y; // Update tracked pins. { @@ -3686,15 +4539,17 @@ pub fn eraseRowBounded( } while (node.next) |next| { - const next_rows = next.data.rows.ptr(next.data.memory.ptr); + const next_page = next.page(); + const next_rows = next_page.rows.ptr(next_page.memory.ptr); - try node.data.cloneRowFrom( - &next.data, - &rows[node.data.size.rows - 1], + try page.cloneRowFrom( + next_page, + &rows[node.rows() - 1], &next_rows[0], ); node = next; + page = next_page; rows = next_rows; // We check to see if this page contains enough rows to satisfy the @@ -3703,15 +4558,17 @@ pub fn eraseRowBounded( // // The logic here is very similar to the one before the loop. const shifted_limit = limit - shifted; - if (node.data.size.rows > shifted_limit) { - node.data.clearCells(&rows[0], 0, node.data.size.cols); + if (node.rows() > shifted_limit) { + // Rotating this bounded prefix changes its cached row coordinates. + self.invalidateNodeLayout(node); + page.clearCells(&rows[0], 0, node.cols()); fastmem.rotateOnce(Row, rows[0 .. shifted_limit + 1]); // Mark the whole page as dirty. // // Technically we only need to mark from the erased row to the // limit but this is a hot function, so we want to minimize work. - node.data.dirty = true; + page.dirty = true; // See the other places we do something similar in this function // for a detailed explanation. @@ -3730,7 +4587,7 @@ pub fn eraseRowBounded( if (p.node != node or p.y > shifted_limit) continue; if (p.y == 0) { p.node = node.prev.?; - p.y = p.node.data.size.rows - 1; + p.y = p.node.rows() - 1; continue; } p.y -= 1; @@ -3739,13 +4596,15 @@ pub fn eraseRowBounded( return; } - fastmem.rotateOnce(Row, rows[0..node.data.size.rows]); + // Rotating the whole page moves every cached row coordinate up by one. + self.invalidateNodeLayout(node); + fastmem.rotateOnce(Row, rows[0..node.rows()]); // Mark the whole page as dirty. - node.data.dirty = true; + page.dirty = true; // Account for the rows shifted in this node. - shifted += node.data.size.rows; + shifted += node.rows(); // See the other places we do something similar in this function // for a detailed explanation. @@ -3763,7 +4622,7 @@ pub fn eraseRowBounded( if (p.node != node) continue; if (p.y == 0) { p.node = node.prev.?; - p.y = p.node.data.size.rows - 1; + p.y = p.node.rows() - 1; continue; } p.y -= 1; @@ -3772,7 +4631,7 @@ pub fn eraseRowBounded( // We reached the end of the page list before the limit, so we clear // the final row since it was rotated down from the top of this page. - node.data.clearCells(&rows[node.data.size.rows - 1], 0, node.data.size.cols); + page.clearCells(&rows[node.rows() - 1], 0, node.cols()); } /// Erase all history rows, optionally up to a bottom-left bound. @@ -3800,9 +4659,8 @@ pub fn eraseActive( /// and the page becomes underutilized (size < capacity). /// /// Callers must ensure that the erased range only removes pages from -/// the front or back of the linked list, never the middle. Middle-page -/// erasure would create serial gaps that page_serial_min cannot -/// represent, leaving dangling references in consumers such as search. +/// the front or back of the linked list, never the middle. The pin and row +/// accounting in this operation is only defined for those boundary ranges. /// Use the public eraseHistory/eraseActive wrappers which enforce this. fn eraseRows( self: *PageList, @@ -3810,6 +4668,7 @@ fn eraseRows( bl_pt: ?point.Point, ) void { defer self.assertIntegrity(); + self.page_compression.markActivity(); // The count of rows that was erased. var erased: usize = 0; @@ -3827,27 +4686,33 @@ fn eraseRows( // page so to handle this we reinit this page, set it to zero // size which will let us grow our active area back. if (chunk.node.next == null and chunk.node.prev == null) { - const page = &chunk.node.data; + // Reinitializing the sole page invalidates every coordinate in it. + self.invalidateNodeLayout(chunk.node); + const page = chunk.node.page(); erased += page.size.rows; page.reinit(); page.size.rows = 0; break; } - erased += chunk.node.data.size.rows; + erased += chunk.node.rows(); self.erasePage(chunk.node); continue; } + // Moving the retained suffix changes every captured row coordinate. + self.invalidateNodeLayout(chunk.node); + // We are modifying our chunk so make sure it is in a good state. - defer chunk.node.data.assertIntegrity(); + const page = chunk.node.page(); + defer page.assertIntegrity(); // The chunk is not a full page so we need to move the rows. // This is a cheap operation because we're just moving cell offsets, // not the actual cell contents. assert(chunk.start == 0); - const rows = chunk.node.data.rows.ptr(chunk.node.data.memory); - const scroll_amount = chunk.node.data.size.rows - chunk.end; + const rows = page.rows.ptr(page.memory); + const scroll_amount = chunk.node.rows() - chunk.end; for (0..scroll_amount) |i| { const src: *Row = &rows[i + chunk.end]; const dst: *Row = &rows[i]; @@ -3861,12 +4726,12 @@ fn eraseRows( // Clear our remaining cells that we didn't shift or swapped // in case we grow back into them. - for (scroll_amount..chunk.node.data.size.rows) |i| { + for (scroll_amount..chunk.node.rows()) |i| { const row: *Row = &rows[i]; - chunk.node.data.clearCells( + page.clearCells( row, 0, - chunk.node.data.size.cols, + chunk.node.cols(), ); } @@ -3884,7 +4749,7 @@ fn eraseRows( } // Our new size is the amount we scrolled - chunk.node.data.size.rows = @intCast(scroll_amount); + page.size.rows = @intCast(scroll_amount); erased += chunk.end; } @@ -3920,17 +4785,10 @@ fn erasePage(self: *PageList, node: *List.Node) void { // Must not be the final page. assert(node.next != null or node.prev != null); - // We only support erasing from the front or back, never the middle. - // Middle erasure would create serial gaps that page_serial_min can't - // represent. If this ever needs to change, we'll need a more - // sophisticated invalidation mechanism. + // We only support erasing from the front or back, never the middle. The + // public erase operations maintain this contract by construction. assert(node.prev == null or node.next == null); - // If we're erasing the first page, update page_serial_min so that - // any external references holding this page's serial will know it - // has been invalidated. - if (node.prev == null) self.page_serial_min = node.next.?.serial; - // Update any tracked pins to move to the previous or next page. const pin_keys = self.tracked_pins.keys(); for (pin_keys) |p| { @@ -3962,6 +4820,9 @@ pub fn pin(self: *const PageList, pt: point.Point) ?Pin { // Grab the top left and move to the point. var p = self.getTopLeft(pt).down(pt.coord().y) orelse return null; + // Incomplete reflow can leave a page narrower than the desired width. + // Never manufacture an out-of-bounds pin for that page. + if (x >= p.node.cols()) return null; p.x = x; return p; } @@ -4014,8 +4875,28 @@ pub fn pinIsValid(self: *const PageList, p: Pin) bool { var it = self.pages.first; while (it) |node| : (it = node.next) { if (node != p.node) continue; - return p.y < node.data.size.rows and - p.x < node.data.size.cols; + return p.y < node.rows() and + p.x < node.cols(); + } + + return false; +} + +/// Returns whether a node pointer and serial still identify the same page in +/// this list. The node pointer is only compared and is safe even if the node +/// has been destroyed or reused since the serial was captured. +pub fn nodeIsValid( + self: *const PageList, + target: *List.Node, + serial: u64, +) bool { + // Reset invalidates the whole prior epoch, so reject those generations + // without scanning the live list. + if (serial < self.page_serial_epoch) return false; + + var it = self.pages.first; + while (it) |node| : (it = node.next) { + if (node == target) return node.serial == serial; } return false; @@ -4069,15 +4950,23 @@ pub fn pointFromPin(self: *const PageList, tag: point.Tag, p: Pin) ?point.Point if (tl.y > p.y) return null; coord.y = p.y - tl.y; } else { - coord.y += tl.node.data.size.rows - tl.y; + coord.y = std.math.add( + u32, + coord.y, + tl.node.rows() - tl.y, + ) catch return null; var node_ = tl.node.next; while (node_) |node| : (node_ = node.next) { if (node == p.node) { - coord.y += p.y; + coord.y = std.math.add(u32, coord.y, p.y) catch return null; break; } - coord.y += node.data.size.rows; + coord.y = std.math.add( + u32, + coord.y, + node.rows(), + ) catch return null; } else { // We never saw our node, meaning we're outside the range. return null; @@ -4099,7 +4988,7 @@ pub fn pointFromPin(self: *const PageList, tag: point.Tag, p: Pin) ?point.Point /// Warning: this is slow and should not be used in performance critical paths pub fn getCell(self: *const PageList, pt: point.Point) ?Cell { const pt_pin = self.pin(pt) orelse return null; - const rac = pt_pin.node.data.getRowAndCell(pt_pin.x, pt_pin.y); + const rac = pt_pin.node.page().getRowAndCell(pt_pin.x, pt_pin.y); return .{ .node = pt_pin.node, .row = rac.row, @@ -4142,13 +5031,13 @@ pub fn diagram( var it = self.pageIterator(.right_down, .{ .screen = .{} }, null); while (it.next()) |chunk| : (page_index += 1) { - cols = chunk.node.data.size.cols; + cols = chunk.node.cols(); // Whether we've just skipped some number of rows and drawn // an ellipsis row (this is reset when a row is not skipped). var skipped = false; - for (0..chunk.node.data.size.rows) |y| { + for (0..chunk.node.rows()) |y| { // Active header if (!active and chunk.node == active_pin.node and @@ -4173,8 +5062,8 @@ pub fn diagram( // Row contents { - const row = chunk.node.data.getRow(y); - const cells = chunk.node.data.getCells(row)[0..cols]; + const row = chunk.node.page().getRow(y); + const cells = chunk.node.page().getCells(row)[0..cols]; var row_has_content = false; @@ -4223,7 +5112,7 @@ pub fn diagram( } try writer.print("{u}", .{cell.codepoint()}); if (cell.hasGrapheme()) { - const grapheme = chunk.node.data.lookupGrapheme(cell).?; + const grapheme = chunk.node.page().lookupGrapheme(cell).?; for (grapheme) |cp| { try writer.print("{u}", .{cp}); } @@ -4351,7 +5240,7 @@ pub fn highlightSemanticContent( // two prompts here. if (it.next()) |next| next: { var prev = next.up(1) orelse break :next; - prev.x = prev.node.data.size.cols - 1; + prev.x = prev.node.cols() - 1; break :end prev; } @@ -4642,7 +5531,7 @@ pub const CellIterator = struct { switch (self.row_it.page_it.direction) { .right_down => { - if (cell.x + 1 < cell.node.data.size.cols) { + if (cell.x + 1 < cell.node.cols()) { // We still have cells in this row, increase x. var copy = cell; copy.x += 1; @@ -4663,7 +5552,7 @@ pub const CellIterator = struct { // We need to move to the previous row and last col if (self.row_it.next()) |next_cell| { var copy = next_cell; - copy.x = next_cell.node.data.size.cols - 1; + copy.x = next_cell.node.cols() - 1; self.cell = copy; } else { self.cell = null; @@ -4796,24 +5685,21 @@ pub const PageIterator = struct { break :none .{ .node = row.node, .start = row.y, - .end = row.node.data.size.rows, + .end = row.node.rows(), }; }, .count => |*limit| count: { assert(limit.* > 0); // should be handled already - const len = @min(row.node.data.size.rows - row.y, limit.*); - if (len > limit.*) { - self.row = row.down(len); - limit.* -= len; - } else { - self.row = null; - } + const available: usize = row.node.rows() - row.y; + const len = @min(available, limit.*); + limit.* -= len; + self.row = if (limit.* > 0) row.down(len) else null; break :count .{ .node = row.node, .start = row.y, - .end = row.y + len, + .end = @intCast(@as(usize, row.y) + len), }; }, @@ -4829,7 +5715,7 @@ pub const PageIterator = struct { break :row .{ .node = row.node, .start = row.y, - .end = row.node.data.size.rows, + .end = row.node.rows(), }; } @@ -4858,7 +5744,7 @@ pub const PageIterator = struct { const next_page = row.node.prev orelse break :next null; break :next .{ .node = next_page, - .y = next_page.data.size.rows - 1, + .y = next_page.rows() - 1, }; }; @@ -4871,18 +5757,15 @@ pub const PageIterator = struct { .count => |*limit| count: { assert(limit.* > 0); // should be handled already - const len = @min(row.y, limit.*); - if (len > limit.*) { - self.row = row.up(len); - limit.* -= len; - } else { - self.row = null; - } + const available: usize = @as(usize, row.y) + 1; + const len = @min(available, limit.*); + limit.* -= len; + self.row = if (limit.* > 0) row.up(len) else null; break :count .{ .node = row.node, - .start = row.y - len, - .end = row.y - 1, + .start = @intCast(available - len), + .end = @intCast(available), }; }, @@ -4894,7 +5777,7 @@ pub const PageIterator = struct { const next_page = row.node.prev orelse break :next null; break :next .{ .node = next_page, - .y = next_page.data.size.rows - 1, + .y = next_page.rows() - 1, }; }; @@ -4928,13 +5811,14 @@ pub const PageIterator = struct { end: size.CellCountInt, pub fn rows(self: Chunk) []Row { - const rows_ptr = self.node.data.rows.ptr(self.node.data.memory); + const page = self.node.page(); + const rows_ptr = page.rows.ptr(page.memory); return rows_ptr[self.start..self.end]; } /// Returns true if this chunk represents every row in the page. pub fn fullPage(self: Chunk) bool { - return self.start == 0 and self.end == self.node.data.size.rows; + return self.start == 0 and self.end == self.node.rows(); } /// Returns true if this chunk overlaps with the given other chunk @@ -5006,12 +5890,12 @@ pub fn getTopLeft(self: *const PageList, tag: point.Tag) Pin { var rem = self.rows; var it = self.pages.last; while (it) |node| : (it = node.prev) { - if (rem <= node.data.size.rows) break :active .{ + if (rem <= node.rows()) break :active .{ .node = node, - .y = node.data.size.rows - rem, + .y = node.rows() - rem, }; - rem -= node.data.size.rows; + rem -= node.rows(); } unreachable; // assertion: we always have enough rows for active @@ -5028,22 +5912,22 @@ pub fn getBottomRight(self: *const PageList, tag: point.Tag) ?Pin { const node = self.pages.last.?; break :last .{ .node = node, - .y = node.data.size.rows - 1, - .x = node.data.size.cols - 1, + .y = node.rows() - 1, + .x = node.cols() - 1, }; }, .viewport => viewport: { var br = self.getTopLeft(.viewport); br = br.down(self.rows - 1).?; - br.x = br.node.data.size.cols - 1; + br.x = br.node.cols() - 1; break :viewport br; }, .history => active: { var br = self.getTopLeft(.active); br = br.up(1) orelse return null; - br.x = br.node.data.size.cols - 1; + br.x = br.node.cols() - 1; break :active br; }, }; @@ -5058,7 +5942,7 @@ fn totalRows(self: *const PageList) usize { var rows: usize = 0; var node_ = self.pages.first; while (node_) |node| { - rows += node.data.size.rows; + rows += node.rows(); node_ = node.next; } @@ -5078,6 +5962,88 @@ pub fn totalPages(self: *const PageList) usize { return pages; } +/// Snapshot of the storage used by page nodes in this list. +/// +/// The raw byte counts describe page backing mappings only. They exclude +/// nodes, allocator metadata, unused preheated pool items, and the small +/// representation values stored in each node. A compressed page retains its +/// raw mapping as virtual address space, but its bytes are counted as +/// decommitted because strict reclamation succeeded before the state was +/// published. +pub const MemoryStats = struct { + /// Pages whose raw backing mappings are resident. + resident_pages: usize = 0, + + /// Pages represented by encoded storage and a decommitted raw mapping. + compressed_pages: usize = 0, + + /// Logical bytes in every raw page mapping. + raw_bytes: usize = 0, + + /// Raw mapping bytes which remain resident. + resident_raw_bytes: usize = 0, + + /// Raw mapping bytes discarded for compressed pages. + decommitted_raw_bytes: usize = 0, + + /// Raw allocation bytes which remain physically resident. + /// + /// This can exceed `resident_raw_bytes` because a pool-owned page uses + /// only part of a standard pool item. Compressing such a page decommits + /// its initialized range, but the unused tail of the item stays resident. + resident_backing_bytes: usize = 0, + + /// Exact encoded allocations retained for compressed pages. + encoded_bytes: usize = 0, + + /// Estimate resident page backing storage after compression. + pub fn estimatedResidentBytes(self: MemoryStats) usize { + return self.resident_backing_bytes + self.encoded_bytes; + } + + /// Estimate physical bytes avoided by compressed page backing storage. + pub fn estimatedSavings(self: MemoryStats) usize { + return self.decommitted_raw_bytes -| self.encoded_bytes; + } +}; + +/// Return a metadata-only snapshot of page backing storage. +/// +/// This never restores compressed pages. It is intended for diagnostics and +/// other infrequent reporting because it traverses the complete page list. +pub fn memoryStats(self: *const PageList) MemoryStats { + var result: MemoryStats = .{}; + var current = self.pages.first; + while (current) |node| : (current = node.next) { + const raw_len = node.metadata().memory.len; + const backing_len = switch (node.owned) { + .pool => PagePool.item_size, + .heap => raw_len, + }; + assert(backing_len >= raw_len); + result.raw_bytes += raw_len; + + switch (node.data) { + .resident => { + result.resident_pages += 1; + result.resident_raw_bytes += raw_len; + result.resident_backing_bytes += backing_len; + }, + + .compressed => |compressed| { + result.compressed_pages += 1; + result.decommitted_raw_bytes += raw_len; + // Strict reclamation covers only Page.memory. A standard pool + // item can have an unused tail which remains resident. + result.resident_backing_bytes += backing_len - raw_len; + result.encoded_bytes += compressed.encoded.len; + }, + } + } + + return result; +} + /// Grow the number of rows available in the page list by n. /// This is only used for testing so it isn't optimized in any way. fn growRows(self: *PageList, n: usize) Allocator.Error!void { @@ -5089,8 +6055,9 @@ fn growRows(self: *PageList, n: usize) Allocator.Error!void { pub fn clearDirty(self: *PageList) void { var page = self.pages.first; while (page) |p| : (page = p.next) { - p.data.dirty = false; - for (p.data.rows.ptr(p.data.memory)[0..p.data.size.rows]) |*row| { + const current_page = p.page(); + current_page.dirty = false; + for (current_page.rows.ptr(current_page.memory)[0..p.rows()]) |*row| { row.dirty = false; } } @@ -5137,7 +6104,7 @@ pub const Pin = struct { row: *pagepkg.Row, cell: *pagepkg.Cell, } { - const rac = self.node.data.getRowAndCell(self.x, self.y); + const rac = self.node.page().getRowAndCell(self.x, self.y); return .{ .row = rac.row, .cell = rac.cell }; } @@ -5147,8 +6114,9 @@ pub const Pin = struct { /// what subset of the cells are returned. The "left/right" subsets are /// inclusive of the x coordinate of the pin. pub inline fn cells(self: Pin, subset: CellSubset) []pagepkg.Cell { - const rac = self.rowAndCell(); - const all = self.node.data.getCells(rac.row); + const page = self.node.page(); + const rac = page.getRowAndCell(self.x, self.y); + const all = page.getCells(rac.row); return switch (subset) { .all => all, .left => all[0 .. self.x + 1], @@ -5159,21 +6127,23 @@ pub const Pin = struct { /// Returns the grapheme codepoints for the given cell. These are only /// the EXTRA codepoints and not the first codepoint. pub inline fn grapheme(self: Pin, cell: *const pagepkg.Cell) ?[]u21 { - return self.node.data.lookupGrapheme(cell); + return self.node.page().lookupGrapheme(cell); } /// Returns the style for the given cell in this pin. pub inline fn style(self: Pin, cell: *const pagepkg.Cell) stylepkg.Style { if (cell.style_id == stylepkg.default_id) return .{}; - return self.node.data.styles.get( - self.node.data.memory, + const page = self.node.page(); + return page.styles.get( + page.memory, cell.style_id, ).*; } /// Check if this pin is dirty. pub inline fn isDirty(self: Pin) bool { - return self.node.data.dirty or self.rowAndCell().row.dirty; + const page = self.node.page(); + return page.dirty or page.getRowAndCell(self.x, self.y).row.dirty; } /// Mark this pin location as dirty. @@ -5354,7 +6324,7 @@ pub const Pin = struct { /// Move the pin right n columns. n must fit within the size. pub inline fn right(self: Pin, n: usize) Pin { - assert(self.x + n < self.node.data.size.cols); + assert(self.x + n < self.node.cols()); var result = self; result.x +|= std.math.cast(size.CellCountInt, n) orelse std.math.maxInt(size.CellCountInt); @@ -5371,7 +6341,7 @@ pub const Pin = struct { /// Move the pin right n columns, stopping at the end of the row. pub inline fn rightClamp(self: Pin, n: size.CellCountInt) Pin { var result = self; - result.x = @min(self.x +| n, self.node.data.size.cols - 1); + result.x = @min(self.x +| n, self.node.cols() - 1); return result; } @@ -5381,25 +6351,18 @@ pub const Pin = struct { /// /// TODO: Unit tests. pub fn leftWrap(self: Pin, n: usize) ?Pin { - // NOTE: This assumes that all pages have the same width, which may - // be violated under certain circumstances by incomplete reflow. - const cols = self.node.data.size.cols; - const remaining_in_row = self.x; - - if (n <= remaining_in_row) return self.left(n); - - const extra_after_remaining = n - remaining_in_row; - - const rows_off = 1 + extra_after_remaining / cols; - - switch (self.upOverflow(rows_off)) { - .offset => |v| { - var result = v; - result.x = @intCast(cols - extra_after_remaining % cols); - return result; - }, - .overflow => return null, + var result = self; + var remaining = n; + while (remaining > result.x) { + remaining -= @as(usize, result.x) + 1; + result = result.up(1) orelse return null; + // Crossing a row boundary lands on that destination row's final + // cell, whose width may differ from ours during reflow. + result.x = result.node.cols() - 1; } + + result.x -= @intCast(remaining); + return result; } /// Move the pin right n cells, wrapping to the next row as needed. @@ -5408,24 +6371,18 @@ pub const Pin = struct { /// /// TODO: Unit tests. pub fn rightWrap(self: Pin, n: usize) ?Pin { - // NOTE: This assumes that all pages have the same width, which may - // be violated under certain circumstances by incomplete reflow. - const cols = self.node.data.size.cols; - const remaining_in_row = cols - self.x - 1; - - if (n <= remaining_in_row) return self.right(n); - - const extra_after_remaining = n - remaining_in_row; - - const rows_off = 1 + extra_after_remaining / cols; - - switch (self.downOverflow(rows_off)) { - .offset => |v| { - var result = v; - result.x = @intCast(extra_after_remaining % cols - 1); + var result = self; + var remaining = n; + while (true) { + const row_remaining = result.node.cols() - result.x - 1; + if (remaining <= row_remaining) { + result.x += @intCast(remaining); return result; - }, - .overflow => return null, + } + + remaining -= @as(usize, row_remaining) + 1; + result = result.down(1) orelse return null; + result.x = 0; } } @@ -5457,7 +6414,7 @@ pub const Pin = struct { }, } { // Index fits within this page - const rows = self.node.data.size.rows - (self.y + 1); + const rows = self.node.rows() - (self.y + 1); if (n <= rows) return .{ .offset = .{ .node = self.node, .y = std.math.cast(size.CellCountInt, self.y + n) orelse @@ -5472,18 +6429,18 @@ pub const Pin = struct { node = node.next orelse return .{ .overflow = .{ .end = .{ .node = node, - .y = node.data.size.rows - 1, - .x = self.x, + .y = node.rows() - 1, + .x = @min(self.x, node.cols() - 1), }, .remaining = n_left, } }; - if (n_left <= node.data.size.rows) return .{ .offset = .{ + if (n_left <= node.rows()) return .{ .offset = .{ .node = node, .y = std.math.cast(size.CellCountInt, n_left - 1) orelse std.math.maxInt(size.CellCountInt), - .x = self.x, + .x = @min(self.x, node.cols() - 1), } }; - n_left -= node.data.size.rows; + n_left -= node.rows(); } } @@ -5509,20 +6466,111 @@ pub const Pin = struct { var n_left: usize = n - self.y; while (true) { node = node.prev orelse return .{ .overflow = .{ - .end = .{ .node = node, .y = 0, .x = self.x }, + .end = .{ + .node = node, + .y = 0, + .x = @min(self.x, node.cols() - 1), + }, .remaining = n_left, } }; - if (n_left <= node.data.size.rows) return .{ .offset = .{ + if (n_left <= node.rows()) return .{ .offset = .{ .node = node, - .y = std.math.cast(size.CellCountInt, node.data.size.rows - n_left) orelse + .y = std.math.cast(size.CellCountInt, node.rows() - n_left) orelse std.math.maxInt(size.CellCountInt), - .x = self.x, + .x = @min(self.x, node.cols() - 1), } }; - n_left -= node.data.size.rows; + n_left -= node.rows(); } } }; +fn mixedWidthPinListForTest(alloc: Allocator) !PageList { + var result = try init(alloc, 2, 1, null); + errdefer result.deinit(); + + // This deliberately constructs a layout that normal PageList operations + // do not expose yet. Keep integrity checks paused through deinit so the + // fixture can exercise mixed-width traversal in isolation. + result.pauseIntegrityChecks(true); + + inline for (.{ 4, 3 }) |cols| { + const node = try result.createPage(.{ .cap = .{ + .cols = cols, + .rows = 1, + } }); + node.page().size.rows = 1; + result.pages.append(node); + result.total_rows += 1; + } + + // Desired geometry is wider than the first and last stored pages. + result.cols = 4; + return result; +} + +test "PageList Pin row movement clamps across mixed-width pages" { + const testing = std.testing; + + var s = try mixedWidthPinListForTest(testing.allocator); + defer s.deinit(); + + const first = s.pages.first.?; + const second = first.next.?; + const third = second.next.?; + + try testing.expect((Pin{ .node = third, .x = 2 }).eql( + (Pin{ .node = second, .x = 3 }).down(1).?, + )); + try testing.expect((Pin{ .node = first, .x = 1 }).eql( + (Pin{ .node = second, .x = 3 }).up(1).?, + )); + + switch ((Pin{ .node = second, .x = 3 }).downOverflow(10)) { + .offset => try testing.expect(false), + .overflow => |overflow| try testing.expect( + (Pin{ .node = third, .x = 2 }).eql(overflow.end), + ), + } + switch ((Pin{ .node = second, .x = 3 }).upOverflow(10)) { + .offset => try testing.expect(false), + .overflow => |overflow| try testing.expect( + (Pin{ .node = first, .x = 1 }).eql(overflow.end), + ), + } +} + +test "PageList Pin wrapping crosses mixed-width pages" { + const testing = std.testing; + + var s = try mixedWidthPinListForTest(testing.allocator); + defer s.deinit(); + + const first = s.pages.first.?; + const second = first.next.?; + const third = second.next.?; + + try testing.expect((Pin{ .node = third, .x = 2 }).eql( + (Pin{ .node = first, .x = 1 }).rightWrap(7).?, + )); + try testing.expect((Pin{ .node = second, .x = 0 }).eql( + (Pin{ .node = third, .x = 2 }).leftWrap(6).?, + )); + try testing.expect((Pin{ .node = first }).leftWrap(1) == null); + try testing.expect((Pin{ .node = third, .x = 2 }).rightWrap(1) == null); +} + +test "PageList Pin rejects columns beyond mixed-width page bounds" { + const testing = std.testing; + + var s = try mixedWidthPinListForTest(testing.allocator); + defer s.deinit(); + + try testing.expect(s.pin(.{ .screen = .{ .x = 1, .y = 0 } }) != null); + try testing.expect(s.pin(.{ .screen = .{ .x = 2, .y = 0 } }) == null); + try testing.expect(s.pin(.{ .screen = .{ .x = 3, .y = 1 } }) != null); + try testing.expect(s.pin(.{ .screen = .{ .x = 3, .y = 2 } }) == null); +} + pub const Cell = struct { node: *List.Node, row: *pagepkg.Row, @@ -5535,7 +6583,7 @@ pub const Cell = struct { /// This is not very performant this is primarily used for assertions /// and testing. pub fn isDirty(self: Cell) bool { - return self.node.data.dirty or self.row.dirty; + return self.node.page().dirty or self.row.dirty; } /// Get the cell style. @@ -5543,8 +6591,9 @@ pub const Cell = struct { /// Not meant for non-test usage since this is inefficient. pub fn style(self: Cell) stylepkg.Style { if (self.cell.style_id == stylepkg.default_id) return .{}; - return self.node.data.styles.get( - self.node.data.memory, + const page = self.node.page(); + return page.styles.get( + page.memory, self.cell.style_id, ).*; } @@ -5556,10 +6605,10 @@ pub const Cell = struct { /// this file then consider a different approach and ask yourself very /// carefully if you really need this. pub fn screenPoint(self: Cell) point.Point { - var y: size.CellCountInt = self.row_idx; + var y: u32 = self.row_idx; var node_ = self.node; while (node_.prev) |node| { - y += node.data.size.rows; + y += node.rows(); node_ = node; } @@ -5570,6 +6619,1293 @@ pub const Cell = struct { } }; +/// Grow a test PageList until it contains at least `count` complete history +/// pages. The production cold-page boundary is intentionally reused here so +/// tests do not duplicate the row-to-page arithmetic. +fn growColdPagesForTest(self: *PageList, count: usize) !void { + while (true) { + const active_node = self.getTopLeft(.active).node; + var cold_count: usize = 0; + var current = self.pages.first; + while (current) |node| : (current = node.next) { + if (node == active_node) break; + cold_count += 1; + } + + if (cold_count >= count) return; + _ = try self.grow(); + } +} + +/// Fill the current tail page to capacity without allocating a successor. +/// Capturing the tail before the loop makes this stop at the allocation +/// boundary needed by bounded-pruning tests. +fn fillLastPageForTest(self: *PageList) !void { + const last = self.pages.last.?; + while (last.rows() < last.capacity().rows) _ = try self.grow(); +} + +/// Verify every live page belongs to the current validity epoch, has an +/// allocated generation below the next serial, and validates through the same +/// pointer-plus-generation lookup used by external references. +fn expectLivePageSerialsValidForTest(self: *const PageList) !void { + const testing = std.testing; + var node = self.pages.first; + while (node) |live| : (node = live.next) { + try testing.expect(live.serial >= self.page_serial_epoch); + try testing.expect(live.serial < self.page_serial); + try testing.expect(self.nodeIsValid(live, live.serial)); + } +} + +test "PageList Pin rightWrap exact row multiple" { + const testing = std.testing; + + var s = try init(testing.allocator, 10, 3, null); + defer s.deinit(); + + const start = s.pin(.{ .active = .{ .x = 5, .y = 0 } }).?; + const wrapped = start.rightWrap(14).?; + _ = wrapped.rowAndCell(); + + try testing.expectEqual( + point.Point{ .active = .{ .x = 9, .y = 1 } }, + s.pointFromPin(.active, wrapped), + ); +} + +test "PageList Pin leftWrap exact row multiple" { + const testing = std.testing; + + var s = try init(testing.allocator, 10, 3, null); + defer s.deinit(); + + const start = s.pin(.{ .active = .{ .x = 5, .y = 2 } }).?; + const wrapped = start.leftWrap(15).?; + _ = wrapped.rowAndCell(); + + try testing.expectEqual( + point.Point{ .active = .{ .x = 0, .y = 1 } }, + s.pointFromPin(.active, wrapped), + ); +} + +test "PageList Pin rightWrap maximum distance" { + const testing = std.testing; + + var s = try init(testing.allocator, 1, 3, null); + defer s.deinit(); + + const start = s.pin(.{ .active = .{ .y = 0 } }).?; + try testing.expectEqual(null, start.rightWrap(std.math.maxInt(usize))); +} + +test "PageList Pin leftWrap maximum distance" { + const testing = std.testing; + + var s = try init(testing.allocator, 1, 3, null); + defer s.deinit(); + + const start = s.pin(.{ .active = .{ .y = 2 } }).?; + try testing.expectEqual(null, start.leftWrap(std.math.maxInt(usize))); +} + +test "PageList incremental compression skips visible history" { + const testing = std.testing; + + var s = try init(testing.allocator, 80, 24, null); + defer s.deinit(); + try s.growColdPagesForTest(3); + + const initial_activity = s.page_compression.activity_serial; + try testing.expect(initial_activity > 0); + + s.scroll(.top); + const top_activity = s.page_compression.activity_serial; + try testing.expect(top_activity != initial_activity); + try testing.expectEqual( + IncrementalCompressionResult.complete, + s.compress(.drain), + ); + + const first = s.pages.first.?; + const second = first.next.?; + try testing.expectEqual(first, s.getTopLeft(.viewport).node); + try testing.expectEqual(first, s.getBottomRight(.viewport).?.node); + try testing.expect(!first.isCompressed()); + + var eligible: CompressionIterator = .init(&s); + var compressed_pages: usize = 0; + while (eligible.next()) |node| { + compressed_pages += 1; + try testing.expect(node.isCompressed()); + } + try testing.expect(compressed_pages > 0); + + // Move the viewport to the start of the second page. Rendering it restores + // that page, while the first page which just left view becomes eligible. + s.scroll(.{ .row = first.rows() }); + try testing.expectEqual(second, s.getTopLeft(.viewport).node); + _ = second.page(); + try testing.expect(!second.isCompressed()); + _ = s.compress(.drain); + try testing.expect(first.isCompressed()); + try testing.expect(!second.isCompressed()); + + // Returning to the active area makes every complete historical page + // eligible again, including the page which was just visible. + s.scroll(.active); + _ = s.compress(.drain); + try testing.expect(second.isCompressed()); + try testing.expect(!s.page_compression.flags.did_compress); + try testing.expect(!s.page_compression.flags.verifying); + try testing.expectEqual(@as(?u64, null), s.page_compression.last_serial); + try testing.expectEqual(@as(u64, 0), s.page_compression.next_serial); +} + +test "PageList owns incremental compression state" { + const testing = std.testing; + + var s = try init(testing.allocator, 80, 24, null); + defer s.deinit(); + + const state: IncrementalCompressionState = .{ + .flags = .{ + .did_compress = true, + .verifying = true, + }, + .activity_serial = 42, + .last_serial = 42, + .next_serial = 43, + }; + + s.page_compression = state; + s.scroll(.top); + try testing.expectEqual( + IncrementalCompressionState{ .activity_serial = 43 }, + s.page_compression, + ); + + // Every scroll restarts traversal, even if clamping leaves the viewport in + // the same place. Missing an eligible page is worse than a no-op pass. + s.page_compression = state; + s.scroll(.top); + try testing.expectEqual( + IncrementalCompressionState{ .activity_serial = 43 }, + s.page_compression, + ); + + s.page_compression = state; + s.scroll(.active); + try testing.expectEqual( + IncrementalCompressionState{ .activity_serial = 43 }, + s.page_compression, + ); + + s.page_compression = state; + try s.resize(.{ .cols = 80, .rows = 24 }); + try testing.expectEqual( + IncrementalCompressionState{ .activity_serial = 43 }, + s.page_compression, + ); + + s.page_compression = state; + s.reset(); + try testing.expectEqual( + IncrementalCompressionState{ .activity_serial = 42 }, + s.page_compression, + ); + + s.page_compression = state; + try testing.expectEqual( + IncrementalCompressionResult.complete, + s.compress(.full), + ); + try testing.expectEqual( + IncrementalCompressionState{ .activity_serial = 42 }, + s.page_compression, + ); + + s.page_compression = state; + var cloned = try s.clone(testing.allocator, .{ + .top = .{ .active = .{} }, + }); + defer cloned.deinit(); + try testing.expectEqual( + IncrementalCompressionState{}, + cloned.page_compression, + ); +} + +test "PageList replacements preserve compression continuation and mark activity" { + const testing = std.testing; + + var s = try init(testing.allocator, 80, 24, null); + defer s.deinit(); + + const state: IncrementalCompressionState = .{ + .flags = .{ .verifying = true }, + .activity_serial = 42, + .last_serial = 7, + .next_serial = 8, + }; + const expected: IncrementalCompressionState = .{ + .flags = .{ .verifying = true }, + .activity_serial = 43, + .last_serial = 7, + .next_serial = 8, + }; + + s.page_compression = state; + const replacement = try s.increaseCapacity(s.pages.first.?, null); + try testing.expectEqual(expected, s.page_compression); + + s.page_compression = state; + _ = (try s.compact(replacement)).?; + try testing.expectEqual(expected, s.page_compression); +} + +test "PageList incremental compression bounds inspected pages" { + const testing = std.testing; + + var s = try init(testing.allocator, 80, 24, null); + defer s.deinit(); + try s.growColdPagesForTest(incremental_compression_max_inspected + 1); + + // Precompress every candidate so the incremental pass exercises its + // metadata-only skip budget without stopping at a resident attempt. + _ = s.compress(.full); + s.page_compression.reset(); + s.page_compression.markActivity(); + try testing.expectEqual( + incremental_compression_max_inspected + 1, + s.memoryStats().compressed_pages, + ); + + var expected_last = s.pages.first.?; + for (1..incremental_compression_max_inspected) |_| + expected_last = expected_last.next.?; + + const first = s.compress(.incremental); + try testing.expectEqual(IncrementalCompressionResult.pending, first); + try testing.expectEqual( + expected_last.serial, + s.page_compression.last_serial.?, + ); + + const second = s.compress(.incremental); + try testing.expectEqual(IncrementalCompressionResult.pending, second); + try testing.expect(s.page_compression.flags.verifying); + try testing.expect(s.page_compression.last_serial == null); + + // The verification pass is bounded independently, too. + try testing.expectEqual( + IncrementalCompressionResult.pending, + s.compress(.incremental), + ); + try testing.expectEqual( + IncrementalCompressionResult.complete, + s.compress(.incremental), + ); +} + +test "PageList incremental compression advances after failure" { + const testing = std.testing; + + var s = try init(testing.allocator, 80, 24, null); + defer s.deinit(); + try s.growColdPagesForTest(2); + + const first = s.pages.first.?; + const second = first.next.?; + var prng = std.Random.DefaultPrng.init(0x494E_4352_5041_5353); + prng.random().bytes(first.page().memory); + + const failed = s.compress(.incremental); + try testing.expectEqual(IncrementalCompressionResult.pending, failed); + try testing.expect(!first.isCompressed()); + + // The unsuccessful first page does not stall the pass. The next step + // continues at the following serial and compresses that page. + const continued = s.compress(.incremental); + try testing.expectEqual(IncrementalCompressionResult.pending, continued); + try testing.expect(second.isCompressed()); +} + +test "PageList incremental compression advances after allocation failure" { + const testing = std.testing; + + var failing = testing.FailingAllocator.init(testing.allocator, .{}); + const alloc = failing.allocator(); + var s = try init(alloc, 80, 24, null); + defer s.deinit(); + try s.growColdPagesForTest(2); + const first = s.pages.first.?; + const second = first.next.?; + + // Pool preheating supplies compression scratch. Failing the allocator's + // next request therefore rejects the exact encoded allocation while the + // source page and pass remain valid. + failing.fail_index = failing.alloc_index; + const failed = s.compress(.incremental); + try testing.expect(failing.has_induced_failure); + try testing.expectEqual(IncrementalCompressionResult.pending, failed); + try testing.expect(!first.isCompressed()); + + // Allow allocations again. The pass must continue with the following page + // rather than retrying the failed candidate. + failing.fail_index = std.math.maxInt(usize); + const continued = s.compress(.incremental); + try testing.expectEqual(IncrementalCompressionResult.pending, continued); + try testing.expect(second.isCompressed()); +} + +test "PageList incremental compression advances after decommit failure" { + const testing = std.testing; + const tw = compressPage_tw; + defer tw.end(.reset) catch unreachable; + + var s = try init(testing.allocator, 80, 24, null); + defer s.deinit(); + try s.growColdPagesForTest(2); + + tw.errorAlways(.decommit, error.DecommitFailed); + const failed = s.compress(.incremental); + try testing.expectEqual(IncrementalCompressionResult.pending, failed); + try testing.expect(!s.pages.first.?.isCompressed()); + try tw.end(.reset); + + // The failed candidate remains resident and the pass continues at the + // next serial once reclamation is available again. + const continued = s.compress(.incremental); + try testing.expectEqual(IncrementalCompressionResult.pending, continued); + try testing.expect(s.pages.first.?.next.?.isCompressed()); +} + +test "PageList incremental compression restarts after replacement" { + const testing = std.testing; + + var s = try init(testing.allocator, 80, 24, null); + defer s.deinit(); + try s.growColdPagesForTest(1); + + const initial = s.compress(.incremental); + try testing.expectEqual(IncrementalCompressionResult.pending, initial); + try testing.expect(s.pages.first.?.isCompressed()); + + const old = s.pages.first.?; + const old_serial = old.serial; + var replacement = old; + while (replacement.page().memory.len <= std_size) { + replacement = try s.increaseCapacity( + replacement, + .grapheme_bytes, + ); + } + try testing.expect(replacement.serial != old_serial); + try testing.expect(replacement.page().memory.len > std_size); + try testing.expect(!replacement.isCompressed()); + + // The exact continuation serial disappeared with the old node. The pass + // restarts at the first page and considers the oversized replacement. + const restarted = s.compress(.incremental); + try testing.expectEqual(IncrementalCompressionResult.pending, restarted); + try testing.expect(replacement.isCompressed()); +} + +test "PageList incremental compression restarts after reset" { + const testing = std.testing; + + var s = try init(testing.allocator, 80, 24, null); + defer s.deinit(); + try s.growColdPagesForTest(1); + + const initial = s.compress(.incremental); + try testing.expectEqual(IncrementalCompressionResult.pending, initial); + try testing.expect(s.pages.first.?.isCompressed()); + + // Reset replaces every page and clears the PageList-owned traversal. + s.reset(); + try s.growColdPagesForTest(1); + const restarted = s.compress(.incremental); + try testing.expectEqual(IncrementalCompressionResult.pending, restarted); + try testing.expect(s.pages.first.?.isCompressed()); +} + +test "PageList incremental compression restarts after active boundary resize" { + const testing = std.testing; + + var s = try init(testing.allocator, 80, 24, null); + defer s.deinit(); + try s.growColdPagesForTest(1); + + const initial = s.compress(.incremental); + try testing.expectEqual(IncrementalCompressionResult.pending, initial); + try testing.expect(s.pages.first.?.isCompressed()); + + const first = s.pages.first.?; + const all_rows: size.CellCountInt = @intCast(s.total_rows); + try s.resize(.{ .rows = all_rows }); + try testing.expectEqual(first, s.getTopLeft(.active).node); + + // Restore the page while it is active. Resize reset the traversal, and + // active contents remain ineligible. + _ = first.page(); + const active = s.compress(.incremental); + try testing.expectEqual(IncrementalCompressionResult.pending, active); + try testing.expectEqual( + IncrementalCompressionResult.complete, + s.compress(.incremental), + ); + + // Shrinking the active area makes the page fully historical again. The + // resize reset the PageList-owned cursor, so the next step can reclaim it. + try s.resize(.{ .rows = 24 }); + try s.growColdPagesForTest(1); + try testing.expectEqual( + IncrementalCompressionResult.pending, + s.compress(.incremental), + ); + try testing.expect(first.isCompressed()); +} + +test "PageList incremental compression restarts after prune reuse" { + const testing = std.testing; + + var s = try init( + testing.allocator, + 80, + 24, + 2 * PagePool.item_size, + ); + defer s.deinit(); + try s.growColdPagesForTest(1); + + const initial = s.compress(.incremental); + try testing.expectEqual(IncrementalCompressionResult.pending, initial); + try testing.expect(s.pages.first.?.isCompressed()); + + const reused = s.pages.first.?; + const old_serial = reused.serial; + while (s.pages.last.?.rows() < s.pages.last.?.capacity().rows) { + _ = try s.grow(); + } + try testing.expectEqual(reused, (try s.grow()).?); + try testing.expect(reused.serial != old_serial); + + // Make the remaining old page fully historical. The continuation serial + // disappeared when its node was recycled, so the pass safely restarts. + try s.growColdPagesForTest(1); + _ = s.compress(.incremental); + try testing.expectEqual(@as(usize, 1), s.memoryStats().compressed_pages); +} + +test "PageList bounded pruning after partial erase preserves live serials" { + const testing = std.testing; + + var s = try init( + testing.allocator, + 80, + 24, + 2 * PagePool.item_size, + ); + defer s.deinit(); + + while (s.totalPages() < 2) _ = try s.grow(); + const first = s.pages.first.?; + const old_serial = first.serial; + const old_rows = first.rows(); + + s.eraseHistory(.{ .history = .{ .y = 0 } }); + try testing.expectEqual(first, s.pages.first.?); + try testing.expectEqual(old_rows - 1, first.rows()); + try testing.expect(!s.nodeIsValid(first, old_serial)); + + try s.fillLastPageForTest(); + _ = try s.grow(); + try s.expectLivePageSerialsValidForTest(); +} + +test "PageList partial erase restarts compression before continuation" { + const testing = std.testing; + + var s = try init(testing.allocator, 80, 24, null); + defer s.deinit(); + try s.growColdPagesForTest(incremental_compression_max_inspected + 1); + _ = s.compress(.full); + + const first = s.pages.first.?; + try testing.expect(first.isCompressed()); + + var marker = first; + for (1..incremental_compression_max_inspected) |_| marker = marker.next.?; + s.page_compression = .{ + .flags = .{ .verifying = true }, + .last_serial = marker.serial, + .next_serial = s.page_serial, + }; + + const activity = s.page_compression.activity_serial; + s.eraseHistory(.{ .history = .{ .y = 0 } }); + try testing.expect(!first.isCompressed()); + try testing.expect(activity != s.page_compression.activity_serial); + + // The changed generation is before the saved marker, so continuation must + // restart and recompress it instead of reporting verification complete. + try testing.expectEqual( + IncrementalCompressionResult.pending, + s.compress(.incremental), + ); + try testing.expect(first.isCompressed()); +} + +test "PageList bounded pruning after split invalidation preserves live serials" { + const testing = std.testing; + + var s = try init( + testing.allocator, + 80, + 24, + 2 * PagePool.item_size, + ); + defer s.deinit(); + + while (s.totalPages() < 2) _ = try s.grow(); + const first = s.pages.first.?; + const old_serial = first.serial; + const activity = s.page_compression.activity_serial; + + try s.split(.{ + .node = first, + .y = first.rows() / 2, + .x = 0, + }); + try testing.expect(!s.nodeIsValid(first, old_serial)); + try testing.expect(activity != s.page_compression.activity_serial); + + try s.fillLastPageForTest(); + _ = try s.grow(); + try s.expectLivePageSerialsValidForTest(); +} + +test "PageList repeated bounded pruning after split preserves live serials" { + const testing = std.testing; + + var s = try init( + testing.allocator, + 80, + 24, + 3 * PagePool.item_size, + ); + defer s.deinit(); + + const epoch = s.page_serial_epoch; + while (s.totalPages() < 3) _ = try s.grow(); + const first = s.pages.first.?; + try s.split(.{ + .node = first, + .y = first.rows() / 2, + .x = 0, + }); + + // The split target has a fresh serial but precedes older successor pages. + // Prune both the old source and then that target while verifying ordinary + // list mutation does not advance the whole-list validity epoch. + for (0..2) |_| { + while (s.pages.last.?.rows() < s.pages.last.?.capacity().rows) { + _ = try s.grow(); + } + _ = try s.grow(); + + // Ordinary pruning invalidates one generation at a time through live + // list validation; only reset may begin a new whole-list epoch. + try testing.expectEqual(epoch, s.page_serial_epoch); + try s.expectLivePageSerialsValidForTest(); + } +} + +test "PageList bounded pruning after front replacement preserves live serials" { + const testing = std.testing; + + var s = try init( + testing.allocator, + 80, + 24, + 2 * PagePool.item_size, + ); + defer s.deinit(); + + while (s.totalPages() < 2) _ = try s.grow(); + const old = s.pages.first.?; + const old_serial = old.serial; + const replacement = try s.increaseCapacity(old, null); + try testing.expect(replacement != old); + try testing.expect(!s.nodeIsValid(old, old_serial)); + + try s.fillLastPageForTest(); + _ = try s.grow(); + + try s.expectLivePageSerialsValidForTest(); +} + +test "PageList bounded pruning after middle replacement preserves live serials" { + const testing = std.testing; + + var s = try init( + testing.allocator, + 80, + 24, + 3 * PagePool.item_size, + ); + defer s.deinit(); + + while (s.totalPages() < 3) _ = try s.grow(); + const old = s.pages.first.?.next.?; + const old_serial = old.serial; + const replacement = try s.increaseCapacity(old, null); + try testing.expect(replacement != old); + try testing.expect(!s.nodeIsValid(old, old_serial)); + + // Prune the original first page and then the fresh middle replacement. + for (0..2) |_| { + try s.fillLastPageForTest(); + _ = try s.grow(); + try s.expectLivePageSerialsValidForTest(); + } +} + +test "PageList incremental compression restarts after earlier replacement" { + const testing = std.testing; + + var s = try init(testing.allocator, 80, 24, null); + defer s.deinit(); + try s.growColdPagesForTest(3); + + _ = s.compress(.incremental); + _ = s.compress(.incremental); + try testing.expect(s.pages.first.?.isCompressed()); + try testing.expect(s.pages.first.?.next.?.isCompressed()); + + // Replace a page before the still-valid continuation marker. The list's + // allocation serial changes even though the marker itself remains, so the + // next step must restart and inspect the replacement. + const old_first = s.pages.first.?; + const old_serial = old_first.serial; + const replacement = try s.increaseCapacity( + old_first, + .grapheme_bytes, + ); + try testing.expect(replacement.serial != old_serial); + try testing.expect(!replacement.isCompressed()); + + _ = s.compress(.incremental); + try testing.expect(replacement.isCompressed()); +} + +test "PageList incremental compression keeps progress after tail growth" { + const testing = std.testing; + + var s = try init(testing.allocator, 80, 24, null); + defer s.deinit(); + try s.growColdPagesForTest(incremental_compression_max_inspected + 1); + _ = s.compress(.full); + s.page_compression.reset(); + s.page_compression.markActivity(); + + var expected_last = s.pages.first.?; + for (1..incremental_compression_max_inspected) |_| + expected_last = expected_last.next.?; + + const first = s.compress(.incremental); + try testing.expectEqual(IncrementalCompressionResult.pending, first); + try testing.expectEqual( + expected_last.serial, + s.page_compression.last_serial.?, + ); + + // Allocate a new page at the active tail between steps. It is after the + // continuation marker and must not restart progress through cold history. + const next_serial = s.page_serial; + while (s.page_serial == next_serial) _ = try s.grow(); + const continued = s.compress(.incremental); + try testing.expectEqual(IncrementalCompressionResult.pending, continued); + try testing.expect(s.page_compression.flags.verifying); +} + +test "PageList memory stats do not restore compressed pages" { + const testing = std.testing; + + var s = try init(testing.allocator, 80, 24, null); + defer s.deinit(); + try s.growColdPagesForTest(2); + + const before = s.memoryStats(); + try testing.expectEqual(s.totalPages(), before.resident_pages); + try testing.expectEqual(@as(usize, 0), before.compressed_pages); + try testing.expectEqual(s.page_size, before.raw_bytes); + try testing.expectEqual(before.raw_bytes, before.resident_raw_bytes); + try testing.expectEqual(@as(usize, 0), before.decommitted_raw_bytes); + try testing.expectEqual(s.page_size, before.resident_backing_bytes); + try testing.expectEqual(@as(usize, 0), before.encoded_bytes); + try testing.expectEqual( + before.resident_backing_bytes, + before.estimatedResidentBytes(), + ); + try testing.expectEqual(@as(usize, 0), before.estimatedSavings()); + + _ = s.compress(.full); + const first = s.pages.first.?; + try testing.expectEqual(Node.Storage.compressed, first.storage()); + try testing.expect(first.pageIfResident() == null); + + const after = s.memoryStats(); + try testing.expect(first.isCompressed()); + try testing.expectEqual(s.totalPages(), after.resident_pages + after.compressed_pages); + try testing.expectEqual(@as(usize, 2), after.compressed_pages); + try testing.expectEqual(s.page_size, after.raw_bytes); + try testing.expectEqual( + after.raw_bytes, + after.resident_raw_bytes + after.decommitted_raw_bytes, + ); + try testing.expectEqual( + after.resident_backing_bytes + after.encoded_bytes, + after.estimatedResidentBytes(), + ); + try testing.expectEqual( + after.decommitted_raw_bytes - after.encoded_bytes, + after.estimatedSavings(), + ); + + const first_raw_len = first.metadata().memory.len; + const first_encoded_len = first.data.compressed.encoded.len; + _ = first.page(); + try testing.expectEqual(Node.Storage.resident, first.storage()); + try testing.expect(first.pageIfResident() != null); + + const restored = s.memoryStats(); + try testing.expectEqual(after.resident_pages + 1, restored.resident_pages); + try testing.expectEqual(after.compressed_pages - 1, restored.compressed_pages); + try testing.expectEqual(after.raw_bytes, restored.raw_bytes); + try testing.expectEqual( + after.resident_raw_bytes + first_raw_len, + restored.resident_raw_bytes, + ); + try testing.expectEqual( + after.decommitted_raw_bytes - first_raw_len, + restored.decommitted_raw_bytes, + ); + try testing.expectEqual( + after.resident_backing_bytes + first_raw_len, + restored.resident_backing_bytes, + ); + try testing.expectEqual( + after.encoded_bytes - first_encoded_len, + restored.encoded_bytes, + ); +} + +test "PageList preserved page keeps compressed storage" { + const testing = std.testing; + const alloc = testing.allocator; + + var s = try init(alloc, 80, 24, null); + defer s.deinit(); + + const node = s.pages.first.?; + const resident = node.page(); + resident.dirty = true; + resident.getRowAndCell(3, 2).cell.* = .init('X'); + + // Resident nodes can be borrowed without allocating an unnecessary copy. + { + var failing = testing.FailingAllocator.init(alloc, .{ + .fail_index = 0, + }); + var preserved = try node.pagePreservingState(failing.allocator()); + defer preserved.deinit(); + switch (preserved) { + .borrowed => |page_| try testing.expectEqual(resident, page_), + .owned => try testing.expect(false), + } + try testing.expect(!failing.has_induced_failure); + } + + const expected = try alloc.dupe(u8, resident.memory); + defer alloc.free(expected); + const retained_ptr = resident.memory.ptr; + + try testing.expect(s.compressPage(node)); + const stats = s.memoryStats(); + const expected_encoded = try alloc.dupe(u8, node.data.compressed.encoded); + defer alloc.free(expected_encoded); + + // Test decommit simulates physical reclamation by clearing the retained + // mapping. A preserved page must decode elsewhere rather than restoring + // it. + try testing.expect(std.mem.allEqual(u8, node.metadata().memory, 0)); + + // Preserved-page allocation is opportunistic for callers. Failure leaves + // the node and its compressed representation untouched. + var failing = testing.FailingAllocator.init(alloc, .{ + .fail_index = 0, + }); + try testing.expectError( + error.OutOfMemory, + node.pagePreservingState(failing.allocator()), + ); + try testing.expectEqual(Node.Storage.compressed, node.storage()); + try testing.expectEqual(stats, s.memoryStats()); + + var preserved = try node.pagePreservingState(alloc); + defer preserved.deinit(); + switch (preserved) { + .borrowed => try testing.expect(false), + .owned => {}, + } + const page_ = preserved.page(); + + try testing.expect(page_.memory.ptr != retained_ptr); + try testing.expectEqualSlices(u8, expected, page_.memory); + try testing.expect(page_.dirty); + try testing.expectEqual( + @as(u21, 'X'), + page_.getRowAndCell(3, 2).cell.content.codepoint, + ); + + // The node still owns the same compressed representation, and neither its + // storage accounting nor its discarded raw mapping changed while cloning. + try testing.expectEqual(Node.Storage.compressed, node.storage()); + try testing.expectEqual(stats, s.memoryStats()); + try testing.expectEqual(retained_ptr, node.metadata().memory.ptr); + try testing.expect(std.mem.allEqual(u8, node.metadata().memory, 0)); + try testing.expectEqualSlices( + u8, + expected_encoded, + node.data.compressed.encoded, + ); +} + +test "PageList memory stats include unused pool backing" { + const testing = std.testing; + + var s = try init(testing.allocator, 80, 24, null); + defer s.deinit(); + + // Pool allocation ownership is based on the requested layout fitting in a + // standard item. The Page itself exposes only the initialized prefix. + const node = try s.createPage(.{ .cap = .{ .cols = 1, .rows = 1 } }); + try testing.expectEqual(Node.Owned.pool, node.owned); + try testing.expect(node.page().memory.len < PagePool.item_size); + node.page().size.rows = 1; + s.pages.append(node); + s.total_rows += 1; + + const raw_len = node.metadata().memory.len; + const before = s.memoryStats(); + try testing.expect(before.raw_bytes < s.page_size); + try testing.expectEqual(before.raw_bytes, before.resident_raw_bytes); + try testing.expectEqual(s.page_size, before.resident_backing_bytes); + try testing.expectEqual(s.page_size, before.estimatedResidentBytes()); + + try testing.expect(s.compressPage(node)); + const encoded_len = node.data.compressed.encoded.len; + const compressed = s.memoryStats(); + try testing.expectEqual(before.raw_bytes, compressed.raw_bytes); + try testing.expectEqual( + before.resident_raw_bytes - raw_len, + compressed.resident_raw_bytes, + ); + try testing.expectEqual(raw_len, compressed.decommitted_raw_bytes); + try testing.expectEqual( + before.resident_backing_bytes - raw_len, + compressed.resident_backing_bytes, + ); + try testing.expectEqual(encoded_len, compressed.encoded_bytes); + try testing.expectEqual( + before.estimatedResidentBytes() - raw_len + encoded_len, + compressed.estimatedResidentBytes(), + ); +} + +test "PageList does not compress the mixed history and active page" { + const testing = std.testing; + + var s = try init(testing.allocator, 80, 24, null); + defer s.deinit(); + + // One additional row creates history, but the history and all active rows + // still share the first page. The active boundary therefore has a + // historical prefix and must remain resident as one indivisible mapping. + _ = try s.grow(); + const active = s.getTopLeft(.active); + try testing.expectEqual(s.pages.first.?, active.node); + try testing.expect(active.y > 0); + + _ = s.compress(.full); + try testing.expect(!s.pages.first.?.isCompressed()); +} + +test "PageList compresses only complete cold history pages" { + const testing = std.testing; + const alloc = testing.allocator; + + // More active rows than one page at these dimensions can hold ensures the + // active area spans multiple nodes when the pass chooses its boundary. + const active_rows = initialCapacity(80).rows + 1; + var s = try init(alloc, 80, active_rows, null); + defer s.deinit(); + try s.growColdPagesForTest(2); + + // Move the active top into the boundary page so it has both a historical + // prefix and active rows while the active area still spans later pages. + _ = try s.grow(); + + const active = s.getTopLeft(.active); + const active_node = active.node; + try testing.expect(active.y > 0); + try testing.expect(active_node != s.pages.last.?); + + var expected_compressed: usize = 0; + var expected_raw_bytes: usize = 0; + var current = s.pages.first; + while (current) |node| : (current = node.next) { + if (node == active_node) break; + expected_compressed += 1; + expected_raw_bytes += node.metadata().memory.len; + } + try testing.expectEqual(@as(usize, 2), expected_compressed); + + const first = s.pages.first.?; + first.page().getRowAndCell(0, 0).cell.* = .init('X'); + const expected = try alloc.dupe(u8, first.page().memory); + defer alloc.free(expected); + const first_memory = first.page().memory.ptr; + const page_size = s.page_size; + + _ = s.compress(.full); + const memory = s.memoryStats(); + try testing.expectEqual(expected_compressed, memory.compressed_pages); + try testing.expectEqual(expected_raw_bytes, memory.decommitted_raw_bytes); + try testing.expect(memory.encoded_bytes < memory.decommitted_raw_bytes); + try testing.expectEqual(page_size, s.page_size); + + current = s.pages.first; + var actual_encoded_bytes: usize = 0; + while (current) |node| : (current = node.next) { + if (node == active_node) break; + try testing.expect(node.isCompressed()); + actual_encoded_bytes += node.data.compressed.encoded.len; + } + try testing.expectEqual(actual_encoded_bytes, memory.encoded_bytes); + current = active_node; + while (current) |node| : (current = node.next) { + try testing.expect(!node.isCompressed()); + } + + // Restoring the oldest page preserves both the mapping identity and all + // of its bytes even though the pass discarded its physical pages. + try testing.expectEqual(first_memory, first.metadata().memory.ptr); + try testing.expectEqualSlices(u8, expected, first.page().memory); + try testing.expectEqual(first_memory, first.page().memory.ptr); +} + +test "PageList lazily restores compressed history made active by resize" { + const testing = std.testing; + + var s = try init(testing.allocator, 80, 24, null); + defer s.deinit(); + try s.growColdPagesForTest(1); + + const first = s.pages.first.?; + first.page().getRowAndCell(0, 0).cell.* = .init('X'); + const memory_ptr = first.page().memory.ptr; + const memory_len = first.page().memory.len; + const page_size = s.page_size; + + _ = s.compress(.full); + try testing.expect(first.isCompressed()); + + // Pull all scrollback into the active area by making the viewport as tall + // as the complete screen. A row-only resize needs only page metadata, so + // the newly active page can remain compressed until its contents are used. + const all_rows: size.CellCountInt = @intCast(s.total_rows); + try s.resize(.{ .rows = all_rows }); + const active = s.getTopLeft(.active); + try testing.expectEqual(first, active.node); + try testing.expectEqual(@as(size.CellCountInt, 0), active.y); + try testing.expect(first.isCompressed()); + try testing.expectEqual(page_size, s.page_size); + + // The compression pass must not reconsider the node now that it is active. + // Content access follows the normal page boundary, which recommits and + // restores the retained mapping before returning the cell. + _ = s.compress(.full); + try testing.expect(first.isCompressed()); + const cell = s.getCell(.{ .active = .{} }).?; + try testing.expectEqual(@as(u21, 'X'), cell.cell.content.codepoint); + try testing.expect(!first.isCompressed()); + try testing.expectEqual(memory_ptr, first.page().memory.ptr); + try testing.expectEqual(memory_len, first.page().memory.len); + try testing.expectEqual(page_size, s.page_size); +} + +test "PageList full and incremental compression skip a spanning viewport" { + const testing = std.testing; + + var full = try init(testing.allocator, 80, 24, null); + defer full.deinit(); + try full.growColdPagesForTest(3); + + var incremental = try init(testing.allocator, 80, 24, null); + defer incremental.deinit(); + try incremental.growColdPagesForTest(3); + + // Start near the end of the first page so the viewport intersects both + // the first and second historical page mappings. + const first = full.pages.first.?; + const overlap_rows: usize = full.rows / 2; + const viewport_row: usize = first.rows() - overlap_rows; + full.scroll(.{ .row = viewport_row }); + incremental.scroll(.{ .row = viewport_row }); + try testing.expect( + full.getTopLeft(.viewport).node != + full.getBottomRight(.viewport).?.node, + ); + const second = first.next.?; + try testing.expectEqual(first, full.getTopLeft(.viewport).node); + try testing.expectEqual(second, full.getBottomRight(.viewport).?.node); + + _ = full.compress(.full); + _ = incremental.compress(.drain); + try testing.expectEqual(full.memoryStats(), incremental.memoryStats()); + try testing.expect(!first.isCompressed()); + try testing.expect(!second.isCompressed()); + + const full_active = full.getTopLeft(.active).node; + const incremental_active = incremental.getTopLeft(.active).node; + var full_node = full.pages.first.?; + var incremental_node = incremental.pages.first.?; + while (full_node != full_active) { + try testing.expectEqual( + full_node.isCompressed(), + incremental_node.isCompressed(), + ); + + full_node = full_node.next.?; + incremental_node = incremental_node.next.?; + } + try testing.expectEqual(incremental_active, incremental_node); + + var eligible: CompressionIterator = .init(&full); + var compressed_pages: usize = 0; + while (eligible.next()) |node| { + compressed_pages += 1; + try testing.expect(node.isCompressed()); + } + try testing.expect(compressed_pages > 0); +} + +test "PageList cold compression continues after an incompressible page" { + const testing = std.testing; + + var s = try init(testing.allocator, 80, 24, null); + defer s.deinit(); + try s.growColdPagesForTest(2); + + const first = s.pages.first.?; + const second = first.next.?; + var prng = std.Random.DefaultPrng.init(0x434F_4C44_5041_4745); + prng.random().bytes(first.page().memory); + + const page_size = s.page_size; + _ = s.compress(.full); + const memory = s.memoryStats(); + try testing.expectEqual(@as(usize, 1), memory.compressed_pages); + try testing.expect(!first.isCompressed()); + try testing.expect(second.isCompressed()); + try testing.expectEqual( + second.metadata().memory.len, + memory.decommitted_raw_bytes, + ); + try testing.expect(memory.encoded_bytes < memory.decommitted_raw_bytes); + try testing.expectEqual(page_size, s.page_size); + + // Failed resident candidates are deliberately retried on later passes, + // while the successful page remains compressed and is skipped. + _ = s.compress(.full); + try testing.expectEqual(memory, s.memoryStats()); +} + +test "PageList compression restores through page access" { + const testing = std.testing; + const alloc = testing.allocator; + + var s = try init(alloc, 80, 24, null); + defer s.deinit(); + + const node = s.pages.first.?; + const page = node.page(); + page.dirty = true; + page.getRowAndCell(3, 2).cell.* = .init('X'); + + const expected = try alloc.dupe(u8, page.memory); + defer alloc.free(expected); + const memory_ptr = page.memory.ptr; + const memory_len = page.memory.len; + const page_size = s.page_size; + + try testing.expect(s.compressPage(node)); + try testing.expect(node.isCompressed()); + try testing.expectEqual(@as(size.CellCountInt, 24), node.rows()); + try testing.expectEqual(@as(size.CellCountInt, 80), node.cols()); + try testing.expectEqual(memory_ptr, node.metadata().memory.ptr); + try testing.expectEqual(memory_len, node.metadata().memory.len); + try testing.expectEqual(page_size, s.page_size); + + // Pin access restores the page without changing its retained mapping. + const page_pin: Pin = .{ .node = node, .x = 3, .y = 2 }; + try testing.expectEqual( + @as(u21, 'X'), + page_pin.rowAndCell().cell.content.codepoint, + ); + try testing.expect(!node.isCompressed()); + try testing.expectEqual(memory_ptr, node.page().memory.ptr); + try testing.expectEqualSlices(u8, expected, node.page().memory); + try testing.expect(node.page().dirty); + + // Recompressing exercises reuse of the page-pool scratch item. Page + // iterator chunks also restore before exposing row memory. + try testing.expect(s.compressPage(node)); + var page_it = (Pin{ .node = node }).pageIterator(.right_down, null); + const chunk = page_it.next().?; + try testing.expectEqual(node.rows(), chunk.rows().len); + try testing.expect(!node.isCompressed()); + try testing.expectEqualSlices(u8, expected, node.page().memory); + + // Read-only PageList operations restore through the same boundary. + try testing.expect(s.compressPage(node)); + var cloned = try s.clone(alloc, .{ + .top = .{ .screen = .{} }, + }); + defer cloned.deinit(); + try testing.expect(!node.isCompressed()); + try testing.expectEqual( + @as(u21, 'X'), + cloned.pages.first.?.page().getRowAndCell(3, 2).cell.content.codepoint, + ); +} + +test "PageList compression uses temporary scratch for oversized pages" { + const testing = std.testing; + const alloc = testing.allocator; + + var s = try init(alloc, 80, 24, null); + defer s.deinit(); + + var node = s.pages.first.?; + while (node.page().memory.len <= std_size) { + node = try s.increaseCapacity(node, .grapheme_bytes); + } + + const expected = try alloc.dupe(u8, node.page().memory); + defer alloc.free(expected); + const memory_ptr = node.page().memory.ptr; + const memory_len = node.page().memory.len; + const page_size = s.page_size; + + try testing.expect(s.compressPage(node)); + try testing.expect(node.isCompressed()); + try testing.expectEqual(page_size, s.page_size); + try testing.expectEqual(memory_ptr, node.metadata().memory.ptr); + try testing.expectEqual(memory_len, node.metadata().memory.len); + + try testing.expectEqualSlices(u8, expected, node.page().memory); + try testing.expect(!node.isCompressed()); + try testing.expectEqual(memory_ptr, node.page().memory.ptr); +} + +test "PageList compression leaves incompressible pages resident" { + const testing = std.testing; + const alloc = testing.allocator; + + var s = try init(alloc, 80, 24, null); + defer s.deinit(); + + const node = s.pages.first.?; + const original = try alloc.dupe(u8, node.page().memory); + defer alloc.free(original); + defer @memcpy(node.page().memory, original); + + var prng = std.Random.DefaultPrng.init(0x5041_4745_4C49_5354); + prng.random().bytes(node.page().memory); + const page_size = s.page_size; + + try testing.expect(!s.compressPage(node)); + try testing.expect(!node.isCompressed()); + try testing.expectEqual(page_size, s.page_size); +} + +test "PageList reset discards malformed compressed data" { + const testing = std.testing; + + var s = try init(testing.allocator, 80, 24, null); + defer s.deinit(); + + const node = s.pages.first.?; + try testing.expect(s.compressPage(node)); + @memset(node.data.compressed.encoded, 0xFF); + + s.reset(); + try testing.expect(!s.pages.first.?.isCompressed()); + try testing.expectEqual(@as(usize, 1), s.totalPages()); +} + +test "PageList deinit discards malformed compressed data" { + const testing = std.testing; + + var s = try init(testing.allocator, 80, 24, null); + const node = s.pages.first.?; + try testing.expect(s.compressPage(node)); + @memset(node.data.compressed.encoded, 0xFF); + + s.deinit(); +} + +test "PageList prune reuses malformed compressed page memory" { + const testing = std.testing; + + var s = try init( + testing.allocator, + 80, + 24, + 2 * PagePool.item_size, + ); + defer s.deinit(); + + // Allocate the second page so the first one can be pruned and reused. + while (s.pages.first == s.pages.last) _ = try s.grow(); + const first = s.pages.first.?; + try testing.expect(s.compressPage(first)); + @memset(first.data.compressed.encoded, 0xFF); + + var reused = false; + const growth_limit = @as(usize, s.pages.last.?.capacity().rows) + 1; + for (0..growth_limit) |_| { + if (try s.grow()) |new_node| { + if (new_node == first) { + reused = true; + break; + } + } + } + + try testing.expect(reused); + try testing.expectEqual(first, s.pages.last.?); + try testing.expect(!first.isCompressed()); + try testing.expectEqual(@as(size.CellCountInt, 1), first.rows()); + first.page().assertIntegrity(); +} + test "PageList" { const testing = std.testing; const alloc = testing.allocator; @@ -5710,7 +8046,7 @@ test "PageList init more than max cols" { // We expect a single, non-standard page try testing.expect(s.pages.first != null); - try testing.expect(s.pages.first.?.data.memory.len > std_size); + try testing.expect(s.pages.first.?.page().memory.len > std_size); // Initial total rows should be our row count try testing.expectEqual(s.rows, s.total_rows); @@ -5794,17 +8130,17 @@ test "PageList pointFromPin active from prior page" { var s = try init(alloc, 80, 24, null); defer s.deinit(); // Grow so we take up at least 5 pages. - const page = &s.pages.last.?.data; + const page = s.pages.last.?.page(); var cur_page = s.pages.last.?; - cur_page.data.pauseIntegrityChecks(true); + cur_page.page().pauseIntegrityChecks(true); for (0..page.capacity.rows * 5) |_| { if (try s.grow()) |new_page| { - cur_page.data.pauseIntegrityChecks(false); + cur_page.page().pauseIntegrityChecks(false); cur_page = new_page; - cur_page.data.pauseIntegrityChecks(true); + cur_page.page().pauseIntegrityChecks(true); } } - cur_page.data.pauseIntegrityChecks(false); + cur_page.page().pauseIntegrityChecks(false); { try testing.expectEqual(point.Point{ @@ -5837,17 +8173,17 @@ test "PageList pointFromPin traverse pages" { defer s.deinit(); // Grow so we take up at least 2 pages. - const page = &s.pages.last.?.data; + const page = s.pages.last.?.page(); var cur_page = s.pages.last.?; - cur_page.data.pauseIntegrityChecks(true); + cur_page.page().pauseIntegrityChecks(true); for (0..page.capacity.rows * 2) |_| { if (try s.grow()) |new_page| { - cur_page.data.pauseIntegrityChecks(false); + cur_page.page().pauseIntegrityChecks(false); cur_page = new_page; - cur_page.data.pauseIntegrityChecks(true); + cur_page.page().pauseIntegrityChecks(true); } } - cur_page.data.pauseIntegrityChecks(false); + cur_page.page().pauseIntegrityChecks(false); { const pages = s.totalPages(); @@ -5875,6 +8211,44 @@ test "PageList pointFromPin traverse pages" { }) == null); } } + +test "PageList pointFromPin rejects overflowing screen coordinate" { + const testing = std.testing; + + // Use maximum-height metadata-only pages to model a valid scrollback just + // beyond the u32 coordinate range without allocating their backing cells. + const page_count = 65_539; + const rows_per_page = std.math.maxInt(size.CellCountInt); + const nodes = try testing.allocator.alloc(Node, page_count); + defer testing.allocator.free(nodes); + + for (nodes, 0..) |*node, i| { + node.* = .{ + .prev = if (i > 0) &nodes[i - 1] else null, + .next = if (i + 1 < nodes.len) &nodes[i + 1] else null, + .data = .{ .resident = undefined }, + .serial = @intCast(i), + .owned = .heap, + }; + node.data.resident.size = .{ + .cols = 1, + .rows = rows_per_page, + }; + } + + var s: PageList = undefined; + s.pages = .{ + .first = &nodes[0], + .last = &nodes[nodes.len - 1], + }; + + try testing.expect(s.pointFromPin(.screen, .{ + .node = &nodes[nodes.len - 1], + .y = 0, + .x = 0, + }) == null); +} + test "PageList active after grow" { const testing = std.testing; const alloc = testing.allocator; @@ -5937,8 +8311,8 @@ test "PageList grow allows exceeding max size for active area" { { var it = s.pages.first; while (it) |page| : (it = page.next) { - page.data.size.rows = 1; - page.data.capacity.rows = 1; + page.page().size.rows = 1; + page.page().capacity.rows = 1; } // Avoid integrity check failures @@ -5967,7 +8341,7 @@ test "PageList grow prune required with a single page" { // This is important because it triggers a scenario where our calculated // minSize() which is supposed to accommodate 2 pages is no longer true. while (true) { - const layout = Page.layout(s.pages.first.?.data.capacity); + const layout = Page.layout(s.pages.first.?.capacity()); if (layout.total_size > std_size) break; _ = try s.increaseCapacity(s.pages.first.?, .grapheme_bytes); } @@ -5980,7 +8354,7 @@ test "PageList grow prune required with a single page" { // page. const rem = rem: { const page = s.pages.first.?; - break :rem page.data.capacity.rows - page.data.size.rows; + break :rem page.capacity().rows - page.rows(); }; for (0..rem) |_| try testing.expect(try s.grow() == null); @@ -6205,6 +8579,20 @@ test "PageList scroll delta row back overflow" { }, s.scrollbar()); } +test "PageList scroll minimum row delta" { + const testing = std.testing; + + var s = try init(testing.allocator, 10, 3, null); + defer s.deinit(); + + // Create one row of history so scrolling all the way back has an + // observable result. + try s.growRows(1); + s.scroll(.{ .delta_row = std.math.minInt(isize) }); + + try testing.expectEqual(Viewport.top, s.viewport); +} + test "PageList scroll delta row forward" { const testing = std.testing; const alloc = testing.allocator; @@ -6870,7 +9258,7 @@ test "PageList: jump zero prompts" { defer s.deinit(); try s.growRows(3); try testing.expect(s.pages.first == s.pages.last); - const page = &s.pages.first.?.data; + const page = s.pages.first.?.page(); { const rac = page.getRowAndCell(0, 1); rac.row.semantic_prompt = .prompt; @@ -6890,6 +9278,16 @@ test "PageList: jump zero prompts" { }, s.scrollbar()); } +test "PageList: jump minimum prompt delta" { + const testing = std.testing; + + var s = try init(testing.allocator, 10, 3, null); + defer s.deinit(); + + s.scroll(.{ .delta_prompt = std.math.minInt(isize) }); + try testing.expectEqual(Viewport.active, s.viewport); +} + test "Screen: jump back one prompt" { const testing = std.testing; const alloc = testing.allocator; @@ -6898,7 +9296,7 @@ test "Screen: jump back one prompt" { defer s.deinit(); try s.growRows(3); try testing.expect(s.pages.first == s.pages.last); - const page = &s.pages.first.?.data; + const page = s.pages.first.?.page(); { const rac = page.getRowAndCell(0, 1); rac.row.semantic_prompt = .prompt; @@ -7016,7 +9414,7 @@ test "PageList grow fit in capacity" { defer s.deinit(); // So we know we're using capacity to grow - const last = &s.pages.last.?.data; + const last = s.pages.last.?.page(); try testing.expect(last.size.rows < last.capacity.rows); // Grow @@ -7039,7 +9437,7 @@ test "PageList grow allocate" { // Grow to capacity const last_node = s.pages.last.?; - const last = &s.pages.last.?.data; + const last = s.pages.last.?.page(); for (0..last.capacity.rows - last.size.rows) |_| { try testing.expect(try s.grow() == null); } @@ -7058,6 +9456,47 @@ test "PageList grow allocate" { } } +test "PageList Cell screenPoint supports long scrollback" { + const testing = std.testing; + + // A modest number of full-size page nodes is enough to exceed the u16 + // row range without allocating any page backing memory. screenPoint only + // reads the linked metadata while calculating the absolute coordinate. + const page_count = 307; + const rows_per_page = std_capacity.rows; + const nodes = try testing.allocator.alloc(Node, page_count); + defer testing.allocator.free(nodes); + + for (nodes, 0..) |*node, i| { + node.* = .{ + .prev = if (i > 0) &nodes[i - 1] else null, + .next = if (i + 1 < nodes.len) &nodes[i + 1] else null, + .data = .{ .resident = undefined }, + .serial = @intCast(i), + .owned = .heap, + }; + node.data.resident.size = .{ + .cols = 1, + .rows = rows_per_page, + }; + } + + const expected_y: u32 = (page_count - 1) * @as(u32, rows_per_page); + try testing.expect(expected_y > std.math.maxInt(size.CellCountInt)); + + const cell: Cell = .{ + .node = &nodes[nodes.len - 1], + .row = undefined, + .cell = undefined, + .row_idx = 0, + .col_idx = 0, + }; + try testing.expectEqual(point.Point{ .screen = .{ + .x = 0, + .y = expected_y, + } }, cell.screenPoint()); +} + test "PageList grow prune scrollback" { const testing = std.testing; const alloc = testing.allocator; @@ -7068,14 +9507,14 @@ test "PageList grow prune scrollback" { // Grow to capacity const page1_node = s.pages.last.?; - const page1 = page1_node.data; + const page1 = page1_node.page(); for (0..page1.capacity.rows - page1.size.rows) |_| { try testing.expect(try s.grow() == null); } // Grow and allocate one more page. Then fill that page up. const page2_node = (try s.grow()).?; - const page2 = page2_node.data; + const page2 = page2_node.page(); for (0..page2.capacity.rows - page2.size.rows) |_| { try testing.expect(try s.grow() == null); } @@ -7137,14 +9576,14 @@ test "PageList grow prune scrollback with viewport pin not in pruned page" { // Grow to capacity of first page const page1_node = s.pages.last.?; - const page1 = page1_node.data; + const page1 = page1_node.page(); for (0..page1.capacity.rows - page1.size.rows) |_| { try testing.expect(try s.grow() == null); } // Grow and allocate second page, then fill it up const page2_node = (try s.grow()).?; - const page2 = page2_node.data; + const page2 = page2_node.page(); for (0..page2.capacity.rows - page2.size.rows) |_| { try testing.expect(try s.grow() == null); } @@ -7195,7 +9634,7 @@ test "PageList eraseRows invalidates viewport offset cache" { defer s.deinit(); // Grow so we take up several pages worth of history - const page = &s.pages.last.?.data; + const page = s.pages.last.?.page(); { var cur_page = s.pages.last.?; for (0..page.capacity.rows * 3) |_| { @@ -7235,7 +9674,7 @@ test "PageList eraseRow invalidates viewport offset cache" { defer s.deinit(); // Grow so we take up several pages worth of history - const page = &s.pages.last.?.data; + const page = s.pages.last.?.page(); { var cur_page = s.pages.last.?; for (0..page.capacity.rows * 3) |_| { @@ -7274,7 +9713,7 @@ test "PageList eraseRowBounded invalidates viewport offset cache" { defer s.deinit(); // Grow so we take up several pages worth of history - const page = &s.pages.last.?.data; + const page = s.pages.last.?.page(); { var cur_page = s.pages.last.?; for (0..page.capacity.rows * 3) |_| { @@ -7306,6 +9745,53 @@ test "PageList eraseRowBounded invalidates viewport offset cache" { }, s.scrollbar()); } +test "PageList row erasure renews affected page generations" { + const testing = std.testing; + + var s = try init(testing.allocator, 80, 24, null); + defer s.deinit(); + while (s.totalPages() < 2) _ = try s.grow(); + + const first = s.pages.first.?; + const second = first.next.?; + var first_serial = first.serial; + var second_serial = second.serial; + var activity = s.page_compression.activity_serial; + + try s.eraseRow(.{ .history = .{ .y = 0 } }); + try testing.expect(!s.nodeIsValid(first, first_serial)); + try testing.expect(!s.nodeIsValid(second, second_serial)); + try testing.expect(activity != s.page_compression.activity_serial); + + first_serial = first.serial; + second_serial = second.serial; + activity = s.page_compression.activity_serial; + try s.eraseRowBounded( + .{ .history = .{ .y = 0 } }, + first.rows() + 1, + ); + try testing.expect(!s.nodeIsValid(first, first_serial)); + try testing.expect(!s.nodeIsValid(second, second_serial)); + try testing.expect(activity != s.page_compression.activity_serial); +} + +test "PageList trailing row truncation renews page generation" { + const testing = std.testing; + + var s = try init(testing.allocator, 80, 24, null); + defer s.deinit(); + + const node = s.pages.last.?; + const old_serial = node.serial; + const trimmed = s.trimTrailingBlankRows(1); + s.total_rows -= trimmed; + try testing.expectEqual(@as(size.CellCountInt, 1), trimmed); + try testing.expect(!s.nodeIsValid(node, old_serial)); + + _ = try s.grow(); + try s.expectLivePageSerialsValidForTest(); +} + test "PageList eraseRowBounded multi-page invalidates viewport offset cache" { const testing = std.testing; const alloc = testing.allocator; @@ -7314,7 +9800,7 @@ test "PageList eraseRowBounded multi-page invalidates viewport offset cache" { defer s.deinit(); // Grow so we take up several pages worth of history - const page = &s.pages.last.?.data; + const page = s.pages.last.?.page(); { var cur_page = s.pages.last.?; for (0..page.capacity.rows * 3) |_| { @@ -7355,7 +9841,7 @@ test "PageList eraseRowBounded full page shift invalidates viewport offset cache defer s.deinit(); // Grow so we take up several pages worth of history - const page = &s.pages.last.?.data; + const page = s.pages.last.?.page(); { var cur_page = s.pages.last.?; for (0..page.capacity.rows * 4) |_| { @@ -7398,7 +9884,7 @@ test "PageList eraseRowBounded exhausts pages invalidates viewport offset cache" defer s.deinit(); // Grow so we take up several pages worth of history - const page = &s.pages.last.?.data; + const page = s.pages.last.?.page(); { var cur_page = s.pages.last.?; for (0..page.capacity.rows * 3) |_| { @@ -7442,11 +9928,11 @@ test "PageList increaseCapacity to increase styles" { var s = try init(alloc, 2, 2, 0); defer s.deinit(); - const original_styles_cap = s.pages.first.?.data.capacity.styles; + const original_styles_cap = s.pages.first.?.capacity().styles; { try testing.expect(s.pages.first == s.pages.last); - const page = &s.pages.first.?.data; + const page = s.pages.first.?.page(); // Write all our data so we can assert its the same after for (0..s.rows) |y| { @@ -7465,7 +9951,7 @@ test "PageList increaseCapacity to increase styles" { { try testing.expect(s.pages.first == s.pages.last); - const page = &s.pages.first.?.data; + const page = s.pages.first.?.page(); // Verify capacity doubled try testing.expectEqual( @@ -7493,11 +9979,11 @@ test "PageList increaseCapacity to increase graphemes" { var s = try init(alloc, 2, 2, 0); defer s.deinit(); - const original_cap = s.pages.first.?.data.capacity.grapheme_bytes; + const original_cap = s.pages.first.?.capacity().grapheme_bytes; { try testing.expect(s.pages.first == s.pages.last); - const page = &s.pages.first.?.data; + const page = s.pages.first.?.page(); for (0..s.rows) |y| { for (0..s.cols) |x| { @@ -7514,7 +10000,7 @@ test "PageList increaseCapacity to increase graphemes" { { try testing.expect(s.pages.first == s.pages.last); - const page = &s.pages.first.?.data; + const page = s.pages.first.?.page(); try testing.expectEqual(original_cap * 2, page.capacity.grapheme_bytes); @@ -7537,11 +10023,11 @@ test "PageList increaseCapacity to increase hyperlinks" { var s = try init(alloc, 2, 2, 0); defer s.deinit(); - const original_cap = s.pages.first.?.data.capacity.hyperlink_bytes; + const original_cap = s.pages.first.?.capacity().hyperlink_bytes; { try testing.expect(s.pages.first == s.pages.last); - const page = &s.pages.first.?.data; + const page = s.pages.first.?.page(); for (0..s.rows) |y| { for (0..s.cols) |x| { @@ -7558,7 +10044,7 @@ test "PageList increaseCapacity to increase hyperlinks" { { try testing.expect(s.pages.first == s.pages.last); - const page = &s.pages.first.?.data; + const page = s.pages.first.?.page(); try testing.expectEqual(original_cap * 2, page.capacity.hyperlink_bytes); @@ -7581,11 +10067,11 @@ test "PageList increaseCapacity to increase string_bytes" { var s = try init(alloc, 2, 2, 0); defer s.deinit(); - const original_cap = s.pages.first.?.data.capacity.string_bytes; + const original_cap = s.pages.first.?.capacity().string_bytes; { try testing.expect(s.pages.first == s.pages.last); - const page = &s.pages.first.?.data; + const page = s.pages.first.?.page(); for (0..s.rows) |y| { for (0..s.cols) |x| { @@ -7602,7 +10088,7 @@ test "PageList increaseCapacity to increase string_bytes" { { try testing.expect(s.pages.first == s.pages.last); - const page = &s.pages.first.?.data; + const page = s.pages.first.?.page(); try testing.expectEqual(original_cap * 2, page.capacity.string_bytes); @@ -7657,7 +10143,7 @@ test "PageList increaseCapacity returns OutOfSpace at max capacity" { ) catch |err| { // Before OutOfSpace, we should have reached maxInt try testing.expectEqual(error.OutOfSpace, err); - try testing.expectEqual(max_styles, s.pages.first.?.data.capacity.styles); + try testing.expectEqual(max_styles, s.pages.first.?.capacity().styles); break; }; } @@ -7675,7 +10161,7 @@ test "PageList increaseCapacity after col shrink" { try testing.expectEqual(5, s.cols); { - const page = &s.pages.first.?.data; + const page = s.pages.first.?.page(); try testing.expectEqual(5, page.size.cols); try testing.expect(page.capacity.cols >= 10); } @@ -7684,7 +10170,7 @@ test "PageList increaseCapacity after col shrink" { _ = try s.increaseCapacity(s.pages.first.?, .styles); { - const page = &s.pages.first.?.data; + const page = s.pages.first.?.page(); // size.cols should still be 5, not reverted to capacity.cols try testing.expectEqual(5, page.size.cols); try testing.expectEqual(5, s.cols); @@ -7700,19 +10186,19 @@ test "PageList increaseCapacity multi-page" { // Grow to create a second page const page1_node = s.pages.last.?; - page1_node.data.pauseIntegrityChecks(true); - for (0..page1_node.data.capacity.rows - page1_node.data.size.rows) |_| { + page1_node.page().pauseIntegrityChecks(true); + for (0..page1_node.capacity().rows - page1_node.rows()) |_| { try testing.expect(try s.grow() == null); } - page1_node.data.pauseIntegrityChecks(false); + page1_node.page().pauseIntegrityChecks(false); try testing.expect(try s.grow() != null); // Now we have two pages try testing.expect(s.pages.first != s.pages.last); const page2_node = s.pages.last.?; - const page1_styles_cap = s.pages.first.?.data.capacity.styles; - const page2_styles_cap = page2_node.data.capacity.styles; + const page1_styles_cap = s.pages.first.?.capacity().styles; + const page2_styles_cap = page2_node.capacity().styles; // Increase capacity on the first page only _ = try s.increaseCapacity(s.pages.first.?, .styles); @@ -7720,13 +10206,13 @@ test "PageList increaseCapacity multi-page" { // First page capacity should be doubled try testing.expectEqual( page1_styles_cap * 2, - s.pages.first.?.data.capacity.styles, + s.pages.first.?.capacity().styles, ); // Second page should be unchanged try testing.expectEqual( page2_styles_cap, - s.pages.last.?.data.capacity.styles, + s.pages.last.?.capacity().styles, ); } @@ -7738,7 +10224,7 @@ test "PageList increaseCapacity preserves dirty flag" { defer s.deinit(); // Set page dirty flag and mark some rows as dirty - const page = &s.pages.first.?.data; + const page = s.pages.first.?.page(); page.dirty = true; const rows = page.rows.ptr(page.memory); @@ -7751,10 +10237,10 @@ test "PageList increaseCapacity preserves dirty flag" { const new_node = try s.increaseCapacity(s.pages.first.?, .styles); // The page dirty flag should be preserved - try testing.expect(new_node.data.dirty); + try testing.expect(new_node.page().dirty); // Row dirty flags should be preserved - const new_rows = new_node.data.rows.ptr(new_node.data.memory); + const new_rows = new_node.page().rows.ptr(new_node.page().memory); try testing.expect(new_rows[0].dirty); try testing.expect(!new_rows[1].dirty); try testing.expect(new_rows[2].dirty); @@ -7793,12 +10279,12 @@ test "PageList pageIterator two pages" { // Grow to capacity const page1_node = s.pages.last.?; - const page1 = page1_node.data; - page1_node.data.pauseIntegrityChecks(true); + const page1 = page1_node.page(); + page1_node.page().pauseIntegrityChecks(true); for (0..page1.capacity.rows - page1.size.rows) |_| { try testing.expect(try s.grow() == null); } - page1_node.data.pauseIntegrityChecks(false); + page1_node.page().pauseIntegrityChecks(false); try testing.expect(try s.grow() != null); // Iterate the active area @@ -7806,9 +10292,9 @@ test "PageList pageIterator two pages" { { const chunk = it.next().?; try testing.expect(chunk.node == s.pages.first.?); - const start = chunk.node.data.size.rows - s.rows + 1; + const start = chunk.node.rows() - s.rows + 1; try testing.expectEqual(start, chunk.start); - try testing.expectEqual(chunk.node.data.size.rows, chunk.end); + try testing.expectEqual(chunk.node.rows(), chunk.end); } { const chunk = it.next().?; @@ -7829,12 +10315,12 @@ test "PageList pageIterator history two pages" { // Grow to capacity const page1_node = s.pages.last.?; - const page1 = page1_node.data; - page1_node.data.pauseIntegrityChecks(true); + const page1 = page1_node.page(); + page1_node.page().pauseIntegrityChecks(true); for (0..page1.capacity.rows - page1.size.rows) |_| { try testing.expect(try s.grow() == null); } - page1_node.data.pauseIntegrityChecks(false); + page1_node.page().pauseIntegrityChecks(false); try testing.expect(try s.grow() != null); // Iterate the active area @@ -7882,12 +10368,12 @@ test "PageList pageIterator reverse two pages" { // Grow to capacity const page1_node = s.pages.last.?; - const page1 = page1_node.data; - page1_node.data.pauseIntegrityChecks(true); + const page1 = page1_node.page(); + page1_node.page().pauseIntegrityChecks(true); for (0..page1.capacity.rows - page1.size.rows) |_| { try testing.expect(try s.grow() == null); } - page1_node.data.pauseIntegrityChecks(false); + page1_node.page().pauseIntegrityChecks(false); try testing.expect(try s.grow() != null); // Iterate the active area @@ -7904,9 +10390,9 @@ test "PageList pageIterator reverse two pages" { { const chunk = it.next().?; try testing.expect(chunk.node == s.pages.first.?); - const start = chunk.node.data.size.rows - s.rows + 1; + const start = chunk.node.rows() - s.rows + 1; try testing.expectEqual(start, chunk.start); - try testing.expectEqual(chunk.node.data.size.rows, chunk.end); + try testing.expectEqual(chunk.node.rows(), chunk.end); count += chunk.end - chunk.start; } try testing.expect(it.next() == null); @@ -7922,12 +10408,12 @@ test "PageList pageIterator reverse history two pages" { // Grow to capacity const page1_node = s.pages.last.?; - const page1 = page1_node.data; - page1_node.data.pauseIntegrityChecks(true); + const page1 = page1_node.page(); + page1_node.page().pauseIntegrityChecks(true); for (0..page1.capacity.rows - page1.size.rows) |_| { try testing.expect(try s.grow() == null); } - page1_node.data.pauseIntegrityChecks(false); + page1_node.page().pauseIntegrityChecks(false); try testing.expect(try s.grow() != null); // Iterate the active area @@ -7943,6 +10429,76 @@ test "PageList pageIterator reverse history two pages" { try testing.expect(it.next() == null); } +test "PageList PageIterator reverse count includes row zero" { + const testing = std.testing; + const alloc = testing.allocator; + + var s = try init(alloc, 2, 2, null); + defer s.deinit(); + + var it: PageIterator = .{ + .row = s.getTopLeft(.screen), + .limit = .{ .count = 1 }, + .direction = .left_up, + }; + const chunk = it.next().?; + try testing.expectEqual(@as(size.CellCountInt, 0), chunk.start); + try testing.expectEqual(@as(size.CellCountInt, 1), chunk.end); + try testing.expect(it.next() == null); +} + +test "PageList PageIterator count crosses page boundaries" { + const testing = std.testing; + const alloc = testing.allocator; + + var s = try init(alloc, 80, 24, null); + defer s.deinit(); + + const first = s.pages.first.?; + first.page().pauseIntegrityChecks(true); + while (first.rows() < first.capacity().rows) _ = try s.grow(); + first.page().pauseIntegrityChecks(false); + const second = (try s.grow()).?; + + var down: PageIterator = .{ + .row = .{ .node = first, .y = first.rows() - 1 }, + .limit = .{ .count = 2 }, + .direction = .right_down, + }; + { + const chunk = down.next().?; + try testing.expectEqual(first, chunk.node); + try testing.expectEqual(first.rows() - 1, chunk.start); + try testing.expectEqual(first.rows(), chunk.end); + } + { + const chunk = down.next().?; + try testing.expectEqual(second, chunk.node); + try testing.expectEqual(@as(size.CellCountInt, 0), chunk.start); + try testing.expectEqual(@as(size.CellCountInt, 1), chunk.end); + } + try testing.expect(down.next() == null); + + var up: PageIterator = .{ + .row = .{ .node = second }, + .limit = .{ .count = 2 }, + .direction = .left_up, + }; + { + const chunk = up.next().?; + try testing.expectEqual(second, chunk.node); + try testing.expectEqual(@as(size.CellCountInt, 0), chunk.start); + try testing.expectEqual(@as(size.CellCountInt, 1), chunk.end); + } + { + const chunk = up.next().?; + try testing.expectEqual(first, chunk.node); + try testing.expectEqual(first.rows() - 1, chunk.start); + try testing.expectEqual(first.rows(), chunk.end); + } + try testing.expect(up.next() == null); +} + test "PageList cellIterator" { const testing = std.testing; const alloc = testing.allocator; @@ -7950,7 +10506,7 @@ test "PageList cellIterator" { var s = try init(alloc, 2, 2, 0); defer s.deinit(); try testing.expect(s.pages.first == s.pages.last); - const page = &s.pages.first.?.data; + const page = s.pages.first.?.page(); for (0..s.rows) |y| { for (0..s.cols) |x| { const rac = page.getRowAndCell(x, y); @@ -8000,7 +10556,7 @@ test "PageList cellIterator reverse" { var s = try init(alloc, 2, 2, 0); defer s.deinit(); try testing.expect(s.pages.first == s.pages.last); - const page = &s.pages.first.?.data; + const page = s.pages.first.?.page(); for (0..s.rows) |y| { for (0..s.cols) |x| { const rac = page.getRowAndCell(x, y); @@ -8050,7 +10606,7 @@ test "PageList promptIterator left_up" { var s = try init(alloc, 2, 20, 0); defer s.deinit(); try testing.expect(s.pages.first == s.pages.last); - const page = &s.pages.first.?.data; + const page = s.pages.first.?.page(); // Normal prompt { const rac = page.getRowAndCell(0, 3); @@ -8107,7 +10663,7 @@ test "PageList promptIterator right_down" { var s = try init(alloc, 2, 20, 0); defer s.deinit(); try testing.expect(s.pages.first == s.pages.last); - const page = &s.pages.first.?.data; + const page = s.pages.first.?.page(); // Normal prompt { const rac = page.getRowAndCell(0, 3); @@ -8164,7 +10720,7 @@ test "PageList promptIterator right_down continuation at start" { var s = try init(alloc, 2, 20, 0); defer s.deinit(); try testing.expect(s.pages.first == s.pages.last); - const page = &s.pages.first.?.data; + const page = s.pages.first.?.page(); // Prompt continuation at row 0 (no prior rows - simulates trimmed scrollback) { @@ -8207,7 +10763,7 @@ test "PageList promptIterator right_down with prompt before continuation" { var s = try init(alloc, 2, 20, 0); defer s.deinit(); try testing.expect(s.pages.first == s.pages.last); - const page = &s.pages.first.?.data; + const page = s.pages.first.?.page(); // Prompt on row 2, continuation on rows 3-4 // Starting iteration from row 3 should still find the prompt at row 2 @@ -8246,7 +10802,7 @@ test "PageList promptIterator right_down limit inclusive" { var s = try init(alloc, 2, 20, 0); defer s.deinit(); try testing.expect(s.pages.first == s.pages.last); - const page = &s.pages.first.?.data; + const page = s.pages.first.?.page(); // Prompt on row 5 { @@ -8278,7 +10834,7 @@ test "PageList promptIterator left_up limit inclusive" { var s = try init(alloc, 2, 20, 0); defer s.deinit(); try testing.expect(s.pages.first == s.pages.last); - const page = &s.pages.first.?.data; + const page = s.pages.first.?.page(); // Prompt on row 5 { @@ -8311,7 +10867,7 @@ test "PageList highlightSemanticContent prompt" { var s = try init(alloc, 10, 20, 0); defer s.deinit(); try testing.expect(s.pages.first == s.pages.last); - const page = &s.pages.first.?.data; + const page = s.pages.first.?.page(); // Prompt on row 5 { @@ -8365,7 +10921,7 @@ test "PageList highlightSemanticContent prompt with output" { var s = try init(alloc, 10, 20, 0); defer s.deinit(); try testing.expect(s.pages.first == s.pages.last); - const page = &s.pages.first.?.data; + const page = s.pages.first.?.page(); // Prompt on row 5 { @@ -8430,7 +10986,7 @@ test "PageList highlightSemanticContent prompt multiline" { var s = try init(alloc, 10, 20, 0); defer s.deinit(); try testing.expect(s.pages.first == s.pages.last); - const page = &s.pages.first.?.data; + const page = s.pages.first.?.page(); // Prompt starts on row 5 { @@ -8486,7 +11042,7 @@ test "PageList highlightSemanticContent prompt only" { var s = try init(alloc, 10, 20, 0); defer s.deinit(); try testing.expect(s.pages.first == s.pages.last); - const page = &s.pages.first.?.data; + const page = s.pages.first.?.page(); // Prompt on row 5 with only prompt content (no input) { @@ -8530,7 +11086,7 @@ test "PageList highlightSemanticContent prompt to end of screen" { var s = try init(alloc, 10, 20, 0); defer s.deinit(); try testing.expect(s.pages.first == s.pages.last); - const page = &s.pages.first.?.data; + const page = s.pages.first.?.page(); // Single prompt on row 15, no following prompt { @@ -8578,7 +11134,7 @@ test "PageList highlightSemanticContent input basic" { var s = try init(alloc, 10, 20, 0); defer s.deinit(); try testing.expect(s.pages.first == s.pages.last); - const page = &s.pages.first.?.data; + const page = s.pages.first.?.page(); // Prompt on row 5 { @@ -8633,7 +11189,7 @@ test "PageList highlightSemanticContent input with output" { var s = try init(alloc, 10, 20, 0); defer s.deinit(); try testing.expect(s.pages.first == s.pages.last); - const page = &s.pages.first.?.data; + const page = s.pages.first.?.page(); // Prompt on row 5 { @@ -8698,7 +11254,7 @@ test "PageList highlightSemanticContent input multiline with continuation" { var s = try init(alloc, 10, 20, 0); defer s.deinit(); try testing.expect(s.pages.first == s.pages.last); - const page = &s.pages.first.?.data; + const page = s.pages.first.?.page(); // Prompt on row 5 { @@ -8775,7 +11331,7 @@ test "PageList highlightSemanticContent input no input returns null" { var s = try init(alloc, 10, 20, 0); defer s.deinit(); try testing.expect(s.pages.first == s.pages.last); - const page = &s.pages.first.?.data; + const page = s.pages.first.?.page(); // Prompt on row 5 with only prompt, then immediately output { @@ -8823,7 +11379,7 @@ test "PageList highlightSemanticContent input to end of screen" { var s = try init(alloc, 10, 20, 0); defer s.deinit(); try testing.expect(s.pages.first == s.pages.last); - const page = &s.pages.first.?.data; + const page = s.pages.first.?.page(); // Single prompt on row 15, no following prompt { @@ -8871,7 +11427,7 @@ test "PageList highlightSemanticContent input prompt only returns null" { var s = try init(alloc, 10, 20, 0); defer s.deinit(); try testing.expect(s.pages.first == s.pages.last); - const page = &s.pages.first.?.data; + const page = s.pages.first.?.page(); // Prompt on row 5 with only prompt content, no input or output { @@ -8918,7 +11474,7 @@ test "PageList highlightSemanticContent output basic" { var s = try init(alloc, 10, 20, 0); defer s.deinit(); try testing.expect(s.pages.first == s.pages.last); - const page = &s.pages.first.?.data; + const page = s.pages.first.?.page(); // Prompt on row 5 { @@ -8989,7 +11545,7 @@ test "PageList highlightSemanticContent output multiline" { var s = try init(alloc, 10, 20, 0); defer s.deinit(); try testing.expect(s.pages.first == s.pages.last); - const page = &s.pages.first.?.data; + const page = s.pages.first.?.page(); // Prompt on row 5 { @@ -9080,7 +11636,7 @@ test "PageList highlightSemanticContent output stops at next prompt" { var s = try init(alloc, 10, 20, 0); defer s.deinit(); try testing.expect(s.pages.first == s.pages.last); - const page = &s.pages.first.?.data; + const page = s.pages.first.?.page(); // Prompt on row 5 { @@ -9165,7 +11721,7 @@ test "PageList highlightSemanticContent output to end of screen" { var s = try init(alloc, 10, 20, 0); defer s.deinit(); try testing.expect(s.pages.first == s.pages.last); - const page = &s.pages.first.?.data; + const page = s.pages.first.?.page(); // Single prompt on row 15, no following prompt { @@ -9237,7 +11793,7 @@ test "PageList highlightSemanticContent output no output returns null" { var s = try init(alloc, 10, 20, 0); defer s.deinit(); try testing.expect(s.pages.first == s.pages.last); - const page = &s.pages.first.?.data; + const page = s.pages.first.?.page(); // Prompt on row 5 with only prompt and input, no output { @@ -9297,7 +11853,7 @@ test "PageList highlightSemanticContent output skips empty cells" { var s = try init(alloc, 10, 20, 0); defer s.deinit(); try testing.expect(s.pages.first == s.pages.last); - const page = &s.pages.first.?.data; + const page = s.pages.first.?.page(); // Prompt on row 5 - only fills first 3 cells, rest are empty with default .output { @@ -9376,17 +11932,17 @@ test "PageList erase" { try testing.expectEqual(@as(usize, 1), s.totalPages()); // Grow so we take up at least 5 pages. - const page = &s.pages.last.?.data; + const page = s.pages.last.?.page(); var cur_page = s.pages.last.?; - cur_page.data.pauseIntegrityChecks(true); + cur_page.page().pauseIntegrityChecks(true); for (0..page.capacity.rows * 5) |_| { if (try s.grow()) |new_page| { - cur_page.data.pauseIntegrityChecks(false); + cur_page.page().pauseIntegrityChecks(false); cur_page = new_page; - cur_page.data.pauseIntegrityChecks(true); + cur_page.page().pauseIntegrityChecks(true); } } - cur_page.data.pauseIntegrityChecks(false); + cur_page.page().pauseIntegrityChecks(false); try testing.expectEqual(@as(usize, 6), s.totalPages()); // Our total rows should be large @@ -9410,17 +11966,17 @@ test "PageList erase reaccounts page size" { const start_size = s.page_size; // Grow so we take up at least 5 pages. - const page = &s.pages.last.?.data; + const page = s.pages.last.?.page(); var cur_page = s.pages.last.?; - cur_page.data.pauseIntegrityChecks(true); + cur_page.page().pauseIntegrityChecks(true); for (0..page.capacity.rows * 5) |_| { if (try s.grow()) |new_page| { - cur_page.data.pauseIntegrityChecks(false); + cur_page.page().pauseIntegrityChecks(false); cur_page = new_page; - cur_page.data.pauseIntegrityChecks(true); + cur_page.page().pauseIntegrityChecks(true); } } - cur_page.data.pauseIntegrityChecks(false); + cur_page.page().pauseIntegrityChecks(false); try testing.expect(s.page_size > start_size); // Erase the entire history, we should be back to just our active set. @@ -9436,17 +11992,17 @@ test "PageList erase row with tracked pin resets to top-left" { defer s.deinit(); // Grow so we take up at least 5 pages. - const page = &s.pages.last.?.data; + const page = s.pages.last.?.page(); var cur_page = s.pages.last.?; - cur_page.data.pauseIntegrityChecks(true); + cur_page.page().pauseIntegrityChecks(true); for (0..page.capacity.rows * 5) |_| { if (try s.grow()) |new_page| { - cur_page.data.pauseIntegrityChecks(false); + cur_page.page().pauseIntegrityChecks(false); cur_page = new_page; - cur_page.data.pauseIntegrityChecks(true); + cur_page.page().pauseIntegrityChecks(true); } } - cur_page.data.pauseIntegrityChecks(false); + cur_page.page().pauseIntegrityChecks(false); // Our total rows should be large try testing.expect(s.total_rows > s.rows); @@ -9515,17 +12071,17 @@ test "PageList erase resets viewport to active if moves within active" { defer s.deinit(); // Grow so we take up at least 5 pages. - const page = &s.pages.last.?.data; + const page = s.pages.last.?.page(); var cur_page = s.pages.last.?; - cur_page.data.pauseIntegrityChecks(true); + cur_page.page().pauseIntegrityChecks(true); for (0..page.capacity.rows * 5) |_| { if (try s.grow()) |new_page| { - cur_page.data.pauseIntegrityChecks(false); + cur_page.page().pauseIntegrityChecks(false); cur_page = new_page; - cur_page.data.pauseIntegrityChecks(true); + cur_page.page().pauseIntegrityChecks(true); } } - cur_page.data.pauseIntegrityChecks(false); + cur_page.page().pauseIntegrityChecks(false); // Move our viewport to the top s.scroll(.{ .delta_row = -@as(isize, @intCast(s.total_rows)) }); @@ -9544,17 +12100,17 @@ test "PageList erase resets viewport if inside erased page but not active" { defer s.deinit(); // Grow so we take up at least 5 pages. - const page = &s.pages.last.?.data; + const page = s.pages.last.?.page(); var cur_page = s.pages.last.?; - cur_page.data.pauseIntegrityChecks(true); + cur_page.page().pauseIntegrityChecks(true); for (0..page.capacity.rows * 5) |_| { if (try s.grow()) |new_page| { - cur_page.data.pauseIntegrityChecks(false); + cur_page.page().pauseIntegrityChecks(false); cur_page = new_page; - cur_page.data.pauseIntegrityChecks(true); + cur_page.page().pauseIntegrityChecks(true); } } - cur_page.data.pauseIntegrityChecks(false); + cur_page.page().pauseIntegrityChecks(false); // Move our viewport to the top s.scroll(.{ .delta_row = -@as(isize, @intCast(s.total_rows)) }); @@ -9573,17 +12129,17 @@ test "PageList erase resets viewport to active if top is inside active" { defer s.deinit(); // Grow so we take up at least 5 pages. - const page = &s.pages.last.?.data; + const page = s.pages.last.?.page(); var cur_page = s.pages.last.?; - cur_page.data.pauseIntegrityChecks(true); + cur_page.page().pauseIntegrityChecks(true); for (0..page.capacity.rows * 5) |_| { if (try s.grow()) |new_page| { - cur_page.data.pauseIntegrityChecks(false); + cur_page.page().pauseIntegrityChecks(false); cur_page = new_page; - cur_page.data.pauseIntegrityChecks(true); + cur_page.page().pauseIntegrityChecks(true); } } - cur_page.data.pauseIntegrityChecks(false); + cur_page.page().pauseIntegrityChecks(false); // Move our viewport to the top s.scroll(.{ .top = {} }); @@ -9613,7 +12169,7 @@ test "PageList erase a one-row active" { try testing.expectEqual(@as(usize, 1), s.totalPages()); // Write our letter - const page = &s.pages.first.?.data; + const page = s.pages.first.?.page(); for (0..s.rows) |y| { const rac = page.getRowAndCell(0, y); rac.cell.* = .{ @@ -9736,13 +12292,13 @@ test "PageList eraseRowBounded full rows two pages" { // Grow to two pages so our active area straddles { - const page = &s.pages.last.?.data; + const page = s.pages.last.?.page(); page.pauseIntegrityChecks(true); for (0..page.capacity.rows - page.size.rows) |_| _ = try s.grow(); page.pauseIntegrityChecks(false); try s.growRows(5); try testing.expectEqual(@as(usize, 2), s.totalPages()); - try testing.expectEqual(@as(usize, 5), s.pages.last.?.data.size.rows); + try testing.expectEqual(@as(usize, 5), s.pages.last.?.rows()); } // Pins @@ -9757,11 +12313,11 @@ test "PageList eraseRowBounded full rows two pages" { { try testing.expectEqual(s.pages.last.?.prev.?, p_first.node); - try testing.expectEqual(@as(usize, p_first.node.data.size.rows - 1), p_first.y); + try testing.expectEqual(@as(usize, p_first.node.rows() - 1), p_first.y); try testing.expectEqual(@as(usize, 0), p_first.x); try testing.expectEqual(s.pages.last.?.prev.?, p_first_out.node); - try testing.expectEqual(@as(usize, p_first_out.node.data.size.rows - 2), p_first_out.y); + try testing.expectEqual(@as(usize, p_first_out.node.rows() - 2), p_first_out.y); try testing.expectEqual(@as(usize, 0), p_first_out.x); try testing.expectEqual(s.pages.last.?, p_in.node); @@ -9784,12 +12340,12 @@ test "PageList eraseRowBounded full rows two pages" { // In page in first page is shifted try testing.expectEqual(s.pages.last.?.prev.?, p_first.node); - try testing.expectEqual(@as(usize, p_first.node.data.size.rows - 2), p_first.y); + try testing.expectEqual(@as(usize, p_first.node.rows() - 2), p_first.y); try testing.expectEqual(@as(usize, 0), p_first.x); // Out page in first page should not be shifted try testing.expectEqual(s.pages.last.?.prev.?, p_first_out.node); - try testing.expectEqual(@as(usize, p_first_out.node.data.size.rows - 2), p_first_out.y); + try testing.expectEqual(@as(usize, p_first_out.node.rows() - 2), p_first_out.y); try testing.expectEqual(@as(usize, 0), p_first_out.x); // In page is shifted @@ -9863,7 +12419,7 @@ test "PageList clone partial trimmed left reclaims styles" { // Style the rows we're trimming { try testing.expect(s.pages.first == s.pages.last); - const page = &s.pages.first.?.data; + const page = s.pages.first.?.page(); const style: stylepkg.Style = .{ .flags = .{ .bold = true } }; const style_id = try page.styles.add(page.memory, style); @@ -9895,7 +12451,7 @@ test "PageList clone partial trimmed left reclaims styles" { { try testing.expect(s2.pages.first == s2.pages.last); - const page = &s2.pages.first.?.data; + const page = s2.pages.first.?.page(); try testing.expectEqual(0, page.styles.count()); } } @@ -10093,7 +12649,7 @@ test "PageList resize (no reflow) less rows" { // This is required for our writing below to work try testing.expect(s.pages.first == s.pages.last); - const page = &s.pages.first.?.data; + const page = s.pages.first.?.page(); // Write into all rows so we don't get trim behavior for (0..s.rows) |y| { @@ -10127,7 +12683,7 @@ test "PageList resize (no reflow) one rows" { // This is required for our writing below to work try testing.expect(s.pages.first == s.pages.last); - const page = &s.pages.first.?.data; + const page = s.pages.first.?.page(); // Write into all rows so we don't get trim behavior for (0..s.rows) |y| { @@ -10161,7 +12717,7 @@ test "PageList resize (no reflow) less rows cursor on bottom" { // This is required for our writing below to work try testing.expect(s.pages.first == s.pages.last); - const page = &s.pages.first.?.data; + const page = s.pages.first.?.page(); // Write into all rows so we don't get trim behavior for (0..s.rows) |y| { @@ -10213,7 +12769,7 @@ test "PageList resize (no reflow) less rows cursor in scrollback" { // This is required for our writing below to work try testing.expect(s.pages.first == s.pages.last); - const page = &s.pages.first.?.data; + const page = s.pages.first.?.page(); // Write into all rows so we don't get trim behavior for (0..s.rows) |y| { @@ -10264,7 +12820,7 @@ test "PageList resize (no reflow) less rows trims blank lines" { var s = try init(alloc, 10, 5, 0); defer s.deinit(); try testing.expect(s.pages.first == s.pages.last); - const page = &s.pages.first.?.data; + const page = s.pages.first.?.page(); // Write codepoint into first line { @@ -10323,7 +12879,7 @@ test "PageList resize (no reflow) less rows trims blank lines cursor in blank li var s = try init(alloc, 10, 5, 0); defer s.deinit(); try testing.expect(s.pages.first == s.pages.last); - const page = &s.pages.first.?.data; + const page = s.pages.first.?.page(); // Write codepoint into first line { @@ -10366,7 +12922,7 @@ test "PageList resize (no reflow) less rows trims blank lines erases pages" { var s = try init(alloc, 100, 5, 0); defer s.deinit(); try testing.expect(s.pages.first == s.pages.last); - const page = &s.pages.first.?.data; + const page = s.pages.first.?.page(); // Resize to take up two pages { @@ -10399,7 +12955,7 @@ test "PageList resize (no reflow) more rows extends blank lines" { var s = try init(alloc, 10, 3, 0); defer s.deinit(); try testing.expect(s.pages.first == s.pages.last); - const page = &s.pages.first.?.data; + const page = s.pages.first.?.page(); // Write codepoint into first line { @@ -10479,7 +13035,7 @@ test "PageList resize (no reflow) less cols" { var it = s.rowIterator(.right_down, .{ .screen = .{} }, null); while (it.next()) |offset| { const rac = offset.rowAndCell(); - const cells = offset.node.data.getCells(rac.row); + const cells = offset.node.page().getCells(rac.row); try testing.expectEqual(@as(usize, 5), cells.len); } } @@ -10503,7 +13059,7 @@ test "PageList resize (no reflow) less cols pin in trimmed cols" { var it = s.rowIterator(.right_down, .{ .screen = .{} }, null); while (it.next()) |offset| { const rac = offset.rowAndCell(); - const cells = offset.node.data.getCells(rac.row); + const cells = offset.node.page().getCells(rac.row); try testing.expectEqual(@as(usize, 5), cells.len); } @@ -10521,7 +13077,7 @@ test "PageList resize (no reflow) less cols clears graphemes" { defer s.deinit(); // Add a grapheme. - const page = &s.pages.first.?.data; + const page = s.pages.first.?.page(); { const rac = page.getRowAndCell(9, 0); rac.cell.* = .{ @@ -10539,7 +13095,7 @@ test "PageList resize (no reflow) less cols clears graphemes" { var it = s.pageIterator(.right_down, .{ .screen = .{} }, null); while (it.next()) |chunk| { - try testing.expectEqual(@as(usize, 0), chunk.node.data.graphemeCount()); + try testing.expectEqual(@as(usize, 0), chunk.node.page().graphemeCount()); } } @@ -10558,7 +13114,7 @@ test "PageList resize (no reflow) more cols" { var it = s.rowIterator(.right_down, .{ .screen = .{} }, null); while (it.next()) |offset| { const rac = offset.rowAndCell(); - const cells = offset.node.data.getCells(rac.row); + const cells = offset.node.page().getCells(rac.row); try testing.expectEqual(@as(usize, 10), cells.len); } } @@ -10571,7 +13127,7 @@ test "PageList resize (no reflow) more cols with spacer head" { defer s.deinit(); { try testing.expect(s.pages.first == s.pages.last); - const page = &s.pages.first.?.data; + const page = s.pages.first.?.page(); { const rac = page.getRowAndCell(0, 0); @@ -10614,7 +13170,7 @@ test "PageList resize (no reflow) more cols with spacer head" { { try testing.expect(s.pages.first == s.pages.last); - const page = &s.pages.first.?.data; + const page = s.pages.first.?.page(); { const rac = page.getRowAndCell(0, 0); @@ -10654,7 +13210,7 @@ test "PageList resize (no reflow) grow cols fast path with spacer head" { // Place a spacer_head at the last column (col 4) on two rows // to simulate a wide character that didn't fit at the right edge. { - const page = &s.pages.first.?.data; + const page = s.pages.first.?.page(); // Row 0: 'x' at col 0..3, spacer_head at col 4, wrap = true { @@ -10693,7 +13249,7 @@ test "PageList resize (no reflow) grow cols fast path with spacer head" { // Verify the old spacer_head positions are now narrow. { - const page = &s.pages.first.?.data; + const page = s.pages.first.?.page(); { const rac = page.getRowAndCell(4, 0); try testing.expectEqual(pagepkg.Cell.Wide.narrow, rac.cell.wide); @@ -10749,14 +13305,14 @@ test "PageList resize (no reflow) more cols forces less rows per page" { var it = s.pages.first; while (it) |page| : (it = page.next) { if (page == s.pages.last.?) break; - try testing.expectEqual(page.data.capacity.rows, page.data.size.rows); + try testing.expectEqual(page.capacity().rows, page.rows()); } } // Now we need to resize again to a col size that further shrinks // our last capacity. { - const page = &s.pages.first.?.data; + const page = s.pages.first.?.page(); try testing.expect(page.size.rows == page.capacity.rows); const new_cols = new_cols: { var new_cols = page.size.cols + 50; @@ -10779,7 +13335,7 @@ test "PageList resize (no reflow) more cols forces less rows per page" { var it = s.pages.first; while (it) |page| : (it = page.next) { if (page == s.pages.last.?) break; - try testing.expectEqual(page.data.capacity.rows, page.data.size.rows); + try testing.expectEqual(page.capacity().rows, page.rows()); } } } @@ -10803,7 +13359,7 @@ test "PageList resize (no reflow) less cols then more cols" { var it = s.rowIterator(.right_down, .{ .screen = .{} }, null); while (it.next()) |offset| { const rac = offset.rowAndCell(); - const cells = offset.node.data.getCells(rac.row); + const cells = offset.node.page().getCells(rac.row); try testing.expectEqual(@as(usize, 5), cells.len); } } @@ -10823,7 +13379,7 @@ test "PageList resize (no reflow) less rows and cols" { var it = s.rowIterator(.right_down, .{ .screen = .{} }, null); while (it.next()) |offset| { const rac = offset.rowAndCell(); - const cells = offset.node.data.getCells(rac.row); + const cells = offset.node.page().getCells(rac.row); try testing.expectEqual(@as(usize, 5), cells.len); } } @@ -10841,8 +13397,8 @@ test "PageList resize less rows and cols cursor at bottom" { } }).?); defer s.untrackPin(cursor_pin); - // Shrink both axes so the original cursor.y is strictly past the new row - // count, after resizeWithoutReflow has already reduced self.rows. + // Shrink both axes such that the original cursor.y is strictly past the + // new row count, so resizeWithoutReflow leaves self.rows < c.y + 1. try s.resize(.{ .cols = 79, .rows = 20, @@ -10859,6 +13415,58 @@ test "PageList resize less rows and cols cursor at bottom" { } }, s.pointFromPin(.active, cursor_pin.*).?); } +test "PageList resize less rows and cols cursor near top pushed to scrollback" { + const testing = std.testing; + const alloc = testing.allocator; + + var s = try init(alloc, 80, 24, null); + defer s.deinit(); + + // Fill every active row with non-blank content so that shrinking rows + // can't trim trailing blank lines and instead pushes the top rows into + // scrollback. + { + var it = s.rowIterator(.right_down, .{ .active = .{} }, null); + while (it.next()) |p| { + const rac = p.rowAndCell(); + const cells = p.node.page().getCells(rac.row); + for (cells, 0..) |*cell, x| cell.* = .{ + .content_tag = .codepoint, + .content = .{ .codepoint = @intCast('A' + (x % 26)) }, + }; + } + } + + // Cursor near the top of the active area. After we shrink rows the active + // area top moves down past this pin, so it ends up in scrollback. + const cursor_pin = try s.trackPin(s.pin(.{ .active = .{ + .x = 0, + .y = 0, + } }).?); + defer s.untrackPin(cursor_pin); + + // Shrink both axes with reflow. resizeWithoutReflow shrinks self.rows + // first, leaving the cursor pin above the new active area, then resizeCols + // walks .left_up from the cursor pin toward the active-area top. + try s.resize(.{ + .cols = 79, + .rows = 20, + .reflow = true, + .cursor = .{ .x = 0, .y = 0, .pin = cursor_pin }, + }); + try testing.expectEqual(@as(usize, 79), s.cols); + try testing.expectEqual(@as(usize, 20), s.rows); + + // The active area is anchored to the bottom, so shrinking rows pushed the + // top-of-screen cursor into scrollback: it no longer resolves to an + // active-area coordinate, but it remains a valid screen pin. + try testing.expect(s.pointFromPin(.active, cursor_pin.*) == null); + try testing.expect(s.pointFromPin(.screen, cursor_pin.*) != null); + + // Integrity must hold after the resize. + s.assertIntegrity(); +} + test "PageList resize (no reflow) more rows and less cols" { const testing = std.testing; const alloc = testing.allocator; @@ -10875,7 +13483,7 @@ test "PageList resize (no reflow) more rows and less cols" { var it = s.rowIterator(.right_down, .{ .screen = .{} }, null); while (it.next()) |offset| { const rac = offset.rowAndCell(); - const cells = offset.node.data.getCells(rac.row); + const cells = offset.node.page().getCells(rac.row); try testing.expectEqual(@as(usize, 5), cells.len); } } @@ -10915,7 +13523,7 @@ test "PageList resize (no reflow) empty screen" { var it = s.rowIterator(.right_down, .{ .screen = .{} }, null); while (it.next()) |offset| { const rac = offset.rowAndCell(); - const cells = offset.node.data.getCells(rac.row); + const cells = offset.node.page().getCells(rac.row); try testing.expectEqual(@as(usize, 10), cells.len); } } @@ -10934,7 +13542,7 @@ test "PageList resize (no reflow) more cols forces smaller cap" { var s = try init(alloc, cap.cols, cap.rows, null); defer s.deinit(); try testing.expect(s.pages.first == s.pages.last); - const page = &s.pages.first.?.data; + const page = s.pages.first.?.page(); for (0..s.rows) |y| { for (0..s.cols) |x| { const rac = page.getRowAndCell(x, y); @@ -10954,7 +13562,7 @@ test "PageList resize (no reflow) more cols forces smaller cap" { var it = s.rowIterator(.right_down, .{ .screen = .{} }, null); while (it.next()) |offset| { const rac = offset.rowAndCell(); - const cells = offset.node.data.getCells(rac.row); + const cells = offset.node.page().getCells(rac.row); try testing.expectEqual(@as(usize, cap2.cols), cells.len); try testing.expectEqual(@as(u21, 'A'), cells[0].content.codepoint); } @@ -10970,7 +13578,7 @@ test "PageList resize (no reflow) more rows adds blank rows if cursor at bottom" // Grow to 5 total rows, simulating 3 active + 2 scrollback try s.growRows(2); try testing.expect(s.pages.first == s.pages.last); - const page = &s.pages.first.?.data; + const page = s.pages.first.?.page(); for (0..s.totalRows()) |y| { const rac = page.getRowAndCell(0, y); rac.cell.* = .{ @@ -11040,7 +13648,7 @@ test "PageList resize reflow more cols no wrapped rows" { var s = try init(alloc, 5, 3, 0); defer s.deinit(); try testing.expect(s.pages.first == s.pages.last); - const page = &s.pages.first.?.data; + const page = s.pages.first.?.page(); for (0..s.rows) |y| { for (0..s.cols) |x| { const rac = page.getRowAndCell(x, y); @@ -11059,7 +13667,7 @@ test "PageList resize reflow more cols no wrapped rows" { var it = s.rowIterator(.right_down, .{ .screen = .{} }, null); while (it.next()) |offset| { const rac = offset.rowAndCell(); - const cells = offset.node.data.getCells(rac.row); + const cells = offset.node.page().getCells(rac.row); try testing.expectEqual(@as(usize, 10), cells.len); try testing.expectEqual(@as(u21, 'A'), cells[0].content.codepoint); } @@ -11072,7 +13680,7 @@ test "PageList resize reflow more cols wrapped rows" { var s = try init(alloc, 2, 4, 0); defer s.deinit(); try testing.expect(s.pages.first == s.pages.last); - const page = &s.pages.first.?.data; + const page = s.pages.first.?.page(); for (0..s.rows) |y| { if (y % 2 == 0) { const rac = page.getRowAndCell(0, y); @@ -11110,7 +13718,7 @@ test "PageList resize reflow more cols wrapped rows" { // First row should be unwrapped const offset = it.next().?; const rac = offset.rowAndCell(); - const cells = offset.node.data.getCells(rac.row); + const cells = offset.node.page().getCells(rac.row); try testing.expect(!rac.row.wrap); try testing.expectEqual(@as(usize, 4), cells.len); try testing.expectEqual(@as(u21, 'A'), cells[0].content.codepoint); @@ -11126,7 +13734,7 @@ test "PageList resize reflow invalidates viewport offset cache" { defer s.deinit(); try s.growRows(20); - const page = &s.pages.last.?.data; + const page = s.pages.last.?.page(); for (0..s.rows) |y| { if (y % 2 == 0) { const rac = page.getRowAndCell(0, y); @@ -11188,7 +13796,7 @@ test "PageList resize reflow more cols creates multiple pages" { // Wrap every other row so every line is wrapped for reflow { try testing.expect(s.pages.first == s.pages.last); - const page = &s.pages.first.?.data; + const page = s.pages.first.?.page(); for (0..s.rows) |y| { if (y % 2 == 0) { const rac = page.getRowAndCell(0, y); @@ -11220,8 +13828,8 @@ test "PageList resize reflow more cols creates multiple pages" { count += 1; // All pages should have the new capacity - try testing.expectEqual(newcap.cols, page.data.capacity.cols); - try testing.expectEqual(newcap.rows, page.data.capacity.rows); + try testing.expectEqual(newcap.cols, page.capacity().cols); + try testing.expectEqual(newcap.rows, page.capacity().rows); } // We should have more than one page, meaning we created at least @@ -11241,7 +13849,7 @@ test "PageList resize reflow more cols wrap across page boundary" { // Grow to the capacity of the first page. { - const page = &s.pages.first.?.data; + const page = s.pages.first.?.page(); page.pauseIntegrityChecks(true); for (page.size.rows..page.capacity.rows) |_| { _ = try s.grow(); @@ -11255,7 +13863,7 @@ test "PageList resize reflow more cols wrap across page boundary" { // At this point, we have some rows on the first page, and some on the second. // We can now wrap across the boundary condition. { - const page = &s.pages.first.?.data; + const page = s.pages.first.?.page(); const y = page.size.rows - 1; { const rac = page.getRowAndCell(0, y); @@ -11270,7 +13878,7 @@ test "PageList resize reflow more cols wrap across page boundary" { } } { - const page2 = &s.pages.last.?.data; + const page2 = s.pages.last.?.page(); const y = 0; { const rac = page2.getRowAndCell(0, y); @@ -11372,7 +13980,7 @@ test "PageList resize reflow more cols wrap across page boundary cursor in secon // Grow to the capacity of the first page. { - const page = &s.pages.first.?.data; + const page = s.pages.first.?.page(); page.pauseIntegrityChecks(true); for (page.size.rows..page.capacity.rows) |_| { _ = try s.grow(); @@ -11386,7 +13994,7 @@ test "PageList resize reflow more cols wrap across page boundary cursor in secon // At this point, we have some rows on the first page, and some on the second. // We can now wrap across the boundary condition. { - const page = &s.pages.first.?.data; + const page = s.pages.first.?.page(); const y = page.size.rows - 1; { const rac = page.getRowAndCell(0, y); @@ -11401,7 +14009,7 @@ test "PageList resize reflow more cols wrap across page boundary cursor in secon } } { - const page2 = &s.pages.last.?.data; + const page2 = s.pages.last.?.page(); const y = 0; { const rac = page2.getRowAndCell(0, y); @@ -11458,7 +14066,7 @@ test "PageList resize reflow less cols wrap across page boundary cursor in secon // Grow to the capacity of the first page. { - const page = &s.pages.first.?.data; + const page = s.pages.first.?.page(); page.pauseIntegrityChecks(true); for (page.size.rows..page.capacity.rows) |_| { _ = try s.grow(); @@ -11472,7 +14080,7 @@ test "PageList resize reflow less cols wrap across page boundary cursor in secon // At this point, we have some rows on the first page, and some on the second. // We can now wrap across the boundary condition. { - const page = &s.pages.first.?.data; + const page = s.pages.first.?.page(); const y = page.size.rows - 1; { const rac = page.getRowAndCell(0, y); @@ -11487,7 +14095,7 @@ test "PageList resize reflow less cols wrap across page boundary cursor in secon } } { - const page2 = &s.pages.last.?.data; + const page2 = s.pages.last.?.page(); const y = 0; { const rac = page2.getRowAndCell(0, y); @@ -11634,7 +14242,7 @@ test "PageList resize reflow more cols cursor in wrapped row" { var s = try init(alloc, 2, 4, 0); defer s.deinit(); try testing.expect(s.pages.first == s.pages.last); - const page = &s.pages.first.?.data; + const page = s.pages.first.?.page(); { { const rac = page.getRowAndCell(0, 0); @@ -11685,7 +14293,7 @@ test "PageList resize reflow more cols cursor in not wrapped row" { var s = try init(alloc, 2, 4, 0); defer s.deinit(); try testing.expect(s.pages.first == s.pages.last); - const page = &s.pages.first.?.data; + const page = s.pages.first.?.page(); { { const rac = page.getRowAndCell(0, 0); @@ -11736,7 +14344,7 @@ test "PageList resize reflow more cols cursor in wrapped row that isn't unwrappe var s = try init(alloc, 2, 4, 0); defer s.deinit(); try testing.expect(s.pages.first == s.pages.last); - const page = &s.pages.first.?.data; + const page = s.pages.first.?.page(); { { const rac = page.getRowAndCell(0, 0); @@ -11802,7 +14410,7 @@ test "PageList resize reflow more cols no reflow preserves semantic prompt" { defer s.deinit(); { try testing.expect(s.pages.first == s.pages.last); - const page = &s.pages.first.?.data; + const page = s.pages.first.?.page(); const rac = page.getRowAndCell(0, 1); rac.row.semantic_prompt = .prompt; } @@ -11814,7 +14422,7 @@ test "PageList resize reflow more cols no reflow preserves semantic prompt" { { try testing.expect(s.pages.first == s.pages.last); - const page = &s.pages.first.?.data; + const page = s.pages.first.?.page(); const rac = page.getRowAndCell(0, 1); try testing.expect(rac.row.semantic_prompt == .prompt); } @@ -11831,7 +14439,7 @@ test "PageList resize reflow exceeds hyperlink memory forcing capacity increase" // Grow to the capacity of the first page and add // one more row so that we have two pages total. { - const page = &s.pages.first.?.data; + const page = s.pages.first.?.page(); page.pauseIntegrityChecks(true); for (page.size.rows..page.capacity.rows) |_| { _ = try s.grow(); @@ -11865,7 +14473,7 @@ test "PageList resize reflow exceeds hyperlink memory forcing capacity increase" // Almost hit string alloc cap in bottom right of first page. // Mark the final row as wrapped. { - const page = &s.pages.first.?.data; + const page = s.pages.first.?.page(); const id = try page.insertHyperlink(.{ .id = .{ .implicit = 0 }, .uri = "a" ** (pagepkg.string_bytes_default - 1), @@ -11889,7 +14497,7 @@ test "PageList resize reflow exceeds hyperlink memory forcing capacity increase" // Almost hit string alloc cap in top left of second page. // Mark the first row as a wrap continuation. { - const page = &s.pages.last.?.data; + const page = s.pages.last.?.page(); const id = try page.insertHyperlink(.{ .id = .{ .implicit = 1 }, .uri = "a" ** (pagepkg.string_bytes_default - 1), @@ -11925,7 +14533,7 @@ test "PageList resize reflow exceeds grapheme memory forcing capacity increase" // Grow to the capacity of the first page and add // one more row so that we have two pages total. { - const page = &s.pages.first.?.data; + const page = s.pages.first.?.page(); page.pauseIntegrityChecks(true); for (page.size.rows..page.capacity.rows) |_| { _ = try s.grow(); @@ -11959,7 +14567,7 @@ test "PageList resize reflow exceeds grapheme memory forcing capacity increase" // Almost hit grapheme alloc cap in bottom right of first page. // Mark the final row as wrapped. { - const page = &s.pages.first.?.data; + const page = s.pages.first.?.page(); const rac = page.getRowAndCell(page.size.cols - 1, page.size.rows - 1); rac.row.wrap = true; rac.cell.* = .{ @@ -11992,7 +14600,7 @@ test "PageList resize reflow exceeds grapheme memory forcing capacity increase" // Almost hit grapheme alloc cap in top left of second page. // Mark the first row as a wrap continuation. { - const page = &s.pages.last.?.data; + const page = s.pages.last.?.page(); const rac = page.getRowAndCell(0, 0); rac.row.wrap = true; rac.cell.* = .{ @@ -12037,7 +14645,7 @@ test "PageList resize reflow exceeds style memory forcing capacity increase" { // Grow to the capacity of the first page and add // one more row so that we have two pages total. { - const page = &s.pages.first.?.data; + const page = s.pages.first.?.page(); page.pauseIntegrityChecks(true); for (page.size.rows..page.capacity.rows) |_| { _ = try s.grow(); @@ -12055,7 +14663,7 @@ test "PageList resize reflow exceeds style memory forcing capacity increase" { // Give each cell in the final row of the first page a unique style. // Mark the final row as wrapped. { - const page = &s.pages.first.?.data; + const page = s.pages.first.?.page(); for (0..s.cols) |x| { const id = page.styles.add( page.memory, @@ -12082,7 +14690,7 @@ test "PageList resize reflow exceeds style memory forcing capacity increase" { // Do the same for the first row of the second page. // Mark the first row as a wrap continuation. { - const page = &s.pages.last.?.data; + const page = s.pages.last.?.page(); for (0..s.cols) |x| { const id = page.styles.add( page.memory, @@ -12118,7 +14726,7 @@ test "PageList resize reflow more cols unwrap wide spacer head" { defer s.deinit(); { try testing.expect(s.pages.first == s.pages.last); - const page = &s.pages.first.?.data; + const page = s.pages.first.?.page(); { const rac = page.getRowAndCell(0, 0); @@ -12162,7 +14770,7 @@ test "PageList resize reflow more cols unwrap wide spacer head" { { try testing.expect(s.pages.first == s.pages.last); - const page = &s.pages.first.?.data; + const page = s.pages.first.?.page(); { const rac = page.getRowAndCell(0, 0); @@ -12191,7 +14799,7 @@ test "PageList resize reflow more cols unwrap wide spacer head across two rows" defer s.deinit(); { try testing.expect(s.pages.first == s.pages.last); - const page = &s.pages.first.?.data; + const page = s.pages.first.?.page(); { const rac = page.getRowAndCell(0, 0); @@ -12251,7 +14859,7 @@ test "PageList resize reflow more cols unwrap wide spacer head across two rows" { try testing.expect(s.pages.first == s.pages.last); - const page = &s.pages.first.?.data; + const page = s.pages.first.?.page(); { const rac = page.getRowAndCell(0, 0); @@ -12295,7 +14903,7 @@ test "PageList resize reflow more cols unwrap still requires wide spacer head" { defer s.deinit(); { try testing.expect(s.pages.first == s.pages.last); - const page = &s.pages.first.?.data; + const page = s.pages.first.?.page(); { const rac = page.getRowAndCell(0, 0); @@ -12338,7 +14946,7 @@ test "PageList resize reflow more cols unwrap still requires wide spacer head" { { try testing.expect(s.pages.first == s.pages.last); - const page = &s.pages.first.?.data; + const page = s.pages.first.?.page(); { const rac = page.getRowAndCell(0, 0); @@ -12376,7 +14984,7 @@ test "PageList resize reflow less cols no reflow preserves semantic prompt" { defer s.deinit(); { try testing.expect(s.pages.first == s.pages.last); - const page = &s.pages.first.?.data; + const page = s.pages.first.?.page(); { const rac = page.getRowAndCell(0, 1); rac.row.semantic_prompt = .prompt; @@ -12419,7 +15027,7 @@ test "PageList resize reflow less cols no reflow preserves semantic prompt on fi defer s.deinit(); { try testing.expect(s.pages.first == s.pages.last); - const page = &s.pages.first.?.data; + const page = s.pages.first.?.page(); const rac = page.getRowAndCell(0, 0); rac.row.semantic_prompt = .prompt; } @@ -12431,7 +15039,7 @@ test "PageList resize reflow less cols no reflow preserves semantic prompt on fi { try testing.expect(s.pages.first == s.pages.last); - const page = &s.pages.first.?.data; + const page = s.pages.first.?.page(); const rac = page.getRowAndCell(0, 0); try testing.expect(rac.row.semantic_prompt == .prompt); } @@ -12445,7 +15053,7 @@ test "PageList resize reflow less cols wrap preserves semantic prompt" { defer s.deinit(); { try testing.expect(s.pages.first == s.pages.last); - const page = &s.pages.first.?.data; + const page = s.pages.first.?.page(); const rac = page.getRowAndCell(0, 0); rac.row.semantic_prompt = .prompt; } @@ -12457,7 +15065,7 @@ test "PageList resize reflow less cols wrap preserves semantic prompt" { { try testing.expect(s.pages.first == s.pages.last); - const page = &s.pages.first.?.data; + const page = s.pages.first.?.page(); const rac = page.getRowAndCell(0, 0); try testing.expect(rac.row.semantic_prompt == .prompt); } @@ -12470,7 +15078,7 @@ test "PageList resize reflow less cols no wrapped rows" { var s = try init(alloc, 10, 3, 0); defer s.deinit(); try testing.expect(s.pages.first == s.pages.last); - const page = &s.pages.first.?.data; + const page = s.pages.first.?.page(); for (0..s.rows) |y| { const end = 4; assert(end < s.cols); @@ -12494,7 +15102,7 @@ test "PageList resize reflow less cols no wrapped rows" { var offset_copy = offset; offset_copy.x = @intCast(x); const rac = offset_copy.rowAndCell(); - const cells = offset.node.data.getCells(rac.row); + const cells = offset.node.page().getCells(rac.row); try testing.expectEqual(@as(usize, 5), cells.len); try testing.expectEqual(@as(u21, @intCast(x)), cells[x].content.codepoint); } @@ -12508,7 +15116,7 @@ test "PageList resize reflow less cols wrapped rows" { var s = try init(alloc, 4, 2, null); defer s.deinit(); try testing.expect(s.pages.first == s.pages.last); - const page = &s.pages.first.?.data; + const page = s.pages.first.?.page(); for (0..s.rows) |y| { for (0..s.cols) |x| { const rac = page.getRowAndCell(x, y); @@ -12538,7 +15146,7 @@ test "PageList resize reflow less cols wrapped rows" { // First row should be wrapped const offset = it.next().?; const rac = offset.rowAndCell(); - const cells = offset.node.data.getCells(rac.row); + const cells = offset.node.page().getCells(rac.row); try testing.expect(rac.row.wrap); try testing.expectEqual(@as(usize, 2), cells.len); try testing.expectEqual(@as(u21, 0), cells[0].content.codepoint); @@ -12546,7 +15154,7 @@ test "PageList resize reflow less cols wrapped rows" { { const offset = it.next().?; const rac = offset.rowAndCell(); - const cells = offset.node.data.getCells(rac.row); + const cells = offset.node.page().getCells(rac.row); try testing.expect(!rac.row.wrap); try testing.expectEqual(@as(usize, 2), cells.len); try testing.expectEqual(@as(u21, 2), cells[0].content.codepoint); @@ -12555,7 +15163,7 @@ test "PageList resize reflow less cols wrapped rows" { // First row should be wrapped const offset = it.next().?; const rac = offset.rowAndCell(); - const cells = offset.node.data.getCells(rac.row); + const cells = offset.node.page().getCells(rac.row); try testing.expect(rac.row.wrap); try testing.expectEqual(@as(usize, 2), cells.len); try testing.expectEqual(@as(u21, 0), cells[0].content.codepoint); @@ -12563,7 +15171,7 @@ test "PageList resize reflow less cols wrapped rows" { { const offset = it.next().?; const rac = offset.rowAndCell(); - const cells = offset.node.data.getCells(rac.row); + const cells = offset.node.page().getCells(rac.row); try testing.expect(!rac.row.wrap); try testing.expectEqual(@as(usize, 2), cells.len); try testing.expectEqual(@as(u21, 2), cells[0].content.codepoint); @@ -12578,7 +15186,7 @@ test "PageList resize reflow less cols wrapped rows with graphemes" { defer s.deinit(); { try testing.expect(s.pages.first == s.pages.last); - const page = &s.pages.first.?.data; + const page = s.pages.first.?.page(); for (0..s.rows) |y| { for (0..s.cols) |x| { const rac = page.getRowAndCell(x, y); @@ -12608,13 +15216,13 @@ test "PageList resize reflow less cols wrapped rows with graphemes" { } try testing.expect(s.pages.first == s.pages.last); - const page = &s.pages.first.?.data; + const page = s.pages.first.?.page(); var it = s.rowIterator(.right_down, .{ .screen = .{} }, null); { // First row should be wrapped const offset = it.next().?; const rac = offset.rowAndCell(); - const cells = offset.node.data.getCells(rac.row); + const cells = offset.node.page().getCells(rac.row); try testing.expect(rac.row.wrap); try testing.expectEqual(@as(usize, 2), cells.len); try testing.expectEqual(@as(u21, 0), cells[0].content.codepoint); @@ -12622,7 +15230,7 @@ test "PageList resize reflow less cols wrapped rows with graphemes" { { const offset = it.next().?; const rac = offset.rowAndCell(); - const cells = offset.node.data.getCells(rac.row); + const cells = offset.node.page().getCells(rac.row); try testing.expect(!rac.row.wrap); try testing.expect(rac.row.grapheme); try testing.expectEqual(@as(usize, 2), cells.len); @@ -12636,7 +15244,7 @@ test "PageList resize reflow less cols wrapped rows with graphemes" { // First row should be wrapped const offset = it.next().?; const rac = offset.rowAndCell(); - const cells = offset.node.data.getCells(rac.row); + const cells = offset.node.page().getCells(rac.row); try testing.expect(rac.row.wrap); try testing.expectEqual(@as(usize, 2), cells.len); try testing.expectEqual(@as(u21, 0), cells[0].content.codepoint); @@ -12644,7 +15252,7 @@ test "PageList resize reflow less cols wrapped rows with graphemes" { { const offset = it.next().?; const rac = offset.rowAndCell(); - const cells = offset.node.data.getCells(rac.row); + const cells = offset.node.page().getCells(rac.row); try testing.expect(!rac.row.wrap); try testing.expect(rac.row.grapheme); try testing.expectEqual(@as(usize, 2), cells.len); @@ -12663,7 +15271,7 @@ test "PageList resize reflow less cols cursor in wrapped row" { var s = try init(alloc, 4, 2, null); defer s.deinit(); try testing.expect(s.pages.first == s.pages.last); - const page = &s.pages.first.?.data; + const page = s.pages.first.?.page(); for (0..s.rows) |y| { for (0..s.cols) |x| { const rac = page.getRowAndCell(x, y); @@ -12698,7 +15306,7 @@ test "PageList resize reflow less cols wraps spacer head" { defer s.deinit(); { try testing.expect(s.pages.first == s.pages.last); - const page = &s.pages.first.?.data; + const page = s.pages.first.?.page(); { const rac = page.getRowAndCell(0, 0); @@ -12756,7 +15364,7 @@ test "PageList resize reflow less cols wraps spacer head" { { try testing.expect(s.pages.first == s.pages.last); - const page = &s.pages.first.?.data; + const page = s.pages.first.?.page(); { const rac = page.getRowAndCell(0, 0); @@ -12793,7 +15401,7 @@ test "PageList resize reflow less cols cursor goes to scrollback" { var s = try init(alloc, 4, 2, null); defer s.deinit(); try testing.expect(s.pages.first == s.pages.last); - const page = &s.pages.first.?.data; + const page = s.pages.first.?.page(); for (0..s.rows) |y| { for (0..s.cols) |x| { const rac = page.getRowAndCell(x, y); @@ -12824,7 +15432,7 @@ test "PageList resize reflow less cols cursor in unchanged row" { var s = try init(alloc, 4, 2, null); defer s.deinit(); try testing.expect(s.pages.first == s.pages.last); - const page = &s.pages.first.?.data; + const page = s.pages.first.?.page(); for (0..s.rows) |y| { for (0..2) |x| { const rac = page.getRowAndCell(x, y); @@ -12858,7 +15466,7 @@ test "PageList resize reflow less cols cursor in blank cell" { var s = try init(alloc, 6, 2, null); defer s.deinit(); try testing.expect(s.pages.first == s.pages.last); - const page = &s.pages.first.?.data; + const page = s.pages.first.?.page(); for (0..s.rows) |y| { for (0..2) |x| { const rac = page.getRowAndCell(x, y); @@ -12892,7 +15500,7 @@ test "PageList resize reflow less cols cursor in final blank cell" { var s = try init(alloc, 6, 2, null); defer s.deinit(); try testing.expect(s.pages.first == s.pages.last); - const page = &s.pages.first.?.data; + const page = s.pages.first.?.page(); for (0..s.rows) |y| { for (0..2) |x| { const rac = page.getRowAndCell(x, y); @@ -12926,7 +15534,7 @@ test "PageList resize reflow less cols cursor in wrapped blank cell" { var s = try init(alloc, 6, 2, null); defer s.deinit(); try testing.expect(s.pages.first == s.pages.last); - const page = &s.pages.first.?.data; + const page = s.pages.first.?.page(); for (0..s.rows) |y| { for (0..2) |x| { const rac = page.getRowAndCell(x, y); @@ -12960,7 +15568,7 @@ test "PageList resize reflow less cols blank lines" { var s = try init(alloc, 4, 3, 0); defer s.deinit(); try testing.expect(s.pages.first == s.pages.last); - const page = &s.pages.first.?.data; + const page = s.pages.first.?.page(); for (0..1) |y| { for (0..4) |x| { const rac = page.getRowAndCell(x, y); @@ -12981,7 +15589,7 @@ test "PageList resize reflow less cols blank lines" { // First row should be wrapped const offset = it.next().?; const rac = offset.rowAndCell(); - const cells = offset.node.data.getCells(rac.row); + const cells = offset.node.page().getCells(rac.row); try testing.expect(rac.row.wrap); try testing.expectEqual(@as(usize, 2), cells.len); try testing.expectEqual(@as(u21, 0), cells[0].content.codepoint); @@ -12989,7 +15597,7 @@ test "PageList resize reflow less cols blank lines" { { const offset = it.next().?; const rac = offset.rowAndCell(); - const cells = offset.node.data.getCells(rac.row); + const cells = offset.node.page().getCells(rac.row); try testing.expect(!rac.row.wrap); try testing.expectEqual(@as(usize, 2), cells.len); try testing.expectEqual(@as(u21, 2), cells[0].content.codepoint); @@ -13003,7 +15611,7 @@ test "PageList resize reflow less cols blank lines between" { var s = try init(alloc, 4, 3, 0); defer s.deinit(); try testing.expect(s.pages.first == s.pages.last); - const page = &s.pages.first.?.data; + const page = s.pages.first.?.page(); { for (0..4) |x| { const rac = page.getRowAndCell(x, 0); @@ -13037,7 +15645,7 @@ test "PageList resize reflow less cols blank lines between" { { const offset = it.next().?; const rac = offset.rowAndCell(); - const cells = offset.node.data.getCells(rac.row); + const cells = offset.node.page().getCells(rac.row); try testing.expect(rac.row.wrap); try testing.expectEqual(@as(usize, 2), cells.len); try testing.expectEqual(@as(u21, 0), cells[0].content.codepoint); @@ -13045,7 +15653,7 @@ test "PageList resize reflow less cols blank lines between" { { const offset = it.next().?; const rac = offset.rowAndCell(); - const cells = offset.node.data.getCells(rac.row); + const cells = offset.node.page().getCells(rac.row); try testing.expect(!rac.row.wrap); try testing.expectEqual(@as(usize, 2), cells.len); try testing.expectEqual(@as(u21, 2), cells[0].content.codepoint); @@ -13059,7 +15667,7 @@ test "PageList resize reflow less cols blank lines between no scrollback" { var s = try init(alloc, 5, 3, 0); defer s.deinit(); try testing.expect(s.pages.first == s.pages.last); - const page = &s.pages.first.?.data; + const page = s.pages.first.?.page(); { const rac = page.getRowAndCell(0, 0); rac.cell.* = .{ @@ -13084,7 +15692,7 @@ test "PageList resize reflow less cols blank lines between no scrollback" { { const offset = it.next().?; const rac = offset.rowAndCell(); - const cells = offset.node.data.getCells(rac.row); + const cells = offset.node.page().getCells(rac.row); try testing.expect(!rac.row.wrap); try testing.expectEqual(@as(usize, 2), cells.len); try testing.expectEqual(@as(u21, 'A'), cells[0].content.codepoint); @@ -13092,13 +15700,13 @@ test "PageList resize reflow less cols blank lines between no scrollback" { { const offset = it.next().?; const rac = offset.rowAndCell(); - const cells = offset.node.data.getCells(rac.row); + const cells = offset.node.page().getCells(rac.row); try testing.expectEqual(@as(u21, 0), cells[0].content.codepoint); } { const offset = it.next().?; const rac = offset.rowAndCell(); - const cells = offset.node.data.getCells(rac.row); + const cells = offset.node.page().getCells(rac.row); try testing.expect(!rac.row.wrap); try testing.expectEqual(@as(usize, 2), cells.len); try testing.expectEqual(@as(u21, 'C'), cells[0].content.codepoint); @@ -13112,7 +15720,7 @@ test "PageList resize reflow less cols cursor not on last line preserves locatio var s = try init(alloc, 5, 5, 1); defer s.deinit(); try testing.expect(s.pages.first == s.pages.last); - const page = &s.pages.first.?.data; + const page = s.pages.first.?.page(); for (0..s.rows) |y| { for (0..2) |x| { const rac = page.getRowAndCell(x, y); @@ -13157,7 +15765,7 @@ test "PageList resize reflow less cols copy style" { defer s.deinit(); { try testing.expect(s.pages.first == s.pages.last); - const page = &s.pages.first.?.data; + const page = s.pages.first.?.page(); // Create a style const style: stylepkg.Style = .{ .flags = .{ .bold = true } }; @@ -13191,8 +15799,8 @@ test "PageList resize reflow less cols copy style" { const style_id = rac.cell.style_id; try testing.expect(style_id != 0); - const style = offset.node.data.styles.get( - offset.node.data.memory, + const style = offset.node.page().styles.get( + offset.node.page().memory, style_id, ); try testing.expect(style.flags.bold); @@ -13211,7 +15819,7 @@ test "PageList resize reflow less cols to eliminate a wide char" { defer s.deinit(); { try testing.expect(s.pages.first == s.pages.last); - const page = &s.pages.first.?.data; + const page = s.pages.first.?.page(); { const rac = page.getRowAndCell(0, 0); @@ -13238,7 +15846,7 @@ test "PageList resize reflow less cols to eliminate a wide char" { { try testing.expect(s.pages.first == s.pages.last); - const page = &s.pages.first.?.data; + const page = s.pages.first.?.page(); { const rac = page.getRowAndCell(0, 0); @@ -13256,7 +15864,7 @@ test "PageList resize reflow less cols to wrap a wide char" { defer s.deinit(); { try testing.expect(s.pages.first == s.pages.last); - const page = &s.pages.first.?.data; + const page = s.pages.first.?.page(); { const rac = page.getRowAndCell(0, 0); @@ -13290,7 +15898,7 @@ test "PageList resize reflow less cols to wrap a wide char" { { try testing.expect(s.pages.first == s.pages.last); - const page = &s.pages.first.?.data; + const page = s.pages.first.?.page(); { const rac = page.getRowAndCell(0, 0); @@ -13324,7 +15932,7 @@ test "PageList resize reflow less cols to wrap a multi-codepoint grapheme with a defer s.deinit(); { try testing.expect(s.pages.first == s.pages.last); - const page = &s.pages.first.?.data; + const page = s.pages.first.?.page(); // We want to make the screen look like this: // @@ -13383,7 +15991,7 @@ test "PageList resize reflow less cols to wrap a multi-codepoint grapheme with a { try testing.expect(s.pages.first == s.pages.last); - const page = &s.pages.first.?.data; + const page = s.pages.first.?.page(); { const rac = page.getRowAndCell(0, 0); @@ -13445,7 +16053,7 @@ test "PageList resize reflow less cols copy kitty placeholder" { defer s.deinit(); { try testing.expect(s.pages.first == s.pages.last); - const page = &s.pages.first.?.data; + const page = s.pages.first.?.page(); // Write unicode placeholders for (0..s.cols - 1) |x| { @@ -13486,7 +16094,7 @@ test "PageList resize reflow more cols clears kitty placeholder" { defer s.deinit(); { try testing.expect(s.pages.first == s.pages.last); - const page = &s.pages.first.?.data; + const page = s.pages.first.?.page(); // Write unicode placeholders for (0..s.cols - 1) |x| { @@ -13529,7 +16137,7 @@ test "PageList resize reflow wrap moves kitty placeholder" { defer s.deinit(); { try testing.expect(s.pages.first == s.pages.last); - const page = &s.pages.first.?.data; + const page = s.pages.first.?.page(); // Write unicode placeholders for (2..s.cols - 1) |x| { @@ -13586,20 +16194,44 @@ test "PageList reset invalidates stale untracked refs even if node memory is reu var s = try init(alloc, 80, 24, null); defer s.deinit(); - const old_serial = s.pages.first.?.serial; - try testing.expect(old_serial >= s.page_serial_min); - try testing.expect(old_serial < s.page_serial); - - s.reset(); + var stale_nodes: [page_preheat * 4]*List.Node = undefined; + var stale_serials: [stale_nodes.len]u64 = undefined; + var stale_len: usize = 0; + var reused: ?struct { *List.Node, u64 } = null; + + while (stale_len < stale_nodes.len and reused == null) { + const old_node = s.pages.first.?; + const old_serial = old_node.serial; + try testing.expect(old_serial >= s.page_serial_epoch); + try testing.expect(old_serial < s.page_serial); + stale_nodes[stale_len] = old_node; + stale_serials[stale_len] = old_serial; + stale_len += 1; + + s.reset(); + + const new_node = s.pages.first.?; + for (stale_nodes[0..stale_len], stale_serials[0..stale_len]) |node, serial| { + if (node == new_node) { + reused = .{ node, serial }; + break; + } + } + } - // The important safety property is that stale serials are rejected before - // the node pointer is inspected. Reset rebuilds the page list from the - // pools, so old untracked refs may contain node pointers that are no - // longer safe to dereference. - try testing.expect(old_serial < s.page_serial_min); + try testing.expect(reused != null); + const old_node, const old_serial = reused.?; + const new_node = s.pages.first.?; + const new_serial = new_node.serial; - const new_serial = s.pages.first.?.serial; - try testing.expect(new_serial >= s.page_serial_min); + // Reset advances the epoch before rebuilding from the node pool. Reject + // the stale generation before inspecting its pointer, even when that exact + // address now belongs to a new live generation. + try testing.expectEqual(old_node, new_node); + try testing.expect(old_serial < s.page_serial_epoch); + try testing.expect(!s.nodeIsValid(old_node, old_serial)); + try testing.expect(s.nodeIsValid(new_node, new_serial)); + try testing.expect(new_serial >= s.page_serial_epoch); try testing.expect(new_serial < s.page_serial); } @@ -13685,7 +16317,7 @@ test "PageList resize reflow grapheme map capacity exceeded" { // during reflow. Since each source page can only hold this many graphemes, // we create two source pages with graphemes that will merge into one // destination page. - const grapheme_capacity = s.pages.first.?.data.graphemeCapacity(); + const grapheme_capacity = s.pages.first.?.page().graphemeCapacity(); // Use slightly more than half the capacity per page, so combined they // exceed the capacity of a single destination page. const graphemes_per_page = grapheme_capacity / 2 + grapheme_capacity / 4; @@ -13693,7 +16325,7 @@ test "PageList resize reflow grapheme map capacity exceeded" { // Grow to the capacity of the first page and add more rows // so that we have two pages total. { - const page = &s.pages.first.?.data; + const page = s.pages.first.?.page(); page.pauseIntegrityChecks(true); for (page.size.rows..page.capacity.rows) |_| { _ = try s.grow(); @@ -13715,7 +16347,7 @@ test "PageList resize reflow grapheme map capacity exceeded" { // Add graphemes to the end of the first page (last rows) { - const page = &s.pages.first.?.data; + const page = s.pages.first.?.page(); const start_row = page.size.rows - graphemes_per_page; for (0..graphemes_per_page) |i| { const y = start_row + i; @@ -13730,7 +16362,7 @@ test "PageList resize reflow grapheme map capacity exceeded" { // Add graphemes to the beginning of the second page { - const page = &s.pages.last.?.data; + const page = s.pages.last.?.page(); const count = @min(graphemes_per_page, page.size.rows); for (0..count) |y| { const rac = page.getRowAndCell(0, y); @@ -13773,7 +16405,7 @@ test "PageList resize grow cols with unwrap fixes viewport pin" { // Fill all rows with wrapped content (pairs that unwrap when cols increase) var it = s.pageIterator(.right_down, .{ .screen = .{} }, null); while (it.next()) |chunk| { - const page = &chunk.node.data; + const page = chunk.node.page(); for (chunk.start..chunk.end) |y| { const rac = page.getRowAndCell(0, y); if (y % 2 == 0) { @@ -13817,16 +16449,16 @@ test "PageList grow reuses non-standard page without leak" { defer s.deinit(); // Increase the first page capacity to make it non-standard (larger than std_size). - while (s.pages.first.?.data.memory.len <= std_size) { + while (s.pages.first.?.page().memory.len <= std_size) { _ = try s.increaseCapacity(s.pages.first.?, .grapheme_bytes); } // The first page should now have non-standard memory size. - try testing.expect(s.pages.first.?.data.memory.len > std_size); + try testing.expect(s.pages.first.?.page().memory.len > std_size); // First, fill up the first page's capacity const first_page = s.pages.first.?; - while (first_page.data.size.rows < first_page.data.capacity.rows) { + while (first_page.rows() < first_page.capacity().rows) { _ = try s.grow(); } @@ -13836,23 +16468,23 @@ test "PageList grow reuses non-standard page without leak" { // Continue growing until we exceed max_size AND the last page is full while (s.page_size + PagePool.item_size <= s.maxSize() or - s.pages.last.?.data.size.rows < s.pages.last.?.data.capacity.rows) + s.pages.last.?.rows() < s.pages.last.?.capacity().rows) { _ = try s.grow(); } // The first page should still be non-standard - try testing.expect(s.pages.first.?.data.memory.len > std_size); + try testing.expect(s.pages.first.?.page().memory.len > std_size); // Verify we have enough rows for active area (so prune path isn't skipped) try testing.expect(s.totalRows() >= s.rows); // Verify last page is full (so grow will need to allocate/reuse) - try testing.expect(s.pages.last.?.data.size.rows == s.pages.last.?.data.capacity.rows); + try testing.expect(s.pages.last.?.page().size.rows == s.pages.last.?.capacity().rows); // Remember the first page memory pointer before the reuse attempt const first_page_ptr = s.pages.first.?; - const first_page_mem_ptr = s.pages.first.?.data.memory.ptr; + const first_page_mem_ptr = s.pages.first.?.page().memory.ptr; // Create a tracked pin pointing to the non-standard first page const tracked_pin = try s.trackPin(.{ .node = first_page_ptr, .x = 0, .y = 0 }); @@ -13871,7 +16503,7 @@ test "PageList grow reuses non-standard page without leak" { // If the non-standard page was properly destroyed and not reused, // the last page should not have the same memory pointer - try testing.expect(s.pages.last.?.data.memory.ptr != first_page_mem_ptr); + try testing.expect(s.pages.last.?.page().memory.ptr != first_page_mem_ptr); // The tracked pin should have been moved to the new first page and marked as garbage try testing.expectEqual(s.pages.first.?, tracked_pin.node); @@ -13900,19 +16532,19 @@ test "PageList grow non-standard page prune protection" { defer s.deinit(); // Make the first page non-standard - while (s.pages.first.?.data.memory.len <= std_size) { + while (s.pages.first.?.page().memory.len <= std_size) { _ = try s.increaseCapacity( s.pages.first.?, .grapheme_bytes, ); } - try testing.expect(s.pages.first.?.data.memory.len > std_size); + try testing.expect(s.pages.first.?.page().memory.len > std_size); const first_page_node = s.pages.first.?; - const first_page_cap = first_page_node.data.capacity.rows; + const first_page_cap = first_page_node.capacity().rows; // Fill first page to capacity - while (first_page_node.data.size.rows < first_page_cap) _ = try s.grow(); + while (first_page_node.rows() < first_page_cap) _ = try s.grow(); // Grow until we have a second page (first page fills up first) var second_node: ?*List.Node = null; @@ -13921,8 +16553,8 @@ test "PageList grow non-standard page prune protection" { // Fill the second page to capacity so that the next grow() triggers prune const last_node = s.pages.last.?; - const second_cap = last_node.data.capacity.rows; - while (last_node.data.size.rows < second_cap) _ = try s.grow(); + const second_cap = last_node.capacity().rows; + while (last_node.rows() < second_cap) _ = try s.grow(); // Now the last page is full. The next grow must either: // 1. Prune the first page and reuse it, OR @@ -13939,7 +16571,7 @@ test "PageList grow non-standard page prune protection" { try testing.expect(s.totalRows() >= s.rows); // Verify last page is at capacity (so grow must prune or allocate new) - try testing.expectEqual(second_cap, last_node.data.size.rows); + try testing.expectEqual(second_cap, last_node.rows()); // The next grow should trigger prune consideration. // Without the fix, this would destroy the non-standard first page, @@ -13973,12 +16605,12 @@ test "PageList resize (no reflow) more cols remaps pins in backfill path" { // Trim a history row so the first page has spare capacity. // This triggers the backfill path in resizeWithoutReflowGrowCols. s.eraseHistory(.{ .history = .{ .y = 0 } }); - try testing.expect(first_page.data.size.rows < first_page.data.capacity.rows); + try testing.expect(first_page.rows() < first_page.capacity().rows); // Ensure the resize takes the slow path (new capacity > current capacity). const new_cols: size.CellCountInt = cols + 1; - const adjusted = try second_page.data.capacity.adjust(.{ .cols = new_cols }); - try testing.expect(second_page.data.capacity.cols < adjusted.cols); + const adjusted = try second_page.capacity().adjust(.{ .cols = new_cols }); + try testing.expect(second_page.capacity().cols < adjusted.cols); // Track a pin in row 0 of the second page. This row will be copied // to the first page during backfill and the pin must be remapped. @@ -14005,7 +16637,7 @@ test "PageList resize (no reflow) more cols remaps pins in backfill path" { } } try testing.expect(found); - try testing.expect(tracked.y < tracked.node.data.size.rows); + try testing.expect(tracked.y < tracked.node.rows()); // Verify the pin still points to the cell with our marker content. const cell = tracked.rowAndCell().cell; @@ -14013,23 +16645,108 @@ test "PageList resize (no reflow) more cols remaps pins in backfill path" { try testing.expectEqual(marker, cell.content.codepoint); } -test "PageList compact std_size page returns null" { +test "PageList compact pool page produces exact-size heap page" { const testing = std.testing; const alloc = testing.allocator; var s = try init(alloc, 80, 24, 0); defer s.deinit(); - // A freshly created page should be at std_size + // A freshly created page is pool-owned at std_size. const node = s.pages.first.?; - try testing.expect(node.data.memory.len <= std_size); + try testing.expectEqual(.pool, node.owned); + try testing.expect(node.page().memory.len <= std_size); + const original_size = node.page().size; + + // Compacting it should produce a much smaller exact-size heap page. + const new_node = (try s.compact(node)).?; + try testing.expectEqual(.heap, new_node.owned); + try testing.expect(new_node.page().memory.len < std_size); + try testing.expectEqual(original_size.rows, new_node.rows()); + try testing.expectEqual(original_size.cols, new_node.cols()); + try testing.expectEqual(new_node, s.pages.first.?); + + // Our page size accounting should exactly match the compacted + // page since it is the only page in the list. + try testing.expectEqual(new_node.page().memory.len, s.page_size); + + // Compacting again should be a no-op since it is already exact. + try testing.expectEqual(null, try s.compact(new_node)); +} + +test "PageList compact then grow allocates new page" { + const testing = std.testing; + const alloc = testing.allocator; - // compact should return null since there's nothing to compact - const result = try s.compact(node); - try testing.expectEqual(null, result); + var s = try init(alloc, 80, 24, null); + defer s.deinit(); + + // Compact the only page. It now has no spare row capacity. + const node = (try s.compact(s.pages.first.?)).?; + try testing.expectEqual(node.rows(), node.capacity().rows); + + // Growing must allocate a fresh standard page from the pool, + // exercising that a compacted page remains a valid live page. + _ = try s.grow(); + try testing.expect(s.pages.first != s.pages.last); + try testing.expectEqual(.pool, s.pages.last.?.owned); + try testing.expectEqual(@as(usize, 25), s.totalRows()); +} + +test "PageList compact then reset frees heap pages" { + const testing = std.testing; + const alloc = testing.allocator; + + var s = try init(alloc, 80, 24, 0); + defer s.deinit(); + + // Compact the only page so the list contains a sub-std_size + // heap-owned page. + const node = (try s.compact(s.pages.first.?)).?; + try testing.expectEqual(.heap, node.owned); + try testing.expect(node.page().memory.len < std_size); + + // Reset must free the heap page (testing allocator catches leaks + // and invalid frees) and rebuild from the pool. + s.reset(); + try testing.expectEqual(.pool, s.pages.first.?.owned); + try testing.expectEqual(@as(usize, s.rows), s.totalRows()); +} + +test "PageList compact then clone" { + const testing = std.testing; + const alloc = testing.allocator; + + var s = try init(alloc, 80, 24, null); + defer s.deinit(); + + // Write a marker so we can verify contents survive. + { + const node = s.pages.first.?; + const rac = node.page().getRowAndCell(1, 2); + rac.cell.* = .{ + .content_tag = .codepoint, + .content = .{ .codepoint = 'X' }, + }; + } - // Page should still be the same - try testing.expectEqual(node, s.pages.first.?); + // Compact so the source list contains a sub-std_size heap page. + const node = (try s.compact(s.pages.first.?)).?; + try testing.expectEqual(.heap, node.owned); + try testing.expect(node.page().memory.len < std_size); + + var s2 = try s.clone(alloc, .{ + .top = .{ .screen = .{} }, + }); + defer s2.deinit(); + try testing.expectEqual(@as(usize, s.rows), s2.totalRows()); + + // Verify the marker survived the clone. + { + const node2 = s2.pages.first.?; + const rac = node2.page().getRowAndCell(1, 2); + try testing.expectEqual(@as(u21, 'X'), rac.cell.content.codepoint); + } } test "PageList compact oversized page" { @@ -14041,11 +16758,11 @@ test "PageList compact oversized page" { // Grow until we have multiple pages const page1_node = s.pages.first.?; - page1_node.data.pauseIntegrityChecks(true); - for (0..page1_node.data.capacity.rows - page1_node.data.size.rows) |_| { + page1_node.page().pauseIntegrityChecks(true); + for (0..page1_node.capacity().rows - page1_node.rows()) |_| { _ = try s.grow(); } - page1_node.data.pauseIntegrityChecks(false); + page1_node.page().pauseIntegrityChecks(false); _ = try s.grow(); try testing.expect(s.pages.first != s.pages.last); @@ -14053,7 +16770,7 @@ test "PageList compact oversized page" { // Write content to verify it's preserved { - const page = &node.data; + const page = node.page(); for (0..page.size.rows) |y| { for (0..s.cols) |x| { const rac = page.getRowAndCell(x, y); @@ -14070,30 +16787,30 @@ test "PageList compact oversized page" { defer s.untrackPin(tracked); // Make the page oversized - while (node.data.memory.len <= std_size) { + while (node.page().memory.len <= std_size) { node = try s.increaseCapacity(node, .grapheme_bytes); } - try testing.expect(node.data.memory.len > std_size); - const oversized_len = node.data.memory.len; - const original_size = node.data.size; + try testing.expect(node.page().memory.len > std_size); + const oversized_len = node.page().memory.len; + const original_size = node.page().size; const second_node = node.next.?; // Set dirty flag after increaseCapacity - node.data.dirty = true; + node.page().dirty = true; // Compact the page const new_node = try s.compact(node); try testing.expect(new_node != null); // Verify memory is smaller - try testing.expect(new_node.?.data.memory.len < oversized_len); + try testing.expect(new_node.?.page().memory.len < oversized_len); // Verify size preserved - try testing.expectEqual(original_size.rows, new_node.?.data.size.rows); - try testing.expectEqual(original_size.cols, new_node.?.data.size.cols); + try testing.expectEqual(original_size.rows, new_node.?.rows()); + try testing.expectEqual(original_size.cols, new_node.?.cols()); // Verify dirty flag preserved - try testing.expect(new_node.?.data.dirty); + try testing.expect(new_node.?.page().dirty); // Verify linked list integrity try testing.expectEqual(new_node.?, s.pages.first.?); @@ -14107,7 +16824,7 @@ test "PageList compact oversized page" { try testing.expectEqual(@as(size.CellCountInt, 10), tracked.y); // Verify content preserved - const page = &new_node.?.data; + const page = new_node.?.page(); for (0..page.size.rows) |y| { for (0..s.cols) |x| { const rac = page.getRowAndCell(x, y); @@ -14119,7 +16836,73 @@ test "PageList compact oversized page" { } } -test "PageList compact insufficient savings returns null" { +test "PageList destroyed pool page reuse is zeroed" { + const testing = std.testing; + const alloc = testing.allocator; + + var s = try init(alloc, 80, 24, null); + defer s.deinit(); + + // Create a page and scribble over its entire backing memory, + // then destroy it so the buffer returns to the pool free list. + const node = try s.createPage(.{ .cap = initialCapacity(80) }); + node.page().size.rows = 1; + const mem_ptr = node.page().memory.ptr; + @memset(node.page().memory, 0xAA); + s.destroyNode(node); + + // Reusing the buffer must produce a fully valid, zeroed page. + const node2 = try s.createPage(.{ .cap = initialCapacity(80) }); + try testing.expectEqual(mem_ptr, node2.page().memory.ptr); + node2.page().size.rows = node2.capacity().rows; + + const cells_len = @as(usize, node2.capacity().cols) * + @as(usize, node2.capacity().rows); + const cells = node2.page().cells.ptr(node2.page().memory)[0..cells_len]; + try testing.expect(std.mem.allEqual( + u64, + @as([]const u64, @ptrCast(cells)), + 0, + )); + node2.page().assertIntegrity(); + s.destroyNode(node2); +} + +test "PageList increaseCapacity from zero-capacity dimensions" { + const testing = std.testing; + const alloc = testing.allocator; + + var s = try init(alloc, 80, 24, 0); + defer s.deinit(); + + // Compact the only page. A plain page has no styled, grapheme, + // or hyperlink content so the exact capacity is zero in every + // managed dimension. + var node = (try s.compact(s.pages.first.?)).?; + try testing.expectEqual(0, node.capacity().styles); + try testing.expectEqual(0, node.capacity().grapheme_bytes); + try testing.expectEqual(0, node.capacity().string_bytes); + try testing.expectEqual(0, node.capacity().hyperlink_bytes); + + // Increasing each dimension from zero must actually grow it. + // Regression: 0 * 2 == 0 used to "succeed" without growing, + // which turned caller retry loops into infinite loops. + node = try s.increaseCapacity(node, .styles); + try testing.expect(node.capacity().styles > 0); + node = try s.increaseCapacity(node, .grapheme_bytes); + try testing.expect(node.capacity().grapheme_bytes > 0); + node = try s.increaseCapacity(node, .string_bytes); + try testing.expect(node.capacity().string_bytes > 0); + node = try s.increaseCapacity(node, .hyperlink_bytes); + try testing.expect(node.capacity().hyperlink_bytes > 0); + + // Increasing a non-zero dimension still doubles. + const styles = node.capacity().styles; + node = try s.increaseCapacity(node, .styles); + try testing.expectEqual(styles * 2, node.capacity().styles); +} + +test "PageList compact after increaseCapacity" { const testing = std.testing; const alloc = testing.allocator; @@ -14128,23 +16911,15 @@ test "PageList compact insufficient savings returns null" { var node = s.pages.first.?; - // Make the page slightly oversized (just one increase) - // This might not provide enough savings to justify compaction + // Grow the page capacity. The content is unchanged, so compaction + // should always shrink it back down to an exact-size heap page. node = try s.increaseCapacity(node, .grapheme_bytes); + const grown_len = node.page().memory.len; - // If the page is still at or below std_size, compact returns null - if (node.data.memory.len <= std_size) { - const result = try s.compact(node); - try testing.expectEqual(null, result); - } else { - // If it did grow beyond std_size, verify that compaction - // works or returns null based on savings calculation - const result = try s.compact(node); - // Either it compacted or determined insufficient savings - if (result) |new_node| { - try testing.expect(new_node.data.memory.len < node.data.memory.len); - } - } + const new_node = (try s.compact(node)).?; + try testing.expectEqual(.heap, new_node.owned); + try testing.expect(new_node.page().memory.len < grown_len); + try testing.expect(new_node.page().memory.len < std_size); } test "PageList split at middle row" { @@ -14154,7 +16929,7 @@ test "PageList split at middle row" { var s = try init(alloc, 10, 10, 0); defer s.deinit(); - const page = &s.pages.first.?.data; + const page = s.pages.first.?.page(); // Write content to rows: row 0 gets codepoint 0, row 1 gets 1, etc. for (0..page.size.rows) |y| { @@ -14173,8 +16948,8 @@ test "PageList split at middle row" { try testing.expect(s.pages.first != null); try testing.expect(s.pages.first.?.next != null); - const first_page = &s.pages.first.?.data; - const second_page = &s.pages.first.?.next.?.data; + const first_page = s.pages.first.?.page(); + const second_page = s.pages.first.?.next.?.page(); // First page should have rows 0-4 (5 rows) try testing.expectEqual(@as(usize, 5), first_page.size.rows); @@ -14201,7 +16976,7 @@ test "PageList split at row 0 is no-op" { var s = try init(alloc, 10, 10, 0); defer s.deinit(); - const page = &s.pages.first.?.data; + const page = s.pages.first.?.page(); // Write content to all rows for (0..page.size.rows) |y| { @@ -14235,7 +17010,7 @@ test "PageList split at last row" { var s = try init(alloc, 10, 10, 0); defer s.deinit(); - const page = &s.pages.first.?.data; + const page = s.pages.first.?.page(); // Write content to all rows for (0..page.size.rows) |y| { @@ -14254,8 +17029,8 @@ test "PageList split at last row" { try testing.expect(s.pages.first != null); try testing.expect(s.pages.first.?.next != null); - const first_page = &s.pages.first.?.data; - const second_page = &s.pages.first.?.next.?.data; + const first_page = s.pages.first.?.page(); + const second_page = s.pages.first.?.next.?.page(); // First page should have 9 rows try testing.expectEqual(@as(usize, 9), first_page.size.rows); @@ -14441,8 +17216,8 @@ test "PageList split middle page preserves linked list order" { // Now we have 2 pages const page1 = s.pages.first.?; const page2 = s.pages.first.?.next.?; - try testing.expectEqual(@as(usize, 4), page1.data.size.rows); - try testing.expectEqual(@as(usize, 8), page2.data.size.rows); + try testing.expectEqual(@as(usize, 4), page1.rows()); + try testing.expectEqual(@as(usize, 8), page2.rows()); // Split page2 at row 4 to create: page1 -> page2 (rows 0-3) -> page3 (rows 4-7) const split_pin2: Pin = .{ .node = page2, .y = 4, .x = 0 }; @@ -14469,9 +17244,9 @@ test "PageList split middle page preserves linked list order" { try testing.expect(last.next == null); // Verify row counts - try testing.expectEqual(@as(usize, 4), first.data.size.rows); - try testing.expectEqual(@as(usize, 4), middle.data.size.rows); - try testing.expectEqual(@as(usize, 4), last.data.size.rows); + try testing.expectEqual(@as(usize, 4), first.rows()); + try testing.expectEqual(@as(usize, 4), middle.rows()); + try testing.expectEqual(@as(usize, 4), last.rows()); } test "PageList split last page makes new page the last" { @@ -14489,7 +17264,7 @@ test "PageList split last page makes new page the last" { // Now split the last page const last_before_split = s.pages.last.?; - try testing.expectEqual(@as(usize, 5), last_before_split.data.size.rows); + try testing.expectEqual(@as(usize, 5), last_before_split.rows()); const split_pin2: Pin = .{ .node = last_before_split, .y = 2, .x = 0 }; try s.split(split_pin2); @@ -14501,8 +17276,8 @@ test "PageList split last page makes new page the last" { try testing.expect(new_last.next == null); // Verify row counts: original last has 2 rows, new last has 3 rows - try testing.expectEqual(@as(usize, 2), last_before_split.data.size.rows); - try testing.expectEqual(@as(usize, 3), new_last.data.size.rows); + try testing.expectEqual(@as(usize, 2), last_before_split.rows()); + try testing.expectEqual(@as(usize, 3), new_last.rows()); } test "PageList split first page keeps original as first" { @@ -14534,9 +17309,9 @@ test "PageList split first page keeps original as first" { try testing.expectEqual(second_page, inserted.next.?); // Verify row counts: first has 2, inserted has 3, second has 5 - try testing.expectEqual(@as(usize, 2), s.pages.first.?.data.size.rows); - try testing.expectEqual(@as(usize, 3), inserted.data.size.rows); - try testing.expectEqual(@as(usize, 5), second_page.data.size.rows); + try testing.expectEqual(@as(usize, 2), s.pages.first.?.rows()); + try testing.expectEqual(@as(usize, 3), inserted.rows()); + try testing.expectEqual(@as(usize, 5), second_page.rows()); } test "PageList split preserves wrap flags" { @@ -14546,7 +17321,7 @@ test "PageList split preserves wrap flags" { var s = try init(alloc, 10, 10, 0); defer s.deinit(); - const page = &s.pages.first.?.data; + const page = s.pages.first.?.page(); // Set wrap flags on rows that will be in the second page after split // Row 5: wrap = true (this is the start of a wrapped line) @@ -14568,7 +17343,7 @@ test "PageList split preserves wrap flags" { const split_pin: Pin = .{ .node = s.pages.first.?, .y = 5, .x = 0 }; try s.split(split_pin); - const second_page = &s.pages.first.?.next.?.data; + const second_page = s.pages.first.?.next.?.page(); // Verify wrap flags are preserved in new page // Original row 5 is now row 0 in second page @@ -14600,7 +17375,7 @@ test "PageList split preserves styled cells" { var s = try init(alloc, 10, 10, 0); defer s.deinit(); - const page = &s.pages.first.?.data; + const page = s.pages.first.?.page(); // Create a style and apply it to cells in rows 5-7 (which will be in the second page) const style: stylepkg.Style = .{ .flags = .{ .bold = true } }; @@ -14623,8 +17398,8 @@ test "PageList split preserves styled cells" { const split_pin: Pin = .{ .node = s.pages.first.?, .y = 5, .x = 0 }; try s.split(split_pin); - const first_page = &s.pages.first.?.data; - const second_page = &s.pages.first.?.next.?.data; + const first_page = s.pages.first.?.page(); + const second_page = s.pages.first.?.next.?.page(); // First page should have no styles (all styled rows moved to second page) try testing.expectEqual(@as(usize, 0), first_page.styles.count()); @@ -14651,7 +17426,7 @@ test "PageList split preserves grapheme clusters" { var s = try init(alloc, 10, 10, 0); defer s.deinit(); - const page = &s.pages.first.?.data; + const page = s.pages.first.?.page(); // Add a grapheme cluster to row 6 (will be row 1 in second page after split at 5) { @@ -14670,8 +17445,8 @@ test "PageList split preserves grapheme clusters" { const split_pin: Pin = .{ .node = s.pages.first.?, .y = 5, .x = 0 }; try s.split(split_pin); - const first_page = &s.pages.first.?.data; - const second_page = &s.pages.first.?.next.?.data; + const first_page = s.pages.first.?.page(); + const second_page = s.pages.first.?.next.?.page(); // First page should have no graphemes (the grapheme row moved to second page) try testing.expectEqual(@as(usize, 0), first_page.graphemeCount()); @@ -14699,7 +17474,7 @@ test "PageList split preserves hyperlinks" { var s = try init(alloc, 10, 10, 0); defer s.deinit(); - const page = &s.pages.first.?.data; + const page = s.pages.first.?.page(); // Add a hyperlink to row 7 (will be row 2 in second page after split at 5) const hyperlink_id = try page.insertHyperlink(.{ @@ -14719,8 +17494,8 @@ test "PageList split preserves hyperlinks" { const split_pin: Pin = .{ .node = s.pages.first.?, .y = 5, .x = 0 }; try s.split(split_pin); - const first_page = &s.pages.first.?.data; - const second_page = &s.pages.first.?.next.?.data; + const first_page = s.pages.first.?.page(); + const second_page = s.pages.first.?.next.?.page(); // First page should have no hyperlinks (the hyperlink row moved to second page) try testing.expectEqual(@as(usize, 0), first_page.hyperlink_set.count()); diff --git a/vendor/libghostty-vt/src/terminal/Screen.zig b/vendor/libghostty-vt/src/terminal/Screen.zig index becda78b..6c4db323 100644 --- a/vendor/libghostty-vt/src/terminal/Screen.zig +++ b/vendor/libghostty-vt/src/terminal/Screen.zig @@ -113,7 +113,7 @@ pub const SemanticPrompt = struct { pub const SemanticClick = union(enum) { none, - click_events, + click_events: osc.semantic_prompt.ClickEvents, cl: osc.semantic_prompt.Click, }; }; @@ -530,20 +530,30 @@ pub fn clone( } // We move the top pin back in bounds to the top row. + const node = pages.pages.first.?; break :start try pages.trackPin(.{ - .node = pages.pages.first.?, - .x = if (sel.rectangle) ordered.tl.x else 0, + .node = node, + .x = if (sel.rectangle) + @min(ordered.tl.x, node.cols() - 1) + else + 0, }); }; // If we got to this point it means that the selection is not // fully out of bounds, so we move the bottom right pin back // in bounds if it isn't already. - const end_pin = pin_remap.get(ordered.br) orelse try pages.trackPin(.{ - .node = pages.pages.last.?, - .x = if (sel.rectangle) ordered.br.x else pages.cols - 1, - .y = pages.pages.last.?.data.size.rows - 1, - }); + const end_pin = pin_remap.get(ordered.br) orelse end: { + const node = pages.pages.last.?; + break :end try pages.trackPin(.{ + .node = node, + .x = if (sel.rectangle) + @min(ordered.br.x, node.cols() - 1) + else + node.cols() - 1, + .y = node.rows() - 1, + }); + }; break :sel .{ .bounds = .{ .tracked = .{ @@ -584,7 +594,7 @@ pub fn increaseCapacity( // capacity below it will be short the ref count on our // current style and hyperlink, so we need to init those. const new_node = try self.pages.increaseCapacity(node, adjustment); - const new_page: *Page = &new_node.data; + const new_page: *Page = new_node.page(); // Re-add the style, if the page somehow doesn't have enough // memory to add it, we emit a warning and gracefully degrade @@ -651,7 +661,7 @@ pub fn cursorCellEndOfPrev(self: *Screen) *pagepkg.Cell { assert(self.cursor.y > 0); var page_pin = self.cursor.page_pin.up(1).?; - page_pin.x = self.pages.cols - 1; + page_pin.x = page_pin.node.cols() - 1; const page_rac = page_pin.rowAndCell(); return page_rac.cell; } @@ -808,7 +818,7 @@ pub fn cursorDownScroll(self: *Screen) !void { // so our cursor is in the correct place we just have to clear // the cells. if (self.pages.rows == 1) { - const page: *Page = &self.cursor.page_pin.node.data; + const page: *Page = self.cursor.page_pin.node.page(); self.clearCells( page, self.cursor.page_row, @@ -845,10 +855,9 @@ pub fn cursorDownScroll(self: *Screen) !void { // allocate, prune scrollback, whatever. _ = try self.pages.grow(); - self.cursorChangePin(new_pin: { - // We do this all in a block here because referencing this pin - // after cursorChangePin is unsafe, and we want to keep it out - // of scope. + const new_pin = new_pin: { + // Calculate this before cursorChangePin because that function may + // adjust the underlying page and invalidate references to its pin. // If our pin page change it means that the page that the pin // was on was pruned. In this case, grow() moves the pin to @@ -874,10 +883,27 @@ pub fn cursorDownScroll(self: *Screen) !void { } break :new_pin page_pin; - }); - const page_rac = self.cursor.page_pin.rowAndCell(); - self.cursor.page_row = page_rac.row; - self.cursor.page_cell = page_rac.cell; + }; + + if (self.cursor.page_pin.node == new_pin.node) { + // Scrolling normally stays within one page. Resolve the page once + // while refreshing the cursor pointers. The cursor already holds + // live row and cell pointers into this mapping, and grow does not + // compress pages, so the node must still be resident here. + self.cursorMarkDirty(); + const page = new_pin.node.pageAssumeResident(); + const page_rac = page.getRowAndCell(new_pin.x, new_pin.y); + self.cursor.page_pin.* = new_pin; + self.cursor.page_row = page_rac.row; + self.cursor.page_cell = page_rac.cell; + } else { + // Crossing a page may require migrating the cursor's style and + // hyperlink references, so retain the general path here. + self.cursorChangePin(new_pin); + const page_rac = self.cursor.page_pin.rowAndCell(); + self.cursor.page_row = page_rac.row; + self.cursor.page_cell = page_rac.cell; + } // Our new row is always dirty self.cursorMarkDirty(); @@ -885,7 +911,7 @@ pub fn cursorDownScroll(self: *Screen) !void { // Clear the new row so it gets our bg color. We only do this // if we have a bg color at all. if (self.cursor.style.bg_color != .none) { - const page: *Page = &self.cursor.page_pin.node.data; + const page: *Page = self.cursor.page_pin.node.page(); self.clearCells( page, self.cursor.page_row, @@ -934,8 +960,8 @@ pub fn cursorScrollAbove(self: *Screen) !void { // lot cheaper in 99% of cases. const old_pin = self.cursor.page_pin.*; - if (try self.pages.grow()) |_| { - try self.cursorScrollAboveRotate(); + if (try self.pages.grow()) |new_node| { + try self.cursorScrollAboveRotate(new_node); } else { // In this case, it means grow() didn't allocate a new page. @@ -950,11 +976,13 @@ pub fn cursorScrollAbove(self: *Screen) !void { self.cursor.page_pin.* = self.cursor.page_pin.down(1).?; const pin = self.cursor.page_pin; - const page: *Page = &self.cursor.page_pin.node.data; + const page: *Page = self.cursor.page_pin.node.page(); // Rotate the rows so that the newly created empty row is at the // beginning. e.g. [ 0 1 2 3 ] in to [ 3 0 1 2 ]. var rows = page.rows.ptr(page.memory.ptr); + // Rotating this suffix changes which logical row its coordinates identify. + self.pages.invalidateNodeLayout(pin.node); fastmem.rotateOnceR(Row, rows[pin.y..page.size.rows]); // Mark the whole page as dirty. @@ -988,7 +1016,7 @@ pub fn cursorScrollAbove(self: *Screen) !void { // 1 |5E00000000| | 4 // +----------+ : // +-------------+ - try self.cursorScrollAboveRotate(); + try self.cursorScrollAboveRotate(null); } } @@ -1003,7 +1031,10 @@ pub fn cursorScrollAbove(self: *Screen) !void { } } -fn cursorScrollAboveRotate(self: *Screen) !void { +fn cursorScrollAboveRotate( + self: *Screen, + fresh_node: ?*PageList.List.Node, +) !void { self.cursorChangePin(self.cursor.page_pin.down(1).?); // Go through each of the pages following our pin, shift all rows @@ -1011,11 +1042,17 @@ fn cursorScrollAboveRotate(self: *Screen) !void { var current = self.pages.pages.last.?; while (current != self.cursor.page_pin.node) : (current = current.prev.?) { const prev = current.prev.?; - const prev_page = &prev.data; - const cur_page = ¤t.data; + const prev_page = prev.page(); + const cur_page = current.page(); const prev_rows = prev_page.rows.ptr(prev_page.memory.ptr); const cur_rows = cur_page.rows.ptr(cur_page.memory.ptr); + // A newly allocated tail has no earlier references to invalidate. + if (fresh_node == null or current != fresh_node.?) { + // Rotating this page moves every cached row coordinate down by one. + self.pages.invalidateNodeLayout(current); + } + // Rotate the pages down: [ 0 1 2 3 ] => [ 3 0 1 2 ] fastmem.rotateOnceR(Row, cur_rows[0..cur_page.size.rows]); @@ -1033,8 +1070,10 @@ fn cursorScrollAboveRotate(self: *Screen) !void { // Our current is our cursor page, we need to rotate down from // our cursor and clear our row. assert(current == self.cursor.page_pin.node); - const cur_page = ¤t.data; + const cur_page = current.page(); const cur_rows = cur_page.rows.ptr(cur_page.memory.ptr); + // Rotating the cursor-page suffix changes its cached row coordinates. + self.pages.invalidateNodeLayout(current); fastmem.rotateOnceR(Row, cur_rows[self.cursor.page_pin.y..cur_page.size.rows]); self.clearCells( cur_page, @@ -1055,6 +1094,161 @@ fn cursorScrollAboveRotate(self: *Screen) !void { self.cursor.page_cell = page_rac.cell; } +/// Scroll a full-width scroll region that ends at the cursor row up by +/// one row. The cursor must be on the bottom row of the region. `limit` +/// is the number of rows in the region above the cursor (region height +/// minus one) and must be at least 1. +/// +/// The top row of the region is discarded (NOT moved into scrollback). +/// All other rows in the region shift up by one and the cursor row +/// becomes a blank row, filled with the current background color (like +/// other scroll operations such as deleteLines). +/// +/// The cursor stays at the same screen position (the new blank row). +/// Content outside of the region is unmodified. +/// +/// This is a very hot path for scroll region usage (e.g. a program +/// on the alt screen using DECSTBM and scrolling via LF/IND) so this +/// is optimized for the common case where the full region is within +/// a single page. +pub fn cursorScrollRegionUp(self: *Screen, limit: usize) !void { + assert(limit >= 1); + assert(self.cursor.y >= limit); + defer self.assertIntegrity(); + + const pin: *Pin = self.cursor.page_pin; + + // If the region crosses a page boundary we take a slower path. This + // is rare: it requires the active area to span multiple pages with + // the split point inside the scroll region. + if (pin.y < limit) return try self.cursorScrollRegionUpSlow(limit); + + // Fast path: the entire region is in a single page. We can clear + // the top row and rotate it down to the cursor row, updating any + // tracked pins along the way. + // + // The cursor's cached row and cell pointers refer into this mapping. + // PageList cannot compress the cursor page while those pointers are + // installed, so the node is known to be resident here. + const page: *Page = pin.node.pageAssumeResident(); + const rows = page.rows.ptr(page.memory.ptr)[pin.y - limit ..][0 .. limit + 1]; + + // Clear the erased (top) row. + { + const row: *Row = &rows[0]; + + // Whether our blank cell is a plain zero cell. This is true + // unless the cursor has a background color set (see blankCell). + const blank_is_zero = self.cursor.style_id == style.default_id or + self.cursor.style.bg_color == .none; + + if (!row.managedMemory() and blank_is_zero) { + // Hot path: the row has no managed memory (styles, + // graphemes, hyperlinks) and our blank is zero so this is + // a straight zero fill. This is the overwhelmingly common + // case for scroll region usage. + const cells = page.getCells(row); + @memset(@as([]u64, @ptrCast(cells)), 0); + } else { + // The generic clear handles managed memory and fills the + // row with our blank cell, preserving the background color. + self.clearCells(page, row, page.getCells(row)); + } + } + + // Rotate the region rows so the now-blank top row moves to the + // bottom (the cursor row) and everything else shifts up by one. + // Rotating the region changes which logical row its coordinates identify. + self.pages.invalidateNodeLayout(pin.node); + fastmem.rotateOnce(Row, rows); + + // Mark the whole page as dirty. + // + // Technically we only need to mark the rotated rows but this is + // a hot function, so we want to minimize work. + page.dirty = true; + + // If our viewport is a pin and it's within the rotated region + // then we need to shift its cached row offset up. See + // PageList.eraseRowBounded for details; this mirrors that logic. + if (self.pages.viewport == .pin) viewport: { + if (self.pages.viewport_pin_row_offset) |*v| { + const p = self.pages.viewport_pin; + if (p.node != pin.node or + p.y < pin.y - limit or + p.y > pin.y or + p.y == 0) break :viewport; + v.* -= 1; + } + } + + // Update tracked pins within the region since their rows moved up + // by one. The cursor's own pin is skipped because the cursor stays + // at the region bottom (the new blank row). + const pin_keys = self.pages.tracked_pins.keys(); + for (pin_keys) |p| { + if (p.node != pin.node or + p == pin or + p.y < pin.y - limit or + p.y > pin.y) continue; + if (p.y == 0) p.x = 0 else p.y -= 1; + } + + // The cursor pin is unchanged, but the Row structure at the pin + // position now contains the blank row, so we need to refresh our + // cached row/cell pointers. We compute them directly from the row + // slice we already have rather than going through the pin since + // this is a hot path. + const cursor_row: *Row = &rows[limit]; + self.cursor.page_row = cursor_row; + self.cursor.page_cell = &page.getCells(cursor_row)[self.cursor.x]; +} + +/// Slow path for cursorScrollRegionUp: the scroll region spans +/// multiple pages so we use the generic PageList erase machinery. +fn cursorScrollRegionUpSlow(self: *Screen, limit: usize) !void { + // The call to eraseRowBounded below will move our tracked cursor + // pin up by one row since it is inside the erased region, but we + // don't actually want that: the cursor stays put, on the new blank + // row at the region bottom. We keep the old pin and put it back + // after, exactly like cursorDownScroll does for the no-scrollback + // case. + // + // This matters beyond performance: when the cursor is on the first + // row of a page, eraseRowBounded moves the tracked pin to the + // previous page. Moving the cursor back down with cursorDown would + // then cross pages via cursorChangePin, which migrates the cursor + // style refcount from a page that never held it, corrupting the + // style accounting. + const old_pin = self.cursor.page_pin.*; + + try self.pages.eraseRowBounded( + .{ .active = .{ .y = @intCast(self.cursor.y - limit) } }, + limit, + ); + + // We don't use `cursorChangePin` here because we aren't actually + // changing the pin, we're keeping it the same. Since the page + // never changes, the cursor's style ref stays valid and no style + // accounting needs to be updated. + self.cursor.page_pin.* = old_pin; + + // We do, however, need to refresh the cached page row and cell, + // because the row contents at our pin position changed (it now + // contains the blank row). + const page_rac = self.cursor.page_pin.rowAndCell(); + self.cursor.page_row = page_rac.row; + self.cursor.page_cell = page_rac.cell; + + // eraseRowBounded clears the new row with zero cells so if our + // blank cell isn't zero (bg color is set) we need to fill it. + const blank = self.blankCell(); + if (!blank.isZero()) { + const cells: [*]Cell = @ptrCast(self.cursor.page_cell); + @memset((cells - self.cursor.x)[0..self.pages.cols], blank); + } +} + /// Move the cursor down if we're not at the bottom of the screen. Otherwise /// scroll. Currently only used for testing. inline fn cursorDownOrScroll(self: *Screen) !void { @@ -1107,7 +1301,7 @@ pub fn cursorCopy(self: *Screen, other: Cursor, opts: struct { // If the other cursor had a hyperlink, add it to ours. if (opts.hyperlink and other.hyperlink_id != 0) { // Get the hyperlink from the other cursor's page. - const other_page = &other.page_pin.node.data; + const other_page = other.page_pin.node.page(); const other_link = other_page.hyperlink_set.get(other_page.memory, other.hyperlink_id); const uri = other_link.uri.slice(other_page.memory); @@ -1162,7 +1356,7 @@ inline fn cursorChangePin(self: *Screen, new: Pin) void { // Release the style directly from the old page instead of going through // manualStyleUpdate, because the cursor position may have already been // updated but the pin has not, which would fail integrity checks. - const old_page: *Page = &self.cursor.page_pin.node.data; + const old_page: *Page = self.cursor.page_pin.node.page(); old_page.styles.release(old_page.memory, self.cursor.style_id); self.cursor.style = .{}; self.cursor.style_id = style.default_id; @@ -1170,7 +1364,7 @@ inline fn cursorChangePin(self: *Screen, new: Pin) void { // If we have a hyperlink then we need to release it from the old page. if (self.cursor.hyperlink != null) { - const old_page: *Page = &self.cursor.page_pin.node.data; + const old_page: *Page = self.cursor.page_pin.node.page(); old_page.hyperlink_set.release(old_page.memory, self.cursor.hyperlink_id); } @@ -1245,12 +1439,12 @@ pub fn cursorResetWrap(self: *Screen) void { // If the last cell in the row is a spacer head we need to clear it. const cells = self.cursor.page_pin.cells(.all); - const cell = cells[self.cursor.page_pin.node.data.size.cols - 1]; + const cell = cells[self.cursor.page_pin.node.cols() - 1]; if (cell.wide == .spacer_head) { self.clearCells( - &self.cursor.page_pin.node.data, + self.cursor.page_pin.node.page(), page_row, - cells[self.cursor.page_pin.node.data.size.cols - 1 ..][0..1], + cells[self.cursor.page_pin.node.cols() - 1 ..][0..1], ); } } @@ -1347,19 +1541,19 @@ pub fn clearRows( var it = self.pages.pageIterator(.right_down, tl, bl); while (it.next()) |chunk| { + const page = chunk.node.page(); for (chunk.rows()) |*row| { const cells_offset = row.cells; - const cells_multi: [*]Cell = row.cells.ptr(chunk.node.data.memory); - const cells = cells_multi[0..self.pages.cols]; + const cells = page.getCells(row); // Clear all cells if (protected) { - self.clearUnprotectedCells(&chunk.node.data, row, cells); + self.clearUnprotectedCells(page, row, cells); // We need to preserve other row attributes since we only // cleared unprotected cells. row.cells = cells_offset; } else { - self.clearCells(&chunk.node.data, row, cells); + self.clearCells(page, row, cells); row.* = .{ .cells = cells_offset }; } @@ -1377,6 +1571,8 @@ pub fn clearCells( row: *Row, cells: []Cell, ) void { + if (cells.len == 0) return; + // This whole operation does unsafe things, so we just want to assert // the end state. page.pauseIntegrityChecks(true); @@ -1408,7 +1604,7 @@ pub fn clearCells( // If we have no left/right scroll region we can be sure // that we've cleared all the graphemes, so we clear the // flag, otherwise we ask the page to update the flag. - if (cells.len == self.pages.cols) { + if (cells.len == page.size.cols) { row.grapheme = false; } else { page.updateRowGraphemeFlag(row); @@ -1424,7 +1620,7 @@ pub fn clearCells( // If we have no left/right scroll region we can be sure // that we've cleared all the hyperlinks, so we clear the // flag, otherwise we ask the page to update the flag. - if (cells.len == self.pages.cols) { + if (cells.len == page.size.cols) { row.hyperlink = false; } else { page.updateRowHyperlinkFlag(row); @@ -1432,15 +1628,27 @@ pub fn clearCells( } if (row.styled) { - for (cells) |*cell| { - if (cell.hasStyling()) - page.styles.release(page.memory, cell.style_id); + // Styled cells overwhelmingly come in runs sharing the same + // style (e.g. a colored status bar or a highlighted region), + // so group them and release each run with a single ref-count + // update rather than per cell. + var i: usize = 0; + while (i < cells.len) { + const id = cells[i].style_id; + if (id == style.default_id) { + i += 1; + continue; + } + var j = i + 1; + while (j < cells.len and cells[j].style_id == id) j += 1; + page.styles.releaseMultiple(page.memory, id, @intCast(j - i)); + i = j; } // If we have no left/right scroll region we can be sure // that we've cleared all the styles, so we clear the // flag, otherwise we ask the page to update the flag. - if (cells.len == self.pages.cols) { + if (cells.len == page.size.cols) { row.styled = false; } else { page.updateRowStyledFlag(row); @@ -1449,7 +1657,7 @@ pub fn clearCells( if (comptime build_options.kitty_graphics) { if (row.kitty_virtual_placeholder and - cells.len == self.pages.cols) + cells.len == page.size.cols) { for (cells) |c| { if (c.codepoint() == kitty.graphics.unicode.placeholder) { @@ -1525,12 +1733,12 @@ pub fn splitCellBoundary( self: *Screen, x: size.CellCountInt, ) void { - const page = &self.cursor.page_pin.node.data; + const page = self.cursor.page_pin.node.page(); page.pauseIntegrityChecks(true); defer page.pauseIntegrityChecks(false); - const cols = self.cursor.page_pin.node.data.size.cols; + const cols = self.cursor.page_pin.node.cols(); // `x` may be up to an INCLUDING `cols`, since that signifies splitting // the boundary to the right of the final cell in the row. @@ -1574,12 +1782,12 @@ pub fn splitCellBoundary( if (self.cursor.page_pin.up(1)) |p_row| { const p_rac = p_row.rowAndCell(); const p_cells = p_row.cells(.all); - const p_cell = p_cells[p_row.node.data.size.cols - 1]; + const p_cell = p_cells[p_row.node.cols() - 1]; if (p_cell.wide == .spacer_head) { self.clearCells( - &p_row.node.data, + p_row.node.page(), p_rac.row, - p_cells[p_row.node.data.size.cols - 1 ..][0..1], + p_cells[p_row.node.cols() - 1 ..][0..1], ); } } @@ -1690,7 +1898,7 @@ pub inline fn resize( if (self.cursor.hyperlink_id != 0) { // Note we do NOT use endHyperlink because we want to keep // our allocated self.cursor.hyperlink valid. - var page = &self.cursor.page_pin.node.data; + var page = self.cursor.page_pin.node.page(); page.hyperlink_set.release(page.memory, self.cursor.hyperlink_id); self.cursor.hyperlink_id = 0; self.cursor.hyperlink = null; @@ -1727,7 +1935,7 @@ pub inline fn resize( // For `.last`, only clear the current line where the cursor is. // For `.true`, clear all prompt lines starting from the beginning. .last => { - const page = &self.cursor.page_pin.node.data; + const page = self.cursor.page_pin.node.page(); const row = self.cursor.page_row; const cells = page.getCells(row); self.clearCells(page, row, cells); @@ -1753,7 +1961,7 @@ pub inline fn resize( // shell is going to expect this space to be available. var it = start.rowIterator(.right_down, null); while (it.next()) |pin| { - const page = &pin.node.data; + const page = pin.node.page(); const row = pin.rowAndCell().row; const cells = page.getCells(row); self.clearCells(page, row, cells); @@ -1992,6 +2200,12 @@ pub fn setAttribute( .unknown => return, } + // If the attribute didn't change our style then we can skip the + // style update entirely: our current style ID is already correct. + // This is a common case in the wild where programs re-assert the + // same style repeatedly (e.g. per span or per line). + if (self.cursor.style.eql(old_style)) return; + try self.manualStyleUpdate(); } @@ -2011,7 +2225,7 @@ pub fn setAttribute( /// there was still no room for the new style. pub fn manualStyleUpdate(self: *Screen) PageList.IncreaseCapacityError!void { defer self.assertIntegrity(); - var page: *Page = &self.cursor.page_pin.node.data; + var page: *Page = self.cursor.page_pin.node.page(); // std.log.warn("active styles={}", .{page.styles.count()}); @@ -2062,7 +2276,7 @@ pub fn manualStyleUpdate(self: *Screen) PageList.IncreaseCapacityError!void { }, }; - page = &node.data; + page = node.page(); break :id page.styles.add( page.memory, self.cursor.style, @@ -2110,13 +2324,13 @@ fn splitForCapacity( ) PageList.SplitError!void { // Get our capacities. We include our target row because its // capacity will be preserved. - const bytes_above = Page.layout(pin.node.data.exactRowCapacity( + const bytes_above = Page.layout(pin.node.page().exactRowCapacity( 0, pin.y + 1, )).total_size; - const bytes_below = Page.layout(pin.node.data.exactRowCapacity( + const bytes_below = Page.layout(pin.node.page().exactRowCapacity( pin.y, - pin.node.data.size.rows, + pin.node.rows(), )).total_size; // We need to track the old cursor pin because if our split @@ -2152,8 +2366,8 @@ pub fn appendGrapheme( cell: *Cell, cp: u21, ) PageList.IncreaseCapacityError!void { - defer self.cursor.page_pin.node.data.assertIntegrity(); - self.cursor.page_pin.node.data.appendGrapheme( + defer self.cursor.page_pin.node.page().assertIntegrity(); + self.cursor.page_pin.node.page().appendGrapheme( self.cursor.page_row, cell, cp, @@ -2184,7 +2398,7 @@ pub fn appendGrapheme( .gt => self.cursorCellRight(@intCast(cell_idx - self.cursor.x)), }; - self.cursor.page_pin.node.data.appendGrapheme( + self.cursor.page_pin.node.page().appendGrapheme( self.cursor.page_row, reloaded_cell, cp, @@ -2214,13 +2428,13 @@ pub fn startHyperlink( .id = if (id_) |id| .{ .explicit = id, } else implicit: { - defer self.cursor.hyperlink_implicit_id += 1; + defer self.cursor.hyperlink_implicit_id +%= 1; break :implicit .{ .implicit = self.cursor.hyperlink_implicit_id }; }, }; errdefer switch (link.id) { .explicit => {}, - .implicit => self.cursor.hyperlink_implicit_id -= 1, + .implicit => self.cursor.hyperlink_implicit_id -%= 1, }; // Loop until we have enough page memory to add the hyperlink @@ -2261,9 +2475,6 @@ fn startHyperlinkOnce( self: *Screen, source: hyperlink.Hyperlink, ) (Allocator.Error || Page.InsertHyperlinkError)!void { - // End any prior hyperlink - self.endHyperlink(); - // Allocate our new Hyperlink entry in non-page memory. This // lets us quickly get access to URI, ID. const link = try self.alloc.create(hyperlink.Hyperlink); @@ -2271,8 +2482,12 @@ fn startHyperlinkOnce( link.* = try source.dupe(self.alloc); errdefer link.deinit(self.alloc); + // End any prior hyperlink only after duplicating the new value. The + // source slices are allowed to reference our current hyperlink. + self.endHyperlink(); + // Insert the hyperlink into page memory - var page = &self.cursor.page_pin.node.data; + var page = self.cursor.page_pin.node.page(); const id: hyperlink.Id = try page.insertHyperlink(link.*); // Save it all @@ -2299,7 +2514,7 @@ pub fn endHyperlink(self: *Screen) void { // how RefCountedSet works). This causes some memory fragmentation but // is fine because if it is ever pruned the context deleted callback // will be called. - var page: *Page = &self.cursor.page_pin.node.data; + var page: *Page = self.cursor.page_pin.node.page(); page.hyperlink_set.release(page.memory, self.cursor.hyperlink_id); self.cursor.hyperlink.?.deinit(self.alloc); self.alloc.destroy(self.cursor.hyperlink.?); @@ -2311,7 +2526,7 @@ pub fn endHyperlink(self: *Screen) void { pub fn cursorSetHyperlink(self: *Screen) PageList.IncreaseCapacityError!void { assert(self.cursor.hyperlink_id != 0); - var page = &self.cursor.page_pin.node.data; + var page = self.cursor.page_pin.node.page(); if (page.setHyperlink( self.cursor.page_row, self.cursor.page_cell, @@ -2349,9 +2564,12 @@ pub fn cursorSetHyperlink(self: *Screen) PageList.IncreaseCapacityError!void { .string_bytes, ); assert(new_node == self.cursor.page_pin.node); - page = &new_node.data; + page = new_node.page(); } + // The hyperlink map is fixed-capacity, so reaching this error + // means live entries fill the usable map capacity and the page + // must grow. _ = try self.increaseCapacity( self.cursor.page_pin.node, .hyperlink_bytes, @@ -2425,8 +2643,28 @@ pub fn select(self: *Screen, sel_: ?Selection) Allocator.Error!void { const tracked_sel = if (sel.tracked()) sel else try sel.track(self); errdefer if (!sel.tracked()) tracked_sel.deinit(self); - // Untrack prior selection - if (self.selection) |*old| old.deinit(self); + // Untrack prior selection pins that aren't also owned by the replacement. + // A caller may pass our current tracked selection back to us by value, so + // releasing both old pins unconditionally would leave the replacement + // pointing at freed pool entries. + if (self.selection) |old| { + const new_bounds = tracked_sel.bounds.tracked; + switch (old.bounds) { + .untracked => old.deinit(self), + .tracked => |old_bounds| { + if (old_bounds.start != new_bounds.start and + old_bounds.start != new_bounds.end) + { + self.pages.untrackPin(old_bounds.start); + } + if (old_bounds.end != new_bounds.start and + old_bounds.end != new_bounds.end) + { + self.pages.untrackPin(old_bounds.end); + } + }, + } + } self.selection = tracked_sel; self.dirty.selection = true; } @@ -2551,7 +2789,7 @@ pub fn selectLine(self: *const Screen, opts: SelectLine) ?Selection { // First, check the current row for semantic boundaries before the clicked position. if (semantic_prompt_state) |v| { const row = it_prev.rowAndCell().row; - const cells = it_prev.node.data.getCells(row); + const cells = it_prev.node.page().getCells(row); // Scan backwards from clicked position to find where our content starts for (0..opts.pin.x + 1) |i| { const x_rev = opts.pin.x - i; @@ -2579,7 +2817,7 @@ pub fn selectLine(self: *const Screen, opts: SelectLine) ?Selection { if (semantic_prompt_state) |v| { // We need to check every cell in this row in reverse // order since we're going up and back. - const cells = p.node.data.getCells(row); + const cells = p.node.page().getCells(row); for (0..cells.len) |x| { const x_rev = cells.len - 1 - x; const cell = cells[x_rev]; @@ -2607,7 +2845,7 @@ pub fn selectLine(self: *const Screen, opts: SelectLine) ?Selection { if (semantic_prompt_state) |v| { // We need to check every cell in this row - const cells = p.node.data.getCells(row); + const cells = p.node.page().getCells(row); // If this is our pin row we can start from our x because // the start_pin logic already found the real start. @@ -2620,7 +2858,7 @@ pub fn selectLine(self: *const Screen, opts: SelectLine) ?Selection { // so we scan forward to find where our content ends. if (start_offset == 0 and cells[0].semantic_content != v) { var prev = p.up(1).?; - prev.x = p.node.data.size.cols - 1; + prev.x = prev.node.cols() - 1; break :end_pin prev; } @@ -2636,7 +2874,7 @@ pub fn selectLine(self: *const Screen, opts: SelectLine) ?Selection { if (!row.wrap) { var copy = p; - copy.x = p.node.data.size.cols - 1; + copy.x = p.node.cols() - 1; break :end_pin copy; } } @@ -2826,7 +3064,7 @@ pub fn selectWord( // If we are going to the next row and it isn't wrapped, we // return the previous. - if (p.x == p.node.data.size.cols - 1 and !rac.row.wrap) { + if (p.x == p.node.cols() - 1 and !rac.row.wrap) { break :end p; } @@ -2846,7 +3084,7 @@ pub fn selectWord( // If we are going to the next row and it isn't wrapped, we // return the previous. - if (p.x == p.node.data.size.cols - 1 and !rac.row.wrap) { + if (p.x == p.node.cols() - 1 and !rac.row.wrap) { break :start prev; } @@ -2898,7 +3136,7 @@ pub fn selectOutput(self: *Screen, pin: Pin) ?Selection { // the prompt and will trim the trailing whitespace. const start_pin = self.pages.getTopLeft(.screen); var end_pin = next.up(1) orelse return null; - end_pin.x = end_pin.node.data.size.cols - 1; + end_pin.x = end_pin.node.cols() - 1; var cell_it = end_pin.cellIterator(.left_up, start_pin); while (cell_it.next()) |p| { const cell = p.rowAndCell().cell; @@ -3027,7 +3265,7 @@ fn promptClickLine(self: *Screen, click_pin: Pin) PromptClickMove { ); row_it: while (row_it.next()) |row_pin| { const rac = row_pin.rowAndCell(); - const cells = row_pin.node.data.getCells(rac.row); + const cells = row_pin.node.page().getCells(rac.row); // Determine if this row is our cursor. const is_cursor_row = row_pin.node == cursor_pin.node and @@ -3103,7 +3341,7 @@ fn promptClickLine(self: *Screen, click_pin: Pin) PromptClickMove { ); row_it: while (row_it.next()) |row_pin| { const rac = row_pin.rowAndCell(); - const cells = row_pin.node.data.getCells(rac.row); + const cells = row_pin.node.page().getCells(rac.row); // Determine the length of the cells we look at in this row. const end_len: usize = end_len: { @@ -3253,7 +3491,7 @@ pub fn testWriteString(self: *Screen, text: []const u8) !void { break :cell cell; }; - try self.cursor.page_pin.node.data.appendGrapheme( + try self.cursor.page_pin.node.page().appendGrapheme( self.cursor.page_row, cell, c, @@ -3287,7 +3525,7 @@ pub fn testWriteString(self: *Screen, text: []const u8) !void { // If we have a ref-counted style, increase. if (self.cursor.style_id != style.default_id) { - const page = self.cursor.page_pin.node.data; + const page = self.cursor.page_pin.node.page(); page.styles.use(page.memory, self.cursor.style_id); self.cursor.page_row.styled = true; } @@ -3344,7 +3582,7 @@ pub fn testWriteString(self: *Screen, text: []const u8) !void { // If we have a ref-counted style, increase twice. if (self.cursor.style_id != style.default_id) { - const page = self.cursor.page_pin.node.data; + const page = self.cursor.page_pin.node.page(); page.styles.use(page.memory, self.cursor.style_id); page.styles.use(page.memory, self.cursor.style_id); self.cursor.page_row.styled = true; @@ -3484,7 +3722,7 @@ test "Screen cursorCopy style deref" { var s2 = try Screen.init(alloc, .{ .cols = 10, .rows = 10, .max_scrollback = 0 }); defer s2.deinit(); - const page = &s2.cursor.page_pin.node.data; + const page = s2.cursor.page_pin.node.page(); // Bold should create our style try s2.setAttribute(.{ .bold = {} }); @@ -3508,15 +3746,15 @@ test "Screen cursorCopy style deref new page" { defer s2.deinit(); // We need to get the cursor on a new page. - const first_page_size = s2.pages.pages.first.?.data.capacity.rows; + const first_page_size = s2.pages.pages.first.?.capacity().rows; // Fill the scrollback with blank lines until // there are only 5 rows left on the first page. - s2.pages.pages.first.?.data.pauseIntegrityChecks(true); + s2.pages.pages.first.?.page().pauseIntegrityChecks(true); for (0..first_page_size - 5) |_| { try s2.testWriteString("\n"); } - s2.pages.pages.first.?.data.pauseIntegrityChecks(false); + s2.pages.pages.first.?.page().pauseIntegrityChecks(false); try s2.testWriteString("1\n2\n3\n4\n5\n6\n7\n8\n9\n10"); @@ -3542,10 +3780,10 @@ test "Screen cursorCopy style deref new page" { // +-------------+ // This should be PAGE 1 - const page = &s2.cursor.page_pin.node.data; + const page = s2.cursor.page_pin.node.page(); // It should be the last page in the list. - try testing.expectEqual(&s2.pages.pages.last.?.data, page); + try testing.expectEqual(s2.pages.pages.last.?.page(), page); // It should have a previous page. try testing.expect(s2.cursor.page_pin.node.prev != null); @@ -3564,7 +3802,7 @@ test "Screen cursorCopy style deref new page" { try testing.expect(!s2.cursor.style.flags.bold); try testing.expectEqual(@as(usize, 0), page.styles.count()); // The page after the page the cursor is now in should be page 1. - try testing.expectEqual(page, &s2.cursor.page_pin.node.next.?.data); + try testing.expectEqual(page, s2.cursor.page_pin.node.next.?.page()); // The cursor should be at 0, 0 try testing.expect(s2.cursor.x == 0); try testing.expect(s2.cursor.y == 0); @@ -3580,7 +3818,7 @@ test "Screen cursorCopy style copy" { var s2 = try Screen.init(alloc, .{ .cols = 10, .rows = 10, .max_scrollback = 0 }); defer s2.deinit(); - const page = &s2.cursor.page_pin.node.data; + const page = s2.cursor.page_pin.node.page(); try s2.cursorCopy(s.cursor, .{}); try testing.expect(s2.cursor.style.flags.bold); try testing.expectEqual(@as(usize, 1), page.styles.count()); @@ -3595,7 +3833,7 @@ test "Screen cursorCopy hyperlink deref" { var s2 = try Screen.init(alloc, .{ .cols = 10, .rows = 10, .max_scrollback = 0 }); defer s2.deinit(); - const page = &s2.cursor.page_pin.node.data; + const page = s2.cursor.page_pin.node.page(); // Create a hyperlink for the cursor. try s2.startHyperlink("https://example.com/", null); @@ -3608,6 +3846,54 @@ test "Screen cursorCopy hyperlink deref" { try testing.expect(s2.cursor.hyperlink_id == 0); } +test "Screen write regrows compacted page capacity" { + const testing = std.testing; + const alloc = testing.allocator; + + var s = try Screen.init(alloc, .{ + .cols = 80, + .rows = 24, + .max_scrollback = 0, + }); + defer s.deinit(); + + // Compact the active page so every managed capacity dimension is + // zero, then reload the cursor since its cached row/cell pointers + // point into the replaced page. + { + const node = (try s.pages.compact(s.cursor.page_pin.node)).?; + try testing.expectEqual(0, node.capacity().styles); + try testing.expectEqual(0, node.capacity().grapheme_bytes); + try testing.expectEqual(0, node.capacity().string_bytes); + try testing.expectEqual(0, node.capacity().hyperlink_bytes); + s.cursorReload(); + } + + // Styled write: exercises the manualStyleUpdate single-retry + // path. Prior to increaseCapacity handling zero dimensions, the + // retry would fail and the style would be dropped. + try s.setAttribute(.{ .bold = {} }); + try s.testWriteString("A"); + + // Grapheme write: exercises the appendGrapheme single-retry path. + // We can't use testWriteString here because it appends graphemes + // directly on the page without the capacity retry. + try s.testWriteString("a"); + try s.appendGrapheme(s.cursorCellLeft(1), 0x0301); + + // Hyperlink: exercises the startHyperlink retry loop, which used + // to loop forever when capacity growth from zero didn't grow. + try s.startHyperlink("https://example.com/", null); + try s.testWriteString("B"); + s.endHyperlink(); + + // Verify the content landed on the page. + const page = s.cursor.page_pin.node.page(); + try testing.expect(page.styles.count() >= 1); + try testing.expect(page.hyperlink_set.count() >= 1); + try testing.expect(page.graphemeCount() >= 1); +} + test "Screen cursorCopy hyperlink deref new page" { const testing = std.testing; const alloc = testing.allocator; @@ -3619,15 +3905,15 @@ test "Screen cursorCopy hyperlink deref new page" { defer s2.deinit(); // We need to get the cursor on a new page. - const first_page_size = s2.pages.pages.first.?.data.capacity.rows; + const first_page_size = s2.pages.pages.first.?.capacity().rows; // Fill the scrollback with blank lines until // there are only 5 rows left on the first page. - s2.pages.pages.first.?.data.pauseIntegrityChecks(true); + s2.pages.pages.first.?.page().pauseIntegrityChecks(true); for (0..first_page_size - 5) |_| { try s2.testWriteString("\n"); } - s2.pages.pages.first.?.data.pauseIntegrityChecks(false); + s2.pages.pages.first.?.page().pauseIntegrityChecks(false); try s2.testWriteString("1\n2\n3\n4\n5\n6\n7\n8\n9\n10"); @@ -3653,10 +3939,10 @@ test "Screen cursorCopy hyperlink deref new page" { // +-------------+ // This should be PAGE 1 - const page = &s2.cursor.page_pin.node.data; + const page = s2.cursor.page_pin.node.page(); // It should be the last page in the list. - try testing.expectEqual(&s2.pages.pages.last.?.data, page); + try testing.expectEqual(s2.pages.pages.last.?.page(), page); // It should have a previous page. try testing.expect(s2.cursor.page_pin.node.prev != null); @@ -3675,7 +3961,7 @@ test "Screen cursorCopy hyperlink deref new page" { try testing.expectEqual(@as(usize, 0), page.hyperlink_set.count()); try testing.expect(s2.cursor.hyperlink_id == 0); // The page after the page the cursor is now in should be page 1. - try testing.expectEqual(page, &s2.cursor.page_pin.node.next.?.data); + try testing.expectEqual(page, s2.cursor.page_pin.node.next.?.page()); // The cursor should be at 0, 0 try testing.expect(s2.cursor.x == 0); try testing.expect(s2.cursor.y == 0); @@ -3690,12 +3976,12 @@ test "Screen cursorCopy hyperlink copy" { // Create a hyperlink for the cursor. try s.startHyperlink("https://example.com/", null); - try testing.expectEqual(@as(usize, 1), s.cursor.page_pin.node.data.hyperlink_set.count()); + try testing.expectEqual(@as(usize, 1), s.cursor.page_pin.node.page().hyperlink_set.count()); try testing.expect(s.cursor.hyperlink_id != 0); var s2 = try Screen.init(alloc, .{ .cols = 10, .rows = 10, .max_scrollback = 0 }); defer s2.deinit(); - const page = &s2.cursor.page_pin.node.data; + const page = s2.cursor.page_pin.node.page(); try testing.expectEqual(@as(usize, 0), page.hyperlink_set.count()); try testing.expect(s2.cursor.hyperlink_id == 0); @@ -3715,12 +4001,12 @@ test "Screen cursorCopy hyperlink copy disabled" { // Create a hyperlink for the cursor. try s.startHyperlink("https://example.com/", null); - try testing.expectEqual(@as(usize, 1), s.cursor.page_pin.node.data.hyperlink_set.count()); + try testing.expectEqual(@as(usize, 1), s.cursor.page_pin.node.page().hyperlink_set.count()); try testing.expect(s.cursor.hyperlink_id != 0); var s2 = try Screen.init(alloc, .{ .cols = 10, .rows = 10, .max_scrollback = 0 }); defer s2.deinit(); - const page = &s2.cursor.page_pin.node.data; + const page = s2.cursor.page_pin.node.page(); try testing.expectEqual(@as(usize, 0), page.hyperlink_set.count()); try testing.expect(s2.cursor.hyperlink_id == 0); @@ -3737,7 +4023,7 @@ test "Screen style basics" { var s = try Screen.init(alloc, .{ .cols = 80, .rows = 24, .max_scrollback = 1000 }); defer s.deinit(); - const page = &s.cursor.page_pin.node.data; + const page = s.cursor.page_pin.node.page(); try testing.expectEqual(@as(usize, 0), page.styles.count()); // Set a new style @@ -3759,7 +4045,7 @@ test "Screen style reset to default" { var s = try Screen.init(alloc, .{ .cols = 80, .rows = 24, .max_scrollback = 1000 }); defer s.deinit(); - const page = &s.cursor.page_pin.node.data; + const page = s.cursor.page_pin.node.page(); try testing.expectEqual(@as(usize, 0), page.styles.count()); // Set a new style @@ -3779,7 +4065,7 @@ test "Screen style reset with unset" { var s = try Screen.init(alloc, .{ .cols = 80, .rows = 24, .max_scrollback = 1000 }); defer s.deinit(); - const page = &s.cursor.page_pin.node.data; + const page = s.cursor.page_pin.node.page(); try testing.expectEqual(@as(usize, 0), page.styles.count()); // Set a new style @@ -3836,7 +4122,7 @@ test "Screen clearRows active styled line" { try s.setAttribute(.{ .unset = {} }); // We should have one style - const page = &s.cursor.page_pin.node.data; + const page = s.cursor.page_pin.node.page(); try testing.expectEqual(@as(usize, 1), page.styles.count()); s.clearRows(.{ .active = .{} }, null, false); @@ -3849,6 +4135,38 @@ test "Screen clearRows active styled line" { try testing.expectEqualStrings("", str); } +test "Screen clearCells empty range" { + const testing = std.testing; + + var s = try Screen.init(testing.allocator, .default); + defer s.deinit(); + + const page = s.cursor.page_pin.node.page(); + const row = s.cursor.page_row; + const cells = page.getCells(row); + s.clearCells(page, row, cells[0..0]); +} + +test "Screen clearRows uses stored page width" { + const testing = std.testing; + + var s = try Screen.init(testing.allocator, .{ + .cols = 2, + .rows = 1, + .max_scrollback = 0, + }); + defer s.deinit(); + + try s.testWriteString("AB"); + const node = s.pages.pages.first.?; + s.pages.cols = 4; + + s.clearRows(.{ .screen = .{} }, null, false); + const cells = node.page().getCells(&node.page().rows.ptr(node.page().memory)[0]); + try testing.expectEqual(@as(usize, 2), cells.len); + for (cells) |cell| try testing.expect(cell.isEmpty()); +} + test "Screen clearRows protected" { const testing = std.testing; const alloc = testing.allocator; @@ -3974,6 +4292,27 @@ test "Screen eraseRows active partial" { } } +test "Screen: cursorCellEndOfPrev across mixed-width pages" { + const testing = std.testing; + var s = try init(testing.allocator, .{ + .cols = 4, + .rows = 2, + .max_scrollback = 0, + }); + defer s.deinit(); + + try s.testWriteString("ABCDE"); + const first = s.pages.pages.first.?; + try s.pages.split(.{ .node = first, .y = 1 }); + s.cursorReload(); + const second = first.next.?; + first.page().size.cols = 2; + + try testing.expectEqual(second, s.cursor.page_pin.node); + const expected = (Pin{ .node = first, .x = 1 }).rowAndCell().cell; + try testing.expectEqual(expected, s.cursorCellEndOfPrev()); +} + test "Screen: cursorDown across pages preserves style" { const testing = std.testing; const alloc = testing.allocator; @@ -3982,7 +4321,7 @@ test "Screen: cursorDown across pages preserves style" { defer s.deinit(); // Scroll down enough to go to another page - const start_page = &s.pages.pages.last.?.data; + const start_page = s.pages.pages.last.?.page(); const rem = start_page.capacity.rows; start_page.pauseIntegrityChecks(true); for (0..rem) |_| try s.cursorDownOrScroll(); @@ -3992,21 +4331,21 @@ test "Screen: cursorDown across pages preserves style" { // assertion fails then the bug is in the test: we should be scrolling // above enough for a new page to show up. { - const page = &s.cursor.page_pin.node.data; + const page = s.cursor.page_pin.node.page(); try testing.expect(start_page != page); } // Scroll back to the previous page s.cursorUp(1); { - const page = &s.cursor.page_pin.node.data; + const page = s.cursor.page_pin.node.page(); try testing.expect(start_page == page); } // Go back up, set a style try s.setAttribute(.{ .bold = {} }); { - const page = &s.cursor.page_pin.node.data; + const page = s.cursor.page_pin.node.page(); const styleval = page.styles.get( page.memory, s.cursor.style_id, @@ -4017,7 +4356,7 @@ test "Screen: cursorDown across pages preserves style" { // Go back down into the next page and we should have that style s.cursorDown(1); { - const page = &s.cursor.page_pin.node.data; + const page = s.cursor.page_pin.node.page(); const styleval = page.styles.get( page.memory, s.cursor.style_id, @@ -4034,7 +4373,7 @@ test "Screen: cursorUp across pages preserves style" { defer s.deinit(); // Scroll down enough to go to another page - const start_page = &s.pages.pages.last.?.data; + const start_page = s.pages.pages.last.?.page(); const rem = start_page.capacity.rows; start_page.pauseIntegrityChecks(true); for (0..rem) |_| try s.cursorDownOrScroll(); @@ -4044,14 +4383,14 @@ test "Screen: cursorUp across pages preserves style" { // assertion fails then the bug is in the test: we should be scrolling // above enough for a new page to show up. { - const page = &s.cursor.page_pin.node.data; + const page = s.cursor.page_pin.node.page(); try testing.expect(start_page != page); } // Go back up, set a style try s.setAttribute(.{ .bold = {} }); { - const page = &s.cursor.page_pin.node.data; + const page = s.cursor.page_pin.node.page(); const styleval = page.styles.get( page.memory, s.cursor.style_id, @@ -4062,7 +4401,7 @@ test "Screen: cursorUp across pages preserves style" { // Go back down into the prev page and we should have that style s.cursorUp(1); { - const page = &s.cursor.page_pin.node.data; + const page = s.cursor.page_pin.node.page(); try testing.expect(start_page == page); const styleval = page.styles.get( @@ -4081,7 +4420,7 @@ test "Screen: cursorAbsolute across pages preserves style" { defer s.deinit(); // Scroll down enough to go to another page - const start_page = &s.pages.pages.last.?.data; + const start_page = s.pages.pages.last.?.page(); const rem = start_page.capacity.rows; start_page.pauseIntegrityChecks(true); for (0..rem) |_| try s.cursorDownOrScroll(); @@ -4091,14 +4430,14 @@ test "Screen: cursorAbsolute across pages preserves style" { // assertion fails then the bug is in the test: we should be scrolling // above enough for a new page to show up. { - const page = &s.cursor.page_pin.node.data; + const page = s.cursor.page_pin.node.page(); try testing.expect(start_page != page); } // Go back up, set a style try s.setAttribute(.{ .bold = {} }); { - const page = &s.cursor.page_pin.node.data; + const page = s.cursor.page_pin.node.page(); const styleval = page.styles.get( page.memory, s.cursor.style_id, @@ -4109,7 +4448,7 @@ test "Screen: cursorAbsolute across pages preserves style" { // Go back down into the prev page and we should have that style s.cursorAbsolute(1, 1); { - const page = &s.cursor.page_pin.node.data; + const page = s.cursor.page_pin.node.page(); try testing.expect(start_page == page); const styleval = page.styles.get( @@ -4136,13 +4475,13 @@ test "Screen: cursorAbsolute to page with insufficient capacity" { defer s.deinit(); // Scroll down enough to go to another page - const start_page = &s.pages.pages.last.?.data; + const start_page = s.pages.pages.last.?.page(); const rem = start_page.capacity.rows; start_page.pauseIntegrityChecks(true); for (0..rem) |_| try s.cursorDownOrScroll(); start_page.pauseIntegrityChecks(false); - const new_page = &s.cursor.page_pin.node.data; + const new_page = s.cursor.page_pin.node.page(); // We need our page to change for this test to make sense. If this // assertion fails then the bug is in the test: we should be scrolling @@ -4177,7 +4516,7 @@ test "Screen: cursorAbsolute to page with insufficient capacity" { // Go back up into the start page and we should still have that style. s.cursorAbsolute(1, 1); { - const cur_page = &s.cursor.page_pin.node.data; + const cur_page = s.cursor.page_pin.node.page(); // The page we're on now should NOT equal start_page, since its // capacity should have been adjusted, which invalidates our ptr. try testing.expect(start_page != cur_page); @@ -4191,7 +4530,7 @@ test "Screen: cursorAbsolute to page with insufficient capacity" { try testing.expect(styleval.flags.bold); } - s.cursor.page_pin.node.data.assertIntegrity(); + s.cursor.page_pin.node.page().assertIntegrity(); new_page.assertIntegrity(); } @@ -4295,7 +4634,7 @@ test "Screen: scrolling across pages preserves style" { defer s.deinit(); try s.setAttribute(.{ .bold = {} }); try s.testWriteString("1ABCD\n2EFGH\n3IJKL"); - const start_page = &s.pages.pages.last.?.data; + const start_page = s.pages.pages.last.?.page(); // Scroll down enough to go to another page const rem = start_page.capacity.rows - start_page.size.rows + 1; @@ -4306,7 +4645,7 @@ test "Screen: scrolling across pages preserves style" { // We need our page to change for this test o make sense. If this // assertion fails then the bug is in the test: we should be scrolling // above enough for a new page to show up. - const page = &s.pages.pages.last.?.data; + const page = s.pages.pages.last.?.page(); try testing.expect(start_page != page); const styleval = page.styles.get( @@ -4571,6 +4910,223 @@ test "Screen: scrolling moves selection" { } } +test "Screen: cursorScrollRegionUp simple" { + const testing = std.testing; + const alloc = testing.allocator; + + var s = try init(alloc, .{ .cols = 5, .rows = 5, .max_scrollback = 0 }); + defer s.deinit(); + try s.testWriteString("1ABCD\n2EFGH\n3IJKL\n4MNOP\n5QRST"); + + // Scroll a region ending at row 2 (zero-indexed) up by one. This + // emulates a scroll region of rows 0-2 with the cursor at the + // region bottom. + s.cursorAbsolute(1, 2); + try s.cursorScrollRegionUp(2); + + // The cursor stays in place, on the new blank row. + try testing.expectEqual(@as(size.CellCountInt, 1), s.cursor.x); + try testing.expectEqual(@as(size.CellCountInt, 2), s.cursor.y); + + // Rows in the region scrolled, rows below are unchanged, and + // nothing was moved into scrollback. + { + const contents = try s.dumpStringAlloc(alloc, .{ .screen = .{} }); + defer alloc.free(contents); + try testing.expectEqualStrings("2EFGH\n3IJKL\n\n4MNOP\n5QRST", contents); + } +} + +test "Screen: cursorScrollRegionUp renews page generation" { + const testing = std.testing; + const alloc = testing.allocator; + + var s = try init(alloc, .{ .cols = 5, .rows = 5, .max_scrollback = 0 }); + defer s.deinit(); + try s.testWriteString("1ABCD\n2EFGH\n3IJKL\n4MNOP\n5QRST"); + s.cursorAbsolute(0, 2); + + const node = s.cursor.page_pin.node; + const serial = node.serial; + try s.cursorScrollRegionUp(2); + + try testing.expect(!s.pages.nodeIsValid(node, serial)); +} + +test "Screen: cursorScrollRegionUp moves selection" { + const testing = std.testing; + const alloc = testing.allocator; + + var s = try init(alloc, .{ .cols = 5, .rows = 5, .max_scrollback = 0 }); + defer s.deinit(); + try s.testWriteString("1ABCD\n2EFGH\n3IJKL\n4MNOP\n5QRST"); + + // Select the second row. + try s.select(Selection.init( + s.pages.pin(.{ .active = .{ .x = 0, .y = 1 } }).?, + s.pages.pin(.{ .active = .{ .x = s.pages.cols - 1, .y = 1 } }).?, + false, + )); + + s.cursorAbsolute(0, 2); + try s.cursorScrollRegionUp(2); + + // Our selection should've moved up with its row. + { + const sel = s.selection.?; + try testing.expectEqual(point.Point{ .active = .{ + .x = 0, + .y = 0, + } }, s.pages.pointFromPin(.active, sel.start()).?); + try testing.expectEqual(point.Point{ .active = .{ + .x = s.pages.cols - 1, + .y = 0, + } }, s.pages.pointFromPin(.active, sel.end()).?); + } + + { + const contents = try s.dumpStringAlloc(alloc, .{ .viewport = .{} }); + defer alloc.free(contents); + try testing.expectEqualStrings("2EFGH\n3IJKL\n\n4MNOP\n5QRST", contents); + } +} + +test "Screen: cursorScrollRegionUp region spans pages" { + const testing = std.testing; + const alloc = testing.allocator; + + var s = try init(alloc, .{ .cols = 10, .rows = 5, .max_scrollback = 10 }); + defer s.deinit(); + + // We need to get the cursor to a new page + 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"); + + // At this point: + // +----------+ = PAGE 0 + // ... : : + // +-------------+ ACTIVE + // 4305 |1A00000000| | 0 + // 4306 |2B00000000| | 1 + // 4307 |3C00000000| | 2 + // +----------+ : + // +----------+ : = PAGE 1 + // 0 |4D00000000| | 3 + // :^ : : = PIN 0 + // 1 |5E00000000| | 4 + // +----------+ : + // +-------------+ + + // Move the cursor to the first row of the second page and give it + // a non-default style. This is important: it verifies that the + // cursor's style ref stays accounted on the correct page even + // though eraseRowBounded moves the cursor's tracked pin across + // the page boundary. + s.cursorAbsolute(0, 3); + try s.setAttribute(.{ .bold = {} }); + try testing.expect(s.cursor.page_pin.node == s.pages.pages.last.?); + try testing.expectEqual(@as(usize, 0), s.cursor.page_pin.y); + + // Scroll a region of active rows 1-3 with the cursor at the region + // bottom. The region spans the page boundary so this exercises the + // slow path. + try s.cursorScrollRegionUp(2); + + // The cursor stays in place, on the new blank row. + try testing.expectEqual(@as(size.CellCountInt, 0), s.cursor.x); + try testing.expectEqual(@as(size.CellCountInt, 3), s.cursor.y); + + { + const contents = try s.dumpStringAlloc(alloc, .{ .viewport = .{} }); + defer alloc.free(contents); + try testing.expectEqualStrings("1A\n3C\n4D\n\n5E", contents); + } + + // Our cursor style must remain usable: write a styled cell and + // verify the style ref counting is intact on the cursor's page. + try s.testWriteString("X"); + { + const page = s.cursor.page_pin.node.page(); + const styles = page.styles.count(); + try testing.expectEqual(@as(usize, 1), styles); + } +} + +test "Screen: cursorScrollRegionUp region spans pages with background SGR" { + const testing = std.testing; + const alloc = testing.allocator; + + var s = try init(alloc, .{ .cols = 10, .rows = 5, .max_scrollback = 10 }); + defer s.deinit(); + + // We need to get the cursor to a new page. See the previous test + // for a diagram of the page layout. + 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, 3); + try s.setAttribute(.{ .direct_color_bg = .{ .r = 0xFF, .g = 0, .b = 0 } }); + try testing.expect(s.cursor.page_pin.node == s.pages.pages.last.?); + try testing.expectEqual(@as(usize, 0), s.cursor.page_pin.y); + + try s.cursorScrollRegionUp(2); + + { + const contents = try s.dumpStringAlloc(alloc, .{ .viewport = .{} }); + defer alloc.free(contents); + try testing.expectEqualStrings("1A\n3C\n4D\n\n5E", contents); + } + + // The new blank row must be filled with our background color. + for (0..s.pages.cols) |x| { + const list_cell = s.pages.getCell(.{ .active = .{ + .x = @intCast(x), + .y = 3, + } }).?; + try testing.expect(list_cell.cell.content_tag == .bg_color_rgb); + try testing.expectEqual(Cell.RGB{ + .r = 0xFF, + .g = 0, + .b = 0, + }, list_cell.cell.content.color_rgb); + } +} + +test "Screen: cursorScrollRegionUp with styled erased row" { + const testing = std.testing; + const alloc = testing.allocator; + + var s = try init(alloc, .{ .cols = 5, .rows = 3, .max_scrollback = 0 }); + defer s.deinit(); + + // Write a styled row at the top so the erased row has managed + // memory that must be released. + try s.setAttribute(.{ .bold = {} }); + try s.testWriteString("1ABCD"); + try s.setAttribute(.{ .unset = {} }); + try s.testWriteString("\n2EFGH\n3IJKL"); + + s.cursorAbsolute(0, 2); + try s.cursorScrollRegionUp(2); + + { + const contents = try s.dumpStringAlloc(alloc, .{ .screen = .{} }); + defer alloc.free(contents); + try testing.expectEqualStrings("2EFGH\n3IJKL", contents); + } + + // The style should be gone from the page since the only user + // was the erased row. + const page = s.cursor.page_pin.node.page(); + try testing.expectEqual(@as(usize, 0), page.styles.count()); +} + test "Screen: scrolling moves viewport" { const testing = std.testing; const alloc = testing.allocator; @@ -4759,7 +5315,10 @@ test "Screen: scroll above same page" { // +----------+ : // +-------------+ + const node = s.cursor.page_pin.node; + const serial = node.serial; try s.cursorScrollAbove(); + try testing.expect(!s.pages.nodeIsValid(node, serial)); // +----------+ = PAGE 0 // 0 |1ABCD00000| @@ -4805,10 +5364,10 @@ test "Screen: scroll above same page but cursor on previous page" { defer s.deinit(); // We need to get the cursor to a new page - const first_page_size = s.pages.pages.first.?.data.capacity.rows; - s.pages.pages.first.?.data.pauseIntegrityChecks(true); + 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.?.data.pauseIntegrityChecks(false); + s.pages.pages.first.?.page().pauseIntegrityChecks(false); try s.setAttribute(.{ .direct_color_bg = .{ .r = 155 } }); try s.testWriteString("1A\n2B\n3C\n4D\n5E"); @@ -4834,7 +5393,13 @@ test "Screen: scroll above same page but cursor on previous page" { // +----------+ : // +-------------+ + const first_node = s.pages.pages.first.?; + const second_node = first_node.next.?; + const first_serial = first_node.serial; + const second_serial = second_node.serial; try s.cursorScrollAbove(); + try testing.expect(!s.pages.nodeIsValid(first_node, first_serial)); + try testing.expect(!s.pages.nodeIsValid(second_node, second_serial)); // +----------+ = PAGE 0 // ... : : @@ -4886,10 +5451,10 @@ test "Screen: scroll above same page but cursor on previous page last row" { defer s.deinit(); // We need to get the cursor to a new page - const first_page_size = s.pages.pages.first.?.data.capacity.rows; - s.pages.pages.first.?.data.pauseIntegrityChecks(true); + const first_page_size = s.pages.pages.first.?.capacity().rows; + s.pages.pages.first.?.page().pauseIntegrityChecks(true); for (0..first_page_size - 2) |_| try s.testWriteString("\n"); - s.pages.pages.first.?.data.pauseIntegrityChecks(false); + s.pages.pages.first.?.page().pauseIntegrityChecks(false); try s.setAttribute(.{ .direct_color_bg = .{ .r = 155 } }); try s.testWriteString("1A\n2B\n3C\n4D\n5E"); @@ -4964,8 +5529,8 @@ test "Screen: scroll above same page but cursor on previous page last row" { // This catches a case of memory corruption where the cursor // is moved between pages without accounting for style refs. try s.setAttribute(.{ .reset_bg = {} }); - s.pages.pages.first.?.data.assertIntegrity(); - s.pages.pages.last.?.data.assertIntegrity(); + s.pages.pages.first.?.page().assertIntegrity(); + s.pages.pages.last.?.page().assertIntegrity(); } test "Screen: scroll above creates new page" { @@ -4976,10 +5541,10 @@ test "Screen: scroll above creates new page" { defer s.deinit(); // We need to get the cursor to a new page - const first_page_size = s.pages.pages.first.?.data.capacity.rows; - s.pages.pages.first.?.data.pauseIntegrityChecks(true); + 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.?.data.pauseIntegrityChecks(false); + s.pages.pages.first.?.page().pauseIntegrityChecks(false); try s.setAttribute(.{ .direct_color_bg = .{ .r = 155 } }); try s.testWriteString("1ABCD\n2EFGH\n3IJKL"); @@ -4999,7 +5564,10 @@ test "Screen: scroll above creates new page" { // 4307 |3IJKL00000| | 2 // +----------+ : // +-------------+ + const node = s.pages.pages.first.?; + const serial = node.serial; try s.cursorScrollAbove(); + try testing.expect(!s.pages.nodeIsValid(node, serial)); // +----------+ = PAGE 0 // ... : : @@ -5048,10 +5616,10 @@ test "Screen: scroll above with cursor on non-final row" { defer s.deinit(); // Get the cursor to be 2 rows above a new page - const first_page_size = s.pages.pages.first.?.data.capacity.rows; - s.pages.pages.first.?.data.pauseIntegrityChecks(true); + 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.?.data.pauseIntegrityChecks(false); + s.pages.pages.first.?.page().pauseIntegrityChecks(false); // Write 3 lines of text, forcing the last line into the first // row of a new page. Move our cursor onto the previous page. @@ -5124,10 +5692,10 @@ test "Screen: scroll above no scrollback bottom of page" { var s = try init(alloc, .{ .cols = 10, .rows = 3, .max_scrollback = 0 }); defer s.deinit(); - const first_page_size = s.pages.pages.first.?.data.capacity.rows; - s.pages.pages.first.?.data.pauseIntegrityChecks(true); + 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.?.data.pauseIntegrityChecks(false); + s.pages.pages.first.?.page().pauseIntegrityChecks(false); try s.setAttribute(.{ .direct_color_bg = .{ .r = 155 } }); try s.testWriteString("1ABCD\n2EFGH\n3IJKL"); @@ -5498,6 +6066,51 @@ test "Screen: clone contains subset of selection" { } } +test "Screen: clone clamps clipped selections to mixed-width pages" { + const testing = std.testing; + const alloc = testing.allocator; + + var s = try init(alloc, .{ .cols = 4, .rows = 3, .max_scrollback = 0 }); + defer s.deinit(); + + const first = s.pages.pages.first.?; + try s.pages.split(.{ .node = first, .y = 2 }); + try s.pages.split(.{ .node = first, .y = 1 }); + const middle = first.next.?; + const last = middle.next.?; + middle.page().size.cols = 2; + + try s.select(Selection.init( + .{ .node = first }, + .{ .node = last, .x = 3 }, + false, + )); + var linear = try s.clone( + alloc, + .{ .screen = .{} }, + .{ .screen = .{ .y = 1 } }, + ); + defer linear.deinit(); + const linear_end = linear.selection.?.end(); + _ = linear_end.rowAndCell(); + try testing.expectEqual(@as(size.CellCountInt, 1), linear_end.x); + + try s.select(Selection.init( + .{ .node = first, .x = 3 }, + .{ .node = last, .x = 3 }, + true, + )); + var rectangle = try s.clone( + alloc, + .{ .screen = .{ .y = 1 } }, + null, + ); + defer rectangle.deinit(); + const rectangle_start = rectangle.selection.?.start(); + _ = rectangle_start.rowAndCell(); + try testing.expectEqual(@as(size.CellCountInt, 1), rectangle_start.x); +} + test "Screen: clone contains subset of rectangle selection" { const testing = std.testing; const alloc = testing.allocator; @@ -6533,7 +7146,7 @@ test "Screen: resize more cols bounded scrollback keeps viewport valid" { // That cuts the total row count down and is what stresses the viewport pin. var it = s.pages.pageIterator(.right_down, .{ .screen = .{} }, null); while (it.next()) |chunk| { - const page = &chunk.node.data; + const page = chunk.node.page(); for (chunk.start..chunk.end) |y| { const rac = page.getRowAndCell(0, y); if (y % 2 == 0) { @@ -7652,6 +8265,49 @@ test "Screen: select untracked" { try testing.expectEqual(tracked, s.pages.countTrackedPins()); } +test "Screen: select replaces existing pins" { + const testing = std.testing; + const alloc = testing.allocator; + + var s = try init(alloc, .{ .cols = 10, .rows = 10, .max_scrollback = 0 }); + defer s.deinit(); + try s.testWriteString("ABC DEF\n 123\n456"); + + const tracked = s.pages.countTrackedPins(); + try s.select(Selection.init( + s.pages.pin(.{ .active = .{ .x = 0, .y = 0 } }).?, + s.pages.pin(.{ .active = .{ .x = 3, .y = 0 } }).?, + false, + )); + try testing.expectEqual(tracked + 2, s.pages.countTrackedPins()); + + // Replacing the selection must untrack the prior selection's pins + // rather than leak them. + try s.select(Selection.init( + s.pages.pin(.{ .active = .{ .x = 0, .y = 1 } }).?, + s.pages.pin(.{ .active = .{ .x = 2, .y = 1 } }).?, + false, + )); + try testing.expectEqual(tracked + 2, s.pages.countTrackedPins()); +} + +test "Screen: reselecting tracked selection preserves its pins" { + const testing = std.testing; + const alloc = testing.allocator; + + var s = try init(alloc, .{ .cols = 10, .rows = 2, .max_scrollback = 0 }); + defer s.deinit(); + + try s.select(Selection.init( + s.pages.pin(.{ .active = .{ .x = 1, .y = 0 } }).?, + s.pages.pin(.{ .active = .{ .x = 3, .y = 0 } }).?, + false, + )); + + try s.select(s.selection.?); + try testing.expectEqual(Selection.Order.forward, s.selection.?.order(&s)); +} + test "Screen: selectAll" { const testing = std.testing; const alloc = testing.allocator; @@ -8342,6 +8998,37 @@ test "Screen: selectLine semantic boundary first cell of row" { } } +test "Screen: selectLine semantic boundary across mixed-width pages" { + const testing = std.testing; + const alloc = testing.allocator; + + var s = try init(alloc, .{ .cols = 4, .rows = 2, .max_scrollback = 0 }); + defer s.deinit(); + + s.cursorSetSemanticContent(.{ .input = .clear_explicit }); + try s.testWriteString("ABCD"); + s.cursorSetSemanticContent(.output); + try s.testWriteString("E"); + + const first = s.pages.pages.first.?; + try s.pages.split(.{ .node = first, .y = 1 }); + const second = first.next.?; + first.page().size.cols = 2; + s.pages.pauseIntegrityChecks(true); + defer s.pages.pauseIntegrityChecks(false); + + try testing.expectEqual(@as(size.CellCountInt, 2), first.cols()); + try testing.expectEqual(@as(size.CellCountInt, 4), second.cols()); + + var sel = s.selectLine(.{ .pin = .{ + .node = first, + .x = 1, + } }).?; + defer sel.deinit(&s); + try testing.expect((Pin{ .node = first, .x = 0 }).eql(sel.start())); + try testing.expect((Pin{ .node = first, .x = 1 }).eql(sel.end())); +} + test "Screen: selectLine semantic all same content" { const testing = std.testing; const alloc = testing.allocator; @@ -9141,7 +9828,7 @@ test "Screen: selectionString with zero width joiner" { const cell = pin.rowAndCell().cell; try testing.expectEqual(@as(u21, 0x1F468), cell.content.codepoint); try testing.expectEqual(Cell.Wide.wide, cell.wide); - const cps = pin.node.data.lookupGrapheme(cell).?; + const cps = pin.node.page().lookupGrapheme(cell).?; try testing.expectEqual(@as(usize, 1), cps.len); } @@ -9276,14 +9963,14 @@ test "Screen: selectionString multi-page" { var s = try init(alloc, .{ .cols = 10, .rows = 3, .max_scrollback = 2048 }); defer s.deinit(); - const first_page_size = s.pages.pages.first.?.data.capacity.rows; + const first_page_size = s.pages.pages.first.?.capacity().rows; // Lazy way to seek to the first page boundary. - s.pages.pages.first.?.data.pauseIntegrityChecks(true); + s.pages.pages.first.?.page().pauseIntegrityChecks(true); for (0..first_page_size - 1) |_| { try s.testWriteString("\n"); } - s.pages.pages.first.?.data.pauseIntegrityChecks(false); + s.pages.pages.first.?.page().pauseIntegrityChecks(false); try s.testWriteString("123456789\n!@#$%^&*(\n123456789"); @@ -9374,25 +10061,77 @@ test "Screen: hyperlink start/end" { defer s.deinit(); try testing.expect(s.cursor.hyperlink_id == 0); { - const page = &s.cursor.page_pin.node.data; + const page = s.cursor.page_pin.node.page(); try testing.expectEqual(0, page.hyperlink_set.count()); } try s.startHyperlink("http://example.com", null); try testing.expect(s.cursor.hyperlink_id != 0); { - const page = &s.cursor.page_pin.node.data; + const page = s.cursor.page_pin.node.page(); try testing.expectEqual(1, page.hyperlink_set.count()); } s.endHyperlink(); try testing.expect(s.cursor.hyperlink_id == 0); { - const page = &s.cursor.page_pin.node.data; + const page = s.cursor.page_pin.node.page(); try testing.expectEqual(0, page.hyperlink_set.count()); } } +test "Screen: hyperlink accepts its current values" { + const testing = std.testing; + const alloc = testing.allocator; + + var s = try init(alloc, .{ .cols = 5, .rows = 5, .max_scrollback = 0 }); + defer s.deinit(); + + try s.startHyperlink("http://example.com", "current"); + const current = s.cursor.hyperlink.?; + try s.startHyperlink(current.uri, current.id.explicit); + + try testing.expectEqualStrings("http://example.com", s.cursor.hyperlink.?.uri); + try testing.expectEqualStrings("current", s.cursor.hyperlink.?.id.explicit); +} + +test "Screen: implicit hyperlink ID wraps" { + const testing = std.testing; + const alloc = testing.allocator; + + var s = try init(alloc, .{ .cols = 5, .rows = 5, .max_scrollback = 0 }); + defer s.deinit(); + + s.cursor.hyperlink_implicit_id = std.math.maxInt(size.OffsetInt); + try s.startHyperlink("http://example.com", null); + + try testing.expectEqual(@as(size.OffsetInt, 0), s.cursor.hyperlink_implicit_id); + try testing.expectEqual( + std.math.maxInt(size.OffsetInt), + s.cursor.hyperlink.?.id.implicit, + ); + + // A failed allocation must roll the wrapped counter back to its + // original value as well. + s.endHyperlink(); + s.cursor.hyperlink_implicit_id = std.math.maxInt(size.OffsetInt); + var failing = testing.FailingAllocator.init(alloc, .{}); + failing.fail_index = failing.alloc_index; + { + const original_alloc = s.alloc; + defer s.alloc = original_alloc; + s.alloc = failing.allocator(); + try testing.expectError( + error.OutOfMemory, + s.startHyperlink("http://example.com", null), + ); + } + try testing.expectEqual( + std.math.maxInt(size.OffsetInt), + s.cursor.hyperlink_implicit_id, + ); +} + test "Screen: hyperlink reuse" { const testing = std.testing; const alloc = testing.allocator; @@ -9402,7 +10141,7 @@ test "Screen: hyperlink reuse" { try testing.expect(s.cursor.hyperlink_id == 0); { - const page = &s.cursor.page_pin.node.data; + const page = s.cursor.page_pin.node.page(); try testing.expectEqual(0, page.hyperlink_set.count()); } @@ -9415,14 +10154,14 @@ test "Screen: hyperlink reuse" { try s.startHyperlink("http://example.com", null); try testing.expectEqual(id, s.cursor.hyperlink_id); { - const page = &s.cursor.page_pin.node.data; + const page = s.cursor.page_pin.node.page(); try testing.expectEqual(1, page.hyperlink_set.count()); } s.endHyperlink(); try testing.expect(s.cursor.hyperlink_id == 0); { - const page = &s.cursor.page_pin.node.data; + const page = s.cursor.page_pin.node.page(); try testing.expectEqual(0, page.hyperlink_set.count()); } } @@ -9442,7 +10181,7 @@ test "Screen: hyperlink cursor state on resize" { try s.startHyperlink("http://example.com", null); try testing.expect(s.cursor.hyperlink_id != 0); { - const page = &s.cursor.page_pin.node.data; + const page = s.cursor.page_pin.node.page(); try testing.expectEqual(1, page.hyperlink_set.count()); } @@ -9450,14 +10189,14 @@ test "Screen: hyperlink cursor state on resize" { try s.resize(.{ .cols = 10, .rows = 10 }); try testing.expect(s.cursor.hyperlink_id != 0); { - const page = &s.cursor.page_pin.node.data; + const page = s.cursor.page_pin.node.page(); try testing.expectEqual(1, page.hyperlink_set.count()); } s.endHyperlink(); try testing.expect(s.cursor.hyperlink_id == 0); { - const page = &s.cursor.page_pin.node.data; + const page = s.cursor.page_pin.node.page(); try testing.expectEqual(0, page.hyperlink_set.count()); } } @@ -9478,8 +10217,8 @@ test "Screen: cursorSetHyperlink OOM + URI too large for string alloc" { // Figure out how many cells should can have hyperlinks in this page, // and write twice that number, to guarantee the capacity needs to be // increased at some point. - const base_capacity = s.cursor.page_pin.node.data.hyperlinkCapacity(); - const base_string_bytes = s.cursor.page_pin.node.data.capacity.string_bytes; + const base_capacity = s.cursor.page_pin.node.page().hyperlinkCapacity(); + const base_string_bytes = s.cursor.page_pin.node.capacity().string_bytes; for (0..base_capacity * 2) |_| { try s.cursorSetHyperlink(); if (s.cursor.x >= s.pages.cols - 1) { @@ -9491,9 +10230,9 @@ test "Screen: cursorSetHyperlink OOM + URI too large for string alloc" { } // Make sure the capacity really did increase. - try testing.expect(base_capacity < s.cursor.page_pin.node.data.hyperlinkCapacity()); + try testing.expect(base_capacity < s.cursor.page_pin.node.page().hyperlinkCapacity()); // And that our string_bytes increased as well. - try testing.expect(base_string_bytes < s.cursor.page_pin.node.data.capacity.string_bytes); + try testing.expect(base_string_bytes < s.cursor.page_pin.node.capacity().string_bytes); } test "Screen: increaseCapacity cursor style ref count preserved" { @@ -9516,7 +10255,7 @@ test "Screen: increaseCapacity cursor style ref count preserved" { const old_style = s.cursor.style; { - const page = &s.pages.pages.last.?.data; + const page = s.pages.pages.last.?.page(); // 5 chars + cursor = 6 refs try testing.expectEqual( 6, @@ -9545,7 +10284,7 @@ test "Screen: increaseCapacity cursor style ref count preserved" { // After increaseCapacity, the 5 chars are cloned (5 refs) and // the cursor's style is re-added (1 ref) = 6 total. { - const page = &s.pages.pages.last.?.data; + const page = s.pages.pages.last.?.page(); const ref_count = page.styles.refCount(page.memory, s.cursor.style_id); try testing.expectEqual(6, ref_count); } @@ -9569,7 +10308,7 @@ test "Screen: increaseCapacity cursor hyperlink ref count preserved" { try testing.expect(s.pages.pages.first == s.cursor.page_pin.node); { - const page = &s.pages.pages.last.?.data; + const page = s.pages.pages.last.?.page(); // Cursor has the hyperlink active = 1 count in hyperlink_set try testing.expectEqual(1, page.hyperlink_set.count()); try testing.expect(s.cursor.hyperlink_id != 0); @@ -9589,7 +10328,7 @@ test "Screen: increaseCapacity cursor hyperlink ref count preserved" { // After increaseCapacity, the hyperlink is re-added to the new page. { - const page = &s.pages.pages.last.?.data; + const page = s.pages.pages.last.?.page(); try testing.expectEqual(1, page.hyperlink_set.count()); } } @@ -9619,7 +10358,7 @@ test "Screen: increaseCapacity cursor with both style and hyperlink preserved" { const old_style = s.cursor.style; { - const page = &s.pages.pages.last.?.data; + const page = s.pages.pages.last.?.page(); // 5 chars + cursor = 6 refs for bold style try testing.expectEqual( 6, @@ -9649,7 +10388,7 @@ test "Screen: increaseCapacity cursor with both style and hyperlink preserved" { // After increaseCapacity, both style and hyperlink are re-added to the new page. { - const page = &s.pages.pages.last.?.data; + const page = s.pages.pages.last.?.page(); const ref_count = page.styles.refCount(page.memory, s.cursor.style_id); try testing.expectEqual(6, ref_count); try testing.expectEqual(1, page.hyperlink_set.count()); @@ -9688,11 +10427,11 @@ test "Screen: increaseCapacity non-cursor page returns early" { // Grow pages until we have multiple pages. The cursor's pin stays on // the first page since we're just adding rows. const first_page_node = s.pages.pages.first.?; - first_page_node.data.pauseIntegrityChecks(true); - for (0..first_page_node.data.capacity.rows - first_page_node.data.size.rows) |_| { + first_page_node.page().pauseIntegrityChecks(true); + for (0..first_page_node.capacity().rows - first_page_node.rows()) |_| { _ = try s.pages.grow(); } - first_page_node.data.pauseIntegrityChecks(false); + first_page_node.page().pauseIntegrityChecks(false); _ = try s.pages.grow(); // Now we have two pages @@ -9703,8 +10442,8 @@ test "Screen: increaseCapacity non-cursor page returns early" { try testing.expect(s.cursor.page_pin.node == s.pages.pages.first.?); try testing.expect(s.cursor.page_pin.node != second_page); - const second_page_styles_cap = second_page.data.capacity.styles; - const cursor_page_styles_cap = s.cursor.page_pin.node.data.capacity.styles; + const second_page_styles_cap = second_page.capacity().styles; + const cursor_page_styles_cap = s.cursor.page_pin.node.capacity().styles; // Call increaseCapacity on the second page (NOT the cursor's page) const new_second_page = try s.increaseCapacity(second_page, .styles); @@ -9712,13 +10451,13 @@ test "Screen: increaseCapacity non-cursor page returns early" { // The second page should have increased capacity try testing.expectEqual( second_page_styles_cap * 2, - new_second_page.data.capacity.styles, + new_second_page.capacity().styles, ); // The cursor's page (first page) should be unchanged try testing.expectEqual( cursor_page_styles_cap, - s.cursor.page_pin.node.data.capacity.styles, + s.cursor.page_pin.node.capacity().styles, ); // Cursor state should be completely unchanged since we didn't touch its page @@ -9739,9 +10478,9 @@ test "Screen: cursorDown to page with insufficient capacity" { // cursor movement functions). The bug pattern: // // 1. cursorDown creates a by-value copy of the pin via page_pin.down(n) - // 2. cursorChangePin is called, which may trigger adjustCapacity + // 2. cursorChangePin is called, which may trigger increaseCapacity // if the target page's style map is full - // 3. adjustCapacity frees the old page and creates a new one + // 3. increaseCapacity frees the old page and creates a new one // 4. The local pin copy still points to the freed page // 5. rowAndCell() on the stale pin accesses freed memory @@ -9753,18 +10492,18 @@ test "Screen: cursorDown to page with insufficient capacity" { defer s.deinit(); // Scroll down enough to create a second page - const start_page = &s.pages.pages.last.?.data; + const start_page = s.pages.pages.last.?.page(); const rem = start_page.capacity.rows; start_page.pauseIntegrityChecks(true); for (0..rem) |_| try s.cursorDownOrScroll(); start_page.pauseIntegrityChecks(false); // Cursor should now be on a new page - const new_page = &s.cursor.page_pin.node.data; + const new_page = s.cursor.page_pin.node.page(); try testing.expect(start_page != new_page); // Fill new_page's style map to capacity. When we move INTO this page - // with a style set, adjustCapacity will be triggered. + // with a style set, increaseCapacity will be triggered. { new_page.pauseIntegrityChecks(true); defer new_page.pauseIntegrityChecks(false); @@ -9790,10 +10529,10 @@ test "Screen: cursorDown to page with insufficient capacity" { if (s.cursor.page_pin.down(1)) |next_pin| { if (next_pin.node != cur_node) { // Cursor is at 'row', moving down crosses to new_page - try testing.expect(&next_pin.node.data == new_page); + try testing.expect(next_pin.node.page() == new_page); // This cursorDown triggers the bug: the local page_pin copy - // becomes stale after adjustCapacity, causing rowAndCell() + // becomes stale after increaseCapacity, causing rowAndCell() // to access freed memory. s.cursorDown(1); @@ -9825,7 +10564,7 @@ test "Screen setAttribute increases capacity when style map is full" { try s.testWriteString("line1\nline2\nline3\nline4\nline5"); // Get the page and fill its style map to capacity - const page = &s.cursor.page_pin.node.data; + const page = s.cursor.page_pin.node.page(); const original_styles_capacity = page.capacity.styles; // Fill the style map to capacity using the StyleSet's layout capacity @@ -9856,7 +10595,7 @@ test "Screen setAttribute increases capacity when style map is full" { try testing.expect(s.cursor.style_id != style.default_id); // Either the capacity increased or the page was split/changed - const current_page = &s.cursor.page_pin.node.data; + const current_page = s.cursor.page_pin.node.page(); const capacity_increased = current_page.capacity.styles > original_styles_capacity; const page_changed = current_page != page; try testing.expect(capacity_increased or page_changed); @@ -9882,7 +10621,7 @@ test "Screen setAttribute splits page on OutOfSpace at max styles" { // Increase the page's style capacity to max by repeatedly calling increaseCapacity // Use Screen.increaseCapacity to properly maintain cursor state const max_styles = std.math.maxInt(size.CellCountInt); - while (s.cursor.page_pin.node.data.capacity.styles < max_styles) { + while (s.cursor.page_pin.node.capacity().styles < max_styles) { _ = s.increaseCapacity( s.cursor.page_pin.node, .styles, @@ -9890,7 +10629,7 @@ test "Screen setAttribute splits page on OutOfSpace at max styles" { } // Get the page reference after increaseCapacity - cursor may have moved - var page = &s.cursor.page_pin.node.data; + var page = s.cursor.page_pin.node.page(); try testing.expectEqual(max_styles, page.capacity.styles); // Fill the style map to capacity using the StyleSet's layout capacity diff --git a/vendor/libghostty-vt/src/terminal/Selection.zig b/vendor/libghostty-vt/src/terminal/Selection.zig index 5258210c..be6854e5 100644 --- a/vendor/libghostty-vt/src/terminal/Selection.zig +++ b/vendor/libghostty-vt/src/terminal/Selection.zig @@ -155,12 +155,12 @@ pub fn topLeft(self: Selection, s: *const Screen) Pin { .reverse => self.end(), .mirrored_forward => pin: { var p = self.start(); - p.x = self.end().x; + p.x = @min(self.end().x, p.node.cols() - 1); break :pin p; }, .mirrored_reverse => pin: { var p = self.end(); - p.x = self.start().x; + p.x = @min(self.start().x, p.node.cols() - 1); break :pin p; }, }; @@ -173,12 +173,12 @@ pub fn bottomRight(self: Selection, s: *const Screen) Pin { .reverse => self.start(), .mirrored_forward => pin: { var p = self.end(); - p.x = self.start().x; + p.x = @min(self.start().x, p.node.cols() - 1); break :pin p; }, .mirrored_reverse => pin: { var p = self.start(); - p.x = self.end().x; + p.x = @min(self.end().x, p.node.cols() - 1); break :pin p; }, }; @@ -326,6 +326,7 @@ pub fn containedRowCached( br: point.Coordinate, p: point.Coordinate, ) ?Selection { + _ = s; if (p.y < tl.y or p.y > br.y) return null; // Rectangle case: we can return early as the x range will always be the @@ -333,12 +334,12 @@ pub fn containedRowCached( if (self.rectangle) return init( start: { var copy: Pin = pin; - copy.x = tl.x; + copy.x = @min(tl.x, copy.node.cols() - 1); break :start copy; }, end: { var copy: Pin = pin; - copy.x = br.x; + copy.x = @min(br.x, copy.node.cols() - 1); break :end copy; }, true, @@ -355,7 +356,7 @@ pub fn containedRowCached( tl_pin, end: { var copy: Pin = pin; - copy.x = s.pages.cols - 1; + copy.x = copy.node.cols() - 1; break :end copy; }, false, @@ -387,7 +388,7 @@ pub fn containedRowCached( }, end: { var copy: Pin = pin; - copy.x = s.pages.cols - 1; + copy.x = copy.node.cols() - 1; break :end copy; }, false, @@ -432,7 +433,7 @@ pub fn adjust( var current = end_pin.*; while (current.down(1)) |next| : (current = next) { const rac = next.rowAndCell(); - const cells = next.node.data.getCells(rac.row); + const cells = next.node.page().getCells(rac.row); if (page.Cell.hasTextAny(cells)) { end_pin.* = next; break; @@ -494,7 +495,7 @@ pub fn adjust( ); while (it.next()) |next| { const rac = next.rowAndCell(); - const cells = next.node.data.getCells(rac.row); + const cells = next.node.page().getCells(rac.row); if (page.Cell.hasTextAny(cells)) { end_pin.* = next; end_pin.x = @intCast(cells.len - 1); @@ -505,7 +506,7 @@ pub fn adjust( .beginning_of_line => end_pin.x = 0, - .end_of_line => end_pin.x = end_pin.node.data.size.cols - 1, + .end_of_line => end_pin.x = end_pin.node.cols() - 1, } } @@ -1054,6 +1055,32 @@ test "Selection: order, standard" { } } +test "Selection: rectangle corners clamp across mixed-width pages" { + const testing = std.testing; + var s = try Screen.init(testing.allocator, .{ + .cols = 4, + .rows = 2, + .max_scrollback = 0, + }); + defer s.deinit(); + + const first = s.pages.pages.first.?; + try s.pages.split(.{ .node = first, .y = 1 }); + const second = first.next.?; + second.page().size.cols = 2; + + const sel = Selection.init( + .{ .node = first, .x = 3 }, + .{ .node = second, .x = 1 }, + true, + ); + try testing.expectEqual(.mirrored_forward, sel.order(&s)); + + const bottom_right = sel.bottomRight(&s); + _ = bottom_right.rowAndCell(); + try testing.expect((Pin{ .node = second, .x = 1 }).eql(bottom_right)); +} + test "Selection: order, rectangle" { const testing = std.testing; const alloc = testing.allocator; @@ -1602,3 +1629,46 @@ test "Selection: containedRow" { ).?); } } + +test "Selection: containedRow clamps mixed-width pages" { + const testing = std.testing; + var s = try Screen.init(testing.allocator, .{ + .cols = 4, + .rows = 3, + .max_scrollback = 0, + }); + defer s.deinit(); + + const first = s.pages.pages.first.?; + try s.pages.split(.{ .node = first, .y = 2 }); + try s.pages.split(.{ .node = first, .y = 1 }); + const middle = first.next.?; + const last = middle.next.?; + middle.page().size.cols = 2; + + const linear = Selection.init( + .{ .node = first, .x = 1 }, + .{ .node = last, .x = 1 }, + false, + ); + const linear_row = linear.containedRow( + &s, + .{ .node = middle }, + ).?; + _ = linear_row.end().rowAndCell(); + try testing.expect((Pin{ .node = middle }).eql(linear_row.start())); + try testing.expect((Pin{ .node = middle, .x = 1 }).eql(linear_row.end())); + + const rectangle = Selection.init( + .{ .node = first, .x = 1 }, + .{ .node = last, .x = 3 }, + true, + ); + const rectangle_row = rectangle.containedRow( + &s, + .{ .node = middle }, + ).?; + _ = rectangle_row.end().rowAndCell(); + try testing.expect((Pin{ .node = middle, .x = 1 }).eql(rectangle_row.start())); + try testing.expect((Pin{ .node = middle, .x = 1 }).eql(rectangle_row.end())); +} diff --git a/vendor/libghostty-vt/src/terminal/SelectionGesture.zig b/vendor/libghostty-vt/src/terminal/SelectionGesture.zig index f0e11f63..2be1e8aa 100644 --- a/vendor/libghostty-vt/src/terminal/SelectionGesture.zig +++ b/vendor/libghostty-vt/src/terminal/SelectionGesture.zig @@ -228,7 +228,8 @@ pub fn validatedLeftClickPin( } pub const Press = struct { - /// The time when the press event occurred. Use a monotonic timer. + /// The time when the press event occurred. Prefer a monotonic timer; + /// backwards timestamps reset the repeat sequence. /// This can be null if you're on a system that doesn't support /// time for some reason. In that case, we only support single clicks. time: ?Time, @@ -387,8 +388,8 @@ pub fn drag( .cell => dragSelection( click_pin.*, d.pin, - @intFromFloat(@max(0, self.left_click_xpos)), - @intFromFloat(@max(0, d.xpos)), + pixelFromFloat(self.left_click_xpos), + pixelFromFloat(d.xpos), d.rectangle, d.geometry, ), @@ -630,7 +631,8 @@ fn pressRepeat( const time = p.time orelse return error.PressRequiresReset; { const prev_time = self.left_click_time orelse return error.PressRequiresReset; - const since = timeSince(time, prev_time); + const since = timeSince(time, prev_time) orelse + return error.PressRequiresReset; if (since > p.repeat_interval) return error.PressRequiresReset; } @@ -664,11 +666,29 @@ fn pressRepeat( self.left_click_behavior = p.behaviors[self.left_click_count - 1]; } -fn timeSince(time: Time, prev_time: Time) u64 { - if (comptime freestanding_wasm) return time -| prev_time; +fn timeSince(time: Time, prev_time: Time) ?u64 { + if (comptime freestanding_wasm) { + if (time < prev_time) return null; + return time - prev_time; + } + + if (time.order(prev_time) == .lt) return null; return time.since(prev_time); } +/// Convert a caller-provided floating-point position to a pixel coordinate. +/// Negative and NaN values clamp to the origin, matching the drag behavior +/// outside the left edge of the surface. +fn pixelFromFloat(value: f64) u32 { + if (std.math.isNan(value) or value <= 0) return 0; + + // @intFromFloat requires a value representable by the destination type. + // Saturate first so positive infinity and oversized coordinates are safe. + const max: f64 = @floatFromInt(std.math.maxInt(u32)); + if (value >= max) return std.math.maxInt(u32); + return @intFromFloat(value); +} + fn pressSelection( self: *const SelectionGesture, screen: *Screen, @@ -713,6 +733,8 @@ fn dragSelection( // Rectangular selections are handled similarly, except that // entire columns are considered rather than individual cells. + if (geometry.columns == 0 or geometry.cell_width == 0) return null; + // We only include cells in the selection if the threshold point lies // between the start and end points of the selection. A threshold of // 60% of the cell width was chosen empirically because it felt good. @@ -721,7 +743,12 @@ fn dragSelection( )); // We use this to clamp the pixel positions below. - const max_x = geometry.columns * geometry.cell_width - 1; + const pixel_span = std.math.mul( + u32, + geometry.columns, + geometry.cell_width, + ) catch std.math.maxInt(u32); + const max_x = pixel_span - 1; // We need to know how far across in the cell the drag pos is, so // we subtract the padding and then take it modulo the cell width. @@ -935,6 +962,16 @@ fn testPress(t: *Terminal, x: u16, y: u32, time: ?std.time.Instant) Press { }; } +fn testInstant(ns: u64) std.time.Instant { + return switch (builtin.os.tag) { + .windows, .uefi, .wasi => .{ .timestamp = ns }, + else => .{ .timestamp = .{ + .sec = @intCast(ns / std.time.ns_per_s), + .nsec = @intCast(ns % std.time.ns_per_s), + } }, + }; +} + fn testDrag(t: *Terminal, x: u16, y: u32, xpos: f64, ypos: f64) Drag { return .{ .pin = t.screens.active.pages.pin(.{ .active = .{ @@ -1532,6 +1569,58 @@ test "SelectionGesture drag returns selection and records autoscroll" { try testing.expectEqual(.down, gesture.left_drag_autoscroll); } +test "SelectionGesture drag clamps unrepresentable positions" { + var t = try Terminal.init(testing.allocator, .{ .cols = 5, .rows = 5 }); + defer t.deinit(testing.allocator); + + var gesture: SelectionGesture = .init; + defer gesture.deinit(&t); + + var press_event = testPress(&t, 1, 1, try std.time.Instant.now()); + press_event.xpos = 10; + _ = try gesture.press(&t, press_event); + + const positive = gesture.drag( + &t, + testDrag(&t, 1, 1, std.math.inf(f64), 50), + ).?; + try testing.expect((testPin(&t, 1, 1)).eql(positive.start())); + try testing.expect((testPin(&t, 1, 1)).eql(positive.end())); + + try testing.expectEqual(null, gesture.drag( + &t, + testDrag(&t, 1, 1, std.math.nan(f64), 50), + )); +} + +test "SelectionGesture drag saturates overflowing geometry" { + var t = try Terminal.init(testing.allocator, .{ .cols = 5, .rows = 5 }); + defer t.deinit(testing.allocator); + + var gesture: SelectionGesture = .init; + defer gesture.deinit(&t); + + _ = try gesture.press(&t, testPress(&t, 1, 1, try std.time.Instant.now())); + var drag_event = testDrag(&t, 1, 1, 10, 50); + drag_event.geometry.columns = std.math.maxInt(u32); + drag_event.geometry.cell_width = std.math.maxInt(u32); + try testing.expectEqual(null, gesture.drag(&t, drag_event)); +} + +test "SelectionGesture drag rejects empty geometry" { + var t = try Terminal.init(testing.allocator, .{ .cols = 5, .rows = 5 }); + defer t.deinit(testing.allocator); + + var gesture: SelectionGesture = .init; + defer gesture.deinit(&t); + + _ = try gesture.press(&t, testPress(&t, 1, 1, try std.time.Instant.now())); + var drag_event = testDrag(&t, 1, 1, 10, 50); + drag_event.geometry.columns = 0; + drag_event.geometry.cell_width = 0; + try testing.expectEqual(null, gesture.drag(&t, drag_event)); +} + test "SelectionGesture release clears autoscroll and records drag" { var t = try Terminal.init(testing.allocator, .{ .cols = 5, .rows = 5 }); defer t.deinit(testing.allocator); @@ -1965,6 +2054,22 @@ test "SelectionGesture expired repeat resets click count" { try testing.expectEqual(@as(u3, 1), gesture.left_click_count); } +test "SelectionGesture backwards repeat time resets click count" { + var t = try Terminal.init(testing.allocator, .{ .cols = 5, .rows = 5 }); + defer t.deinit(testing.allocator); + + var gesture: SelectionGesture = .init; + defer gesture.deinit(&t); + + const earlier = testInstant(std.time.ns_per_s); + const later = testInstant(2 * std.time.ns_per_s); + _ = try gesture.press(&t, testPress(&t, 1, 1, later)); + _ = try gesture.press(&t, testPress(&t, 1, 1, earlier)); + + try testing.expectEqual(@as(u3, 1), gesture.left_click_count); + try testing.expectEqual(.eq, gesture.left_click_time.?.order(earlier)); +} + test "SelectionGesture screen switch resets click count" { var t = try Terminal.init(testing.allocator, .{ .cols = 5, .rows = 5 }); defer t.deinit(testing.allocator); diff --git a/vendor/libghostty-vt/src/terminal/Tabstops.zig b/vendor/libghostty-vt/src/terminal/Tabstops.zig index 68138cbf..8dbe6789 100644 --- a/vendor/libghostty-vt/src/terminal/Tabstops.zig +++ b/vendor/libghostty-vt/src/terminal/Tabstops.zig @@ -170,11 +170,11 @@ pub fn reset(self: *Tabstops, interval: usize) void { @memset(&self.prealloc_stops, 0); @memset(self.dynamic_stops, 0); - if (interval > 0) { - var i: usize = interval; - while (i < self.cols - 1) : (i += interval) { - self.set(i); - } + if (interval == 0 or self.cols <= 1) return; + + var i: usize = interval; + while (i < self.cols - 1) : (i += interval) { + self.set(i); } } @@ -230,6 +230,13 @@ test "Tabstops: interval" { try testing.expect(t.get(8)); } +test "Tabstops: interval with zero columns" { + var t: Tabstops = try init(testing.allocator, 0, 8); + defer t.deinit(testing.allocator); + + try testing.expectEqual(@as(usize, 0), t.cols); +} + test "Tabstops: count on 80" { // https://superuser.com/questions/710019/why-there-are-11-tabstops-on-a-80-column-console diff --git a/vendor/libghostty-vt/src/terminal/Terminal.zig b/vendor/libghostty-vt/src/terminal/Terminal.zig index d41e4750..08920127 100644 --- a/vendor/libghostty-vt/src/terminal/Terminal.zig +++ b/vendor/libghostty-vt/src/terminal/Terminal.zig @@ -17,6 +17,7 @@ const modespkg = @import("modes.zig"); const charsets = @import("charsets.zig"); const csi = @import("csi.zig"); const hyperlink = @import("hyperlink.zig"); +const glyph = @import("apc/glyph.zig"); const kitty = @import("kitty.zig"); const osc = @import("osc.zig"); const point = @import("point.zig"); @@ -29,6 +30,7 @@ const Stream = @import("stream_terminal.zig").Stream; const size = @import("size.zig"); const pagepkg = @import("page.zig"); const style = @import("style.zig"); +const PageList = @import("PageList.zig"); const Screen = @import("Screen.zig"); const ScreenSet = @import("ScreenSet.zig"); const Page = pagepkg.Page; @@ -81,6 +83,9 @@ modes: modespkg.ModeState = .{}, /// The most recently set mouse shape for the terminal. mouse_shape: mouse.Shape = .text, +/// Per-session Glyph Protocol registrations. +glyph_glossary: glyph.Glossary = .empty, + /// 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 @@ -146,6 +151,49 @@ pub const Colors = struct { }; }; +/// Returns the current color for an xterm OSC color target. +/// +/// Unsupported dynamic and special colors return null. The cursor color +/// follows xterm-style reporting and falls back to the foreground color when +/// no explicit cursor color is set. +pub fn colorForXterm(self: *const Terminal, target: osc.color.Target) ?color.RGB { + return switch (target) { + .palette => |i| self.colors.palette.current[i], + .dynamic => |dynamic| switch (dynamic) { + .foreground => self.colors.foreground.get(), + .background => self.colors.background.get(), + .cursor => self.colors.cursor.get() orelse + self.colors.foreground.get(), + .pointer_foreground, + .pointer_background, + .tektronix_foreground, + .tektronix_background, + .highlight_background, + .tektronix_cursor, + .highlight_foreground, + => null, + }, + .special => null, + }; +} + +/// Returns the current color for a Kitty color protocol key. +/// +/// Only palette, foreground, background, and cursor colors are backed by +/// Terminal state. Unsupported keys, or supported dynamic colors without a +/// value, return null. +pub fn colorForKitty(self: *const Terminal, key: kitty.color.Kind) ?color.RGB { + return switch (key) { + .palette => |palette| self.colors.palette.current[palette], + .special => |special| switch (special) { + .foreground => self.colors.foreground.get(), + .background => self.colors.background.get(), + .cursor => self.colors.cursor.get(), + else => null, + }, + }; +} + /// This is a set of dirty flags the renderer can use to determine /// what parts of the screen need to be redrawn. It is up to the renderer /// to clear these flags. @@ -165,6 +213,11 @@ pub const Dirty = packed struct { /// Set when the pre-edit is modified. preedit: bool = false, + + /// Set when Glyph Protocol registrations may have changed. Registered + /// glyphs can affect already-visible PUA cells, so this requires a full + /// render-state rebuild. + glyph_glossary: bool = false, }; /// Scrolling region is the area of the screen designated where scrolling @@ -256,6 +309,7 @@ pub fn deinit(self: *Terminal, alloc: Allocator) void { self.screens.deinit(alloc); self.pwd.deinit(alloc); self.title.deinit(alloc); + self.glyph_glossary.deinit(alloc); self.* = undefined; } @@ -311,6 +365,520 @@ pub fn printRepeat(self: *Terminal, count_req: usize) !void { } } +/// Print multiple codepoints to the terminal at once. This is +/// semantically identical to calling `print` for each codepoint in +/// order, but is much faster because it can batch cell writes and +/// hoist per-codepoint checks out of the hot loop. +/// +/// The codepoints must all be printable: it is illegal for any +/// codepoint in this slice to be a C0 control character. Therefore, +/// this should only be called as a result of a proper VT parser +/// (like our own). +/// +/// This is optimized for the common case: ASCII, soft-wrap, etc. +/// Sequences of codepoints that require special handling (e.g. wide characters, +/// grapheme clustering) are handled correctly but fall back to the +/// 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 { + 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..]); + if (consumed > 0) { + i += consumed; + continue; + } + + // Consuming zero bytes means that the fast path can't handle + // the next codepoint or the terminal is in a state we can't + // fast-path. Fall back to the slow cp-by-cp print then try + // fast paths again. + try self.print(@intCast(cps[i])); + i += 1; + } +} + +/// Attempt to print a prefix of `cps` using a batched fast path that +/// writes cells directly. Returns the number of codepoints consumed. +/// A return value of zero means the caller must print the first +/// codepoint via the normal `print` path. +/// +/// 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; + + 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). + // + // Codepoints above 0xFF are batchable if their width is 1 or 2 + // (excluding zero-width characters such as combining marks, ZWJ, + // and variation selectors) and, when grapheme clustering (mode + // 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). + // They also can never interact with grapheme clustering, + // which print() only performs for c > 0xFF, so they're + // immediately eligible for the narrow fill with no further + // checks. + const cp0 = cps[0]; + if (cp0 <= 0xFF) { + // C0 control characters (0x00-0x0F) aren't printable. The + // stream never sends these (they're routed to execute), but + // printSlice is a public API so defer to print() for safety. + if (cp0 < 0x10) return 0; + return self.printSliceFill( + .narrow, + cps, + grapheme_cluster, + allow_unicode, + ); + } + + if (!allow_unicode) return 0; + if (comptime build_options.kitty_graphics) { + // The Kitty graphics placeholder requires row bookkeeping. + if (cp0 == kitty.graphics.unicode.placeholder) return 0; + } + + // 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; + } + + // The width lookup is a runtime value while printSliceFill is + // specialized at comptime by width class, so this switch selects + // between the two instantiations rather than passing the width + // through as an argument. + return switch (unicode.table.get(@intCast(cp0)).width) { + 1 => self.printSliceFill( + .narrow, + cps, + grapheme_cluster, + allow_unicode, + ), + 2 => self.printSliceFill( + .wide, + cps, + grapheme_cluster, + allow_unicode, + ), + else => 0, + }; +} + +/// The width class of a printSlice batch. Each batch contains only +/// codepoints of a single width class because they fill cells +/// differently: wide codepoints occupy a (wide, spacer_tail) cell +/// pair while narrow codepoints occupy a single cell. +const PrintSliceWidth = enum(u1) { + narrow, + wide, + + /// The number of cells each codepoint of this width class occupies. + fn cellsPerCp(comptime self: PrintSliceWidth) usize { + return switch (self) { + .narrow => 1, + .wide => 2, + }; + } +}; + +/// Whether a codepoint above 0xFF is eligible for the batched print +/// fast path with the given width class. +inline fn printSliceEligible(cp: u32, comptime width: PrintSliceWidth) bool { + assert(cp > 0xFF); + if (comptime build_options.kitty_graphics) { + if (cp == kitty.graphics.unicode.placeholder) return false; + } + + return unicode.table.get(@intCast(cp)).width == comptime @as(u2, switch (width) { + .narrow => 1, + .wide => 2, + }); +} + +/// The row-filling portion of the printSlice fast path, specialized by +/// width class. The first codepoint must already be validated by the +/// caller (printSliceFast). +fn printSliceFill( + self: *Terminal, + comptime width: PrintSliceWidth, + cps: []const u32, + grapheme_cluster: bool, + allow_unicode: bool, +) !usize { + const screen: *Screen = self.screens.active; + + // Our fast path can only handle "simple" cells. A simple cell is + // a codepoint cell (no grapheme data or bg-color tag), narrow, and + // not a hyperlink. The mask covers every field that must match + // the expected value (see printSliceCheckExpected) exactly. + const SimpleMask = pagepkg.Mask(Cell, &.{ + "content_tag", + "style_id", + "wide", + "hyperlink", + }, 4); + + // The bit offset of the codepoint content within a Cell, used to + // construct cell values from a template without field assignments. + const cp_shift = @bitOffsetOf(Cell, "content"); + + // Determine the run of codepoints in the same width class that we + // can batch. For codepoints after the first, the previous codepoint + // in the run is always written as a fresh, single-codepoint cell, + // so the grapheme break check against it is exact. + const run_len: usize = run: { + var idx: usize = 1; + + // Vectorized scan for the narrow class: codepoints in + // [0x10, 0xFF] are always eligible with no further checks + // and dominate real-world input, so scan for the first + // codepoint outside that range several lanes at a time. + // Anything else (including eligible unicode) proceeds via + // the scalar loop below. + if (comptime width == .narrow) { + const lanes = 8; + const V = @Vector(lanes, u32); + const lo: V = @splat(0x10); + const hi: V = @splat(0xFF); + while (idx + lanes <= cps.len) { + const v: V = cps[idx..][0..lanes].*; + const in_range = (v >= lo) & (v <= hi); + if (!@reduce(.And, in_range)) { + const bits: std.meta.Int(.unsigned, lanes) = @bitCast(in_range); + idx += @ctz(~bits); + break; + } + idx += lanes; + } + } + + while (idx < cps.len) : (idx += 1) { + const cp = cps[idx]; + if (comptime width == .narrow) { + if (cp >= 0x10 and cp <= 0xFF) continue; + } + if (cp > 0xFF and allow_unicode and printSliceEligible(cp, width)) { + if (!grapheme_cluster) continue; + var state: uucode.grapheme.BreakState = .default; + if (unicode.graphemeBreak(@intCast(cps[idx - 1]), @intCast(cp), &state)) continue; + } + break :run idx; + } + break :run cps.len; + }; + assert(run_len > 0); + + // After doing any printing, wrapping, scrolling, etc. we want to + // ensure that our screen remains in a consistent state. + defer screen.assertIntegrity(); + + // The number of cells each codepoint occupies. + const cells_per_cp: usize = comptime width.cellsPerCp(); + + var printed: usize = 0; + outer: while (printed < run_len) { + // If we're soft-wrapping, handle that first so that our cursor + // is in the row/column that will receive the next codepoint. + if (screen.cursor.pending_wrap) try self.printWrap(); + + // Our right margin depends on where our cursor is now, + // matching the logic in print(). + const right_limit: usize = if (screen.cursor.x > self.scrolling_region.right) + self.cols + else + self.scrolling_region.right + 1; + + // A degenerate 1-wide region can't hold a wide char; print() + // has special handling so fall back to it. + if (comptime width == .wide) { + if (right_limit - self.scrolling_region.left <= 1) break; + } + + const cursor = &screen.cursor; + const avail: usize = right_limit - cursor.x; + assert(avail > 0); + + // The cursor caches live row and cell pointers into this mapping, so + // its page cannot be compressed while this print path is active. + const page = cursor.page_pin.node.pageAssumeResident(); + const cells: [*]Cell = @ptrCast(cursor.page_cell); + const style_id = cursor.style_id; + const template: Cell = .{ + .content_tag = .codepoint, + .content = .{ .codepoint = 0 }, + .style_id = style_id, + .wide = .narrow, + .protected = cursor.protected, + .semantic_content = cursor.semantic_content, + }; + const template_bits: u64 = @bitCast(template); + const check_expected: u64 = printSliceCheckExpected(style_id); + + if (comptime width == .wide) { + if (avail == 1) { + // Only one cell left in the row: print() writes a + // spacer head (or a blank narrow cell if we're inside + // a right margin) and wraps. We require a simple cell, + // otherwise fall back to print() for the cleanup. + if (!SimpleMask.eqlScalar(cells[0], check_expected)) break; + + var spacer = template; + if (right_limit == self.cols) { + cursor.page_row.wrap = true; + spacer.wide = .spacer_head; + } + cursor.page_row.dirty = true; + if (style_id != style.default_id) cursor.page_row.styled = true; + cells[0] = spacer; + try self.printWrap(); + continue :outer; + } + } + + // Number of codepoints and cells we're writing to this row. + const count = @min(avail / cells_per_cp, run_len - printed); + assert(count > 0); + const cell_count = count * cells_per_cp; + + // Wide cells always come in (wide, spacer_tail) pairs. + const spacer_bits: u64 = if (comptime width == .wide) spacer: { + var spacer = template; + spacer.wide = .spacer_tail; + break :spacer @bitCast(spacer); + } else undefined; + const wide_bits: u64 = if (comptime width == .wide) wb: { + var w = template; + w.wide = .wide; + break :wb @bitCast(w); + } else undefined; + + var k: usize = 0; // cells written + fill: while (k < cell_count) { + // Find the run of simple cells so the store loop below is + // branch-free (and vectorizable). This is an early-exit + // search loop that LLVM won't auto-vectorize, and reused + // rows typically match the whole way through, so scan + // several cells at a time manually. + var simple = k; + simple: { + while (simple + SimpleMask.group_len <= cell_count) { + const p = SimpleMask.eqlPrefix( + cells[0..cell_count], + simple, + check_expected, + ); + simple += p; + if (p != SimpleMask.group_len) break :simple; + } + while (simple < cell_count) : (simple += 1) { + if (!SimpleMask.eqlScalar( + cells[simple], + check_expected, + )) break; + } + } + + if (comptime width == .wide) { + // We can only write whole (wide, spacer) pairs. + const pair_end = k + (simple - k) / 2 * 2; + var idx = k; + while (idx < pair_end) : (idx += 2) { + cells[idx] = @bitCast( + wide_bits | (@as(u64, cps[printed + idx / 2]) << cp_shift), + ); + cells[idx + 1] = @bitCast(spacer_bits); + } + // If the simple run ended mid-pair we stop at the pair + // boundary and handle the offending cell below. + k = pair_end; + if (simple != pair_end) { + // The first cell of the next pair is simple but the + // second isn't; handle both via the general path. + simple = pair_end; + } + } else { + for (k..simple) |idx| { + cells[idx] = @bitCast( + template_bits | (@as(u64, cps[printed + idx]) << cp_shift), + ); + } + k = simple; + } + if (k >= cell_count) break; + + // Bulk path for runs of cells that differ from the + // expected simple cell only by their style: this is the + // common case when styled text overwrites previously + // styled (or default-styled) rows, e.g. TUI redraws. + // These runs are handled wholesale: one scan to find the + // run of identical old styles, two ref-count updates, + // and a branch-free fill. + if (comptime width == .narrow) bulk: { + const first = SimpleMask.pattern(cells[k]); + + // The old cell must be a plain narrow codepoint cell + // with no hyperlink whose only difference is the + // style id (see printSliceCheckExpected: every other + // masked field must be zero). + const style_shift = @bitOffsetOf(Cell, "style_id"); + const old_style: style.Id = @truncate(first >> style_shift); + if (first != printSliceCheckExpected(old_style)) break :bulk; + assert(old_style != style_id); // it failed the simple check + + // Find the run of cells with identical masked bits. + var m = k + 1; + scan: { + while (m + SimpleMask.group_len <= cell_count) { + const p = SimpleMask.eqlPrefix( + cells[0..cell_count], + m, + first, + ); + m += p; + if (p != SimpleMask.group_len) break :scan; + } + while (m < cell_count) : (m += 1) { + if (!SimpleMask.eqlScalar(cells[m], first)) break; + } + } + + // Fix up the style ref counts for the whole run at + // once. Each of the old cells held a reference to + // old_style so the release is safe by construction. + const n = m - k; + if (old_style != style.default_id) { + page.styles.releaseMultiple(page.memory, old_style, @intCast(n)); + } + if (style_id != style.default_id) { + page.styles.useMultiple(page.memory, style_id, @intCast(n)); + } + + for (k..m) |idx| { + cells[idx] = @bitCast( + template_bits | (@as(u64, cps[printed + idx]) << cp_shift), + ); + } + k = m; + continue :fill; + } + + // General path for cells that failed the masked check: + // style-only mismatches are handled inline; anything that + // needs cleanup (wide chars and their spacers, grapheme + // data, hyperlinks) falls back to print(). + const general_count: usize = cells_per_cp; + for (0..general_count) |offset| { + const cell = &cells[k + offset]; + if (cell.wide != .narrow or + cell.hasGrapheme() or + cell.hyperlink) break :fill; + } + for (0..general_count) |offset| { + const cell = &cells[k + offset]; + if (cell.style_id != style_id) { + if (cell.style_id != style.default_id) { + page.styles.release(page.memory, cell.style_id); + } + if (style_id != style.default_id) { + page.styles.use(page.memory, style_id); + } + } + } + if (comptime width == .wide) { + cells[k] = @bitCast( + wide_bits | (@as(u64, cps[printed + k / 2]) << cp_shift), + ); + cells[k + 1] = @bitCast(spacer_bits); + } else { + cells[k] = @bitCast( + template_bits | (@as(u64, cps[printed + k]) << cp_shift), + ); + } + k += cells_per_cp; + } + + if (k > 0) { + assert(k % cells_per_cp == 0); + cursor.page_row.dirty = true; + if (style_id != style.default_id) cursor.page_row.styled = true; + self.previous_char = @intCast(cps[printed + k / cells_per_cp - 1]); + printed += k / cells_per_cp; + + // Advance the cursor. If we filled through the right limit + // then the cursor stays on the last cell with the pending + // wrap flag set, matching print(). + if (cursor.x + k >= right_limit) { + assert(cursor.x + k == right_limit); + screen.cursorRight(@intCast(k - 1)); + cursor.pending_wrap = true; + } else { + screen.cursorRight(@intCast(k)); + } + } + + // We hit a cell that requires the slow path. The cursor is + // exactly at that cell so return and let the caller print the + // next codepoint via print(). + if (k < cell_count) break; + } + + return printed; +} + +/// The expected value of a simple cell (per SimpleMask in +/// printSliceFill) that already has the given style (so no +/// ref-counting is needed). +inline fn printSliceCheckExpected(style_id: style.Id) u64 { + var e: Cell = @bitCast(@as(u64, 0)); + e.style_id = style_id; + return @bitCast(e); +} + pub fn print(self: *Terminal, c: u21) !void { // log.debug("print={x} y={} x={}", .{ c, self.screens.active.cursor.y, self.screens.active.cursor.x }); @@ -381,10 +949,16 @@ pub fn print(self: *Terminal, c: u21) !void { const grapheme_break = brk: { var state: uucode.grapheme.BreakState = .default; if (prev.cell.hasGrapheme()) { - const cps = self.screens.active.cursor.page_pin.node.data.lookupGrapheme(prev.cell).?; + const cps = self.screens.active.cursor.page_pin.node.page().lookupGrapheme(prev.cell).?; for (cps) |cp2| { // log.debug("cp1={x} cp2={x}", .{ previous_codepoint, cp2 }); - assert(!unicode.graphemeBreak(previous_codepoint, cp2, &state)); + // With mode 2027 disabled, zero-width codepoints are + // attached without applying grapheme boundary rules. If + // the mode is enabled later, an existing cell can + // therefore contain one or more breaks. Feed those breaks + // into the state machine so it can reset its context and + // determine the boundary for the new codepoint. + _ = unicode.graphemeBreak(previous_codepoint, cp2, &state); previous_codepoint = cp2; } } @@ -396,33 +970,8 @@ pub fn print(self: *Terminal, c: u21) !void { // If we can NOT break, this means that "c" is part of a grapheme // with the previous char. if (!grapheme_break) { - var desired_wide: enum { no_change, wide, narrow } = .no_change; - - // If this is an emoji variation selector then we need to modify - // the cell width accordingly. VS16 makes the character wide and - // VS15 makes it narrow. - if (c == 0xFE0F or c == 0xFE0E) { - const prev_props = unicode.table.get(previous_codepoint); - // Check if it is a valid variation sequence in - // emoji-variation-sequences.txt, and if not, ignore the char. - if (!prev_props.emoji_vs_base) return; - - switch (c) { - 0xFE0F => desired_wide = .wide, - 0xFE0E => desired_wide = .narrow, - else => unreachable, - } - } else if (!unicode.table.get(c).width_zero_in_grapheme) { - // If we have a code point that contributes to the width of a - // grapheme, it necessarily means that we're at least at width - // 2, since the first code point must be at least width 1 to - // start. (Note that Prepend code points could effectively mean - // the first code point should be width 0, but we don't handle - // that yet.) - desired_wide = .wide; - } - - switch (desired_wide) { + switch (unicode.graphemeWidthEffect(previous_codepoint, c)) { + .ignore => return, .wide => wide: { if (prev.cell.wide == .wide) break :wide; @@ -463,19 +1012,19 @@ pub fn print(self: *Terminal, c: u21) !void { const old_rac = old_pin.rowAndCell(); if (new_pin.node == old_pin.node) { - new_pin.node.data.moveGrapheme(prev.cell, new_rac.cell); + new_pin.node.page().moveGrapheme(prev.cell, new_rac.cell); prev.cell.content_tag = .codepoint; new_rac.cell.content_tag = .codepoint_grapheme; new_rac.row.grapheme = true; } else { - const cps = old_pin.node.data.lookupGrapheme(old_rac.cell).?; + const cps = old_pin.node.page().lookupGrapheme(old_rac.cell).?; for (cps) |cp| { try self.screens.active.appendGrapheme(new_rac.cell, cp); } - old_pin.node.data.clearGrapheme(old_rac.cell); + old_pin.node.page().clearGrapheme(old_rac.cell); } - old_pin.node.data.updateRowGraphemeFlag(old_rac.row); + old_pin.node.page().updateRowGraphemeFlag(old_rac.row); } // Point prev.cell to our new previous cell that @@ -539,7 +1088,7 @@ pub fn print(self: *Terminal, c: u21) !void { break :narrow; }, - else => {}, + .no_change => {}, } log.debug("c={X} grapheme attach to left={} primary_cp={X}", .{ @@ -742,7 +1291,7 @@ fn printCell( const spacer_cell = self.screens.active.cursorCellRight(1); self.screens.active.clearCells( - &self.screens.active.cursor.page_pin.node.data, + self.screens.active.cursor.page_pin.node.page(), self.screens.active.cursor.page_row, spacer_cell[0..1], ); @@ -768,7 +1317,7 @@ fn printCell( const wide_cell = self.screens.active.cursorCellLeft(1); self.screens.active.clearCells( - &self.screens.active.cursor.page_pin.node.data, + self.screens.active.cursor.page_pin.node.page(), self.screens.active.cursor.page_row, wide_cell[0..1], ); @@ -791,7 +1340,7 @@ fn printCell( // If the prior value had graphemes, clear those if (cell.hasGrapheme()) { - const page = &self.screens.active.cursor.page_pin.node.data; + const page = self.screens.active.cursor.page_pin.node.page(); page.clearGrapheme(cell); page.updateRowGraphemeFlag(self.screens.active.cursor.page_row); } @@ -800,7 +1349,7 @@ fn printCell( // cell's new style will be different after writing. const style_changed = cell.style_id != self.screens.active.cursor.style_id; if (style_changed) { - var page = &self.screens.active.cursor.page_pin.node.data; + var page = self.screens.active.cursor.page_pin.node.page(); // Release the old style. if (cell.style_id != style.default_id) { @@ -823,7 +1372,7 @@ fn printCell( }; if (style_changed) { - var page = &self.screens.active.cursor.page_pin.node.data; + var page = self.screens.active.cursor.page_pin.node.page(); // Use the new style. if (cell.style_id != style.default_id) { @@ -852,7 +1401,7 @@ fn printCell( }; } else if (had_hyperlink) { // If the previous cell had a hyperlink then we need to clear it. - var page = &self.screens.active.cursor.page_pin.node.data; + var page = self.screens.active.cursor.page_pin.node.page(); page.clearHyperlink(cell); page.updateRowHyperlinkFlag(self.screens.active.cursor.page_row); } @@ -1216,10 +1765,8 @@ pub fn semanticPrompt( // within a prompt area to SGR mouse events and defers to the // shell to handle them. if (cmd.readOption(.click_events)) |v| { - if (v) { - screen.semantic_prompt.click = .click_events; - break :click; - } + screen.semantic_prompt.click = .{ .click_events = v }; + break :click; } // If click_events was not set or disabled, fallback to `cl`. @@ -1479,10 +2026,19 @@ pub fn index(self: *Terminal) !void { screen.kitty_images.dirty = true; } - // If our scrolling region is at the top, we create scrollback. + // If our scrolling region is at the top, we create scrollback, + // but only if our screen retains scrollback. If our screen + // doesn't retain scrollback (e.g. the alternate screen) then + // creating scrollback is pure overhead: the rows are never + // visible and are simply pruned later. In that case we use the + // in-place region scroll below, unless the region is a single + // row (a one row screen) which cursorScrollRegionUp can't + // handle (and cursorDownScroll special-cases). if (self.scrolling_region.top == 0 and self.scrolling_region.left == 0 and - self.scrolling_region.right == self.cols - 1) + self.scrolling_region.right == self.cols - 1 and + (!screen.no_scrollback or + self.scrolling_region.bottom == 0)) { try screen.cursorScrollAbove(); return; @@ -1490,49 +2046,18 @@ pub fn index(self: *Terminal) !void { // Slow path for left and right scrolling region margins. if (self.scrolling_region.left != 0 or - self.scrolling_region.right != self.cols - 1 or - - // PERF(mitchellh): If we have an SGR background set then - // we need to preserve that background in our erased rows. - // scrollUp does that but eraseRowBounded below does not. - // However, scrollUp is WAY slower. We should optimize this - // case to work in the eraseRowBounded codepath and remove - // this check. - !screen.blankCell().isZero()) + self.scrolling_region.right != self.cols - 1) { try self.scrollUp(1); return; } - // Otherwise use a fast path function from PageList to efficiently - // scroll the contents of the scrolling region. - - // Preserve old cursor just for assertions - const old_cursor = screen.cursor; - - try screen.pages.eraseRowBounded( - .{ .active = .{ .y = self.scrolling_region.top } }, + // Otherwise use a fast path function to efficiently scroll + // the contents of the scrolling region. + try screen.cursorScrollRegionUp( self.scrolling_region.bottom - self.scrolling_region.top, ); - // eraseRow and eraseRowBounded will end up moving the cursor pin - // up by 1, so we need to move it back down. A `cursorReload` - // would be better option but this is more efficient and this is - // a super hot path so we do this instead. - assert(screen.cursor.x == old_cursor.x); - assert(screen.cursor.y == old_cursor.y); - screen.cursor.y -= 1; - screen.cursorDown(1); - - // The operations above can prune our cursor style so we need to - // update. This should never fail because the above can only FREE - // memory. - screen.manualStyleUpdate() catch |err| { - std.log.warn("deleteLines manualStyleUpdate err={}", .{err}); - screen.cursor.style = .{}; - screen.manualStyleUpdate() catch unreachable; - }; - return; } @@ -1601,8 +2126,8 @@ pub fn setCursorPos(self: *Terminal, row_req: usize, col_req: usize) void { // Calculate our new x/y const row = if (row_req == 0) 1 else row_req; const col = if (col_req == 0) 1 else col_req; - const x = @min(params.x_max, col + params.x_offset) -| 1; - const y = @min(params.y_max, row + params.y_offset) -| 1; + const x = @min(params.x_max, col +| params.x_offset) -| 1; + const y = @min(params.y_max, row +| params.y_offset) -| 1; // If the y is unchanged then this is fast pointer math if (y == self.screens.active.cursor.y) { @@ -1691,9 +2216,17 @@ pub fn scrollUp(self: *Terminal, count: usize) !void { // 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. + // + // If our screen doesn't retain scrollback (e.g. the alternate + // screen) then creating scrollback is pure overhead, so we use the + // deleteLines path below instead, unless the region is the full + // screen where cursorScrollAbove has a specialized fast path + // (cursorDownScroll) for scrolling without scrollback. if (self.scrolling_region.top == 0 and self.scrolling_region.left == 0 and - self.scrolling_region.right == self.cols - 1) + self.scrolling_region.right == self.cols - 1 and + (!self.screens.active.no_scrollback or + self.scrolling_region.bottom == self.rows - 1)) { // Scrolling dirties the images because it updates their placements pins. if (comptime build_options.kitty_graphics) { @@ -1731,10 +2264,18 @@ pub const ScrollViewport = union(Tag) { /// Scroll by some delta amount, up is negative. delta: isize, + /// Scroll to the given absolute row offset from the top of the + /// scrollable area. A value of zero is the top row. The requested + /// row becomes the first visible row of the viewport, clamped so + /// the viewport never scrolls beyond the top of the active area. + /// This is the same row space as PageList.Scrollbar offset. + row: usize, + pub const Tag = lib.Enum(lib.target, &.{ "top", "bottom", "delta", + "row", }); const c_union = lib.TaggedUnion( @@ -1755,9 +2296,80 @@ pub fn scrollViewport(self: *Terminal, behavior: ScrollViewport) void { .top => .{ .top = {} }, .bottom => .{ .active = {} }, .delta => |delta| .{ .delta_row = delta }, + .row => |row| .{ .row = row }, }); } +/// Return the current compression activity value. +/// +/// Callers should schedule a `compress` call whenever this value changes. The +/// direction of the change has no meaning; this is an opaque change token +/// rather than a monotonic sequence exposed by Terminal. +/// +/// It is up to the terminal what it decides to compress, but currently +/// we compress cold (non-viewed, non-editable) scrollback history on +/// the primary screen. +/// +/// Note that compression requires specific system features, namely +/// the ability to retain virtual memory allocations while discarding their +/// physical memory backings. Callers must still use `compress` to determine +/// whether compression is supported on the current target. +pub fn compressionActivity(self: *const Terminal) u64 { + const state = &self.screens.get(.primary).?.pages.page_compression; + // For now we don't use the extra 16 bits. + return @as(u64, state.activity_serial); +} + +/// The amount of compression work performed by `compress` before returning. +/// +/// The declaration order is part of the libghostty-vt C ABI. Removed values +/// must leave a `null` hole so later values retain their integer values. +pub const CompressionMode = lib.Enum(lib.target, &.{ + "incremental", + "full", +}); + +/// The scheduling result of a `compress` call. +/// +/// The declaration order is part of the libghostty-vt C ABI. Removed values +/// must leave a `null` hole so later values retain their integer values. +pub const CompressionResult = lib.Enum(lib.target, &.{ + "unsupported", + "pending", + "complete", +}); + +/// Compress cold memory to save resident memory space. +/// +/// Full compression does a full pass compressing everything it can before +/// returning. This is not recommended for interactive terminals because +/// compression is relatively slow and with large scrollbacks this can cause +/// stalls. +/// +/// Incremental compression bounds itself on how much data it can look +/// up to compress and how much compression work it does before returning. +/// It is stateful (we maintain the state) and the return value tells callers +/// whether they should continue calling it in the future. +/// +/// Callers should schedule compression when it doesn't impact user +/// experience, for example during idle times. +pub fn compress( + self: *Terminal, + mode: CompressionMode, +) CompressionResult { + const pages = &self.screens.get(.primary).?.pages; + const result = switch (mode) { + .incremental => pages.compress(.incremental), + .full => pages.compress(.full), + }; + + return switch (result) { + .unsupported => .unsupported, + .pending => .pending, + .complete => .complete, + }; +} + /// To be called before shifting a row (as in insertLines and deleteLines) /// /// Takes care of boundary conditions such as potentially split wide chars @@ -1817,6 +2429,21 @@ fn rowWillBeShifted( } } +/// Renew every live page generation in an inclusive range before a full-width +/// line operation moves logical rows between their coordinates. +fn invalidateFullWidthRowRange( + self: *Terminal, + first: *PageList.List.Node, + last: *PageList.List.Node, +) void { + var node = first; + while (true) : (node = node.next.?) { + // Full-width line movement remaps cached row coordinates in this page. + self.screens.active.pages.invalidateNodeLayout(node); + if (node == last) break; + } +} + // TODO(qwerasd): `insertLines` and `deleteLines` are 99% identical, // the majority of their logic can (and should) be abstracted in to // a single shared helper function, probably on `Screen` not here. @@ -1892,6 +2519,12 @@ pub fn insertLines(self: *Terminal, count: usize) void { }; defer self.screens.active.pages.untrackPin(cur_p); + // Partial-width margins edit cells in stable rows; full-width moves rows. + if (!left_right) self.invalidateFullWidthRowRange( + self.screens.active.cursor.page_pin.node, + cur_p.node, + ); + // Our current y position relative to the cursor var y: usize = rem; @@ -1906,8 +2539,8 @@ pub fn insertLines(self: *Terminal, count: usize) void { const off_rac = off_p.rowAndCell(); const off_row: *Row = off_rac.row; - self.rowWillBeShifted(&cur_p.node.data, cur_row); - self.rowWillBeShifted(&off_p.node.data, off_row); + self.rowWillBeShifted(cur_p.node.page(), cur_row); + self.rowWillBeShifted(off_p.node.page(), off_row); // If our scrolling region is full width, then we unset wrap. if (!left_right) { @@ -1925,8 +2558,8 @@ pub fn insertLines(self: *Terminal, count: usize) void { // If our page doesn't match, then we need to do a copy from // one page to another. This is the slow path. if (src_p.node != dst_p.node) { - dst_p.node.data.clonePartialRowFrom( - &src_p.node.data, + dst_p.node.page().clonePartialRowFrom( + src_p.node.page(), dst_row, src_row, self.scrolling_region.left, @@ -1989,11 +2622,11 @@ pub fn insertLines(self: *Terminal, count: usize) void { src_row.* = dst; // Ensure what we did didn't corrupt the page - cur_p.node.data.assertIntegrity(); + cur_p.node.page().assertIntegrity(); } else { // Left/right scroll margins we have to // copy cells, which is much slower... - const page = &cur_p.node.data; + const page = cur_p.node.page(); page.moveCells( src_row, self.scrolling_region.left, @@ -2005,8 +2638,8 @@ pub fn insertLines(self: *Terminal, count: usize) void { } } else { // Clear the cells for this row, it has been shifted. - self.rowWillBeShifted(&cur_p.node.data, cur_row); - const page = &cur_p.node.data; + self.rowWillBeShifted(cur_p.node.page(), cur_row); + const page = cur_p.node.page(); const cells = page.getCells(cur_row); self.screens.active.clearCells( page, @@ -2087,6 +2720,12 @@ pub fn deleteLines(self: *Terminal, count: usize) void { }; defer self.screens.active.pages.untrackPin(cur_p); + // Partial-width margins edit cells in stable rows; full-width moves rows. + if (!left_right) self.invalidateFullWidthRowRange( + cur_p.node, + cur_p.down(rem - 1).?.node, + ); + // Our current y position relative to the cursor var y: usize = 0; @@ -2101,8 +2740,8 @@ pub fn deleteLines(self: *Terminal, count: usize) void { const off_rac = off_p.rowAndCell(); const off_row: *Row = off_rac.row; - self.rowWillBeShifted(&cur_p.node.data, cur_row); - self.rowWillBeShifted(&off_p.node.data, off_row); + self.rowWillBeShifted(cur_p.node.page(), cur_row); + self.rowWillBeShifted(off_p.node.page(), off_row); // If our scrolling region is full width, then we unset wrap. if (!left_right) { @@ -2120,8 +2759,8 @@ pub fn deleteLines(self: *Terminal, count: usize) void { // If our page doesn't match, then we need to do a copy from // one page to another. This is the slow path. if (src_p.node != dst_p.node) { - dst_p.node.data.clonePartialRowFrom( - &src_p.node.data, + dst_p.node.page().clonePartialRowFrom( + src_p.node.page(), dst_row, src_row, self.scrolling_region.left, @@ -2177,11 +2816,11 @@ pub fn deleteLines(self: *Terminal, count: usize) void { src_row.* = dst; // Ensure what we did didn't corrupt the page - cur_p.node.data.assertIntegrity(); + cur_p.node.page().assertIntegrity(); } else { // Left/right scroll margins we have to // copy cells, which is much slower... - const page = &cur_p.node.data; + const page = cur_p.node.page(); page.moveCells( src_row, self.scrolling_region.left, @@ -2193,8 +2832,8 @@ pub fn deleteLines(self: *Terminal, count: usize) void { } } else { // Clear the cells for this row, it's from out of bounds. - self.rowWillBeShifted(&cur_p.node.data, cur_row); - const page = &cur_p.node.data; + self.rowWillBeShifted(cur_p.node.page(), cur_row); + const page = cur_p.node.page(); const cells = page.getCells(cur_row); self.screens.active.clearCells( page, @@ -2248,7 +2887,7 @@ pub fn insertBlanks(self: *Terminal, count: usize) void { // left is just the cursor position but as a multi-pointer const left: [*]Cell = @ptrCast(self.screens.active.cursor.page_cell); - var page = &self.screens.active.cursor.page_pin.node.data; + var page = self.screens.active.cursor.page_pin.node.page(); // If our X is a wide spacer tail then we need to erase the // previous cell too so we don't split a multi-cell character. @@ -2331,7 +2970,7 @@ pub fn deleteChars(self: *Terminal, count_req: usize) void { // left is just the cursor position but as a multi-pointer const left: [*]Cell = @ptrCast(self.screens.active.cursor.page_cell); - var page = &self.screens.active.cursor.page_pin.node.data; + var page = self.screens.active.cursor.page_pin.node.page(); // Remaining cols from our cursor to the right margin. const rem = self.scrolling_region.right - self.screens.active.cursor.x + 1; @@ -2408,7 +3047,7 @@ pub fn eraseChars(self: *Terminal, count_req: usize) void { // mode was not ISO we also always ignore protection attributes. if (self.screens.active.protected_mode != .iso) { self.screens.active.clearCells( - &self.screens.active.cursor.page_pin.node.data, + self.screens.active.cursor.page_pin.node.page(), self.screens.active.cursor.page_row, cells[0..count], ); @@ -2416,7 +3055,7 @@ pub fn eraseChars(self: *Terminal, count_req: usize) void { } self.screens.active.clearUnprotectedCells( - &self.screens.active.cursor.page_pin.node.data, + self.screens.active.cursor.page_pin.node.page(), self.screens.active.cursor.page_row, cells[0..count], ); @@ -2488,7 +3127,7 @@ pub fn eraseLine( // to fill the entire line. if (!protected) { self.screens.active.clearCells( - &self.screens.active.cursor.page_pin.node.data, + self.screens.active.cursor.page_pin.node.page(), self.screens.active.cursor.page_row, cells[start..end], ); @@ -2496,7 +3135,7 @@ pub fn eraseLine( } self.screens.active.clearUnprotectedCells( - &self.screens.active.cursor.page_pin.node.data, + self.screens.active.cursor.page_pin.node.page(), self.screens.active.cursor.page_row, cells[start..end], ); @@ -2670,7 +3309,7 @@ pub fn decaln(self: *Terminal) !void { // Fill with Es by moving the cursor but reset it after. while (true) { - const page = &self.screens.active.cursor.page_pin.node.data; + const page = self.screens.active.cursor.page_pin.node.page(); const row = self.screens.active.cursor.page_row; const cells_multi: [*]Cell = row.cells.ptr(page.memory); const cells = cells_multi[0..page.size.cols]; @@ -2720,6 +3359,22 @@ pub fn kittyGraphics( return kitty.graphics.execute(alloc, self, cmd); } +/// Execute a Glyph Protocol APC command against this terminal's per-session +/// glossary. The returned response, if any, should be sent back to the pty as +/// a complete APC sequence via `Response.formatWire`. +pub fn glyphProtocol( + self: *Terminal, + alloc: Allocator, + req: *const glyph.Request, +) ?glyph.Response { + const resp = glyph.execute(alloc, &self.glyph_glossary, req); + switch (req.*) { + .register, .clear => self.flags.dirty.glyph_glossary = true, + .support, .query => {}, + } + return resp; +} + /// Set the storage size limit for Kitty graphics across all screens. pub fn setKittyGraphicsSizeLimit( self: *Terminal, @@ -2890,8 +3545,13 @@ pub fn resize( // Resize our tabstops if (self.cols != cols) { + const tabstops: Tabstops = try .init( + alloc, + cols, + TABSTOP_INTERVAL, + ); self.tabstops.deinit(alloc); - self.tabstops = try .init(alloc, cols, 8); + self.tabstops = tabstops; } // Resize primary screen, which supports reflow @@ -2926,13 +3586,33 @@ pub fn resize( }; } +test "Terminal: resize preserves tabstops on allocation failure" { + var failing = testing.FailingAllocator.init(testing.allocator, .{}); + const alloc = failing.allocator(); + var t = try init(alloc, .{ .cols = 10, .rows = 1 }); + defer t.deinit(alloc); + + failing.fail_index = failing.alloc_index; + try testing.expectError(error.OutOfMemory, t.resize(alloc, 513, 1)); + + try testing.expectEqual(@as(size.CellCountInt, 10), t.cols); + try testing.expect(t.tabstops.get(8)); +} + /// Set the pwd for the terminal. pub fn setPwd(self: *Terminal, pwd: []const u8) !void { - self.pwd.clearRetainingCapacity(); - if (pwd.len > 0) { - try self.pwd.appendSlice(self.gpa(), pwd); - try self.pwd.append(self.gpa(), 0); + if (pwd.len == 0) { + self.pwd.clearRetainingCapacity(); + return; } + + const capacity = std.math.add(usize, pwd.len, 1) catch + return error.OutOfMemory; + try self.pwd.ensureTotalCapacity(self.gpa(), capacity); + + self.pwd.items.len = capacity; + std.mem.copyForwards(u8, self.pwd.items[0..pwd.len], pwd); + self.pwd.items[pwd.len] = 0; } /// Returns the pwd for the terminal, if any. The memory is owned by the @@ -2942,13 +3622,41 @@ pub fn getPwd(self: *const Terminal) ?[:0]const u8 { return self.pwd.items[0 .. self.pwd.items.len - 1 :0]; } +test "Terminal: setPwd preserves a sentinel on allocation failure" { + var failing = testing.FailingAllocator.init(testing.allocator, .{}); + const alloc = failing.allocator(); + var t = try init(alloc, .{ .cols = 5, .rows = 1 }); + defer t.deinit(alloc); + + try t.pwd.ensureTotalCapacityPrecise(alloc, 3); + failing.fail_index = failing.alloc_index; + try testing.expectError(error.OutOfMemory, t.setPwd("pwd")); + try testing.expect(t.getPwd() == null); +} + +test "Terminal: setPwd accepts its current value" { + var t = try init(testing.allocator, .{ .cols = 5, .rows = 1 }); + defer t.deinit(testing.allocator); + + try t.setPwd("file:///tmp"); + try t.setPwd(t.getPwd().?); + try testing.expectEqualStrings("file:///tmp", t.getPwd().?); +} + /// Set the title for the terminal, as set by escape sequences (e.g. OSC 0/2). pub fn setTitle(self: *Terminal, t: []const u8) !void { - self.title.clearRetainingCapacity(); - if (t.len > 0) { - try self.title.appendSlice(self.gpa(), t); - try self.title.append(self.gpa(), 0); + if (t.len == 0) { + self.title.clearRetainingCapacity(); + return; } + + const capacity = std.math.add(usize, t.len, 1) catch + return error.OutOfMemory; + try self.title.ensureTotalCapacity(self.gpa(), capacity); + + self.title.items.len = capacity; + std.mem.copyForwards(u8, self.title.items[0..t.len], t); + self.title.items[t.len] = 0; } /// Returns the title for the terminal, if any. The memory is owned by the @@ -2958,6 +3666,27 @@ pub fn getTitle(self: *const Terminal) ?[:0]const u8 { return self.title.items[0 .. self.title.items.len - 1 :0]; } +test "Terminal: setTitle preserves a sentinel on allocation failure" { + var failing = testing.FailingAllocator.init(testing.allocator, .{}); + const alloc = failing.allocator(); + var t = try init(alloc, .{ .cols = 5, .rows = 1 }); + defer t.deinit(alloc); + + try t.title.ensureTotalCapacityPrecise(alloc, 5); + failing.fail_index = failing.alloc_index; + try testing.expectError(error.OutOfMemory, t.setTitle("title")); + try testing.expect(t.getTitle() == null); +} + +test "Terminal: setTitle accepts its current value" { + var t = try init(testing.allocator, .{ .cols = 5, .rows = 1 }); + defer t.deinit(testing.allocator); + + try t.setTitle("Ghostty"); + try t.setTitle(t.getTitle().?); + try testing.expectEqualStrings("Ghostty", t.getTitle().?); +} + /// Switch to the given screen type (alternate or primary). /// /// This does NOT handle behaviors such as clearing the screen, @@ -3171,6 +3900,7 @@ pub fn fullReset(self: *Terminal) void { self.previous_char = null; self.pwd.clearRetainingCapacity(); self.title.clearRetainingCapacity(); + self.glyph_glossary.clearAndFree(self.gpa()); self.status_display = .main; self.scrolling_region = .{ .top = 0, @@ -3193,6 +3923,24 @@ fn clearDirty(t: *Terminal) void { t.screens.active.pages.clearDirty(); } +test "Terminal: setCursorPos saturates overflowing origin offsets" { + const alloc = testing.allocator; + var t = try init(alloc, .{ .cols = 10, .rows = 10 }); + defer t.deinit(alloc); + + t.scrolling_region = .{ + .top = 2, + .bottom = 7, + .left = 3, + .right = 8, + }; + t.modes.set(.origin, true); + + t.setCursorPos(std.math.maxInt(usize), std.math.maxInt(usize)); + try testing.expectEqual(@as(size.CellCountInt, 8), t.screens.active.cursor.x); + try testing.expectEqual(@as(size.CellCountInt, 7), t.screens.active.cursor.y); +} + test "Terminal: input with no control characters" { const alloc = testing.allocator; var t = try init(alloc, .{ .cols = 40, .rows = 40 }); @@ -3288,19 +4036,19 @@ test "Terminal: input glitch text" { // Get our initial grapheme capacity. const grapheme_cap = cap: { const page = t.screens.active.pages.pages.first.?; - break :cap page.data.capacity.grapheme_bytes; + break :cap page.capacity().grapheme_bytes; }; // Print glitch text until our capacity changes while (true) { const page = t.screens.active.pages.pages.first.?; - if (page.data.capacity.grapheme_bytes != grapheme_cap) break; + if (page.capacity().grapheme_bytes != grapheme_cap) break; try t.printString(glitch); } // We're testing to make sure that grapheme capacity gets increased. const page = t.screens.active.pages.pages.first.?; - try testing.expect(page.data.capacity.grapheme_bytes > grapheme_cap); + try testing.expect(page.capacity().grapheme_bytes > grapheme_cap); } test "Terminal: zero-width character at start" { @@ -3536,7 +4284,7 @@ test "Terminal: print over wide char with bold" { try t.print(0x1F600); // Smiley face // verify we have styles in our style map { - const page = &t.screens.active.cursor.page_pin.node.data; + const page = t.screens.active.cursor.page_pin.node.page(); try testing.expectEqual(@as(usize, 1), page.styles.count()); } @@ -3547,7 +4295,7 @@ test "Terminal: print over wide char with bold" { // verify our style is gone { - const page = &t.screens.active.cursor.page_pin.node.data; + const page = t.screens.active.cursor.page_pin.node.page(); try testing.expectEqual(@as(usize, 0), page.styles.count()); } @@ -3566,7 +4314,7 @@ test "Terminal: print over wide char with bg color" { try t.print(0x1F600); // Smiley face // verify we have styles in our style map { - const page = &t.screens.active.cursor.page_pin.node.data; + const page = t.screens.active.cursor.page_pin.node.page(); try testing.expectEqual(@as(usize, 1), page.styles.count()); } @@ -3577,7 +4325,7 @@ test "Terminal: print over wide char with bg color" { // verify our style is gone { - const page = &t.screens.active.cursor.page_pin.node.data; + const page = t.screens.active.cursor.page_pin.node.page(); try testing.expectEqual(@as(usize, 0), page.styles.count()); } @@ -3608,7 +4356,7 @@ test "Terminal: print multicodepoint grapheme, disabled mode 2027" { try testing.expectEqual(@as(u21, 0x1F468), cell.content.codepoint); try testing.expect(cell.hasGrapheme()); try testing.expectEqual(Cell.Wide.wide, cell.wide); - const cps = list_cell.node.data.lookupGrapheme(cell).?; + const cps = list_cell.node.page().lookupGrapheme(cell).?; try testing.expectEqual(@as(usize, 1), cps.len); } { @@ -3617,7 +4365,7 @@ test "Terminal: print multicodepoint grapheme, disabled mode 2027" { try testing.expectEqual(@as(u21, 0), cell.content.codepoint); try testing.expect(!cell.hasGrapheme()); try testing.expectEqual(Cell.Wide.spacer_tail, cell.wide); - try testing.expect(list_cell.node.data.lookupGrapheme(cell) == null); + try testing.expect(list_cell.node.page().lookupGrapheme(cell) == null); } { const list_cell = t.screens.active.pages.getCell(.{ .screen = .{ .x = 2, .y = 0 } }).?; @@ -3625,7 +4373,7 @@ test "Terminal: print multicodepoint grapheme, disabled mode 2027" { try testing.expectEqual(@as(u21, 0x1F469), cell.content.codepoint); try testing.expect(cell.hasGrapheme()); try testing.expectEqual(Cell.Wide.wide, cell.wide); - const cps = list_cell.node.data.lookupGrapheme(cell).?; + const cps = list_cell.node.page().lookupGrapheme(cell).?; try testing.expectEqual(@as(usize, 1), cps.len); } { @@ -3634,7 +4382,7 @@ test "Terminal: print multicodepoint grapheme, disabled mode 2027" { try testing.expectEqual(@as(u21, 0), cell.content.codepoint); try testing.expect(!cell.hasGrapheme()); try testing.expectEqual(Cell.Wide.spacer_tail, cell.wide); - try testing.expect(list_cell.node.data.lookupGrapheme(cell) == null); + try testing.expect(list_cell.node.page().lookupGrapheme(cell) == null); } { const list_cell = t.screens.active.pages.getCell(.{ .screen = .{ .x = 4, .y = 0 } }).?; @@ -3642,7 +4390,7 @@ test "Terminal: print multicodepoint grapheme, disabled mode 2027" { try testing.expectEqual(@as(u21, 0x1F467), cell.content.codepoint); try testing.expect(!cell.hasGrapheme()); try testing.expectEqual(Cell.Wide.wide, cell.wide); - try testing.expect(list_cell.node.data.lookupGrapheme(cell) == null); + try testing.expect(list_cell.node.page().lookupGrapheme(cell) == null); } { const list_cell = t.screens.active.pages.getCell(.{ .screen = .{ .x = 5, .y = 0 } }).?; @@ -3650,12 +4398,66 @@ test "Terminal: print multicodepoint grapheme, disabled mode 2027" { try testing.expectEqual(@as(u21, 0), cell.content.codepoint); try testing.expect(!cell.hasGrapheme()); try testing.expectEqual(Cell.Wide.spacer_tail, cell.wide); - try testing.expect(list_cell.node.data.lookupGrapheme(cell) == null); + try testing.expect(list_cell.node.page().lookupGrapheme(cell) == null); } try testing.expect(t.isDirty(.{ .screen = .{ .x = 0, .y = 0 } })); } +test "Terminal: enabling grapheme mode handles stored breaks" { + var t = try init(testing.allocator, .{ .cols = 5, .rows = 1 }); + defer t.deinit(testing.allocator); + + t.modes.set(.grapheme_cluster, false); + try t.print('a'); + try t.print(0x200B); // Zero width space is stored on the prior cell. + + t.modes.set(.grapheme_cluster, true); + try t.print(0x0301); + + const str = try t.plainString(testing.allocator); + defer testing.allocator.free(str); + try testing.expectEqualStrings("a\xE2\x80\x8B\xCC\x81", str); +} + +// Terminal.print receives one codepoint at a time, so it can't use +// unicode.graphemeWidth directly; that API requires a complete buffered +// cluster or string end. This keeps the streaming printer's cursor advance +// in sync with the buffered measurement API for representative clusters. +fn expectGraphemeWidthParity(cps: []const u21) !void { + var t = try init(testing.allocator, .{ .cols = 80, .rows = 5 }); + defer t.deinit(testing.allocator); + + t.modes.set(.grapheme_cluster, true); + + var expected: usize = 0; + var i: usize = 0; + while (i < cps.len) { + const result = unicode.graphemeWidth(u21, cps[i..]); + try testing.expect(result.len > 0); + i += result.len; + expected += result.width; + } + + for (cps) |cp| try t.print(cp); + try testing.expectEqual(@as(usize, 0), t.screens.active.cursor.y); + try testing.expectEqual(expected, t.screens.active.cursor.x); +} + +test "Terminal: graphemeWidth parity" { + try expectGraphemeWidthParity(&.{ 0x2764, 0xFE0F }); + try expectGraphemeWidthParity(&.{ 'x', 0xFE0F, 0xFE0F }); + try expectGraphemeWidthParity(&.{ 0x231A, 0xFE0E, 0xFE0F }); + try expectGraphemeWidthParity(&.{ 0x1F3F4, 0x200D, 0x2620, 0xFE0F }); + try expectGraphemeWidthParity(&.{ 0x1F468, 0x200D, 0x1F469, 0x200D, 0x1F467 }); + try expectGraphemeWidthParity(&.{ 0x23, 0xFE0F, 0x20E3 }); + try expectGraphemeWidthParity(&.{ '1', 0x20E3 }); + try expectGraphemeWidthParity(&.{ 0x1F44B, 0x1F3FF }); + try expectGraphemeWidthParity(&.{ 0x1F1E6, 0x1F1E7, 0x1F1E8 }); + try expectGraphemeWidthParity(&.{ 'a', 'b' }); + try expectGraphemeWidthParity(&.{ 0x0301, 0x0302 }); +} + test "Terminal: VS16 doesn't make character with 2027 disabled" { var t = try init(testing.allocator, .{ .rows = 5, .cols = 5 }); defer t.deinit(testing.allocator); @@ -3678,7 +4480,7 @@ test "Terminal: VS16 doesn't make character with 2027 disabled" { try testing.expectEqual(@as(u21, 0x2764), cell.content.codepoint); try testing.expect(cell.hasGrapheme()); try testing.expectEqual(Cell.Wide.narrow, cell.wide); - const cps = list_cell.node.data.lookupGrapheme(cell).?; + const cps = list_cell.node.page().lookupGrapheme(cell).?; try testing.expectEqual(@as(usize, 1), cps.len); } } @@ -3759,7 +4561,7 @@ test "Terminal: variation selectors apply to preceding codepoint" { const cell = list_cell.cell; try testing.expectEqual(@as(u21, 0x1F3F4), cell.content.codepoint); try testing.expect(cell.hasGrapheme()); - try testing.expectEqualSlices(u21, &.{ 0x200D, 0x2620, 0xFE0F }, list_cell.node.data.lookupGrapheme(cell).?); + try testing.expectEqualSlices(u21, &.{ 0x200D, 0x2620, 0xFE0F }, list_cell.node.page().lookupGrapheme(cell).?); } test "Terminal: print multicodepoint grapheme, mode 2027" { @@ -3792,7 +4594,7 @@ test "Terminal: print multicodepoint grapheme, mode 2027" { try testing.expectEqual(@as(u21, 0x1F468), cell.content.codepoint); try testing.expect(cell.hasGrapheme()); try testing.expectEqual(Cell.Wide.wide, cell.wide); - const cps = list_cell.node.data.lookupGrapheme(cell).?; + const cps = list_cell.node.page().lookupGrapheme(cell).?; try testing.expectEqual(@as(usize, 4), cps.len); } { @@ -4000,7 +4802,7 @@ test "Terminal: VS15 to make narrow character" { try testing.expectEqual(@as(u21, 0x2614), cell.content.codepoint); try testing.expect(cell.hasGrapheme()); try testing.expectEqual(Cell.Wide.narrow, cell.wide); - const cps = list_cell.node.data.lookupGrapheme(cell).?; + const cps = list_cell.node.page().lookupGrapheme(cell).?; try testing.expectEqual(@as(usize, 1), cps.len); } } @@ -4035,7 +4837,7 @@ test "Terminal: VS15 on already narrow emoji" { try testing.expectEqual(@as(u21, 0x26C8), cell.content.codepoint); try testing.expect(cell.hasGrapheme()); try testing.expectEqual(Cell.Wide.narrow, cell.wide); - const cps = list_cell.node.data.lookupGrapheme(cell).?; + const cps = list_cell.node.page().lookupGrapheme(cell).?; try testing.expectEqual(@as(usize, 1), cps.len); } } @@ -4094,7 +4896,7 @@ test "Terminal: print invalid VS15 in emoji ZWJ sequence" { const cell = list_cell.cell; try testing.expectEqual(@as(u21, '\u{1F469}'), cell.content.codepoint); try testing.expect(cell.hasGrapheme()); - try testing.expectEqualSlices(u21, &.{ '\u{200D}', '\u{1F466}' }, list_cell.node.data.lookupGrapheme(cell).?); + try testing.expectEqualSlices(u21, &.{ '\u{200D}', '\u{1F466}' }, list_cell.node.page().lookupGrapheme(cell).?); try testing.expectEqual(Cell.Wide.wide, cell.wide); } { @@ -4146,7 +4948,7 @@ test "Terminal: VS15 to make narrow character with pending wrap" { try testing.expectEqual(@as(u21, 0x2614), cell.content.codepoint); try testing.expect(cell.hasGrapheme()); try testing.expectEqual(Cell.Wide.narrow, cell.wide); - const cps = list_cell.node.data.lookupGrapheme(cell).?; + const cps = list_cell.node.page().lookupGrapheme(cell).?; try testing.expectEqual(@as(usize, 1), cps.len); } @@ -4197,7 +4999,7 @@ test "Terminal: VS16 to make wide character on next line" { const cell = list_cell.cell; try testing.expectEqual(@as(u21, '#'), cell.content.codepoint); try testing.expect(cell.hasGrapheme()); - try testing.expectEqualSlices(u21, &.{0xFE0F}, list_cell.node.data.lookupGrapheme(cell).?); + try testing.expectEqualSlices(u21, &.{0xFE0F}, list_cell.node.page().lookupGrapheme(cell).?); try testing.expectEqual(Cell.Wide.wide, cell.wide); } { @@ -4248,7 +5050,7 @@ test "Terminal: VS16 to make wide character on next line with hyperlink" { const cell = list_cell.cell; try testing.expectEqual(@as(u21, '#'), cell.content.codepoint); try testing.expect(cell.hasGrapheme()); - try testing.expectEqualSlices(u21, &.{0xFE0F}, list_cell.node.data.lookupGrapheme(cell).?); + try testing.expectEqualSlices(u21, &.{0xFE0F}, list_cell.node.page().lookupGrapheme(cell).?); try testing.expectEqual(Cell.Wide.wide, cell.wide); try testing.expect(cell.hyperlink); } @@ -4286,7 +5088,7 @@ test "Terminal: VS16 to make wide character with pending wrap" { const cell = list_cell.cell; try testing.expectEqual(@as(u21, '#'), cell.content.codepoint); try testing.expect(cell.hasGrapheme()); - try testing.expectEqualSlices(u21, &.{0xFE0F}, list_cell.node.data.lookupGrapheme(cell).?); + try testing.expectEqualSlices(u21, &.{0xFE0F}, list_cell.node.page().lookupGrapheme(cell).?); try testing.expectEqual(Cell.Wide.wide, cell.wide); } { @@ -4325,7 +5127,7 @@ test "Terminal: VS16 to make wide character with mode 2027" { try testing.expectEqual(@as(u21, 0x2764), cell.content.codepoint); try testing.expect(cell.hasGrapheme()); try testing.expectEqual(Cell.Wide.wide, cell.wide); - const cps = list_cell.node.data.lookupGrapheme(cell).?; + const cps = list_cell.node.page().lookupGrapheme(cell).?; try testing.expectEqual(@as(usize, 1), cps.len); } } @@ -4356,7 +5158,7 @@ test "Terminal: VS16 repeated with mode 2027" { try testing.expectEqual(@as(u21, 0x2764), cell.content.codepoint); try testing.expect(cell.hasGrapheme()); try testing.expectEqual(Cell.Wide.wide, cell.wide); - const cps = list_cell.node.data.lookupGrapheme(cell).?; + const cps = list_cell.node.page().lookupGrapheme(cell).?; try testing.expectEqual(@as(usize, 1), cps.len); } { @@ -4365,7 +5167,7 @@ test "Terminal: VS16 repeated with mode 2027" { try testing.expectEqual(@as(u21, 0x2764), cell.content.codepoint); try testing.expect(cell.hasGrapheme()); try testing.expectEqual(Cell.Wide.wide, cell.wide); - const cps = list_cell.node.data.lookupGrapheme(cell).?; + const cps = list_cell.node.page().lookupGrapheme(cell).?; try testing.expectEqual(@as(usize, 1), cps.len); } } @@ -4458,7 +5260,7 @@ test "Terminal: print grapheme ò (o with nonspacing mark) should be narrow" { const cell = list_cell.cell; try testing.expectEqual(@as(u21, 'o'), cell.content.codepoint); try testing.expect(cell.hasGrapheme()); - try testing.expectEqualSlices(u21, &.{0x0300}, list_cell.node.data.lookupGrapheme(cell).?); + try testing.expectEqualSlices(u21, &.{0x0300}, list_cell.node.page().lookupGrapheme(cell).?); try testing.expectEqual(Cell.Wide.narrow, cell.wide); } } @@ -4487,7 +5289,7 @@ test "Terminal: print Devanagari grapheme should be wide" { const cell = list_cell.cell; try testing.expectEqual(@as(u21, 0x0915), cell.content.codepoint); try testing.expect(cell.hasGrapheme()); - try testing.expectEqualSlices(u21, &.{ 0x094D, 0x200D, 0x0937 }, list_cell.node.data.lookupGrapheme(cell).?); + try testing.expectEqualSlices(u21, &.{ 0x094D, 0x200D, 0x0937 }, list_cell.node.page().lookupGrapheme(cell).?); try testing.expectEqual(Cell.Wide.wide, cell.wide); } { @@ -4535,7 +5337,7 @@ test "Terminal: print Devanagari grapheme should be wide on next line" { const cell = list_cell.cell; try testing.expectEqual(@as(u21, 0x0915), cell.content.codepoint); try testing.expect(cell.hasGrapheme()); - try testing.expectEqualSlices(u21, &.{ 0x094D, 0x200D, 0x0937 }, list_cell.node.data.lookupGrapheme(cell).?); + try testing.expectEqualSlices(u21, &.{ 0x094D, 0x200D, 0x0937 }, list_cell.node.page().lookupGrapheme(cell).?); try testing.expectEqual(Cell.Wide.wide, cell.wide); } { @@ -4556,7 +5358,7 @@ test "Terminal: print Devanagari grapheme should be wide on next page" { t.cursorDown(rows - 1); - for (rows..t.screens.active.pages.pages.first.?.data.capacity.rows) |_| { + for (rows..t.screens.active.pages.pages.first.?.capacity().rows) |_| { try t.index(); } @@ -4594,7 +5396,7 @@ test "Terminal: print Devanagari grapheme should be wide on next page" { const cell = list_cell.cell; try testing.expectEqual(@as(u21, 0x0915), cell.content.codepoint); try testing.expect(cell.hasGrapheme()); - try testing.expectEqualSlices(u21, &.{ 0x094D, 0x200D, 0x0937 }, list_cell.node.data.lookupGrapheme(cell).?); + try testing.expectEqualSlices(u21, &.{ 0x094D, 0x200D, 0x0937 }, list_cell.node.page().lookupGrapheme(cell).?); try testing.expectEqual(Cell.Wide.wide, cell.wide); } { @@ -4627,7 +5429,7 @@ test "Terminal: print invalid VS16 with second char (combining)" { const cell = list_cell.cell; try testing.expectEqual(@as(u21, 'n'), cell.content.codepoint); try testing.expect(cell.hasGrapheme()); - try testing.expectEqualSlices(u21, &.{'\u{0303}'}, list_cell.node.data.lookupGrapheme(cell).?); + try testing.expectEqualSlices(u21, &.{'\u{0303}'}, list_cell.node.page().lookupGrapheme(cell).?); try testing.expectEqual(Cell.Wide.narrow, cell.wide); } { @@ -4689,7 +5491,7 @@ test "Terminal: overwrite multicodepoint grapheme clears grapheme data" { try testing.expectEqual(@as(usize, 2), t.screens.active.cursor.x); // We should have one cell with graphemes - const page = &t.screens.active.cursor.page_pin.node.data; + const page = t.screens.active.cursor.page_pin.node.page(); try testing.expectEqual(@as(usize, 1), page.graphemeCount()); // Move back and overwrite wide @@ -4729,7 +5531,7 @@ test "Terminal: overwrite multicodepoint grapheme tail clears grapheme data" { try testing.expectEqual(@as(usize, 2), t.screens.active.cursor.x); // We should have one cell with graphemes - const page = &t.screens.active.cursor.page_pin.node.data; + const page = t.screens.active.cursor.page_pin.node.page(); try testing.expectEqual(@as(usize, 1), page.graphemeCount()); // Move back and overwrite wide @@ -4780,7 +5582,7 @@ test "Terminal: print breaks valid grapheme cluster with Prepend + ASCII for spe try testing.expect(!cell.hasGrapheme()); // This is what we'd expect if we did break correctly: //try testing.expect(cell.hasGrapheme()); - //try testing.expectEqualSlices(u21, &.{'1'}, list_cell.node.data.lookupGrapheme(cell).?); + //try testing.expectEqualSlices(u21, &.{'1'}, list_cell.node.page().lookupGrapheme(cell).?); try testing.expectEqual(Cell.Wide.narrow, cell.wide); } { @@ -5228,7 +6030,7 @@ test "Terminal: print with hyperlink" { try testing.expect(row.hyperlink); const cell = list_cell.cell; try testing.expect(cell.hyperlink); - const id = list_cell.node.data.lookupHyperlink(cell).?; + const id = list_cell.node.page().lookupHyperlink(cell).?; try testing.expectEqual(@as(hyperlink.Id, 1), id); } @@ -5255,7 +6057,7 @@ test "Terminal: print over cell with same hyperlink" { try testing.expect(row.hyperlink); const cell = list_cell.cell; try testing.expect(cell.hyperlink); - const id = list_cell.node.data.lookupHyperlink(cell).?; + const id = list_cell.node.page().lookupHyperlink(cell).?; try testing.expectEqual(@as(hyperlink.Id, 1), id); } @@ -5282,7 +6084,7 @@ test "Terminal: print and end hyperlink" { try testing.expect(row.hyperlink); const cell = list_cell.cell; try testing.expect(cell.hyperlink); - const id = list_cell.node.data.lookupHyperlink(cell).?; + const id = list_cell.node.page().lookupHyperlink(cell).?; try testing.expectEqual(@as(hyperlink.Id, 1), id); } for (3..6) |x| { @@ -5317,7 +6119,7 @@ test "Terminal: print and change hyperlink" { } }).?; const cell = list_cell.cell; try testing.expect(cell.hyperlink); - const id = list_cell.node.data.lookupHyperlink(cell).?; + const id = list_cell.node.page().lookupHyperlink(cell).?; try testing.expectEqual(@as(hyperlink.Id, 1), id); } for (3..6) |x| { @@ -5327,7 +6129,7 @@ test "Terminal: print and change hyperlink" { } }).?; const cell = list_cell.cell; try testing.expect(cell.hyperlink); - const id = list_cell.node.data.lookupHyperlink(cell).?; + const id = list_cell.node.page().lookupHyperlink(cell).?; try testing.expectEqual(@as(hyperlink.Id, 2), id); } @@ -5351,7 +6153,7 @@ test "Terminal: overwrite hyperlink" { .x = @intCast(x), .y = 0, } }).?; - const page = &list_cell.node.data; + const page = list_cell.node.page(); const row = list_cell.row; try testing.expect(!row.hyperlink); const cell = list_cell.cell; @@ -6092,8 +6894,11 @@ test "Terminal: insertLines simple" { try t.printString("GHI"); t.setCursorPos(2, 2); + const node = t.screens.active.cursor.page_pin.node; + const serial = node.serial; t.clearDirty(); t.insertLines(1); + try testing.expect(!t.screens.active.pages.nodeIsValid(node, serial)); try testing.expect(!t.isDirty(.{ .active = .{ .x = 0, .y = 0 } })); try testing.expect(t.isDirty(.{ .active = .{ .x = 0, .y = 1 } })); @@ -6166,7 +6971,7 @@ test "Terminal: insertLines handles style refs" { try t.setAttribute(.{ .unset = {} }); // verify we have styles in our style map - const page = &t.screens.active.cursor.page_pin.node.data; + const page = t.screens.active.cursor.page_pin.node.page(); try testing.expectEqual(@as(usize, 1), page.styles.count()); t.setCursorPos(2, 2); @@ -6250,7 +7055,7 @@ test "Terminal: insertLines across page boundary marks all shifted rows dirty" { defer t.deinit(alloc); const first_page = t.screens.active.pages.pages.first.?; - const first_page_nrows = first_page.data.capacity.rows; + const first_page_nrows = first_page.capacity().rows; // Fill up the first page minus 3 rows for (0..first_page_nrows - 3) |_| try t.linefeed(); @@ -6271,11 +7076,15 @@ test "Terminal: insertLines across page boundary marks all shifted rows dirty" { try t.printString("5EEEE"); // Verify we now have a second page - try testing.expect(first_page.next != null); + const second_page = first_page.next.?; + const first_serial = first_page.serial; + const second_serial = second_page.serial; t.setCursorPos(1, 1); t.clearDirty(); t.insertLines(1); + try testing.expect(!t.screens.active.pages.nodeIsValid(first_page, first_serial)); + try testing.expect(!t.screens.active.pages.nodeIsValid(second_page, second_serial)); try testing.expect(t.isDirty(.{ .active = .{ .x = 0, .y = 0 } })); try testing.expect(t.isDirty(.{ .active = .{ .x = 0, .y = 1 } })); @@ -6582,9 +7391,9 @@ test "Terminal: scrollUp moves hyperlink" { try testing.expect(row.hyperlink); const cell = list_cell.cell; try testing.expect(cell.hyperlink); - const id = list_cell.node.data.lookupHyperlink(cell).?; + const id = list_cell.node.page().lookupHyperlink(cell).?; try testing.expectEqual(@as(hyperlink.Id, 1), id); - const page = &list_cell.node.data; + const page = list_cell.node.page(); try testing.expectEqual(1, page.hyperlink_set.count()); } for (0..3) |x| { @@ -6596,7 +7405,7 @@ test "Terminal: scrollUp moves hyperlink" { try testing.expect(!row.hyperlink); const cell = list_cell.cell; try testing.expect(!cell.hyperlink); - const id = list_cell.node.data.lookupHyperlink(cell); + const id = list_cell.node.page().lookupHyperlink(cell); try testing.expect(id == null); } } @@ -6633,7 +7442,7 @@ test "Terminal: scrollUp clears hyperlink" { try testing.expect(!row.hyperlink); const cell = list_cell.cell; try testing.expect(!cell.hyperlink); - const id = list_cell.node.data.lookupHyperlink(cell); + const id = list_cell.node.page().lookupHyperlink(cell); try testing.expect(id == null); } } @@ -6735,7 +7544,7 @@ test "Terminal: scrollUp left/right scroll region hyperlink" { } }).?; const cell = list_cell.cell; try testing.expect(!cell.hyperlink); - const id = list_cell.node.data.lookupHyperlink(cell); + const id = list_cell.node.page().lookupHyperlink(cell); try testing.expect(id == null); } for (1..4) |x| { @@ -6747,9 +7556,9 @@ test "Terminal: scrollUp left/right scroll region hyperlink" { try testing.expect(row.hyperlink); const cell = list_cell.cell; try testing.expect(cell.hyperlink); - const id = list_cell.node.data.lookupHyperlink(cell).?; + const id = list_cell.node.page().lookupHyperlink(cell).?; try testing.expectEqual(@as(hyperlink.Id, 1), id); - const page = &list_cell.node.data; + const page = list_cell.node.page(); try testing.expectEqual(1, page.hyperlink_set.count()); } for (4..6) |x| { @@ -6759,7 +7568,7 @@ test "Terminal: scrollUp left/right scroll region hyperlink" { } }).?; const cell = list_cell.cell; try testing.expect(!cell.hyperlink); - const id = list_cell.node.data.lookupHyperlink(cell); + const id = list_cell.node.page().lookupHyperlink(cell); try testing.expect(id == null); } } @@ -6775,9 +7584,9 @@ test "Terminal: scrollUp left/right scroll region hyperlink" { try testing.expect(row.hyperlink); const cell = list_cell.cell; try testing.expect(cell.hyperlink); - const id = list_cell.node.data.lookupHyperlink(cell).?; + const id = list_cell.node.page().lookupHyperlink(cell).?; try testing.expectEqual(@as(hyperlink.Id, 1), id); - const page = &list_cell.node.data; + const page = list_cell.node.page(); try testing.expectEqual(1, page.hyperlink_set.count()); } for (1..4) |x| { @@ -6787,7 +7596,7 @@ test "Terminal: scrollUp left/right scroll region hyperlink" { } }).?; const cell = list_cell.cell; try testing.expect(!cell.hyperlink); - const id = list_cell.node.data.lookupHyperlink(cell); + const id = list_cell.node.page().lookupHyperlink(cell); try testing.expect(id == null); } for (4..6) |x| { @@ -6799,9 +7608,9 @@ test "Terminal: scrollUp left/right scroll region hyperlink" { try testing.expect(row.hyperlink); const cell = list_cell.cell; try testing.expect(cell.hyperlink); - const id = list_cell.node.data.lookupHyperlink(cell).?; + const id = list_cell.node.page().lookupHyperlink(cell).?; try testing.expectEqual(@as(hyperlink.Id, 1), id); - const page = &list_cell.node.data; + const page = list_cell.node.page(); try testing.expectEqual(1, page.hyperlink_set.count()); } } @@ -7082,9 +7891,9 @@ test "Terminal: scrollDown hyperlink moves" { try testing.expect(row.hyperlink); const cell = list_cell.cell; try testing.expect(cell.hyperlink); - const id = list_cell.node.data.lookupHyperlink(cell).?; + const id = list_cell.node.page().lookupHyperlink(cell).?; try testing.expectEqual(@as(hyperlink.Id, 1), id); - const page = &list_cell.node.data; + const page = list_cell.node.page(); try testing.expectEqual(1, page.hyperlink_set.count()); } for (0..3) |x| { @@ -7096,7 +7905,7 @@ test "Terminal: scrollDown hyperlink moves" { try testing.expect(!row.hyperlink); const cell = list_cell.cell; try testing.expect(!cell.hyperlink); - const id = list_cell.node.data.lookupHyperlink(cell); + const id = list_cell.node.page().lookupHyperlink(cell); try testing.expect(id == null); } } @@ -7206,9 +8015,9 @@ test "Terminal: scrollDown left/right scroll region hyperlink" { try testing.expect(row.hyperlink); const cell = list_cell.cell; try testing.expect(cell.hyperlink); - const id = list_cell.node.data.lookupHyperlink(cell).?; + const id = list_cell.node.page().lookupHyperlink(cell).?; try testing.expectEqual(@as(hyperlink.Id, 1), id); - const page = &list_cell.node.data; + const page = list_cell.node.page(); try testing.expectEqual(1, page.hyperlink_set.count()); } for (1..4) |x| { @@ -7218,7 +8027,7 @@ test "Terminal: scrollDown left/right scroll region hyperlink" { } }).?; const cell = list_cell.cell; try testing.expect(!cell.hyperlink); - const id = list_cell.node.data.lookupHyperlink(cell); + const id = list_cell.node.page().lookupHyperlink(cell); try testing.expect(id == null); } for (4..6) |x| { @@ -7230,9 +8039,9 @@ test "Terminal: scrollDown left/right scroll region hyperlink" { try testing.expect(row.hyperlink); const cell = list_cell.cell; try testing.expect(cell.hyperlink); - const id = list_cell.node.data.lookupHyperlink(cell).?; + const id = list_cell.node.page().lookupHyperlink(cell).?; try testing.expectEqual(@as(hyperlink.Id, 1), id); - const page = &list_cell.node.data; + const page = list_cell.node.page(); try testing.expectEqual(1, page.hyperlink_set.count()); } } @@ -7246,7 +8055,7 @@ test "Terminal: scrollDown left/right scroll region hyperlink" { } }).?; const cell = list_cell.cell; try testing.expect(!cell.hyperlink); - const id = list_cell.node.data.lookupHyperlink(cell); + const id = list_cell.node.page().lookupHyperlink(cell); try testing.expect(id == null); } for (1..4) |x| { @@ -7258,9 +8067,9 @@ test "Terminal: scrollDown left/right scroll region hyperlink" { try testing.expect(row.hyperlink); const cell = list_cell.cell; try testing.expect(cell.hyperlink); - const id = list_cell.node.data.lookupHyperlink(cell).?; + const id = list_cell.node.page().lookupHyperlink(cell).?; try testing.expectEqual(@as(hyperlink.Id, 1), id); - const page = &list_cell.node.data; + const page = list_cell.node.page(); try testing.expectEqual(1, page.hyperlink_set.count()); } for (4..6) |x| { @@ -7270,7 +8079,7 @@ test "Terminal: scrollDown left/right scroll region hyperlink" { } }).?; const cell = list_cell.cell; try testing.expect(!cell.hyperlink); - const id = list_cell.node.data.lookupHyperlink(cell); + const id = list_cell.node.page().lookupHyperlink(cell); try testing.expect(id == null); } } @@ -7504,7 +8313,7 @@ test "Terminal: eraseChars handles refcounted styles" { try t.print('C'); // verify we have styles in our style map - const page = &t.screens.active.cursor.page_pin.node.data; + const page = t.screens.active.cursor.page_pin.node.page(); try testing.expectEqual(@as(usize, 1), page.styles.count()); t.setCursorPos(1, 1); @@ -7581,7 +8390,7 @@ test "Terminal: eraseChars wide char boundary conditions" { t.setCursorPos(1, 2); t.eraseChars(3); - t.screens.active.cursor.page_pin.node.data.assertIntegrity(); + t.screens.active.cursor.page_pin.node.page().assertIntegrity(); { const str = try t.plainString(alloc); @@ -7611,7 +8420,7 @@ test "Terminal: eraseChars wide char splits proper cell boundaries" { t.setCursorPos(1, 6); // At: て t.eraseChars(4); // Delete: て下 - t.screens.active.cursor.page_pin.node.data.assertIntegrity(); + t.screens.active.cursor.page_pin.node.page().assertIntegrity(); { const str = try t.plainString(alloc); @@ -7638,7 +8447,7 @@ test "Terminal: eraseChars wide char wrap boundary conditions" { t.setCursorPos(2, 2); t.eraseChars(3); - t.screens.active.cursor.page_pin.node.data.assertIntegrity(); + t.screens.active.cursor.page_pin.node.page().assertIntegrity(); { const str = try t.plainString(alloc); @@ -7941,7 +8750,7 @@ test "Terminal: index scrolling with hyperlink" { try testing.expect(row.hyperlink); const cell = list_cell.cell; try testing.expect(cell.hyperlink); - const id = list_cell.node.data.lookupHyperlink(cell).?; + const id = list_cell.node.page().lookupHyperlink(cell).?; try testing.expectEqual(@as(hyperlink.Id, 1), id); } { @@ -7953,7 +8762,7 @@ test "Terminal: index scrolling with hyperlink" { try testing.expect(!row.hyperlink); const cell = list_cell.cell; try testing.expect(!cell.hyperlink); - const id = list_cell.node.data.lookupHyperlink(cell); + const id = list_cell.node.page().lookupHyperlink(cell); try testing.expect(id == null); } } @@ -8115,7 +8924,7 @@ test "Terminal: index bottom of scroll region with hyperlinks" { try testing.expect(row.hyperlink); const cell = list_cell.cell; try testing.expect(cell.hyperlink); - const id = list_cell.node.data.lookupHyperlink(cell).?; + const id = list_cell.node.page().lookupHyperlink(cell).?; try testing.expectEqual(@as(hyperlink.Id, 1), id); } { @@ -8127,7 +8936,7 @@ test "Terminal: index bottom of scroll region with hyperlinks" { try testing.expect(!row.hyperlink); const cell = list_cell.cell; try testing.expect(!cell.hyperlink); - const id = list_cell.node.data.lookupHyperlink(cell); + const id = list_cell.node.page().lookupHyperlink(cell); try testing.expect(id == null); } } @@ -8164,9 +8973,9 @@ test "Terminal: index bottom of scroll region clear hyperlinks" { try testing.expect(!row.hyperlink); const cell = list_cell.cell; try testing.expect(!cell.hyperlink); - const id = list_cell.node.data.lookupHyperlink(cell); + const id = list_cell.node.page().lookupHyperlink(cell); try testing.expect(id == null); - const page = &list_cell.node.data; + const page = list_cell.node.page(); try testing.expectEqual(0, page.hyperlink_set.count()); } } @@ -8381,6 +9190,128 @@ test "Terminal: index bottom of scroll region blank line preserves SGR" { } } +test "Terminal: index bottom of scroll region with top margin and background SGR" { + const alloc = testing.allocator; + var t = try init(alloc, .{ .rows = 5, .cols = 5 }); + defer t.deinit(alloc); + + try t.printString("1\n2\n3\n4\n5"); + t.setTopAndBottomMargin(2, 4); + t.setCursorPos(4, 1); + try t.setAttribute(.{ .direct_color_bg = .{ + .r = 0xFF, + .g = 0, + .b = 0, + } }); + try t.index(); + + // The region (rows 2-4) scrolled up, rows outside are unchanged. + { + const str = try t.plainString(testing.allocator); + defer testing.allocator.free(str); + try testing.expectEqualStrings("1\n3\n4\n\n5", str); + } + + // The cursor is on the new blank row. + try testing.expectEqual(@as(usize, 3), t.screens.active.cursor.y); + + // The new blank row must be filled with our background color. + for (0..t.cols) |x| { + const list_cell = t.screens.active.pages.getCell(.{ .active = .{ + .x = @intCast(x), + .y = 3, + } }).?; + try testing.expect(list_cell.cell.content_tag == .bg_color_rgb); + try testing.expectEqual(Cell.RGB{ + .r = 0xFF, + .g = 0, + .b = 0, + }, list_cell.cell.content.color_rgb); + } +} + +test "Terminal: index bottom of alt screen full region" { + const alloc = testing.allocator; + var t = try init(alloc, .{ .rows = 3, .cols = 5 }); + defer t.deinit(alloc); + + try t.switchScreenMode(.@"1049", true); + try t.printString("A\nB\nC"); + try t.index(); + t.carriageReturn(); + try t.print('D'); + + // Content scrolled up and the scrolled-out row is discarded, NOT + // moved into scrollback (the alt screen has none). + { + const str = try t.plainString(testing.allocator); + defer testing.allocator.free(str); + try testing.expectEqualStrings("B\nC\nD", str); + } + { + const str = try t.screens.active.dumpStringAlloc(alloc, .{ .screen = .{} }); + defer testing.allocator.free(str); + try testing.expectEqualStrings("B\nC\nD", str); + } + + // Primary screen is untouched. + try t.switchScreenMode(.@"1049", false); + { + const str = try t.plainString(testing.allocator); + defer testing.allocator.free(str); + try testing.expectEqualStrings("", str); + } +} + +test "Terminal: index bottom of alt screen top region" { + const alloc = testing.allocator; + var t = try init(alloc, .{ .rows = 5, .cols = 5 }); + defer t.deinit(alloc); + + try t.switchScreenMode(.@"1049", true); + try t.printString("1\n2\n3\n4\n5"); + + // Region at the top of the screen, excluding the last row. On the + // alt screen this must NOT create scrollback. + t.setTopAndBottomMargin(1, 4); + t.setCursorPos(4, 1); + try t.index(); + try t.print('X'); + + { + const str = try t.plainString(testing.allocator); + defer testing.allocator.free(str); + try testing.expectEqualStrings("2\n3\n4\nX\n5", str); + } + { + const str = try t.screens.active.dumpStringAlloc(alloc, .{ .screen = .{} }); + defer testing.allocator.free(str); + try testing.expectEqualStrings("2\n3\n4\nX\n5", str); + } +} + +test "Terminal: scrollUp top region no scrollback" { + const alloc = testing.allocator; + var t = try init(alloc, .{ .rows = 5, .cols = 5, .max_scrollback = 0 }); + defer t.deinit(alloc); + + try t.printString("A\nB\nC\nD\nE"); + t.setTopAndBottomMargin(1, 3); + try t.scrollUp(1); + + // The region scrolled and the scrolled-out row is discarded. + { + const str = try t.plainString(testing.allocator); + defer testing.allocator.free(str); + try testing.expectEqualStrings("B\nC\n\nD\nE", str); + } + { + const str = try t.screens.active.dumpStringAlloc(alloc, .{ .screen = .{} }); + defer testing.allocator.free(str); + try testing.expectEqualStrings("B\nC\n\nD\nE", str); + } +} + test "Terminal: cursorUp basic" { const alloc = testing.allocator; var t = try init(alloc, .{ .rows = 5, .cols = 5 }); @@ -8857,8 +9788,11 @@ test "Terminal: deleteLines simple" { try t.printString("GHI"); t.setCursorPos(2, 2); + const node = t.screens.active.cursor.page_pin.node; + const serial = node.serial; t.clearDirty(); t.deleteLines(1); + try testing.expect(!t.screens.active.pages.nodeIsValid(node, serial)); try testing.expect(!t.isDirty(.{ .active = .{ .x = 0, .y = 0 } })); try testing.expect(t.isDirty(.{ .active = .{ .x = 0, .y = 1 } })); @@ -8919,7 +9853,7 @@ test "Terminal: deleteLines across page boundary marks all shifted rows dirty" { defer t.deinit(alloc); const first_page = t.screens.active.pages.pages.first.?; - const first_page_nrows = first_page.data.capacity.rows; + const first_page_nrows = first_page.capacity().rows; // Fill up the first page minus 3 rows for (0..first_page_nrows - 3) |_| try t.linefeed(); @@ -8940,11 +9874,15 @@ test "Terminal: deleteLines across page boundary marks all shifted rows dirty" { try t.printString("5EEEE"); // Verify we now have a second page - try testing.expect(first_page.next != null); + const second_page = first_page.next.?; + const first_serial = first_page.serial; + const second_serial = second_page.serial; t.setCursorPos(1, 1); t.clearDirty(); t.deleteLines(1); + try testing.expect(!t.screens.active.pages.nodeIsValid(first_page, first_serial)); + try testing.expect(!t.screens.active.pages.nodeIsValid(second_page, second_serial)); try testing.expect(t.isDirty(.{ .active = .{ .x = 0, .y = 0 } })); try testing.expect(t.isDirty(.{ .active = .{ .x = 0, .y = 1 } })); @@ -9534,7 +10472,7 @@ test "Terminal: bold style" { const cell = list_cell.cell; try testing.expectEqual(@as(u21, 'A'), cell.content.codepoint); try testing.expect(cell.style_id != 0); - const page = &t.screens.active.cursor.page_pin.node.data; + const page = t.screens.active.cursor.page_pin.node.page(); try testing.expect(page.styles.refCount(page.memory, t.screens.active.cursor.style_id) > 1); } } @@ -9558,7 +10496,7 @@ test "Terminal: garbage collect overwritten" { } // verify we have no styles in our style map - const page = &t.screens.active.cursor.page_pin.node.data; + const page = t.screens.active.cursor.page_pin.node.page(); try testing.expectEqual(@as(usize, 0), page.styles.count()); } @@ -9580,7 +10518,7 @@ test "Terminal: do not garbage collect old styles in use" { } // verify we have no styles in our style map - const page = &t.screens.active.cursor.page_pin.node.data; + const page = t.screens.active.cursor.page_pin.node.page(); try testing.expectEqual(@as(usize, 1), page.styles.count()); } @@ -9971,7 +10909,7 @@ test "Terminal: insertBlanks deleting graphemes" { try t.print(0x1F467); // We should have one cell with graphemes - const page = &t.screens.active.cursor.page_pin.node.data; + const page = t.screens.active.cursor.page_pin.node.page(); try testing.expectEqual(@as(usize, 1), page.graphemeCount()); t.setCursorPos(1, 1); @@ -10007,7 +10945,7 @@ test "Terminal: insertBlanks shift graphemes" { try t.print(0x1F467); // We should have one cell with graphemes - const page = &t.screens.active.cursor.page_pin.node.data; + const page = t.screens.active.cursor.page_pin.node.page(); try testing.expectEqual(@as(usize, 1), page.graphemeCount()); t.setCursorPos(1, 1); @@ -10075,7 +11013,7 @@ test "Terminal: insertBlanks shifts hyperlinks" { try testing.expect(row.hyperlink); const cell = list_cell.cell; try testing.expect(cell.hyperlink); - const id = list_cell.node.data.lookupHyperlink(cell).?; + const id = list_cell.node.page().lookupHyperlink(cell).?; try testing.expectEqual(@as(hyperlink.Id, 1), id); } for (0..2) |x| { @@ -10085,7 +11023,7 @@ test "Terminal: insertBlanks shifts hyperlinks" { } }).?; const cell = list_cell.cell; try testing.expect(!cell.hyperlink); - const id = list_cell.node.data.lookupHyperlink(cell); + const id = list_cell.node.page().lookupHyperlink(cell); try testing.expect(id == null); } } @@ -10115,7 +11053,7 @@ test "Terminal: insertBlanks pushes hyperlink off end completely" { try testing.expect(!row.hyperlink); const cell = list_cell.cell; try testing.expect(!cell.hyperlink); - const id = list_cell.node.data.lookupHyperlink(cell); + const id = list_cell.node.page().lookupHyperlink(cell); try testing.expect(id == null); } } @@ -10688,7 +11626,7 @@ test "Terminal: deleteChars wide char boundary conditions" { t.setCursorPos(1, 2); t.deleteChars(3); - t.screens.active.cursor.page_pin.node.data.assertIntegrity(); + t.screens.active.cursor.page_pin.node.page().assertIntegrity(); { const str = try t.plainString(alloc); @@ -10740,7 +11678,7 @@ test "Terminal: deleteChars wide char wrap boundary conditions" { t.setCursorPos(2, 2); t.deleteChars(3); - t.screens.active.cursor.page_pin.node.data.assertIntegrity(); + t.screens.active.cursor.page_pin.node.page().assertIntegrity(); { const str = try t.plainString(alloc); @@ -10779,7 +11717,7 @@ test "Terminal: deleteChars wide char across right margin" { t.setCursorPos(1, 2); t.deleteChars(1); - t.screens.active.cursor.page_pin.node.data.assertIntegrity(); + t.screens.active.cursor.page_pin.node.page().assertIntegrity(); // NOTE: This behavior is slightly inconsistent with xterm. xterm // _visually_ splits the wide character (half the wide character shows @@ -10940,14 +11878,14 @@ test "Terminal: restoreCursor uses default style on OutOfSpace" { // Fill the style map to max capacity const max_styles = std.math.maxInt(size.CellCountInt); - while (t.screens.active.cursor.page_pin.node.data.capacity.styles < max_styles) { + while (t.screens.active.cursor.page_pin.node.capacity().styles < max_styles) { _ = t.screens.active.increaseCapacity( t.screens.active.cursor.page_pin.node, .styles, ) catch break; } - const page = &t.screens.active.cursor.page_pin.node.data; + const page = t.screens.active.cursor.page_pin.node.page(); try testing.expectEqual(max_styles, page.capacity.styles); // Fill all style slots using the StyleSet's layout capacity which accounts @@ -11545,6 +12483,234 @@ test "Terminal: printRepeat no previous character" { } } +test "Terminal: printSlice simple ascii" { + const alloc = testing.allocator; + var t = try init(alloc, .{ .cols = 10, .rows = 3 }); + defer t.deinit(alloc); + + try t.printSlice(&.{ 'h', 'e', 'l', 'l', 'o' }); + try testing.expectEqual(@as(usize, 5), t.screens.active.cursor.x); + try testing.expectEqual(@as(u21, 'o'), t.previous_char.?); + try testing.expect(t.isDirty(.{ .active = .{ .x = 0, .y = 0 } })); + + { + const str = try t.plainString(testing.allocator); + defer testing.allocator.free(str); + try testing.expectEqualStrings("hello", str); + } +} + +test "Terminal: printSlice wraps and scrolls" { + const alloc = testing.allocator; + var t = try init(alloc, .{ .cols = 5, .rows = 2 }); + defer t.deinit(alloc); + + // 12 chars: fills row 1 (5), row 2 (5), wraps+scrolls, 2 more. + try t.printSlice(&.{ 'a', 'b', 'c', 'd', 'e', 'f', 'g', 'h', 'i', 'j', 'k', 'l' }); + + { + const str = try t.plainString(testing.allocator); + defer testing.allocator.free(str); + try testing.expectEqualStrings("fghij\nkl", str); + } + try testing.expectEqual(@as(usize, 2), t.screens.active.cursor.x); + try testing.expect(!t.screens.active.cursor.pending_wrap); +} + +test "Terminal: printSlice pending wrap state" { + const alloc = testing.allocator; + var t = try init(alloc, .{ .cols = 5, .rows = 2 }); + defer t.deinit(alloc); + + try t.printSlice(&.{ 'a', 'b', 'c', 'd', 'e' }); + try testing.expectEqual(@as(usize, 4), t.screens.active.cursor.x); + try testing.expect(t.screens.active.cursor.pending_wrap); + + { + const str = try t.plainString(testing.allocator); + defer testing.allocator.free(str); + try testing.expectEqualStrings("abcde", str); + } +} + +/// Differential testing helper: applies the same logical print +/// operations to two terminals, one using per-codepoint print() and +/// the other using printSlice() with random chunking, verifying that +/// the results are identical. +fn testPrintSliceDifferential( + alloc: Allocator, + rand: std.Random, + ops: usize, + cols: size.CellCountInt, + rows: size.CellCountInt, +) !void { + var t1 = try init(alloc, .{ + .cols = cols, + .rows = rows, + }); + defer t1.deinit(alloc); + var t2 = try init(alloc, .{ + .cols = cols, + .rows = rows, + }); + defer t2.deinit(alloc); + + // Alphabet of interesting codepoints: ascii, latin-1, combining + // marks, CJK (wide), emoji (wide), ZWJ, variation selectors. + const alphabet = [_]u21{ + 'a', 'b', 'Z', '0', ' ', 0x10, 0x1F, 0x7F, + 'é', 0xFF, 0x301, 0x4E00, 0x4E01, 0x1F600, 0x200D, 0xFE0F, + 'x', 'y', 0x1F9D1, 0x0308, 0xAD, 0x3042, 0xAC00, 'q', + 'r', 's', 't', 'u', 'v', 'w', '1', '2', + 0x1F1E6, 0x1F1E7, 0x1100, 0x1161, 0x11A8, 0x200C, 0x0430, 0x03B1, + }; + + var cps_buf: [64]u32 = undefined; + var last_n: usize = 0; + + for (0..ops) |_| { + switch (rand.intRangeAtMost(u8, 0, 20)) { + // Print a run of codepoints (most common op). + 0...9 => { + const n = rand.intRangeAtMost(usize, 1, cps_buf.len); + last_n = n; + for (cps_buf[0..n]) |*cp| { + cp.* = alphabet[rand.intRangeLessThan(usize, 0, alphabet.len)]; + } + + // t1: per-codepoint print + for (cps_buf[0..n]) |cp| try t1.print(@intCast(cp)); + + // t2: printSlice with random chunking + var i: usize = 0; + while (i < n) { + const chunk = rand.intRangeAtMost(usize, 1, n - i); + try t2.printSlice(cps_buf[i..][0..chunk]); + i += chunk; + } + }, + 10 => { + t1.carriageReturn(); + t2.carriageReturn(); + try t1.linefeed(); + try t2.linefeed(); + }, + 11 => { + const row = rand.intRangeAtMost(usize, 1, rows); + const col = rand.intRangeAtMost(usize, 1, cols); + t1.setCursorPos(row, col); + t2.setCursorPos(row, col); + }, + 12 => { + const attr: sgr.Attribute = switch (rand.intRangeAtMost(u8, 0, 3)) { + 0 => .{ .unset = {} }, + 1 => .{ .bold = {} }, + 2 => .{ .direct_color_fg = .{ + .r = rand.int(u8), + .g = rand.int(u8), + .b = rand.int(u8), + } }, + 3 => .{ .@"8_fg" = .red }, + else => unreachable, + }; + try t1.setAttribute(attr); + try t2.setAttribute(attr); + }, + 13 => { + const v = rand.boolean(); + t1.modes.set(.insert, v); + t2.modes.set(.insert, v); + }, + 14 => { + const v = rand.boolean(); + t1.modes.set(.wraparound, v); + t2.modes.set(.wraparound, v); + }, + 15 => { + const v = rand.boolean(); + t1.modes.set(.grapheme_cluster, v); + t2.modes.set(.grapheme_cluster, v); + }, + 16 => { + // Margins. + t1.modes.set(.enable_left_and_right_margin, true); + t2.modes.set(.enable_left_and_right_margin, true); + const left = rand.intRangeAtMost(usize, 1, cols / 2); + const right = rand.intRangeAtMost(usize, cols / 2, cols); + t1.setLeftAndRightMargin(left, right); + t2.setLeftAndRightMargin(left, right); + }, + 17 => { + t1.setLeftAndRightMargin(0, 0); + t2.setLeftAndRightMargin(0, 0); + }, + 18 => { + try t1.screens.active.startHyperlink("http://example.com", null); + try t2.screens.active.startHyperlink("http://example.com", null); + }, + 19 => { + t1.screens.active.endHyperlink(); + t2.screens.active.endHyperlink(); + }, + 20 => { + const set: charsets.Charset = if (rand.boolean()) + .dec_special + else + .utf8; + t1.configureCharset(.G0, set); + t2.configureCharset(.G0, set); + }, + else => unreachable, + } + + // Cursor state must match exactly after every op. + try testing.expectEqual(t1.screens.active.cursor.x, t2.screens.active.cursor.x); + try testing.expectEqual(t1.screens.active.cursor.y, t2.screens.active.cursor.y); + try testing.expectEqual( + t1.screens.active.cursor.pending_wrap, + t2.screens.active.cursor.pending_wrap, + ); + + // Full screen contents must match after every op. On failure, + // dump diagnostics that make the failure reproducible. + { + const str1 = try t1.screens.active.dumpStringAlloc(alloc, .{ .screen = .{} }); + defer alloc.free(str1); + const str2 = try t2.screens.active.dumpStringAlloc(alloc, .{ .screen = .{} }); + defer alloc.free(str2); + testing.expectEqualStrings(str1, str2) catch |err| { + std.debug.print("last print cps: {any}\n", .{cps_buf[0..last_n]}); + std.debug.print("modes: 2027={} insert={} wrap={} sr.left={} sr.right={} cols={}\n", .{ + t1.modes.get(.grapheme_cluster), + t1.modes.get(.insert), + t1.modes.get(.wraparound), + t1.scrolling_region.left, + t1.scrolling_region.right, + cols, + }); + return err; + }; + } + } + + // Page integrity (styles refcounts, grapheme maps, etc.) must hold. + try t1.screens.active.cursor.page_pin.node.page().verifyIntegrity(alloc); + try t2.screens.active.cursor.page_pin.node.page().verifyIntegrity(alloc); +} + +test "Terminal: printSlice differential fuzz vs print" { + const alloc = testing.allocator; + + // Multiple seeds and terminal sizes for coverage, including a + // tiny terminal to stress wrap/scroll edge cases. + var prng = std.Random.DefaultPrng.init(0xC0FFEE); + const rand = prng.random(); + try testPrintSliceDifferential(alloc, rand, 500, 80, 24); + try testPrintSliceDifferential(alloc, rand, 500, 10, 4); + try testPrintSliceDifferential(alloc, rand, 500, 5, 2); + try testPrintSliceDifferential(alloc, rand, 200, 2, 2); +} + test "Terminal: printAttributes" { const alloc = testing.allocator; var t = try init(alloc, .{ .rows = 5, .cols = 5 }); @@ -12394,7 +13560,24 @@ test "Terminal: OSC133A click_events=1 sets click to click_events" { .options_unvalidated = "click_events=1", }); - try testing.expectEqual(.click_events, t.screens.active.semantic_prompt.click); + try testing.expectEqual(Screen.SemanticPrompt.SemanticClick{ .click_events = .absolute }, t.screens.active.semantic_prompt.click); +} + +test "Terminal: OSC133A click_events=2 sets click to click_events (relative)" { + const alloc = testing.allocator; + var t = try init(alloc, .{ .cols = 10, .rows = 5 }); + defer t.deinit(alloc); + + // Verify default state is none + try testing.expectEqual(.none, t.screens.active.semantic_prompt.click); + + // OSC 133;A with click_events=2 + try t.semanticPrompt(.{ + .action = .fresh_line_new_prompt, + .options_unvalidated = "click_events=2", + }); + + try testing.expectEqual(Screen.SemanticPrompt.SemanticClick{ .click_events = .relative }, t.screens.active.semantic_prompt.click); } test "Terminal: OSC133A click_events=0 does not set click_events" { @@ -12451,7 +13634,7 @@ test "Terminal: OSC133A click_events=1 takes priority over cl" { }); // click_events should take priority - try testing.expectEqual(.click_events, t.screens.active.semantic_prompt.click); + try testing.expectEqual(Screen.SemanticPrompt.SemanticClick{ .click_events = .absolute }, t.screens.active.semantic_prompt.click); } test "Terminal: OSC133A click_events=0 falls back to cl" { @@ -12988,7 +14171,7 @@ test "Terminal: mode 47 copies cursor both directions" { // Verify that our style is set { try testing.expect(t.screens.active.cursor.style_id != style.default_id); - const page = &t.screens.active.cursor.page_pin.node.data; + const page = t.screens.active.cursor.page_pin.node.page(); try testing.expectEqual(@as(usize, 1), page.styles.count()); try testing.expect(page.styles.refCount(page.memory, t.screens.active.cursor.style_id) > 0); } @@ -13003,7 +14186,7 @@ test "Terminal: mode 47 copies cursor both directions" { // Verify that our style is still set { try testing.expect(t.screens.active.cursor.style_id != style.default_id); - const page = &t.screens.active.cursor.page_pin.node.data; + const page = t.screens.active.cursor.page_pin.node.page(); try testing.expectEqual(@as(usize, 1), page.styles.count()); try testing.expect(page.styles.refCount(page.memory, t.screens.active.cursor.style_id) > 0); } @@ -13075,7 +14258,7 @@ test "Terminal: mode 1047 copies cursor both directions" { // Verify that our style is set { try testing.expect(t.screens.active.cursor.style_id != style.default_id); - const page = &t.screens.active.cursor.page_pin.node.data; + const page = t.screens.active.cursor.page_pin.node.page(); try testing.expectEqual(@as(usize, 1), page.styles.count()); try testing.expect(page.styles.refCount(page.memory, t.screens.active.cursor.style_id) > 0); } @@ -13090,7 +14273,7 @@ test "Terminal: mode 1047 copies cursor both directions" { // Verify that our style is still set { try testing.expect(t.screens.active.cursor.style_id != style.default_id); - const page = &t.screens.active.cursor.page_pin.node.data; + const page = t.screens.active.cursor.page_pin.node.page(); try testing.expectEqual(@as(usize, 1), page.styles.count()); try testing.expect(page.styles.refCount(page.memory, t.screens.active.cursor.style_id) > 0); } @@ -13183,3 +14366,35 @@ test "Terminal: deleteLines wide char at right margin with full clear" { // violation in clearCells. try t.scrollUp(t.rows); } + +test "Terminal: glyph APC stores session glossary entries" { + const alloc = testing.allocator; + var t = try init(alloc, .{ .cols = 80, .rows = 24 }); + defer t.deinit(alloc); + + var register_parser = glyph.CommandParser.init(alloc, 1024 * 1024); + defer register_parser.deinit(); + for ("r;cp=e0a0;AAAAAAAAAAAAAA==") |byte| try register_parser.feed(byte); + var register_req = try register_parser.complete(alloc); + defer register_req.deinit(alloc); + + try testing.expectEqual(glyph.Response{ + .register = .{ .cp = 0xE0A0 }, + }, t.glyphProtocol(alloc, ®ister_req).?); + try testing.expect(t.glyph_glossary.contains(0xE0A0)); + try testing.expect(t.flags.dirty.glyph_glossary); + + var query_parser = glyph.CommandParser.init(alloc, 1024 * 1024); + defer query_parser.deinit(); + for ("q;cp=e0a0") |byte| try query_parser.feed(byte); + var query_req = try query_parser.complete(alloc); + defer query_req.deinit(alloc); + + try testing.expectEqual(glyph.Response{ .query = .{ + .cp = 0xE0A0, + .status = .{ .glossary = true }, + } }, t.glyphProtocol(alloc, &query_req).?); + + t.fullReset(); + try testing.expect(!t.glyph_glossary.contains(0xE0A0)); +} diff --git a/vendor/libghostty-vt/src/terminal/apc.zig b/vendor/libghostty-vt/src/terminal/apc.zig index 7e6f08a7..121f906a 100644 --- a/vendor/libghostty-vt/src/terminal/apc.zig +++ b/vendor/libghostty-vt/src/terminal/apc.zig @@ -2,6 +2,7 @@ const std = @import("std"); const build_options = @import("terminal_options"); const Allocator = std.mem.Allocator; +pub const glyph = @import("apc/glyph.zig"); const kitty_gfx = @import("kitty/graphics.zig"); const log = std.log.scoped(.terminal_apc); @@ -18,15 +19,27 @@ pub const Handler = struct { /// use `.initFull`. max_bytes: std.EnumMap(Protocol, usize) = .initFullWith(.{ .kitty = Protocol.defaultMaxBytes(.kitty), + .glyph = Protocol.defaultMaxBytes(.glyph), }), + /// Protocols recognized by this APC handler. When a protocol is absent, + /// matching APC sequences are ignored so callers see the same behavior as + /// an unsupported protocol: no command execution and no response. + enabled: std.EnumSet(Protocol) = .initFull(), + pub fn deinit(self: *Handler) void { self.state.deinit(); } pub fn start(self: *Handler) void { self.state.deinit(); - self.state = .{ .identify = {} }; + self.state = .{ .identify = .{} }; + } + + /// Enable or disable APC protocol recognition for future APC sequences. + /// This does not affect any APC command already being parsed. + pub fn enable(self: *Handler, protocol: Protocol, enabled: bool) void { + self.enabled.setPresent(protocol, enabled); } pub fn feed(self: *Handler, alloc: Allocator, byte: u8) void { @@ -38,21 +51,50 @@ pub const Handler = struct { .ignore => return, // We identify the APC command by the first byte. - .identify => { - switch (byte) { - // Kitty graphics protocol - 'G' => self.state = if (comptime build_options.kitty_graphics) - .{ .kitty = .init( + .identify => |*id| id: { + // Kitty graphics is detected immediately on the `G` byte, + // since commands begin immediately after with no termination + // character after the 'G'. + if (comptime build_options.kitty_graphics) { + if (id.len == 0 and + byte == 'G' and + self.enabled.contains(.kitty)) + { + self.state = .{ .kitty = .init( alloc, self.max_bytes.get(.kitty) orelse Protocol.defaultMaxBytes(.kitty), - ) } - else - .ignore, + ) }; + break :id; + } + } - // Unknown - else => self.state = .ignore, + // If we hit `;` then identify... + if (byte == ';') { + const str = id.buf[0..id.len]; + if (std.mem.eql(u8, str, "25a1") and + self.enabled.contains(.glyph)) + { + self.state = .{ .glyph = .init( + alloc, + self.max_bytes.get(.glyph) orelse + Protocol.defaultMaxBytes(.glyph), + ) }; + } else { + self.state = .ignore; + } + + break :id; } + + // If we're out of space to buffer then we're done. + if (id.len >= id.buf.len) { + self.state = .ignore; + break :id; + } + + id.buf[id.len] = byte; + id.len += 1; }, .kitty => |*p| if (comptime build_options.kitty_graphics) { @@ -62,6 +104,53 @@ pub const Handler = struct { self.state = .ignore; }; } else unreachable, + + .glyph => |*p| p.feed(byte) catch |err| { + log.warn("glyph protocol error: {}", .{err}); + p.deinit(); + self.state = .ignore; + }, + } + } + + /// Feed a slice of bytes to the handler. This is equivalent to + /// calling feed for each byte in order, but protocol payload bytes + /// are passed through in bulk so large payloads (e.g. Kitty graphics + /// images) avoid per-byte dispatch overhead. + pub fn feedSlice(self: *Handler, alloc: Allocator, bytes: []const u8) void { + var rem = bytes; + while (rem.len > 0) { + switch (self.state) { + .inactive => unreachable, + + // We're ignoring this APC command; drop the whole slice. + .ignore => return, + + // Identification consumes at most a few bytes; step + // through them one at a time until the state changes. + .identify => { + self.feed(alloc, rem[0]); + rem = rem[1..]; + }, + + .kitty => |*p| if (comptime build_options.kitty_graphics) { + p.feedSlice(rem) catch |err| { + log.warn("kitty graphics protocol error: {}", .{err}); + p.deinit(); + self.state = .ignore; + }; + return; + } else unreachable, + + .glyph => |*p| { + p.feedSlice(rem) catch |err| { + log.warn("glyph protocol error: {}", .{err}); + p.deinit(); + self.state = .ignore; + }; + return; + }, + } } } @@ -86,21 +175,40 @@ pub const Handler = struct { break :kitty .{ .kitty = command }; }, + + .glyph => |*p| glyph_cmd: { + const command = p.complete(p.alloc) catch |err| { + log.warn("glyph protocol error: {}", .{err}); + break :glyph_cmd null; + }; + + break :glyph_cmd .{ .glyph = command }; + }, }; } }; pub const State = union(enum) { /// We're not in the middle of an APC command yet. - inactive: void, + inactive, /// We got an unrecognized APC sequence or the APC sequence we /// recognized became invalid. We're just dropping bytes. - ignore: void, - - /// We're waiting to identify the APC sequence. This is done by - /// inspecting the first byte of the sequence. - identify: void, + ignore, + + /// We're waiting to identify the APC sequence. The way this is done + /// is pretty fluid depending on supported APC protocols, but for now + /// our rule is: + /// + /// * 'G' - immediate transition to Kitty graphics protocol + /// * Buffer up to `;` and the bytes before dictate the protocol. + /// If we overflow then we're immediately invalid because we don't + /// support anything longer than this. + /// + identify: struct { + len: u3 = 0, + buf: [4]u8 = undefined, + }, /// Kitty graphics protocol kitty: if (build_options.kitty_graphics) @@ -108,9 +216,13 @@ pub const State = union(enum) { else void, + /// Glyph protocol + glyph: glyph.CommandParser, + pub fn deinit(self: *State) void { switch (self.*) { .inactive, .ignore, .identify => {}, + .glyph => |*v| v.deinit(), .kitty => |*v| if (comptime build_options.kitty_graphics) v.deinit() else @@ -122,6 +234,7 @@ pub const State = union(enum) { /// Possible APC command types. pub const Protocol = enum { kitty, + glyph, /// Returns the default maximum bytes for the given protocol. pub fn defaultMaxBytes(self: Protocol) usize { @@ -129,6 +242,10 @@ pub const Protocol = enum { // Kitty graphics payloads can be very large (e.g. full images // encoded as base64), so the default is set to 65 MiB. .kitty => 65 * 1024 * 1024, + // Glyph protocol messages carry single glyf outlines which + // are small, but base64 encoding inflates them. 1 MiB is + // generous for any single simple-glyph record. + .glyph => 1 * 1024 * 1024, }; } }; @@ -140,12 +257,16 @@ pub const Command = union(Protocol) { else void, + glyph: glyph.Request, + pub fn deinit(self: *Command, alloc: Allocator) void { switch (self.*) { .kitty => |*v| if (comptime build_options.kitty_graphics) v.deinit(alloc) else unreachable, + + .glyph => |*v| v.deinit(alloc), } } }; @@ -246,3 +367,156 @@ test "valid Kitty command" { defer cmd.deinit(alloc); try testing.expect(cmd == .kitty); } + +test "identify with unrecognized command" { + const testing = std.testing; + const alloc = testing.allocator; + + var h: Handler = .{}; + h.start(); + for ("abcd;payload") |c| h.feed(alloc, c); + try testing.expect(h.end() == null); +} + +test "identify buffer overflow" { + const testing = std.testing; + const alloc = testing.allocator; + + var h: Handler = .{}; + h.start(); + for ("abcde;payload") |c| h.feed(alloc, c); + try testing.expect(h.end() == null); +} + +test "identify with no input" { + const testing = std.testing; + + var h: Handler = .{}; + h.start(); + try testing.expect(h.end() == null); +} + +test "identify with unknown partial input" { + const testing = std.testing; + const alloc = testing.allocator; + + var h: Handler = .{}; + h.start(); + for ("25a") |c| h.feed(alloc, c); + try testing.expect(h.end() == null); +} + +test "garbage glyph command" { + const testing = std.testing; + const alloc = testing.allocator; + + var h: Handler = .{}; + h.start(); + for ("25a1;X") |c| h.feed(alloc, c); + + try testing.expect(h.end() == null); +} + +test "valid glyph command" { + const testing = std.testing; + const alloc = testing.allocator; + + var h: Handler = .{}; + h.start(); + for ("25a1;q;cp=E0A0") |c| h.feed(alloc, c); + + var cmd = h.end().?; + defer cmd.deinit(alloc); + try testing.expect(cmd == .glyph); + try testing.expect(cmd.glyph == .query); +} + +test "feedSlice valid Kitty command" { + if (comptime !build_options.kitty_graphics) return error.SkipZigTest; + + const testing = std.testing; + const alloc = testing.allocator; + + var h: Handler = .{}; + h.start(); + h.feedSlice(alloc, "Gf=24,s=10,v=20;aGVsbG8="); + + var cmd = h.end().?; + defer cmd.deinit(alloc); + try testing.expect(cmd == .kitty); + + // The payload is base64-decoded by the parser on completion. + try testing.expectEqualStrings("hello", cmd.kitty.data); +} + +test "feedSlice identify split across slices" { + if (comptime !build_options.kitty_graphics) return error.SkipZigTest; + + const testing = std.testing; + const alloc = testing.allocator; + + var h: Handler = .{}; + h.start(); + h.feedSlice(alloc, "G"); + h.feedSlice(alloc, "f=24,s=10,"); + h.feedSlice(alloc, "v=20;aGVsbG8="); + + var cmd = h.end().?; + defer cmd.deinit(alloc); + try testing.expect(cmd == .kitty); + + // The payload is base64-decoded by the parser on completion. + try testing.expectEqualStrings("hello", cmd.kitty.data); +} + +test "feedSlice unknown APC command is ignored" { + const testing = std.testing; + const alloc = testing.allocator; + + var h: Handler = .{}; + h.start(); + h.feedSlice(alloc, "Xabcdef1234"); + try testing.expect(h.state == .ignore); + h.feedSlice(alloc, "more data that is dropped"); + try testing.expect(h.end() == null); +} + +test "feedSlice valid glyph command" { + const testing = std.testing; + const alloc = testing.allocator; + + var h: Handler = .{}; + h.start(); + h.feedSlice(alloc, "25a1;q;cp=E0A0"); + + var cmd = h.end().?; + defer cmd.deinit(alloc); + try testing.expect(cmd == .glyph); + try testing.expect(cmd.glyph == .query); +} + +test "feedSlice kitty max bytes exceeded" { + if (comptime !build_options.kitty_graphics) return error.SkipZigTest; + + const testing = std.testing; + const alloc = testing.allocator; + + var h: Handler = .{ .max_bytes = .init(.{ .kitty = 4 }) }; + defer h.deinit(); + h.start(); + h.feedSlice(alloc, "Ga=t;abcd"); + try testing.expect(h.state != .ignore); + h.feedSlice(alloc, "e"); + try testing.expect(h.state == .ignore); +} + +test "disabled glyph command is ignored" { + const testing = std.testing; + const alloc = testing.allocator; + + var h: Handler = .{}; + h.enable(.glyph, false); + h.start(); + for ("25a1;q;cp=e0a0") |c| h.feed(alloc, c); + try testing.expect(h.end() == null); +} diff --git a/vendor/libghostty-vt/src/terminal/apc/glyph.zig b/vendor/libghostty-vt/src/terminal/apc/glyph.zig new file mode 100644 index 00000000..727e1ab4 --- /dev/null +++ b/vendor/libghostty-vt/src/terminal/apc/glyph.zig @@ -0,0 +1,164 @@ +//! # Glyph Protocol +//! +//! The Glyph Protocol lets applications register custom glyphs with the +//! terminal at runtime and query whether a given codepoint is already +//! covered by a system font or a prior registration. It eliminates the +//! requirement for users to install patched fonts (e.g. Nerd Fonts) in +//! order to render icons in TUIs. +//! +//! This file documents the current wire protocol surface parsed and formatted +//! by the glyph APC modules. +//! +//! ## Transport +//! +//! Messages use APC (Application Program Command) framing. +//! Terminals that do not implement the protocol can safely ignore APC +//! sequences. Every message is prefixed with the identifier `25a1` +//! (U+25A1 WHITE SQUARE — the canonical tofu symbol). +//! +//! ## Framing +//! +//! ``` +//! ESC _ 25a1 ; <verb> [ ; key=value ]* [ ; <payload> ] ESC \ +//! ``` +//! +//! Four verbs are defined: +//! +//! - `s` — support query +//! - `q` — codepoint query +//! - `r` — register a glyph +//! - `c` — clear registrations +//! +//! ## Support (`s`) +//! +//! Detects whether the terminal implements Glyph Protocol and which +//! payload formats it supports. +//! +//! Request: `ESC _ 25a1 ; s ESC \` +//! Response: `ESC _ 25a1 ; s ; fmt=<list> ESC \` +//! +//! `fmt` is a comma-separated list of supported payload format names: +//! - `glyf` — TrueType simple glyphs (required in v1) +//! - `colrv0` — COLR v0 layered flat-colour glyphs +//! - `colrv1` — COLR v1 paint-graph glyphs +//! +//! Order is not significant. An empty `fmt=` means the terminal recognizes +//! Glyph Protocol but currently advertises no payload formats. Clients must +//! ignore unknown format names. +//! +//! Any reply confirms support; no reply within a timeout means the +//! terminal does not implement the protocol. +//! +//! ## Query (`q`) +//! +//! Asks whether a codepoint is renderable and by whom. +//! +//! Request: `ESC _ 25a1 ; q ; cp=<hex> ESC \` +//! Response: `ESC _ 25a1 ; q ; cp=<hex> ; status=<list> ESC \` +//! +//! `status` is a comma-separated list of coverage names: +//! - empty — nothing renders this codepoint (tofu) +//! - `system` — a system font covers it +//! - `glossary` — a session registration covers it +//! - `system,glossary` — both; the registration shadows the system font +//! +//! Non-PUA codepoints can only report empty or `system`. Clients must ignore +//! unknown coverage names. +//! +//! ## Register (`r`) +//! +//! Registers a glyph outline at a Private Use Area codepoint. +//! +//! Request: +//! `ESC _ 25a1 ; r ; cp=<hex> [; fmt=glyf] [; reply=<0|1|2>] +//! [; upm=<int>] [; aw=<int>] [; lh=<int>] [; width=<1|2>] +//! [; size=<height|advance|contain|cover|stretch>] +//! [; align=<start|center|end>,<start|center|end|baseline>] +//! [; pad=<top>,<right>,<bottom>,<left>] ; <base64-payload> ESC \` +//! +//! Response: +//! `ESC _ 25a1 ; r ; cp=<hex> ; status=0 ESC \` +//! On error: `status=<nonzero> ; reason=<code>` +//! +//! Parameters: +//! - `cp` — target codepoint (hex). Must be in a PUA range: +//! U+E000–U+F8FF, U+F0000–U+FFFFD, or U+100000–U+10FFFD. +//! Non-PUA values are rejected with `reason=out_of_namespace`. +//! - `fmt` — payload format. Default `glyf`; `colrv0` and `colrv1` +//! are optional and advertised via the `s` reply. +//! - `reply` — response verbosity: +//! `1` (default) = success + failure replies +//! `2` = failure replies only (silent success) +//! `0` = no replies (fire-and-forget) +//! - `upm` — units-per-em for the coordinate space. Default 1000. +//! - `aw` — authored advance width in upm units. Default `upm`. +//! - `lh` — authored line height in upm units. Default `upm`. +//! - `width` — Unicode/wcwidth cell width. Must be `1` or `2`; default `1`. +//! This is authoritative for cursor advance, wrapping, and +//! selection geometry. +//! - `size` — scale policy. Default `height`. +//! - `align` — horizontal and vertical placement within the render span. +//! Default `center,center`. +//! - `pad` — fractional insets from the render span edges. Default +//! `0,0,0,0`; degenerate padding is treated as no padding. +//! - payload — base64-encoded payload for the selected `fmt`. +//! +//! The `glyf` subset accepted: +//! - Simple glyphs only (no composites). +//! - Standard flag encoding (on-curve, off-curve, x/y-short, repeat). +//! - No hinting instructions. +//! - Coordinates are in the `upm` space, Y-up, with `y=0` at the baseline; +//! the terminal scales and positions at render time using `aw`, `lh`, +//! `width`, `size`, `align`, and `pad`. +//! +//! `colrv0` and `colrv1` wrap OpenType `COLR`/`CPAL` data together with the +//! simple-glyph outlines they reference. `colrv0` uses layered flat colours; +//! `colrv1` uses the OpenType paint graph and may omit `CPAL` if it does not +//! reference palette indices. +//! +//! A second `r` on the same `cp` overwrites the previous registration. +//! `glyf` outlines render in the current foreground colour. +//! +//! ## Clear (`c`) +//! +//! Removes registrations. +//! +//! Single slot: `ESC _ 25a1 ; c ; cp=<hex> ESC \` +//! All slots: `ESC _ 25a1 ; c ESC \` +//! +//! The terminal acks with `status=0` even if the slot was already empty. +//! Clear replies do not echo `cp`. `cp` must be in a PUA range; non-PUA values return +//! `reason=out_of_namespace`. +//! +//! ## 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. +//! +//! ## Security: PUA-Only Restriction +//! +//! Registration is restricted to the three Unicode Private Use Areas to +//! prevent glyph-spoofing attacks. PUA codepoints never appear in normal +//! text (filenames, URLs, commands), so a registered glyph cannot alter +//! how real text is perceived. The cell buffer always stores the original +//! codepoint — copy/paste, search, and hyperlink detection return the +//! codepoint the application emitted, never the rendered glyph. +//! +//! Reference: <https://raw.githubusercontent.com/raphamorim/rio/779dba839dbb76c551f2efa852b82a2ed669101b/specs/glyph-protocol.md> + +const std = @import("std"); + +pub const request = @import("glyph/request.zig"); +pub const response = @import("glyph/response.zig"); +pub const execute = @import("glyph/execute.zig").execute; + +pub const CommandParser = request.CommandParser; +pub const Request = request.Request; +pub const Response = response.Response; +pub const Glossary = @import("glyph/Glossary.zig"); + +test { + std.testing.refAllDecls(@This()); +} diff --git a/vendor/libghostty-vt/src/terminal/apc/glyph/AGENTS.md b/vendor/libghostty-vt/src/terminal/apc/glyph/AGENTS.md new file mode 100644 index 00000000..c81029c0 --- /dev/null +++ b/vendor/libghostty-vt/src/terminal/apc/glyph/AGENTS.md @@ -0,0 +1,9 @@ +# Glyph Protocol + +- The specification source of truth is: + <https://github.com/raphamorim/rio/blob/main/specs/glyph-protocol.md> +- A summary of the specification is available in + `src/terminal/apc/glyph.zig` at the top. +- Reference the specification whenever any changes are made to + this folder. Prefer the local specification over fetching the + latest, unless it is lacking information. diff --git a/vendor/libghostty-vt/src/terminal/apc/glyph/Glossary.zig b/vendor/libghostty-vt/src/terminal/apc/glyph/Glossary.zig new file mode 100644 index 00000000..28458dbb --- /dev/null +++ b/vendor/libghostty-vt/src/terminal/apc/glyph/Glossary.zig @@ -0,0 +1,444 @@ +/// Glossary is the per-terminal storage for Glyph Protocol +/// codepoints. We use the word Glossary to match up with the spec which +/// also uses this word. +const Glossary = @This(); + +const std = @import("std"); +const assert = std.debug.assert; +const Allocator = std.mem.Allocator; +const CircBuf = @import("../../../datastruct/circ_buf.zig").CircBuf; +const FontGlyph = @import("../../../font/Glyph.zig"); +const Glyf = @import("../../../font/opentype/glyf.zig").Glyf; + +const request = @import("request.zig"); +const RegisterReq = request.Request.Register; + +const DesignMetrics = FontGlyph.DesignMetrics; +const Constraint = FontGlyph.RenderOptions.Constraint; + +/// Maximum entries allowed in the glossary before eviction. +/// Defined by the specification. +pub const max_entries = 1024; + +/// An empty glossary with no registered glyphs. +pub const empty: Glossary = .{ .entries = .empty }; + +/// Errors that can occur while registering a glossary entry. +pub const RegisterError = Allocator.Error || error{OutOfNamespace}; + +/// Errors that can occur while clearing glossary entries. +pub const ClearError = error{OutOfNamespace}; + +/// The set of entries in the glossary keyed by the codepoint. +/// +/// The array hash map preserves insertion order and has O(N) +/// orderedRemove, so we use it as a FIFO too for eviction when +/// the glossary is full. Since the specification limits the protocol +/// to 1024 maximum entries, ordered removal should never be that +/// expensive. +/// +/// I'm also operating under the assumption that full glossaries +/// for a session will be rare, so the eviction cost shouldn't +/// happen regularly. +entries: std.AutoArrayHashMapUnmanaged(u21, Entry), + +/// Release all glyph entries and hash map storage owned by the glossary. +pub fn deinit(self: *Glossary, alloc: Allocator) void { + for (self.entries.values()) |*entry| entry.deinit(alloc); + self.entries.deinit(alloc); + self.* = undefined; +} + +/// Register the given glyph entry. +/// +/// This will act according to the glyph specification +pub fn register( + self: *Glossary, + alloc: Allocator, + cp: u21, + entry: Entry, +) RegisterError!void { + // Validate codepoint according to spec. + if (!isPrivateUse(cp)) return error.OutOfNamespace; + + const gop = try self.entries.getOrPut(alloc, cp); + if (gop.found_existing) { + // Found an existing entry, we need to shift the FIFO so + // that this is now the most recent (at the end). This is + // O(N) but N is usually small and max N is bounded by the spec. + gop.value_ptr.*.deinit(alloc); + assert(self.entries.orderedRemove(cp)); + + // We already had enough capacity for this key before removing it, so + // reinserting the replacement cannot require another allocation. + self.entries.putAssumeCapacity(cp, entry); + return; + } + + // Array hash maps preserve insertion order so always immediately insert. + gop.value_ptr.* = entry; + + // Fast, typical path: we fit within the glossary, just return. + if (self.entries.count() <= max_entries) return; + + // Slow path: we need to evict. + self.entries.values()[0].deinit(alloc); + self.entries.orderedRemoveAt(0); +} + +/// Delete a single entry from the glossary. If the entry doesn't exist, +/// then this does nothing and is safe. +pub fn delete( + self: *Glossary, + alloc: Allocator, + cp: u21, +) ClearError!void { + if (!isPrivateUse(cp)) return error.OutOfNamespace; + const kv = self.entries.fetchOrderedRemove(cp) orelse return; + var entry = kv.value; + entry.deinit(alloc); +} + +/// Clear all entries from the glossary and free up any underlying +/// storage. +pub fn clearAndFree(self: *Glossary, alloc: Allocator) void { + for (self.entries.values()) |*entry| entry.deinit(alloc); + self.entries.deinit(alloc); + self.entries = .empty; +} + +/// Contains returns true if the codepoint is covered by the glossary. +pub fn contains(self: *Glossary, cp: u21) bool { + return self.entries.contains(cp); +} + +/// A single glyph registration entry. +pub const Entry = struct { + /// Stored glyph payload variants. + pub const Glyph = union(enum) { + glyf: Glyf.Outline, + }; + + /// The glyph itself. The tagged union only has glyf right now but + /// will eventually expand to support COLR and maybe other formats. + /// These are stored as raw outlines; rasterization is delayed to + /// renderers. The outlines have been validated. + glyph: Glyph, + + /// Authored metrics for the glyph's design coordinate space. + design: DesignMetrics, + + /// Unicode cell width requested by the registration. + width: request.Width, + + /// Normalized scale, alignment, and padding behavior for rasterization. + constraint: Constraint, + + /// Errors that can occur while constructing a glossary entry from a + /// register request. + pub const InitError = RegisterReq.DecodeError || error{ + /// The register request is missing a required option or has an invalid + /// explicitly-provided option value. + InvalidOptions, + + /// The requested payload format is not supported by this glossary. + UnsupportedFormat, + }; + + /// Initialize a glossary entry from a register request. + /// + /// This validates the request fields needed to construct the entry, + /// decodes the base64 glyph payload, and stores the decoded outline. The + /// returned entry owns decoded glyph memory and must be released with + /// `deinit`. + pub fn init(alloc: Allocator, req: RegisterReq) Entry.InitError!Entry { + // Validate format + const fmt = req.get(.fmt) orelse return error.InvalidOptions; + const design: DesignMetrics = .{ + .units_per_em = req.get(.upm) orelse return error.InvalidOptions, + .advance_width = req.get(.aw) orelse return error.InvalidOptions, + .line_height = req.get(.lh) orelse return error.InvalidOptions, + }; + if (design.units_per_em == 0 or + design.advance_width == 0 or + design.line_height == 0) return error.InvalidOptions; + const width = req.get(.width) orelse return error.InvalidOptions; + + // Get our constraints + const constraint = try constraintFromRegister(req); + + // Decode the payload into some usable glyph format for + // future rasterization. + const glyph: Glyph = switch (fmt) { + .glyf => .{ .glyf = try req.decodeGlyfPayload(alloc) }, + .colrv0, .colrv1 => return error.UnsupportedFormat, + }; + + // No more errors, since we never do glyph cleanup above. + errdefer comptime unreachable; + + return .{ + .glyph = glyph, + .design = design, + .width = width, + .constraint = constraint, + }; + } + + /// Release memory owned by this entry. + pub fn deinit(self: *Entry, alloc: Allocator) void { + switch (self.glyph) { + .glyf => |*outline| outline.deinit(alloc), + } + self.* = undefined; + } + + /// Return the renderer constraint for a register request. + /// + /// Glyph Protocol §8.5 defines sizing, alignment, and padding in terms of + /// the authored extent and render span. This function is the single + /// normalization point for how protocol sizing choices map to the + /// renderer-neutral constraint stored here. + fn constraintFromRegister( + req: RegisterReq, + ) error{InvalidOptions}!Constraint { + // Register.get applies the Glyph Protocol §6.1 defaults when options + // are omitted: size=height, align=center,center, and pad=0,0,0,0. + const size = req.get(.size) orelse return error.InvalidOptions; + const alignment = req.get(.@"align") orelse return error.InvalidOptions; + const pad = req.get(.pad) orelse return error.InvalidOptions; + + return .{ + .size = switch (size) { + // The rasterizer's base transform already maps the design em + // to the cell height. That is the closest existing behavior to + // the protocol's default height-driven mode. + .height => .none, + // There is no width-driven, aspect-preserving constraint mode + // today. Leave the base transform intact rather than forcing a + // fit/contain policy that would unexpectedly prevent overflow. + .advance => .none, + // Constraint.cover currently scales preserving aspect ratio to + // the available bounds, which is the best existing match for + // the protocol's contain mode. + .contain => .cover, + // There is no true protocol-cover equivalent that chooses the + // larger axis scale, so use the nearest named renderer policy. + .cover => .cover, + .stretch => .stretch, + }, + .align_horizontal = switch (alignment.horizontal) { + .start => .start, + .center => .center, + .end => .end, + }, + .align_vertical = switch (alignment.vertical) { + .start => .start, + .center => .center, + .end => .end, + // The current constraint API has no baseline alignment mode. + // Start is the closest stable default because the glyf + // rasterizer's coordinate model already treats y=0 as the + // baseline/bottom before constraints are applied. + .baseline => .start, + }, + .pad_top = pad.top, + .pad_right = pad.right, + .pad_bottom = pad.bottom, + .pad_left = pad.left, + }; + } +}; + +/// Return true if `cp` is in one of the Unicode Private Use Areas. +fn isPrivateUse(cp: u21) bool { + return (cp >= 0xE000 and cp <= 0xF8FF) or + (cp >= 0xF0000 and cp <= 0xFFFFD) or + (cp >= 0x100000 and cp <= 0x10FFFD); +} + +fn testParseRegister(alloc: Allocator, data: []const u8) !RegisterReq { + const raw = try alloc.dupe(u8, data); + errdefer alloc.free(raw); + + const req = try request.Request.parse(alloc, raw); + switch (req) { + .register => |reg| return reg, + else => unreachable, + } +} + +// Base64-encoded glyf payload from the "glyf: decode triangle" test in +// font/opentype/glyf.zig. This is a real simple-glyph record with one contour +// and three on-curve points. +const test_triangle_glyf_payload = "AAEAZABkA4QDhAACAAABAQEB9P5wAyADhPzgAAA="; + +fn testRegisterReq(alloc: Allocator, cp: u21) !RegisterReq { + const data = try std.fmt.allocPrint( + alloc, + "r;cp={x};upm=2048;aw=1024;lh=1536;width=2;size=stretch;align=end,start;pad=0.1,0.2,0.3,0.4;{s}", + .{ cp, test_triangle_glyf_payload }, + ); + errdefer alloc.free(data); + + const req = try request.Request.parse(alloc, data); + switch (req) { + .register => |reg| return reg, + else => unreachable, + } +} + +fn testRegisterEntry(alloc: Allocator, cp: u21) !Entry { + const req = try testRegisterReq(alloc, cp); + defer alloc.free(req.raw); + return try Entry.init(alloc, req); +} + +test "Entry init decodes glyf payload and applies register fields" { + const testing = std.testing; + const alloc = testing.allocator; + + const req = try testRegisterReq(alloc, 0xE000); + defer alloc.free(req.raw); + + var entry = try Entry.init(alloc, req); + defer entry.deinit(alloc); + + try testing.expectEqual(@as(u32, 2048), entry.design.units_per_em); + try testing.expectEqual(@as(u32, 1024), entry.design.advance_width); + try testing.expectEqual(@as(u32, 1536), entry.design.line_height); + try testing.expectEqual(request.Width.wide, entry.width); + try testing.expectEqual(Constraint.Size.stretch, entry.constraint.size); + try testing.expectEqual(Constraint.Align.end, entry.constraint.align_horizontal); + try testing.expectEqual(Constraint.Align.start, entry.constraint.align_vertical); + try testing.expectEqual(@as(f64, 0.1), entry.constraint.pad_top); + try testing.expectEqual(@as(f64, 0.2), entry.constraint.pad_right); + try testing.expectEqual(@as(f64, 0.3), entry.constraint.pad_bottom); + try testing.expectEqual(@as(f64, 0.4), entry.constraint.pad_left); + + try testing.expectEqual(@as(usize, 3), entry.glyph.glyf.points.len); + try testing.expectEqual(@as(usize, 1), entry.glyph.glyf.contours.len); +} + +test "Entry init rejects invalid register payload" { + const testing = std.testing; + const alloc = testing.allocator; + + const req = try testParseRegister(alloc, "r;cp=e000;%%%not-base64%%%"); + defer alloc.free(req.raw); + + try testing.expectError(error.MalformedPayload, Entry.init(alloc, req)); +} + +test "Glossary register overwrites and moves entry to newest position" { + const testing = std.testing; + const alloc = testing.allocator; + + var glossary: Glossary = .empty; + defer glossary.deinit(alloc); + + try glossary.register(alloc, 0xE000, try testRegisterEntry(alloc, 0xE000)); + try glossary.register(alloc, 0xE001, try testRegisterEntry(alloc, 0xE001)); + try glossary.register(alloc, 0xE000, try testRegisterEntry(alloc, 0xE000)); + + try testing.expectEqual(@as(usize, 2), glossary.entries.count()); + try testing.expectEqual(@as(u21, 0xE001), glossary.entries.keys()[0]); + try testing.expectEqual(@as(u21, 0xE000), glossary.entries.keys()[1]); +} + +test "Glossary register evicts oldest entry" { + const testing = std.testing; + const alloc = testing.allocator; + + var glossary: Glossary = .empty; + defer glossary.deinit(alloc); + + for (0..max_entries + 1) |i| { + const cp: u21 = @intCast(0xE000 + i); + try glossary.register(alloc, cp, try testRegisterEntry(alloc, cp)); + } + + try testing.expectEqual(@as(usize, max_entries), glossary.entries.count()); + try testing.expect(!glossary.entries.contains(0xE000)); + try testing.expect(glossary.entries.contains(0xE001)); + try testing.expect(glossary.entries.contains(0xE000 + max_entries)); +} + +test "Glossary register rejects non-PUA codepoint" { + const testing = std.testing; + const alloc = testing.allocator; + + var glossary: Glossary = .empty; + defer glossary.deinit(alloc); + + var entry = try testRegisterEntry(alloc, 0xE000); + defer entry.deinit(alloc); + try testing.expectError(error.OutOfNamespace, glossary.register(alloc, 'A', entry)); +} + +test "Glossary delete removes one PUA slot and ignores empty PUA slot" { + const testing = std.testing; + const alloc = testing.allocator; + + var glossary: Glossary = .empty; + defer glossary.deinit(alloc); + + try glossary.register(alloc, 0xE000, try testRegisterEntry(alloc, 0xE000)); + try glossary.register(alloc, 0xE001, try testRegisterEntry(alloc, 0xE001)); + + try glossary.delete(alloc, 0xE000); + try testing.expectEqual(@as(usize, 1), glossary.entries.count()); + try testing.expect(!glossary.contains(0xE000)); + try testing.expect(glossary.contains(0xE001)); + + try glossary.delete(alloc, 0xE000); + try testing.expectEqual(@as(usize, 1), glossary.entries.count()); + try testing.expect(glossary.contains(0xE001)); +} + +test "Glossary delete rejects non-PUA codepoint" { + const testing = std.testing; + const alloc = testing.allocator; + + var glossary: Glossary = .empty; + defer glossary.deinit(alloc); + + try glossary.register(alloc, 0xE000, try testRegisterEntry(alloc, 0xE000)); + try testing.expectError(error.OutOfNamespace, glossary.delete(alloc, 'A')); + try testing.expectEqual(@as(usize, 1), glossary.entries.count()); + try testing.expect(glossary.contains(0xE000)); +} + +test "Glossary clearAndFree removes all slots and remains reusable" { + const testing = std.testing; + const alloc = testing.allocator; + + var glossary: Glossary = .empty; + defer glossary.deinit(alloc); + + try glossary.register(alloc, 0xE000, try testRegisterEntry(alloc, 0xE000)); + try glossary.register(alloc, 0xE001, try testRegisterEntry(alloc, 0xE001)); + + glossary.clearAndFree(alloc); + try testing.expectEqual(@as(usize, 0), glossary.entries.count()); + try testing.expect(!glossary.contains(0xE000)); + try testing.expect(!glossary.contains(0xE001)); + + try glossary.register(alloc, 0xE002, try testRegisterEntry(alloc, 0xE002)); + try testing.expectEqual(@as(usize, 1), glossary.entries.count()); + try testing.expect(glossary.contains(0xE002)); +} + +test "Glossary contains reports registered slots" { + const testing = std.testing; + const alloc = testing.allocator; + + var glossary: Glossary = .empty; + defer glossary.deinit(alloc); + + try testing.expect(!glossary.contains(0xE000)); + + try glossary.register(alloc, 0xE000, try testRegisterEntry(alloc, 0xE000)); + try testing.expect(glossary.contains(0xE000)); + try testing.expect(!glossary.contains(0xE001)); +} diff --git a/vendor/libghostty-vt/src/terminal/apc/glyph/execute.zig b/vendor/libghostty-vt/src/terminal/apc/glyph/execute.zig new file mode 100644 index 00000000..059029a6 --- /dev/null +++ b/vendor/libghostty-vt/src/terminal/apc/glyph/execute.zig @@ -0,0 +1,367 @@ +const std = @import("std"); +const Allocator = std.mem.Allocator; + +const request = @import("request.zig"); +const response = @import("response.zig"); +const Glossary = @import("Glossary.zig"); +const Request = request.Request; +const Response = response.Response; + +const log = std.log.scoped(.glyph); + +/// Payload formats we support. Hardcoded because the support is +/// fixed. +pub const supported_formats: response.Response.Support.Formats = .{ + .glyf = true, +}; + +/// Execute a Glyph protocol request against the given state. +/// +/// This will never fail, but the response may indiciate an error and +/// the terminal state may not be updated to reflect the command. This will +/// never put the terminal in a corrupt or non-recoverable state. +/// +/// For example, allocation errors can happen, but they're wrapped up in +/// an out of memory response. +/// +/// Query responses only report glossary coverage. Callers that can determine +/// system font coverage must update the returned query response before sending +/// it to the client. +pub fn execute( + alloc: Allocator, + glossary: *Glossary, + req: *const Request, +) ?Response { + log.debug("executing glyph protocol request: {t}", .{req.*}); + return switch (req.*) { + .support => .{ .support = .{ .fmt = supported_formats } }, + .query => |qry| query(glossary, qry), + .register => |reg| register(alloc, glossary, reg), + .clear => |clr| clear(alloc, glossary, clr), + }; +} + +fn query( + glossary: *Glossary, + qry: Request.Query, +) ?Response { + const cp = qry.get(.cp) orelse return null; + return .{ .query = .{ + .cp = cp, + .status = .{ + .glossary = glossary.contains(cp), + }, + } }; +} + +fn register( + alloc: Allocator, + glossary: *Glossary, + reg: Request.Register, +) ?Response { + const reply = reg.get(.reply) orelse .all; + const cp = registerFallible(alloc, glossary, reg) catch |err| return switch (reply) { + .none => null, + .all, .failures => .{ .register = .{ + .cp = reg.get(.cp) orelse 0, + .status = .err, + .reason = switch (err) { + error.OutOfMemory => .{ .other = "out_of_memory" }, + error.OutOfNamespace => .out_of_namespace, + error.PayloadTooLarge => .payload_too_large, + error.MalformedPayload => .malformed_payload, + error.CompositeUnsupported => .composite_unsupported, + error.HintingUnsupported => .hinting_unsupported, + error.InvalidOptions, + error.UnsupportedFormat, + => .malformed_payload, + }, + } }, + }; + + return switch (reply) { + .none, .failures => null, + .all => .{ .register = .{ .cp = cp } }, + }; +} + +fn registerFallible( + alloc: Allocator, + glossary: *Glossary, + reg: Request.Register, +) (Glossary.Entry.InitError || Glossary.RegisterError)!u21 { + const cp = reg.get(.cp) orelse + return error.MalformedPayload; + + var entry = try Glossary.Entry.init(alloc, reg); + errdefer entry.deinit(alloc); + + try glossary.register(alloc, cp, entry); + return cp; +} + +fn clear( + alloc: Allocator, + glossary: *Glossary, + clr: Request.Clear, +) ?Response { + if (clr.get(.cp)) |cp| { + glossary.delete(alloc, cp) catch |err| return .{ .clear = .{ + .status = .err, + .reason = switch (err) { + error.OutOfNamespace => "out_of_namespace", + }, + } }; + } else if (clr.has(.cp)) { + return .{ .clear = .{ + .status = .err, + .reason = "malformed_payload", + } }; + } else { + glossary.clearAndFree(alloc); + } + + return .{ .clear = .{} }; +} + +fn testParse(alloc: Allocator, data: []const u8) !Request { + var parser = request.CommandParser.init(alloc, 1024 * 1024); + defer parser.deinit(); + for (data) |byte| try parser.feed(byte); + return try parser.complete(alloc); +} + +fn testExecute(alloc: Allocator, glossary: *Glossary, req: *const Request) ?Response { + return execute(alloc, glossary, req); +} + +test "execute register stores glyph and returns success" { + const testing = std.testing; + const alloc = testing.allocator; + + var glossary: Glossary = .empty; + defer glossary.deinit(alloc); + + var req = try testParse(alloc, "r;cp=e0a0;AAAAAAAAAAAAAA=="); + defer req.deinit(alloc); + + try testing.expectEqual(Response{ + .register = .{ .cp = 0xE0A0 }, + }, testExecute(alloc, &glossary, &req).?); + try testing.expect(glossary.contains(0xE0A0)); +} + +test "execute register reply failures suppresses success" { + const testing = std.testing; + const alloc = testing.allocator; + + var glossary: Glossary = .empty; + defer glossary.deinit(alloc); + + var req = try testParse(alloc, "r;cp=e0a0;reply=2;AAAAAAAAAAAAAA=="); + defer req.deinit(alloc); + + try testing.expect(testExecute(alloc, &glossary, &req) == null); + try testing.expect(glossary.contains(0xE0A0)); +} + +test "execute register reply none suppresses failure" { + const testing = std.testing; + const alloc = testing.allocator; + + var glossary: Glossary = .empty; + defer glossary.deinit(alloc); + + var req = try testParse(alloc, "r;cp=41;reply=0;%%%not-base64%%%"); + defer req.deinit(alloc); + + try testing.expect(testExecute(alloc, &glossary, &req) == null); + try testing.expect(!glossary.contains('A')); +} + +test "execute register rejects non-PUA" { + const testing = std.testing; + const alloc = testing.allocator; + + var glossary: Glossary = .empty; + defer glossary.deinit(alloc); + + var req = try testParse(alloc, "r;cp=41;AAAAAAAAAAAAAA=="); + defer req.deinit(alloc); + + try testing.expectEqual(Response{ + .register = .{ + .cp = 'A', + .status = .err, + .reason = .out_of_namespace, + }, + }, testExecute(alloc, &glossary, &req).?); + try testing.expect(!glossary.contains('A')); +} + +test "execute register reports malformed payload" { + const testing = std.testing; + const alloc = testing.allocator; + + var glossary: Glossary = .empty; + defer glossary.deinit(alloc); + + var req = try testParse(alloc, "r;cp=e0a0;%%%not-base64%%%"); + defer req.deinit(alloc); + + try testing.expectEqual(Response{ + .register = .{ + .cp = 0xE0A0, + .status = .err, + .reason = .malformed_payload, + }, + }, testExecute(alloc, &glossary, &req).?); + try testing.expect(!glossary.contains(0xE0A0)); +} + +test "execute clear removes all glyphs" { + const testing = std.testing; + const alloc = testing.allocator; + + var glossary: Glossary = .empty; + defer glossary.deinit(alloc); + + var reg1 = try testParse(alloc, "r;cp=e0a0;AAAAAAAAAAAAAA=="); + defer reg1.deinit(alloc); + _ = testExecute(alloc, &glossary, ®1); + + var reg2 = try testParse(alloc, "r;cp=e0a1;AAAAAAAAAAAAAA=="); + defer reg2.deinit(alloc); + _ = testExecute(alloc, &glossary, ®2); + + var req = try testParse(alloc, "c"); + defer req.deinit(alloc); + + try testing.expectEqual(Response{ .clear = .{} }, testExecute(alloc, &glossary, &req).?); + try testing.expect(!glossary.contains(0xE0A0)); + try testing.expect(!glossary.contains(0xE0A1)); +} + +test "execute clear removes one glyph" { + const testing = std.testing; + const alloc = testing.allocator; + + var glossary: Glossary = .empty; + defer glossary.deinit(alloc); + + var reg1 = try testParse(alloc, "r;cp=e0a0;AAAAAAAAAAAAAA=="); + defer reg1.deinit(alloc); + _ = testExecute(alloc, &glossary, ®1); + + var reg2 = try testParse(alloc, "r;cp=e0a1;AAAAAAAAAAAAAA=="); + defer reg2.deinit(alloc); + _ = testExecute(alloc, &glossary, ®2); + + var req = try testParse(alloc, "c;cp=e0a0"); + defer req.deinit(alloc); + + try testing.expectEqual(Response{ .clear = .{} }, testExecute(alloc, &glossary, &req).?); + try testing.expect(!glossary.contains(0xE0A0)); + try testing.expect(glossary.contains(0xE0A1)); +} + +test "execute clear rejects non-PUA" { + const testing = std.testing; + const alloc = testing.allocator; + + var glossary: Glossary = .empty; + defer glossary.deinit(alloc); + + var req = try testParse(alloc, "c;cp=41"); + defer req.deinit(alloc); + + try testing.expectEqual(Response{ + .clear = .{ + .status = .err, + .reason = "out_of_namespace", + }, + }, testExecute(alloc, &glossary, &req).?); +} + +test "execute clear rejects malformed cp without clearing glossary" { + const testing = std.testing; + const alloc = testing.allocator; + + var glossary: Glossary = .empty; + defer glossary.deinit(alloc); + + var reg1 = try testParse(alloc, "r;cp=e0a0;AAAAAAAAAAAAAA=="); + defer reg1.deinit(alloc); + _ = testExecute(alloc, &glossary, ®1); + + var reg2 = try testParse(alloc, "r;cp=e0a1;AAAAAAAAAAAAAA=="); + defer reg2.deinit(alloc); + _ = testExecute(alloc, &glossary, ®2); + + for ([_][]const u8{ "c;cp=zz", "c;cp=", "c;cp=200000" }) |data| { + var req = try testParse(alloc, data); + defer req.deinit(alloc); + + try testing.expectEqual(Response{ + .clear = .{ + .status = .err, + .reason = "malformed_payload", + }, + }, testExecute(alloc, &glossary, &req).?); + try testing.expect(glossary.contains(0xE0A0)); + try testing.expect(glossary.contains(0xE0A1)); + } +} + +test "execute query reports no coverage" { + const testing = std.testing; + const alloc = testing.allocator; + + var glossary: Glossary = .empty; + defer glossary.deinit(alloc); + + var req = try testParse(alloc, "q;cp=e0a0"); + defer req.deinit(alloc); + + try testing.expectEqual(Response{ + .query = .{ + .cp = 0xE0A0, + .status = .{}, + }, + }, testExecute(alloc, &glossary, &req).?); +} + +test "execute query reports glossary coverage" { + const testing = std.testing; + const alloc = testing.allocator; + + var glossary: Glossary = .empty; + defer glossary.deinit(alloc); + + var reg = try testParse(alloc, "r;cp=e0a0;AAAAAAAAAAAAAA=="); + defer reg.deinit(alloc); + _ = testExecute(alloc, &glossary, ®); + + var req = try testParse(alloc, "q;cp=e0a0"); + defer req.deinit(alloc); + + try testing.expectEqual(Response{ + .query = .{ + .cp = 0xE0A0, + .status = .{ .glossary = true }, + }, + }, testExecute(alloc, &glossary, &req).?); +} + +test "execute query without cp returns no response" { + const testing = std.testing; + const alloc = testing.allocator; + + var glossary: Glossary = .empty; + defer glossary.deinit(alloc); + + var req = try testParse(alloc, "q;foo=bar"); + defer req.deinit(alloc); + + try testing.expect(testExecute(alloc, &glossary, &req) == null); +} diff --git a/vendor/libghostty-vt/src/terminal/apc/glyph/request.zig b/vendor/libghostty-vt/src/terminal/apc/glyph/request.zig new file mode 100644 index 00000000..71a2dd4b --- /dev/null +++ b/vendor/libghostty-vt/src/terminal/apc/glyph/request.zig @@ -0,0 +1,855 @@ +const std = @import("std"); +const Allocator = std.mem.Allocator; +const assert = std.debug.assert; +const Glyf = @import("../../../font/opentype/glyf.zig").Glyf; + +/// Maximum decoded glyph payload size accepted by the protocol. +/// This is documented in the spec. +const max_payload_size = 64 * 1024; // 64 KiB + +/// Stateful parser for a single glyph APC payload after the `25a1;` prefix. +pub const CommandParser = struct { + alloc: Allocator, + data: std.ArrayList(u8) = .empty, + + /// Maximum bytes the data payload can buffer. This is to prevent + /// malicious input from causing us to allocate too much memory. + max_bytes: usize, + + pub const Error = Allocator.Error || error{InvalidFormat}; + + /// Create a glyph APC parser that buffers the raw command bytes. + pub fn init(alloc: Allocator, max_bytes: usize) CommandParser { + return .{ .alloc = alloc, .max_bytes = max_bytes }; + } + + /// Release any buffered command bytes owned by the parser. + pub fn deinit(self: *CommandParser) void { + self.data.deinit(self.alloc); + } + + /// Append one more byte of APC payload to the buffered command. + pub fn feed(self: *CommandParser, byte: u8) Allocator.Error!void { + if (self.data.items.len >= self.max_bytes) return error.OutOfMemory; + try self.data.append(self.alloc, byte); + } + + /// Append a slice of APC payload bytes to the buffered command. + /// Equivalent to calling feed for each byte, but appends in bulk. + pub fn feedSlice(self: *CommandParser, bytes: []const u8) Allocator.Error!void { + if (self.data.items.len + bytes.len > self.max_bytes) return error.OutOfMemory; + try self.data.appendSlice(self.alloc, bytes); + } + + /// Finish parsing and return an owned request that can outlive the parser. + pub fn complete(self: *CommandParser, alloc: Allocator) Error!Request { + // Normalize bare single-byte verbs like `s` into `s;` so the parsed + // command always has the standard `verb;...` layout. + if (self.data.items.len == 1) try self.data.append(self.alloc, ';'); + + const raw = try self.data.toOwnedSlice(alloc); + + // Ownership of the buffered bytes has moved to `raw`, so clear the + // array list before we build the final command value. + self.data = .empty; + errdefer alloc.free(raw); + return try Request.parse(alloc, raw); + } +}; + +/// Parsed glyph APC request with the verb classified eagerly. +pub const Request = union(enum) { + /// Support query (bare `s` verb, no options). + support, + + /// Codepoint coverage query. + query: Query, + + /// Glyph registration request. + register: Register, + + /// Registration clear request. + clear: Clear, + + /// Query verb payload with lazily-decoded options. + pub const Query = struct { + raw: []const u8, + + /// Initialize a query command from owned raw command bytes. + pub fn init(raw: []const u8) Query { + return .{ .raw = raw }; + } + + /// Options recognized for the glyph query request. + pub const Option = enum { + /// Target Unicode codepoint encoded in hexadecimal. + cp, + + /// Return the decoded Zig type for a query option. + pub fn Type(comptime self: Option) type { + return switch (self) { + .cp => u21, + }; + } + + /// Return the wire-format option key for this query option. + fn key(comptime self: Option) []const u8 { + return @tagName(self); + } + + /// Read and decode a query option from the raw option string. + pub fn read(comptime self: Option, raw: []const u8) ?self.Type() { + const value = optionValue(raw, self.key()) orelse return null; + return switch (self) { + .cp => std.fmt.parseInt(u21, value, 16) catch null, + }; + } + }; + + /// Lazily decode a query option on demand. + pub fn get(self: Query, comptime option: Option) ?option.Type() { + return option.read(self.rawOptions()); + } + + /// Return the raw option portion of a valid query command. + fn rawOptions(self: Query) []const u8 { + assert(self.raw.len >= 2); + assert(self.raw[0] == 'q'); + assert(self.raw[1] == ';'); + return self.raw[2..]; + } + }; + + /// Register verb payload with lazily-decoded options and optional base64 data. + pub const Register = struct { + raw: []const u8, + payload_idx: usize, + + /// Initialize a register command from owned raw command bytes. + pub fn init(raw: []const u8) ?Register { + if (raw.len < 2) return null; + if (raw[0] != 'r') return null; + if (raw[1] != ';') return null; + const payload_idx = std.mem.lastIndexOfScalar(u8, raw, ';') orelse return null; + if (payload_idx <= 1) return null; + + return .{ + .raw = raw, + .payload_idx = payload_idx, + }; + } + + /// Options recognized for the glyph register verb. + pub const Option = enum { + /// Target Unicode codepoint encoded in hexadecimal. + cp, + + /// Glyph payload format. + fmt, + + /// Requested reply verbosity for registration. + reply, + + /// Units-per-em for the glyph coordinate system. + upm, + + /// Authored advance width in units-per-em units. + aw, + + /// Authored line height in units-per-em units. + lh, + + /// Unicode cell width for terminal layout. + width, + + /// Glyph scale policy. + size, + + /// Glyph placement within the render span. + @"align", + + /// Fractional insets from the render span edges. + pad, + + /// Return the decoded Zig type for a register option. + pub fn Type(comptime self: Option) type { + return switch (self) { + .cp => u21, + .fmt => Format, + .reply => Reply, + .upm => u32, + .aw => u32, + .lh => u32, + .width => Width, + .size => Size, + .@"align" => Align, + .pad => Pad, + }; + } + + /// Return the protocol default value for this option, if any. + pub fn default(comptime self: Option) ?self.Type() { + return switch (self) { + .cp => null, + .fmt => .glyf, + .reply => .all, + .upm => 1000, + .aw => null, + .lh => null, + .width => .narrow, + .size => .height, + .@"align" => .{}, + .pad => .{}, + }; + } + + /// Return the wire-format option key for this register option. + fn key(comptime self: Option) []const u8 { + return @tagName(self); + } + + /// Read and decode a register option from the raw option string. + pub fn read(comptime self: Option, raw: []const u8) ?self.Type() { + const value = optionValue(raw, self.key()) orelse return null; + return switch (self) { + .cp => std.fmt.parseInt(u21, value, 16) catch null, + .fmt => Format.init(value), + .reply => Reply.init(value) orelse .all, + .upm => std.fmt.parseInt(u32, value, 10) catch null, + .aw => std.fmt.parseInt(u32, value, 10) catch null, + .lh => std.fmt.parseInt(u32, value, 10) catch null, + .width => Width.init(value), + .size => Size.init(value), + .@"align" => Align.init(value), + .pad => Pad.init(value), + }; + } + }; + + /// Lazily decode a register option on demand, applying protocol + /// defaults when the option is omitted. + pub fn get(self: Register, comptime option: Option) ?option.Type() { + const raw = self.rawOptions(); + if (optionValue(raw, option.key()) == null) { + return switch (option) { + .aw, .lh => self.get(.upm), + else => option.default(), + }; + } + return option.read(raw); + } + + /// Return the base64 payload carried by a register request. + /// + /// If no payload is present, this returns an empty slice. The returned + /// bytes may still be invalid base64; this function only exposes the raw + /// payload segment and does not validate or decode it. + pub fn payload(self: Register) []const u8 { + assert(self.raw.len >= 2); + assert(self.raw[0] == 'r'); + assert(self.raw[1] == ';'); + return if (self.payload_idx == self.raw.len) + "" + else + self.raw[self.payload_idx + 1 ..]; + } + + /// Errors that can occur while decoding a register glyph payload. + pub const DecodeError = Allocator.Error || error{ + /// The decoded payload exceeds the protocol limit. + PayloadTooLarge, + + /// The payload could not be decoded or parsed as the declared format. + MalformedPayload, + + /// The glyf payload is composite, which the protocol forbids. + CompositeUnsupported, + + /// The glyf payload contains hinting instructions, which the + /// protocol forbids. + HintingUnsupported, + }; + + /// Decode this request's base64 glyf payload into an owned outline. + pub fn decodeGlyfPayload(self: Register, alloc: Allocator) DecodeError!Glyf.Outline { + // Prep base64 decoding, initial validation. + const Decoder = std.base64.standard.Decoder; + const payload_bytes = self.payload(); + const size = Decoder.calcSizeForSlice(payload_bytes) catch + return error.MalformedPayload; + if (size > max_payload_size) return error.PayloadTooLarge; + + // Max payload size is reasonable for stack and its likely + // we'll have stack space. We don't use much stack space in + // the future function calls either, so try a stack allocator + // here and fallback to heap as necessary. + var data_stack = std.heap.stackFallback( + max_payload_size, + alloc, + ); + const data_alloc = data_stack.get(); + const data = try data_alloc.alloc(u8, size); + defer data_alloc.free(data); + + // Base64 decode + Decoder.decode(data, payload_bytes) catch + return error.MalformedPayload; + + // Glyf.Entry borrows from `data`, but only for the duration of the + // 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, + // Unsupported fields + error.CompositeNotSupported => error.CompositeUnsupported, + error.InstructionsNotSupported => error.HintingUnsupported, + // Various semantic issues + error.EndOfStream, + error.EndPointsOutOfOrder, + error.TooManyPoints, + error.CoordinateOverflow, + => error.MalformedPayload, + }; + } + + /// Return the raw option portion of a valid register command. + fn rawOptions(self: Register) []const u8 { + assert(self.raw.len >= 2); + assert(self.raw[0] == 'r'); + assert(self.raw[1] == ';'); + assert(self.payload_idx >= 2); + assert(self.payload_idx <= self.raw.len); + return self.raw[2..self.payload_idx]; + } + }; + + /// Clear verb payload with lazily-decoded options. + pub const Clear = struct { + raw: []const u8, + + /// Initialize a clear command from owned raw command bytes. + pub fn init(raw: []const u8) Clear { + return .{ .raw = raw }; + } + + /// Options recognized for the glyph clear request. + pub const Option = enum { + /// Target Unicode codepoint encoded in hexadecimal. + cp, + + /// Return the decoded Zig type for a clear option. + pub fn Type(comptime self: Option) type { + return switch (self) { + .cp => u21, + }; + } + + /// Return the wire-format option key for this clear option. + fn key(comptime self: Option) []const u8 { + return @tagName(self); + } + + /// Read and decode a clear option from the raw option string. + pub fn read(comptime self: Option, raw: []const u8) ?self.Type() { + const value = optionValue(raw, self.key()) orelse return null; + return switch (self) { + .cp => std.fmt.parseInt(u21, value, 16) catch null, + }; + } + + /// Return whether the option is present in the raw option string, + /// independent of whether its value can be decoded. + pub fn present(comptime self: Option, raw: []const u8) bool { + return optionValue(raw, self.key()) != null; + } + }; + + /// Lazily decode a clear option on demand. + pub fn get(self: Clear, comptime option: Option) ?option.Type() { + return option.read(self.rawOptions()); + } + + /// Return whether a clear option was provided, even if malformed. + pub fn has(self: Clear, comptime option: Option) bool { + return option.present(self.rawOptions()); + } + + /// Return the raw option portion of a valid clear command. + fn rawOptions(self: Clear) []const u8 { + assert(self.raw.len >= 2); + assert(self.raw[0] == 'c'); + assert(self.raw[1] == ';'); + return self.raw[2..]; + } + }; + + /// Parse an owned glyph APC payload into its eagerly-classified request + /// form. + /// + /// The raw format here is strict on its requirements to avoid + /// edge cases: it must contain the request AND the request must + /// end in a semicolon (even if there are no options). The spec itself + /// does not require this but we artificially insert it in our parser + /// to simplify parsing later. + pub fn parse(alloc: Allocator, raw: []const u8) error{InvalidFormat}!Request { + if (raw.len < 2) return error.InvalidFormat; + if (raw[1] != ';') return error.InvalidFormat; + + return switch (raw[0]) { + 's' => { + alloc.free(raw); + return .support; + }, + 'q' => .{ .query = .init(raw) }, + 'r' => .{ .register = Register.init(raw) orelse return error.InvalidFormat }, + 'c' => .{ .clear = .init(raw) }, + else => error.InvalidFormat, + }; + } + + /// Free the raw bytes retained by any request variant. + pub fn deinit(self: *Request, alloc: Allocator) void { + switch (self.*) { + .support => {}, + inline else => |*cmd| if (cmd.raw.len > 0) alloc.free(cmd.raw), + } + } +}; + +/// Glyph payload formats named by the protocol. +pub const Format = enum { + /// TrueType simple glyph outline data. + glyf, + + /// OpenType COLR version 0 layered color glyph data. + colrv0, + + /// OpenType COLR version 1 paint graph glyph data. + colrv1, + + /// Parse a glyph payload format name. + pub fn init(value: []const u8) ?Format { + return std.meta.stringToEnum(Format, value); + } +}; + +/// Register command reply verbosity. +pub const Reply = enum(u2) { + /// Suppress both success and failure replies. + none = 0, + + /// Emit replies for both success and failure cases. + all = 1, + + /// Emit replies only for failure cases. + failures = 2, + + /// Parse the register command reply mode from its single-digit encoding. + pub fn init(value: []const u8) ?Reply { + if (value.len != 1) return null; + return switch (value[0]) { + '0' => .none, + '1' => .all, + '2' => .failures, + else => null, + }; + } +}; + +/// Register command width override for terminal layout. +pub const Width = enum(u2) { + /// One terminal cell. + narrow = 1, + + /// Two terminal cells. + wide = 2, + + /// Parse the register command width from its single-digit encoding. + pub fn init(value: []const u8) ?Width { + if (value.len != 1) return null; + return switch (value[0]) { + '1' => .narrow, + '2' => .wide, + else => null, + }; + } +}; + +/// Register command glyph scale policy. +pub const Size = enum { + height, + advance, + contain, + cover, + stretch, + + /// Parse a glyph scale policy name. + pub fn init(value: []const u8) ?Size { + return std.meta.stringToEnum(Size, value); + } +}; + +/// Register command glyph placement within the render span. +pub const Align = struct { + horizontal: Horizontal = .center, + vertical: Vertical = .center, + + pub const Horizontal = enum { + start, + center, + end, + + fn init(value: []const u8) ?Horizontal { + return std.meta.stringToEnum(Horizontal, value); + } + }; + + pub const Vertical = enum { + start, + center, + end, + baseline, + + fn init(value: []const u8) ?Vertical { + return std.meta.stringToEnum(Vertical, value); + } + }; + + /// Parse an align value in `<horizontal>,<vertical>` form. + pub fn init(value: []const u8) ?Align { + var it = std.mem.splitScalar(u8, value, ','); + const horizontal = Horizontal.init(it.next() orelse return null) orelse return null; + const vertical = Vertical.init(it.next() orelse return null) orelse return null; + if (it.next() != null) return null; + + return .{ + .horizontal = horizontal, + .vertical = vertical, + }; + } +}; + +/// Register command fractional insets from the render span edges. +pub const Pad = struct { + top: f64 = 0, + right: f64 = 0, + bottom: f64 = 0, + left: f64 = 0, + + /// Parse a pad value in `<top>,<right>,<bottom>,<left>` form. + pub fn init(value: []const u8) ?Pad { + var it = std.mem.splitScalar(u8, value, ','); + const top = parseFraction(it.next() orelse return null) orelse return null; + const right = parseFraction(it.next() orelse return null) orelse return null; + const bottom = parseFraction(it.next() orelse return null) orelse return null; + const left = parseFraction(it.next() orelse return null) orelse return null; + if (it.next() != null) return null; + + // Glyph Protocol §8.5.2: "If `l + r ≥ 1` or `t + b ≥ 1` + // the terminal MUST treat the request as if `pad=0,0,0,0`." + if (left + right >= 1 or top + bottom >= 1) return .{}; + + return .{ + .top = top, + .right = right, + .bottom = bottom, + .left = left, + }; + } + + /// Parse one pad component from the spec's `0.0`–`1.0` fractional range. + /// 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; + } +}; + +/// Find the last occurrence of `key=value` for a lazily-parsed option list. +fn optionValue(raw: []const u8, comptime key: []const u8) ?[]const u8 { + var remaining = raw; + var result: ?[]const u8 = null; + while (remaining.len > 0) { + // Options are semicolon-delimited, so each loop peels off one segment + // and checks whether it matches the requested key. + const len = std.mem.indexOfScalar(u8, remaining, ';') orelse remaining.len; + const full = remaining[0..len]; + + if (std.mem.indexOfScalar(u8, full, '=')) |eql_idx| { + if (std.mem.eql(u8, full[0..eql_idx], key)) { + result = full[eql_idx + 1 ..]; + } + } + + if (len == remaining.len) break; + remaining = remaining[len + 1 ..]; + } + + return result; +} + +fn testParse(alloc: Allocator, data: []const u8) CommandParser.Error!Request { + var parser = CommandParser.init(alloc, 1024 * 1024); + defer parser.deinit(); + for (data) |byte| try parser.feed(byte); + return try parser.complete(alloc); +} + +test "support command" { + const testing = std.testing; + + var cmd = try testParse(testing.allocator, "s"); + defer cmd.deinit(testing.allocator); + + try testing.expect(cmd == .support); +} + +test "query command" { + const testing = std.testing; + + var cmd = try testParse(testing.allocator, "q;cp=E0A0"); + defer cmd.deinit(testing.allocator); + + try testing.expect(cmd == .query); + try testing.expectEqual(@as(u21, 0xE0A0), cmd.query.get(.cp).?); +} + +test "register command with payload" { + const testing = std.testing; + + var cmd = try testParse( + testing.allocator, + "r;cp=e0a0;fmt=glyf;upm=1000;reply=2;QQ==", + ); + defer cmd.deinit(testing.allocator); + + try testing.expect(cmd == .register); + try testing.expectEqual(@as(u21, 0xE0A0), cmd.register.get(.cp).?); + try testing.expectEqual(Format.glyf, cmd.register.get(.fmt).?); + try testing.expectEqual(@as(u32, 1000), cmd.register.get(.upm).?); + try testing.expectEqual(Reply.failures, cmd.register.get(.reply).?); + try testing.expectEqualStrings("QQ==", cmd.register.payload()); +} + +test "register command with sizing and placement options" { + const testing = std.testing; + + var cmd = try testParse( + testing.allocator, + "r;cp=e0a0;upm=2048;aw=1024;lh=1536;width=2;size=contain;align=end,baseline;pad=0.1,0.2,0.3,0.4;QQ==", + ); + defer cmd.deinit(testing.allocator); + + try testing.expect(cmd == .register); + try testing.expectEqual(@as(u32, 2048), cmd.register.get(.upm).?); + try testing.expectEqual(@as(u32, 1024), cmd.register.get(.aw).?); + try testing.expectEqual(@as(u32, 1536), cmd.register.get(.lh).?); + try testing.expectEqual(Width.wide, cmd.register.get(.width).?); + try testing.expectEqual(Size.contain, cmd.register.get(.size).?); + try testing.expectEqual(Align{ + .horizontal = .end, + .vertical = .baseline, + }, cmd.register.get(.@"align").?); + try testing.expectEqual(Pad{ + .top = 0.1, + .right = 0.2, + .bottom = 0.3, + .left = 0.4, + }, cmd.register.get(.pad).?); + try testing.expectEqualStrings("QQ==", cmd.register.payload()); +} + +test "register option defaults" { + const testing = std.testing; + const Option = Request.Register.Option; + + try testing.expect(Option.cp.default() == null); + try testing.expectEqual(Format.glyf, Option.fmt.default().?); + try testing.expectEqual(@as(u32, 1000), Option.upm.default().?); + try testing.expect(Option.aw.default() == null); + try testing.expect(Option.lh.default() == null); + try testing.expectEqual(Width.narrow, Option.width.default().?); + try testing.expectEqual(Size.height, Option.size.default().?); + try testing.expectEqual(Align{}, Option.@"align".default().?); + try testing.expectEqual(Pad{}, Option.pad.default().?); + try testing.expectEqual(Reply.all, Option.reply.default().?); +} + +test "register command defaults" { + const testing = std.testing; + + var cmd = try testParse(testing.allocator, "r;cp=e0a0;QQ=="); + defer cmd.deinit(testing.allocator); + + try testing.expect(cmd == .register); + try testing.expectEqual(@as(u21, 0xE0A0), cmd.register.get(.cp).?); + try testing.expectEqual(Format.glyf, cmd.register.get(.fmt).?); + try testing.expectEqual(@as(u32, 1000), cmd.register.get(.upm).?); + try testing.expectEqual(@as(u32, 1000), cmd.register.get(.aw).?); + try testing.expectEqual(@as(u32, 1000), cmd.register.get(.lh).?); + try testing.expectEqual(Width.narrow, cmd.register.get(.width).?); + try testing.expectEqual(Size.height, cmd.register.get(.size).?); + try testing.expectEqual(Align{}, cmd.register.get(.@"align").?); + try testing.expectEqual(Pad{}, cmd.register.get(.pad).?); + try testing.expectEqual(Reply.all, cmd.register.get(.reply).?); +} + +test "register command aw and lh default to upm" { + const testing = std.testing; + + var cmd = try testParse(testing.allocator, "r;cp=e0a0;upm=2048;QQ=="); + defer cmd.deinit(testing.allocator); + + try testing.expect(cmd == .register); + try testing.expectEqual(@as(u32, 2048), cmd.register.get(.aw).?); + try testing.expectEqual(@as(u32, 2048), cmd.register.get(.lh).?); +} + +test "register command invalid sizing and placement options" { + const testing = std.testing; + + var cmd = try testParse( + testing.allocator, + "r;cp=e0a0;width=3;size=invalid;align=center,middle;pad=0,1.2,0,0;QQ==", + ); + defer cmd.deinit(testing.allocator); + + try testing.expect(cmd == .register); + try testing.expect(cmd.register.get(.width) == null); + try testing.expect(cmd.register.get(.size) == null); + try testing.expect(cmd.register.get(.@"align") == null); + try testing.expect(cmd.register.get(.pad) == null); +} + +test "register command degenerate padding defaults to no padding" { + const testing = std.testing; + + var cmd = try testParse( + testing.allocator, + "r;cp=e0a0;pad=0.4,0.2,0.6,0.1;QQ==", + ); + defer cmd.deinit(testing.allocator); + + try testing.expect(cmd == .register); + try testing.expectEqual(Pad{}, cmd.register.get(.pad).?); +} + +test "register command invalid reply falls back to reply=1" { + const testing = std.testing; + + var cmd = try testParse(testing.allocator, "r;cp=e0a0;reply=9;QQ=="); + defer cmd.deinit(testing.allocator); + + try testing.expect(cmd == .register); + try testing.expectEqual(Reply.all, cmd.register.get(.reply).?); +} + +test "register command duplicate options use the last value" { + const testing = std.testing; + + var cmd = try testParse(testing.allocator, "r;cp=e0a0;reply=1;reply=2;QQ=="); + defer cmd.deinit(testing.allocator); + + try testing.expect(cmd == .register); + try testing.expectEqual(Reply.failures, cmd.register.get(.reply).?); +} + +test "register command with invalid payload" { + const testing = std.testing; + + var cmd = try testParse( + testing.allocator, + "r;cp=e0a0;fmt=glyf;%%%not-base64%%%", + ); + defer cmd.deinit(testing.allocator); + + try testing.expect(cmd == .register); + try testing.expectEqual(@as(u21, 0xE0A0), cmd.register.get(.cp).?); + try testing.expectEqual(Format.glyf, cmd.register.get(.fmt).?); + try testing.expectEqualStrings("%%%not-base64%%%", cmd.register.payload()); +} + +test "register command rejects missing payload separator" { + const testing = std.testing; + + for ([_][]const u8{ "r", "r;cp=e0a0", "r;foo" }) |data| { + try testing.expectError( + error.InvalidFormat, + testParse(testing.allocator, data), + ); + } +} + +test "register decodes glyf payload" { + const testing = std.testing; + + var cmd = try testParse(testing.allocator, "r;cp=e0a0;AAAAAAAAAAAAAA=="); + defer cmd.deinit(testing.allocator); + + var outline = try cmd.register.decodeGlyfPayload(testing.allocator); + defer outline.deinit(testing.allocator); + + try testing.expectEqual(@as(usize, 0), outline.points.len); + try testing.expectEqual(@as(usize, 0), outline.contours.len); +} + +test "register rejects malformed glyf payload" { + const testing = std.testing; + + 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)); +} + +test "register response without payload" { + const testing = std.testing; + + var cmd = try testParse( + testing.allocator, + "r;cp=E0A0;status=4;reason=out_of_namespace", + ); + defer cmd.deinit(testing.allocator); + + // Register parsing is request-only, so the final segment is always treated + // as payload rather than as a response field. + try testing.expect(cmd == .register); + try testing.expectEqual(@as(u21, 0xE0A0), cmd.register.get(.cp).?); + try testing.expectEqualStrings("reason=out_of_namespace", cmd.register.payload()); +} + +test "clear command" { + const testing = std.testing; + + var cmd = try testParse(testing.allocator, "c;cp=e0a0"); + defer cmd.deinit(testing.allocator); + + try testing.expect(cmd == .clear); + try testing.expect(cmd.clear.has(.cp)); + try testing.expectEqual(@as(u21, 0xE0A0), cmd.clear.get(.cp).?); +} + +test "clear command tracks malformed cp presence" { + const testing = std.testing; + + for ([_][]const u8{ "c;cp=zz", "c;cp=", "c;cp=200000" }) |data| { + var cmd = try testParse(testing.allocator, data); + defer cmd.deinit(testing.allocator); + + try testing.expect(cmd == .clear); + try testing.expect(cmd.clear.has(.cp)); + try testing.expect(cmd.clear.get(.cp) == null); + } +} + +test "invalid command" { + const testing = std.testing; + + try testing.expectError( + error.InvalidFormat, + testParse(testing.allocator, "x"), + ); +} diff --git a/vendor/libghostty-vt/src/terminal/apc/glyph/response.zig b/vendor/libghostty-vt/src/terminal/apc/glyph/response.zig new file mode 100644 index 00000000..9e40f0ea --- /dev/null +++ b/vendor/libghostty-vt/src/terminal/apc/glyph/response.zig @@ -0,0 +1,346 @@ +const std = @import("std"); + +/// Query response coverage state for a codepoint. +pub const Coverage = packed struct(u2) { + /// A system font covers the codepoint. + system: bool = false, + + /// A session glyph registration covers the codepoint. + glossary: bool = false, + + /// No system font or registered glyph covers the codepoint. + pub const free: Coverage = .{}; + + /// Parse the query response coverage list from its comma-separated form. + /// Unknown coverage names are ignored for forward compatibility. + pub fn init(value: []const u8) ?Coverage { + var result: Coverage = .free; + var it = std.mem.splitScalar(u8, value, ','); + while (it.next()) |name| { + if (std.mem.eql(u8, name, "system")) { + result.system = true; + } else if (std.mem.eql(u8, name, "glossary")) { + result.glossary = true; + } + } + + return result; + } +}; + +/// Response to a glyph APC request, formatted for the wire protocol. +pub const Response = union(enum) { + /// Recommended fixed buffer size for formatting a Glyph Protocol response. + /// + /// Glyph Protocol responses contain only framing plus bounded scalar fields: + /// a u21 codepoint as hex, a decimal u8 status, a small fixed set of + /// supported format names, coverage names, and the reason names produced by + /// the executor. 1024 bytes is therefore far above the longest response we + /// can emit today, while still being small enough for stack allocation in + /// stream handlers. If callers construct responses with arbitrary `.other` + /// or clear reason strings, they must ensure those strings fit or handle the + /// writer error from `formatWire`. + pub const max_wire_bytes = 1024; + + /// Support query response listing supported payload formats. + support: Support, + + /// Codepoint coverage query response. + query: Query, + + /// Glyph registration response (success or error). + register: Register, + + /// Registration clear response. + clear: Clear, + + /// Support query response fields. + pub const Support = struct { + /// Supported payload formats. + fmt: Formats, + + pub const Formats = packed struct(u8) { + /// TrueType simple glyph outlines (required in v1). + glyf: bool = false, + + /// COLR v0 layered flat-colour glyphs. + colrv0: bool = false, + + /// COLR v1 paint-graph glyphs. + colrv1: bool = false, + + _padding: u5 = 0, + }; + }; + + /// Codepoint query response fields. + pub const Query = struct { + /// The queried codepoint. + cp: u21, + + /// Coverage status for the codepoint. + status: Coverage, + }; + + /// Register response fields. + pub const Register = struct { + /// The target codepoint of the registration. + cp: u21, + + /// Result status of the registration encoded as a decimal u8. + status: Status = .ok, + + /// Optional symbolic error reason. + reason: ?Reason = null, + + /// Register error reason codes defined by Glyph Protocol §6.2. + pub const Reason = union(enum) { + /// `cp` is not in any PUA range. + out_of_namespace, + + /// Payload contains composite glyphs. + composite_unsupported, + + /// Payload contains hinting instructions. + hinting_unsupported, + + /// Payload failed to parse as the declared `fmt`. + malformed_payload, + + /// Payload exceeds 64 KiB after base64 decoding. + payload_too_large, + + /// A reason code not known by this version of Ghostty. + other: []const u8, + + /// Return the wire-format reason name. + pub fn name(self: Reason) []const u8 { + return switch (self) { + .out_of_namespace => "out_of_namespace", + .composite_unsupported => "composite_unsupported", + .hinting_unsupported => "hinting_unsupported", + .malformed_payload => "malformed_payload", + .payload_too_large => "payload_too_large", + .other => |value| value, + }; + } + }; + }; + + /// Clear response fields. + pub const Clear = struct { + /// Result status of the clear operation encoded as a decimal u8. + status: Status = .ok, + + /// Optional symbolic error reason. + reason: ?[]const u8 = null, + }; + + /// Status code for register and clear responses. + pub const Status = enum(u8) { + /// The operation completed successfully. + ok = 0, + + /// A generic or unspecified error occurred. + err = 1, + + _, + }; + + /// Write the response in the glyph APC wire format to `writer`. + /// + /// The framing is: `ESC _ 25a1 ; <verb> ; <key=value>* ESC \` + pub fn formatWire( + self: Response, + writer: *std.Io.Writer, + ) std.Io.Writer.Error!void { + try writer.writeAll("\x1b_25a1;"); + switch (self) { + .support => |r| { + // From the spec: + // Order is not significant; clients MUST treat the value as a + // set. An empty fmt= value means the terminal recognises + // Glyph Protocol but currently advertises no payload formats + // — every r will be rejected. Clients MUST ignore names they + // do not recognise rather than failing the reply, so future + // format names are forward- compatible. + try writer.writeAll("s;fmt="); + var first = true; + if (r.fmt.glyf) { + first = false; + try writer.writeAll("glyf"); + } + if (r.fmt.colrv0) { + if (!first) try writer.writeByte(','); + first = false; + try writer.writeAll("colrv0"); + } + if (r.fmt.colrv1) { + if (!first) try writer.writeByte(','); + first = false; + try writer.writeAll("colrv1"); + } + }, + .query => |r| { + // status is a comma-separated list of coverage names — the + // set of sources that can render cp in this session. Order is + // not significant; clients MUST treat the value as a set. + try writer.print("q;cp={x};status=", .{r.cp}); + var first = true; + if (r.status.system) { + first = false; + try writer.writeAll("system"); + } + if (r.status.glossary) { + if (!first) try writer.writeByte(','); + try writer.writeAll("glossary"); + } + }, + .register => |r| { + try writer.print("r;cp={x};status={d}", .{ r.cp, @intFromEnum(r.status) }); + if (r.reason) |reason| { + try writer.writeAll(";reason="); + try writer.writeAll(reason.name()); + } + }, + .clear => |r| { + try writer.print("c;status={d}", .{@intFromEnum(r.status)}); + if (r.reason) |reason| { + try writer.writeAll(";reason="); + try writer.writeAll(reason); + } + }, + } + try writer.writeAll("\x1b\\"); + } +}; + +test "support formats default to no advertised formats" { + const testing = std.testing; + const Formats = Response.Support.Formats; + + try testing.expectEqual(@as(u8, 0), @as(u8, @bitCast(Formats{}))); +} + +test "response support formatWire" { + const testing = std.testing; + + var buf: [256]u8 = undefined; + var writer: std.Io.Writer = .fixed(&buf); + + const resp: Response = .{ .support = .{ .fmt = .{ .glyf = true, .colrv0 = true } } }; + try resp.formatWire(&writer); + try testing.expectEqualStrings("\x1b_25a1;s;fmt=glyf,colrv0\x1b\\", writer.buffered()); +} + +test "response support formatWire with no formats" { + const testing = std.testing; + + var buf: [256]u8 = undefined; + var writer: std.Io.Writer = .fixed(&buf); + + const resp: Response = .{ .support = .{ .fmt = .{} } }; + try resp.formatWire(&writer); + try testing.expectEqualStrings("\x1b_25a1;s;fmt=\x1b\\", writer.buffered()); +} + +test "response query formatWire" { + const testing = std.testing; + + var buf: [256]u8 = undefined; + var writer: std.Io.Writer = .fixed(&buf); + + const resp: Response = .{ .query = .{ .cp = 0xE0A0, .status = .{ .system = true, .glossary = true } } }; + try resp.formatWire(&writer); + try testing.expectEqualStrings("\x1b_25a1;q;cp=e0a0;status=system,glossary\x1b\\", writer.buffered()); +} + +test "response query formatWire with no coverage" { + const testing = std.testing; + + var buf: [256]u8 = undefined; + var writer: std.Io.Writer = .fixed(&buf); + + const resp: Response = .{ .query = .{ .cp = 0xE0A0, .status = .{} } }; + try resp.formatWire(&writer); + try testing.expectEqualStrings("\x1b_25a1;q;cp=e0a0;status=\x1b\\", writer.buffered()); +} + +test "coverage parses comma-separated names" { + const testing = std.testing; + + try testing.expectEqual(Coverage{}, Coverage.init("").?); + try testing.expectEqual(Coverage{ .system = true }, Coverage.init("system").?); + try testing.expectEqual(Coverage{ .glossary = true }, Coverage.init("glossary").?); + try testing.expectEqual(Coverage{ .system = true, .glossary = true }, Coverage.init("system,glossary").?); + try testing.expectEqual(Coverage{ .system = true, .glossary = true }, Coverage.init("glossary,system").?); + try testing.expectEqual(Coverage{ .system = true }, Coverage.init("system,future").?); + try testing.expectEqual(Coverage{}, Coverage.init("future").?); +} + +test "response register success formatWire" { + const testing = std.testing; + + var buf: [256]u8 = undefined; + var writer: std.Io.Writer = .fixed(&buf); + + const resp: Response = .{ .register = .{ .cp = 0xE0A0 } }; + try resp.formatWire(&writer); + try testing.expectEqualStrings("\x1b_25a1;r;cp=e0a0;status=0\x1b\\", writer.buffered()); +} + +test "response register error formatWire" { + const testing = std.testing; + + var buf: [256]u8 = undefined; + var writer: std.Io.Writer = .fixed(&buf); + + const resp: Response = .{ .register = .{ .cp = 0xE0A0, .status = .err, .reason = .out_of_namespace } }; + try resp.formatWire(&writer); + try testing.expectEqualStrings("\x1b_25a1;r;cp=e0a0;status=1;reason=out_of_namespace\x1b\\", writer.buffered()); +} + +test "response register arbitrary status formatWire" { + const testing = std.testing; + + var buf: [256]u8 = undefined; + var writer: std.Io.Writer = .fixed(&buf); + + const resp: Response = .{ .register = .{ .cp = 0xE0A0, .status = @enumFromInt(37), .reason = .payload_too_large } }; + try resp.formatWire(&writer); + try testing.expectEqualStrings("\x1b_25a1;r;cp=e0a0;status=37;reason=payload_too_large\x1b\\", writer.buffered()); +} + +test "register reason names" { + const testing = std.testing; + const Reason = Response.Register.Reason; + + try testing.expectEqualStrings("out_of_namespace", (Reason{ .out_of_namespace = {} }).name()); + try testing.expectEqualStrings("composite_unsupported", (Reason{ .composite_unsupported = {} }).name()); + try testing.expectEqualStrings("hinting_unsupported", (Reason{ .hinting_unsupported = {} }).name()); + try testing.expectEqualStrings("malformed_payload", (Reason{ .malformed_payload = {} }).name()); + try testing.expectEqualStrings("payload_too_large", (Reason{ .payload_too_large = {} }).name()); + try testing.expectEqualStrings("future_reason", (Reason{ .other = "future_reason" }).name()); +} + +test "response clear formatWire" { + const testing = std.testing; + + var buf: [256]u8 = undefined; + var writer: std.Io.Writer = .fixed(&buf); + + const resp: Response = .{ .clear = .{} }; + try resp.formatWire(&writer); + try testing.expectEqualStrings("\x1b_25a1;c;status=0\x1b\\", writer.buffered()); +} + +test "response clear error formatWire" { + const testing = std.testing; + + var buf: [256]u8 = undefined; + var writer: std.Io.Writer = .fixed(&buf); + + const resp: Response = .{ .clear = .{ .status = .err, .reason = "out_of_namespace" } }; + try resp.formatWire(&writer); + try testing.expectEqualStrings("\x1b_25a1;c;status=1;reason=out_of_namespace\x1b\\", writer.buffered()); +} diff --git a/vendor/libghostty-vt/src/terminal/bitmap_allocator.zig b/vendor/libghostty-vt/src/terminal/bitmap_allocator.zig index deeecf55..787f0ace 100644 --- a/vendor/libghostty-vt/src/terminal/bitmap_allocator.zig +++ b/vendor/libghostty-vt/src/terminal/bitmap_allocator.zig @@ -219,7 +219,13 @@ pub fn BitmapAllocator(comptime chunk_size: comptime_int) type { const bitmap_start = 0; const bitmap_end = @sizeOf(u64) * bitmap_count; const chunks_start = alignForward(usize, bitmap_end, @alignOf(u8)); - const chunks_end = chunks_start + (aligned_cap * chunk_size); + + // The chunks region must be exactly the bytes addressable by + // the bitmaps: one chunk per bit of every bitmap. Anything more + // is unreachable waste, while anything less would let alloc + // hand out memory beyond the region, since init marks every + // bitmap bit as free. + const chunks_end = chunks_start + (aligned_chunk_count * chunk_size); const total_size = chunks_end; return Layout{ @@ -452,6 +458,79 @@ test "BitmapAllocator layout" { try testing.expectEqual(@as(usize, 1), layout.bitmap_count); } +test "BitmapAllocator layout chunks region matches bitmap addressable bytes" { + const testing = std.testing; + + // The chunks region must be exactly the bytes addressable by the + // bitmaps: one chunk per bit of every bitmap. Prior to this being + // fixed, the region was over-reserved by a factor of chunk_size + // (~186 KiB of dead space per standard page), while capacities + // smaller than one full bitmap were under-reserved, allowing + // out-of-bounds allocations. + inline for (.{ 1, 2, 4, 16, 32 }) |chunk| { + const Alloc = BitmapAllocator(chunk); + for ([_]usize{ + 1, + chunk, + chunk + 1, + 48, + 64, + 512, + 1024, + 2048, + 8192, + 8193, + }) |cap| { + const layout = Alloc.layout(cap); + const chunks_size = layout.total_size - layout.chunks_start; + + // Reserved == addressable by the bitmaps. This must match + // capacityBytes() which is computed from the bitmap count. + try testing.expectEqual( + layout.bitmap_count * Alloc.bitmap_bit_size * chunk, + chunks_size, + ); + + // We always reserve at least the requested capacity. + try testing.expect(chunks_size >= cap); + } + } +} + +test "BitmapAllocator layout small capacity cannot alloc out of bounds" { + // Regression test: for capacities smaller than one full bitmap of + // chunks, init marks all bitmap bits as free, so alloc will hand out + // chunks up to the full bitmap. The layout must reserve that entire + // addressable region or those allocations would be out of bounds of + // the backing buffer. + const Alloc = BitmapAllocator(16); + const cap = 48; // 3 chunks, bitmap addresses 64 + + 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 every chunk the bitmap can hand out and verify each is + // fully within the backing buffer. Writing to each allocation lets + // the testing allocator catch any out-of-bounds corruption. + const buf_start = @intFromPtr(buf.ptr); + const buf_end = buf_start + buf.len; + var count: usize = 0; + while (bm.alloc(u8, buf, 16)) |slice| { + try testing.expect(@intFromPtr(slice.ptr) >= buf_start); + try testing.expect(@intFromPtr(slice.ptr) + slice.len <= buf_end); + @memset(slice, 0xAA); + count += 1; + } else |err| { + try testing.expectEqual(error.OutOfMemory, err); + } + try testing.expectEqual(Alloc.bitmap_bit_size, count); +} + test "BitmapAllocator alloc sequentially" { const Alloc = BitmapAllocator(4); const cap = 64; diff --git a/vendor/libghostty-vt/src/terminal/c/color.zig b/vendor/libghostty-vt/src/terminal/c/color.zig index 3d244a19..ad92883c 100644 --- a/vendor/libghostty-vt/src/terminal/c/color.zig +++ b/vendor/libghostty-vt/src/terminal/c/color.zig @@ -1,8 +1,46 @@ +const std = @import("std"); const lib = @import("../lib.zig"); const color = @import("../color.zig"); +const x11_color = @import("../x11_color.zig"); +const Result = @import("result.zig").Result; + +/// C: GhosttyColorPaletteMask +pub const PaletteMask = extern struct { + bits: [4]u64, + + /// Convert to the Zig PaletteMask (std.StaticBitSet(256)). + pub fn toZig(self: *const PaletteMask) color.PaletteMask { + var result = color.PaletteMask.initEmpty(); + for (0..256) |i| { + if (((self.bits[i >> 6] >> @as(u6, @intCast(i & 63))) & 1) != 0) { + result.set(i); + } + } + return result; + } +}; + +/// C: GhosttyColorX11Entry +pub const X11Entry = extern struct { + /// Null-terminated color name; null marks the end of the table. + name: ?[*:0]const u8, + color: color.RGB.C, +}; + +/// Comptime-built static table, terminated by a null-name entry. +const x11_entries: [x11_color.entries.len + 1]X11Entry = entries: { + @setEvalBranchQuota(10_000); + var result: [x11_color.entries.len + 1]X11Entry = undefined; + for (x11_color.entries, 0..) |entry, i| result[i] = .{ + .name = entry.name.ptr, + .color = entry.color.cval(), + }; + result[x11_color.entries.len] = .{ .name = null, .color = .{ .r = 0, .g = 0, .b = 0 } }; + break :entries result; +}; pub fn rgb_get( - c: color.RGB.C, + c: *const color.RGB.C, r: *u8, g: *u8, b: *u8, @@ -11,3 +49,538 @@ pub fn rgb_get( g.* = c.g; b.* = c.b; } + +pub fn parse_x11( + name_: ?[*]const u8, + len: usize, + out: *color.RGB.C, +) callconv(lib.calling_conv) Result { + const name = (name_ orelse return .invalid_value)[0..len]; + const trimmed = std.mem.trim(u8, name, " \t"); + const rgb = x11_color.map.get(trimmed) orelse return .invalid_value; + out.* = rgb.cval(); + return .success; +} + +pub fn parse( + value_: ?[*]const u8, + len: usize, + out: *color.RGB.C, +) callconv(lib.calling_conv) Result { + const value = (value_ orelse return .invalid_value)[0..len]; + const rgb = color.RGB.parse(value) catch return .invalid_value; + out.* = rgb.cval(); + return .success; +} + +pub fn palette_default(out: *color.PaletteC) callconv(lib.calling_conv) void { + out.* = color.paletteCval(&color.default); +} + +/// Generate a 256-color palette. The output may alias the base input. +pub fn palette_generate( + base_: ?*const color.PaletteC, + skip_: ?*const PaletteMask, + bg: *const color.RGB.C, + fg: *const color.RGB.C, + harmonious: bool, + out: *color.PaletteC, +) callconv(lib.calling_conv) void { + const base: color.Palette = if (base_) |base| + color.paletteZval(base) + else + color.default; + const skip: color.PaletteMask = if (skip_) |skip| + skip.toZig() + else + .initEmpty(); + const result = color.generate256Color( + base, + skip, + .fromC(bg.*), + .fromC(fg.*), + harmonious, + ); + out.* = color.paletteCval(&result); +} + +pub fn x11_names() callconv(lib.calling_conv) [*]const X11Entry { + return x11_entries[0..].ptr; +} + +pub fn x11_name_count() callconv(lib.calling_conv) usize { + return x11_color.entries.len; +} + +pub fn parse_palette_entry( + value_: ?[*]const u8, + len: usize, + out_index: *u8, + out_rgb: *color.RGB.C, +) callconv(lib.calling_conv) Result { + const value = (value_ orelse return .invalid_value)[0..len]; + const entry = color.parsePaletteEntry(value) catch return .invalid_value; + out_index.* = entry.index; + out_rgb.* = entry.color.cval(); + return .success; +} + +pub fn luminance(c: *const color.RGB.C) callconv(lib.calling_conv) f64 { + return color.RGB.fromC(c.*).luminance(); +} + +pub fn perceived_luminance(c: *const color.RGB.C) callconv(lib.calling_conv) f64 { + return color.RGB.fromC(c.*).perceivedLuminance(); +} + +pub fn contrast(a: *const color.RGB.C, b: *const color.RGB.C) callconv(lib.calling_conv) f64 { + return color.RGB.fromC(a.*).contrast(.fromC(b.*)); +} + +fn expectRgb(expected: color.RGB, actual: color.RGB.C) !void { + try std.testing.expectEqual(expected, color.RGB.fromC(actual)); +} + +fn expectPaletteC(expected: *const color.PaletteC, actual: *const color.PaletteC) !void { + for (expected.*, actual.*) |expected_rgb, actual_rgb| { + try expectRgb(color.RGB.fromC(expected_rgb), actual_rgb); + } +} + +fn generatePalette( + base_: ?*const color.PaletteC, + skip_: ?*const PaletteMask, + bg: color.RGB, + fg: color.RGB, + harmonious: bool, +) color.PaletteC { + var out: color.PaletteC = undefined; + const bg_c = bg.cval(); + const fg_c = fg.cval(); + palette_generate(base_, skip_, &bg_c, &fg_c, harmonious, &out); + return out; +} + +fn setMaskBit(mask: *PaletteMask, idx: usize) void { + mask.bits[idx >> 6] |= @as(u64, 1) << @as(u6, @intCast(idx & 63)); +} + +test "color: parse_x11 valid" { + const testing = std.testing; + + const cases = [_]struct { + input: []const u8, + expected: color.RGB, + }{ + .{ .input = "white", .expected = .{ .r = 255, .g = 255, .b = 255 } }, + .{ .input = "medium spring green", .expected = .{ .r = 0, .g = 250, .b = 154 } }, + .{ .input = "ForestGreen", .expected = .{ .r = 34, .g = 139, .b = 34 } }, + .{ .input = "FoReStGReen", .expected = .{ .r = 34, .g = 139, .b = 34 } }, + .{ .input = " Forest Green ", .expected = .{ .r = 34, .g = 139, .b = 34 } }, + .{ .input = "\tblack\t", .expected = .{ .r = 0, .g = 0, .b = 0 } }, + }; + + for (cases) |case| { + var out: color.RGB.C = undefined; + try testing.expectEqual(.success, parse_x11(case.input.ptr, case.input.len, &out)); + try expectRgb(case.expected, out); + } +} + +test "color: parse_x11 invalid" { + const testing = std.testing; + + const cases = [_][]const u8{ + "nosuchcolor", + "", + " ", + "#ffffff", + }; + + for (cases) |case| { + var out: color.RGB.C = undefined; + try testing.expectEqual(.invalid_value, parse_x11(case.ptr, case.len, &out)); + } + + var out: color.RGB.C = undefined; + try testing.expectEqual(.invalid_value, parse_x11(null, 0, &out)); +} + +test "color: parse valid" { + const testing = std.testing; + + const cases = [_]struct { + input: []const u8, + expected: color.RGB, + }{ + .{ .input = "black", .expected = .{ .r = 0, .g = 0, .b = 0 } }, + .{ .input = "#AABBCC", .expected = .{ .r = 170, .g = 187, .b = 204 } }, + .{ .input = "0A0B0C", .expected = .{ .r = 10, .g = 11, .b = 12 } }, + .{ .input = "FFF", .expected = .{ .r = 255, .g = 255, .b = 255 } }, + .{ .input = "#345", .expected = .{ .r = 51, .g = 68, .b = 85 } }, + .{ .input = "rgb:1/2/3", .expected = .{ .r = 17, .g = 34, .b = 51 } }, + .{ .input = " black ", .expected = .{ .r = 0, .g = 0, .b = 0 } }, + .{ .input = " #AABBCC ", .expected = .{ .r = 170, .g = 187, .b = 204 } }, + }; + + for (cases) |case| { + var out: color.RGB.C = undefined; + try testing.expectEqual(.success, parse(case.input.ptr, case.input.len, &out)); + try expectRgb(case.expected, out); + } +} + +test "color: parse invalid" { + const testing = std.testing; + + const cases = [_][]const u8{ + "", + "notacolor", + "#12345", + }; + + for (cases) |case| { + var out: color.RGB.C = undefined; + try testing.expectEqual(.invalid_value, parse(case.ptr, case.len, &out)); + } + + var out: color.RGB.C = undefined; + try testing.expectEqual(.invalid_value, parse(null, 0, &out)); +} + +test "color: parse_palette_entry valid" { + const testing = std.testing; + + const cases = [_]struct { + input: []const u8, + index: u8, + expected: color.RGB, + }{ + .{ .input = "0=#AABBCC", .index = 0, .expected = .{ .r = 170, .g = 187, .b = 204 } }, + .{ .input = "0xF=#ABCDEF", .index = 15, .expected = .{ .r = 171, .g = 205, .b = 239 } }, + .{ .input = "0b1=#014589", .index = 1, .expected = .{ .r = 1, .g = 69, .b = 137 } }, + .{ .input = "0o7=#234567", .index = 7, .expected = .{ .r = 35, .g = 69, .b = 103 } }, + .{ .input = " 1= #DDEEFF ", .index = 1, .expected = .{ .r = 221, .g = 238, .b = 255 } }, + .{ .input = "1=black", .index = 1, .expected = .{ .r = 0, .g = 0, .b = 0 } }, + }; + + for (cases) |case| { + var index: u8 = undefined; + var rgb: color.RGB.C = undefined; + try testing.expectEqual(.success, parse_palette_entry( + case.input.ptr, + case.input.len, + &index, + &rgb, + )); + try testing.expectEqual(case.index, index); + try expectRgb(case.expected, rgb); + } +} + +test "color: parse_palette_entry invalid" { + const testing = std.testing; + + const cases = [_][]const u8{ + "256=#AABBCC", + "a", + "", + "1=notacolor", + }; + + for (cases) |case| { + var index: u8 = undefined; + var rgb: color.RGB.C = undefined; + try testing.expectEqual(.invalid_value, parse_palette_entry( + case.ptr, + case.len, + &index, + &rgb, + )); + } + + var index: u8 = undefined; + var rgb: color.RGB.C = undefined; + try testing.expectEqual(.invalid_value, parse_palette_entry(null, 0, &index, &rgb)); +} + +test "color: palette_default" { + var out: color.PaletteC = undefined; + palette_default(&out); + + const expected = color.paletteCval(&color.default); + try expectPaletteC(&expected, &out); + + try expectRgb(.{ .r = 0x1D, .g = 0x1F, .b = 0x21 }, out[0]); + try expectRgb(.{ .r = 0, .g = 0, .b = 0 }, out[16]); + try expectRgb(.{ .r = 255, .g = 255, .b = 255 }, out[231]); + try expectRgb(.{ .r = 8, .g = 8, .b = 8 }, out[232]); + try expectRgb(.{ .r = 238, .g = 238, .b = 238 }, out[255]); +} + +test "color: palette_generate base16 preserved" { + const testing = std.testing; + + const base = color.paletteCval(&color.default); + const palette = generatePalette( + &base, + null, + .{ .r = 0, .g = 0, .b = 0 }, + .{ .r = 255, .g = 255, .b = 255 }, + false, + ); + + for (0..16) |i| { + try testing.expectEqual(color.RGB.fromC(base[i]), color.RGB.fromC(palette[i])); + } +} + +test "color: palette_generate cube corners" { + const bg = color.RGB{ .r = 0, .g = 0, .b = 0 }; + const fg = color.RGB{ .r = 255, .g = 255, .b = 255 }; + const palette = generatePalette(null, null, bg, fg, false); + + try expectRgb(bg, palette[16]); + try expectRgb(fg, palette[231]); +} + +test "color: palette_generate light theme harmonious=false" { + const white = color.RGB{ .r = 255, .g = 255, .b = 255 }; + const black = color.RGB{ .r = 0, .g = 0, .b = 0 }; + + const normal = generatePalette(null, null, white, black, false); + try expectRgb(black, normal[16]); + try expectRgb(white, normal[231]); + + const harmonious = generatePalette(null, null, white, black, true); + try expectRgb(white, harmonious[16]); + try expectRgb(black, harmonious[231]); +} + +test "color: palette_generate grayscale monotonic" { + const testing = std.testing; + + const palette = generatePalette( + null, + null, + .{ .r = 0, .g = 0, .b = 0 }, + .{ .r = 255, .g = 255, .b = 255 }, + false, + ); + + var prev_lum: f64 = 0.0; + for (232..256) |i| { + const lum = color.RGB.fromC(palette[i]).luminance(); + try testing.expect(lum >= prev_lum); + prev_lum = lum; + } +} + +test "color: palette_generate skip mask" { + const testing = std.testing; + + const base = color.paletteCval(&color.default); + var skip: PaletteMask = .{ .bits = .{ 0, 0, 0, 0 } }; + setMaskBit(&skip, 20); + setMaskBit(&skip, 100); + setMaskBit(&skip, 240); + + const palette = generatePalette( + &base, + &skip, + .{ .r = 0, .g = 0, .b = 0 }, + .{ .r = 255, .g = 255, .b = 255 }, + false, + ); + + try testing.expectEqual(color.RGB.fromC(base[20]), color.RGB.fromC(palette[20])); + try testing.expectEqual(color.RGB.fromC(base[100]), color.RGB.fromC(palette[100])); + try testing.expectEqual(color.RGB.fromC(base[240]), color.RGB.fromC(palette[240])); + try testing.expect(!color.RGB.fromC(palette[21]).eql(color.RGB.fromC(base[21]))); +} + +test "color: palette_generate dark harmonious no-op" { + const testing = std.testing; + + const bg = color.RGB{ .r = 0, .g = 0, .b = 0 }; + const fg = color.RGB{ .r = 255, .g = 255, .b = 255 }; + const normal = generatePalette(null, null, bg, fg, false); + const harmonious = generatePalette(null, null, bg, fg, true); + + for (16..256) |i| { + try testing.expectEqual(color.RGB.fromC(normal[i]), color.RGB.fromC(harmonious[i])); + } +} + +test "color: palette_generate light harmonious ramp" { + const testing = std.testing; + + const bg = color.RGB{ .r = 255, .g = 255, .b = 255 }; + const fg = color.RGB{ .r = 0, .g = 0, .b = 0 }; + + { + const palette = generatePalette(null, null, bg, fg, false); + var prev_lum: f64 = 0.0; + for (232..256) |i| { + const lum = color.RGB.fromC(palette[i]).luminance(); + try testing.expect(lum >= prev_lum); + prev_lum = lum; + } + } + + { + const palette = generatePalette(null, null, bg, fg, true); + var prev_lum: f64 = 1.0; + for (232..256) |i| { + const lum = color.RGB.fromC(palette[i]).luminance(); + try testing.expect(lum <= prev_lum); + prev_lum = lum; + } + } +} + +test "color: palette_generate NULL base uses default" { + const base = color.paletteCval(&color.default); + const bg = color.RGB{ .r = 5, .g = 10, .b = 15 }; + const fg = color.RGB{ .r = 245, .g = 250, .b = 255 }; + + const null_base = generatePalette(null, null, bg, fg, false); + const explicit = generatePalette(&base, null, bg, fg, false); + try expectPaletteC(&explicit, &null_base); +} + +test "color: palette_generate NULL skip" { + const base = color.paletteCval(&color.default); + const bg = color.RGB{ .r = 5, .g = 10, .b = 15 }; + const fg = color.RGB{ .r = 245, .g = 250, .b = 255 }; + const skip: PaletteMask = .{ .bits = .{ 0, 0, 0, 0 } }; + + const null_skip = generatePalette(&base, null, bg, fg, false); + const explicit = generatePalette(&base, &skip, bg, fg, false); + try expectPaletteC(&explicit, &null_skip); +} + +test "color: palette_generate matches generate256Color" { + const base_c = color.paletteCval(&color.default); + const bg = color.RGB{ .r = 16, .g = 32, .b = 48 }; + const fg = color.RGB{ .r = 240, .g = 224, .b = 208 }; + var skip_c: PaletteMask = .{ .bits = .{ 0, 0, 0, 0 } }; + setMaskBit(&skip_c, 20); + setMaskBit(&skip_c, 100); + setMaskBit(&skip_c, 240); + + const actual = generatePalette(&base_c, &skip_c, bg, fg, true); + const expected_z = color.generate256Color( + color.paletteZval(&base_c), + skip_c.toZig(), + bg, + fg, + true, + ); + const expected = color.paletteCval(&expected_z); + try expectPaletteC(&expected, &actual); +} + +test "color: palette_generate in-place" { + var base = color.paletteCval(&color.default); + const bg = color.RGB{ .r = 0, .g = 0, .b = 0 }; + const fg = color.RGB{ .r = 255, .g = 255, .b = 255 }; + const expected = generatePalette(&base, null, bg, fg, false); + + const bg_c = bg.cval(); + const fg_c = fg.cval(); + palette_generate(&base, null, &bg_c, &fg_c, false, &base); + try expectPaletteC(&expected, &base); +} + +test "color: luminance" { + const testing = std.testing; + + const black = color.RGB{ .r = 0, .g = 0, .b = 0 }; + const white = color.RGB{ .r = 255, .g = 255, .b = 255 }; + const black_c = black.cval(); + const white_c = white.cval(); + try testing.expectApproxEqAbs(@as(f64, 0.0), luminance(&black_c), 0.000001); + try testing.expectApproxEqAbs(@as(f64, 1.0), luminance(&white_c), 0.000001); + + const samples = [_]color.RGB{ + .{ .r = 40, .g = 44, .b = 52 }, + .{ .r = 171, .g = 205, .b = 239 }, + .{ .r = 12, .g = 34, .b = 56 }, + }; + for (samples) |sample| { + const sample_c = sample.cval(); + try testing.expectApproxEqAbs(sample.luminance(), luminance(&sample_c), 0.000001); + } +} + +test "color: perceived_luminance" { + const testing = std.testing; + + const black = color.RGB{ .r = 0, .g = 0, .b = 0 }; + const white = color.RGB{ .r = 255, .g = 255, .b = 255 }; + const dark = color.RGB{ .r = 0x28, .g = 0x2C, .b = 0x34 }; + const black_c = black.cval(); + const white_c = white.cval(); + const dark_c = dark.cval(); + try testing.expectApproxEqAbs(@as(f64, 0.0), perceived_luminance(&black_c), 0.000001); + try testing.expectApproxEqAbs(@as(f64, 1.0), perceived_luminance(&white_c), 0.000001); + try testing.expect(perceived_luminance(&dark_c) < 0.5); + try testing.expect(perceived_luminance(&white_c) > 0.5); + + const samples = [_]color.RGB{ + dark, + .{ .r = 171, .g = 205, .b = 239 }, + .{ .r = 12, .g = 34, .b = 56 }, + }; + for (samples) |sample| { + const sample_c = sample.cval(); + try testing.expectApproxEqAbs( + sample.perceivedLuminance(), + perceived_luminance(&sample_c), + 0.000001, + ); + } +} + +test "color: contrast" { + const testing = std.testing; + + const black = color.RGB{ .r = 0, .g = 0, .b = 0 }; + const white = color.RGB{ .r = 255, .g = 255, .b = 255 }; + const gray = color.RGB{ .r = 128, .g = 128, .b = 128 }; + const black_c = black.cval(); + const white_c = white.cval(); + const gray_c = gray.cval(); + + try testing.expectApproxEqAbs(@as(f64, 21.0), contrast(&white_c, &black_c), 0.000001); + try testing.expectApproxEqAbs( + contrast(&black_c, &white_c), + contrast(&white_c, &black_c), + 0.000001, + ); + try testing.expectApproxEqAbs(@as(f64, 1.0), contrast(&gray_c, &gray_c), 0.000001); +} + +test "color: x11_names" { + const testing = std.testing; + + const names = x11_names(); + const count = x11_name_count(); + try testing.expectEqual(x11_color.entries.len, count); + try testing.expect(count > 700); + + var walked: usize = 0; + while (names[walked].name) |name| : (walked += 1) { + const name_slice = std.mem.span(name); + const expected = x11_color.map.get(name_slice).?; + try testing.expectEqual(expected, color.RGB.fromC(names[walked].color)); + } + + try testing.expectEqual(count, walked); + try testing.expectEqual(@as(?[*:0]const u8, null), names[count].name); +} + +test "color: x11_names static" { + try std.testing.expectEqual(@intFromPtr(x11_names()), @intFromPtr(x11_names())); +} diff --git a/vendor/libghostty-vt/src/terminal/c/color_scheme.zig b/vendor/libghostty-vt/src/terminal/c/color_scheme.zig new file mode 100644 index 00000000..ee022b35 --- /dev/null +++ b/vendor/libghostty-vt/src/terminal/c/color_scheme.zig @@ -0,0 +1,63 @@ +const std = @import("std"); +const lib = @import("../lib.zig"); +const device_status = @import("../device_status.zig"); +const Result = @import("result.zig").Result; + +pub fn report_encode( + scheme: device_status.ColorScheme, + out_: ?[*]u8, + out_len: usize, + out_written: *usize, +) callconv(lib.calling_conv) Result { + var writer: std.Io.Writer = .fixed(if (out_) |out| out[0..out_len] else &.{}); + device_status.encodeColorSchemeReport(&writer, scheme) catch |err| switch (err) { + error.WriteFailed => { + var discarding: std.Io.Writer.Discarding = .init(&.{}); + device_status.encodeColorSchemeReport(&discarding.writer, scheme) catch unreachable; + out_written.* = @intCast(discarding.count); + return .out_of_space; + }, + }; + + out_written.* = writer.end; + return .success; +} + +test "encode color scheme report dark" { + var buf: [device_status.max_color_scheme_report_encode_size]u8 = undefined; + var written: usize = 0; + const result = report_encode(.dark, &buf, buf.len, &written); + try std.testing.expectEqual(.success, result); + try std.testing.expectEqualStrings("\x1B[?997;1n", buf[0..written]); +} + +test "encode color scheme report light" { + var buf: [device_status.max_color_scheme_report_encode_size]u8 = undefined; + var written: usize = 0; + const result = report_encode(.light, &buf, buf.len, &written); + try std.testing.expectEqual(.success, result); + try std.testing.expectEqualStrings("\x1B[?997;2n", buf[0..written]); +} + +test "encode color scheme report with null buffer" { + var written: usize = 0; + const result = report_encode(.dark, null, 0, &written); + try std.testing.expectEqual(.out_of_space, result); + try std.testing.expectEqual(@as(usize, 9), written); +} + +test "encode color scheme report with insufficient buffer" { + var buf: [3]u8 = undefined; + var written: usize = 0; + const result = report_encode(.light, &buf, buf.len, &written); + try std.testing.expectEqual(.out_of_space, result); + try std.testing.expectEqual(@as(usize, 9), written); +} + +test "encode color scheme report with exact buffer" { + var buf: [9]u8 = undefined; + var written: usize = 0; + const result = report_encode(.dark, &buf, buf.len, &written); + try std.testing.expectEqual(.success, result); + try std.testing.expectEqual(@as(usize, 9), written); +} diff --git a/vendor/libghostty-vt/src/terminal/c/grid_ref.zig b/vendor/libghostty-vt/src/terminal/c/grid_ref.zig index db7ff2e8..946ca8a1 100644 --- a/vendor/libghostty-vt/src/terminal/c/grid_ref.zig +++ b/vendor/libghostty-vt/src/terminal/c/grid_ref.zig @@ -98,7 +98,8 @@ pub fn grid_ref_hyperlink_uri( out_len: *usize, ) callconv(lib.calling_conv) Result { const p = ref.toPin() orelse return .invalid_value; - const rac = p.node.data.getRowAndCell(p.x, p.y); + const terminal_page = p.node.page(); + const rac = terminal_page.getRowAndCell(p.x, p.y); const cell = rac.cell; if (!cell.hyperlink) { @@ -106,12 +107,15 @@ pub fn grid_ref_hyperlink_uri( return .success; } - const link_id = p.node.data.lookupHyperlink(cell) orelse { + const link_id = terminal_page.lookupHyperlink(cell) orelse { out_len.* = 0; return .success; }; - const entry = p.node.data.hyperlink_set.get(p.node.data.memory, link_id); - const uri = entry.uri.slice(p.node.data.memory); + const entry = terminal_page.hyperlink_set.get( + terminal_page.memory, + link_id, + ); + const uri = entry.uri.slice(terminal_page.memory); if (out_buf == null or buf_len < uri.len) { out_len.* = uri.len; @@ -133,8 +137,9 @@ pub fn grid_ref_style( if (cell.style_id == stylepkg.default_id) { o.* = .fromStyle(.{}); } else { - o.* = .fromStyle(p.node.data.styles.get( - p.node.data.memory, + const terminal_page = p.node.page(); + o.* = .fromStyle(terminal_page.styles.get( + terminal_page.memory, cell.style_id, ).*); } diff --git a/vendor/libghostty-vt/src/terminal/c/kitty_graphics.zig b/vendor/libghostty-vt/src/terminal/c/kitty_graphics.zig index a00632ed..13a675cc 100644 --- a/vendor/libghostty-vt/src/terminal/c/kitty_graphics.zig +++ b/vendor/libghostty-vt/src/terminal/c/kitty_graphics.zig @@ -52,11 +52,13 @@ else pub const Data = enum(c_int) { invalid = 0, placement_iterator = 1, + generation = 2, pub fn OutType(comptime self: Data) type { return switch (self) { .invalid => void, .placement_iterator => PlacementIterator, + .generation => u64, }; } }; @@ -129,6 +131,7 @@ fn getTyped( .layer_filter = it.layer_filter, }; }, + .generation => out.* = storage.generation, } return .success; } @@ -178,7 +181,7 @@ pub const ImageData = enum(c_int) { compression = 6, data_ptr = 7, data_len = 8, - transmit_time_ns = 9, + generation = 9, pub fn OutType(comptime self: ImageData) type { return switch (self) { @@ -188,7 +191,7 @@ pub const ImageData = enum(c_int) { .compression => ImageCompression, .data_ptr => [*]const u8, .data_len => usize, - .transmit_time_ns => u64, + .generation => u64, }; } }; @@ -260,22 +263,12 @@ fn imageGetTyped( .compression => out.* = image.compression, .data_ptr => out.* = image.data.ptr, .data_len => out.* = image.data.len, - .transmit_time_ns => out.* = instantNanos(image.transmit_time), + .generation => out.* = image.generation, } return .success; } -/// Flattens an Instant's platform timestamp to nanoseconds for the C API. -/// The epoch is unspecified; only equality and ordering are meaningful. -fn instantNanos(instant: std.time.Instant) u64 { - if (@TypeOf(instant.timestamp) == u64) return instant.timestamp; - const ts = instant.timestamp; - const sec: u64 = @intCast(@max(ts.sec, 0)); - const nsec: u64 = @intCast(@max(ts.nsec, 0)); - return sec *| std.time.ns_per_s +| nsec; -} - pub fn placement_iterator_new( alloc_: ?*const CAllocator, out: *PlacementIterator, @@ -990,48 +983,6 @@ test "image_get_handle and image_get with transmitted image" { try testing.expect(data_len > 0); } -test "image_get transmit_time_ns changes on retransmission" { - if (comptime !build_options.kitty_graphics) return error.SkipZigTest; - - var t: terminal_c.Terminal = null; - try testing.expectEqual(Result.success, terminal_c.new( - &lib.alloc.test_allocator, - &t, - .{ .cols = 80, .rows = 24, .max_scrollback = 0 }, - )); - defer terminal_c.free(t); - - const cmd = "\x1b_Ga=T,t=d,f=24,i=1,p=1,s=1,v=2;////////\x1b\\"; - terminal_c.vt_write(t, cmd.ptr, cmd.len); - - var graphics: KittyGraphics = undefined; - try testing.expectEqual(Result.success, terminal_c.get( - t, - .kitty_graphics, - @ptrCast(&graphics), - )); - - var first: u64 = undefined; - try testing.expectEqual(Result.success, image_get( - image_get_handle(graphics, 1), - .transmit_time_ns, - @ptrCast(&first), - )); - try testing.expect(first > 0); - - std.Thread.sleep(1 * std.time.ns_per_ms); - const retransmit = "\x1b_Ga=t,t=d,f=24,i=1,s=1,v=2;AAAAAAAA\x1b\\"; - terminal_c.vt_write(t, retransmit.ptr, retransmit.len); - - var second: u64 = undefined; - try testing.expectEqual(Result.success, image_get( - image_get_handle(graphics, 1), - .transmit_time_ns, - @ptrCast(&second), - )); - try testing.expect(second > first); -} - test "placement_rect with transmit and display" { if (comptime !build_options.kitty_graphics) return error.SkipZigTest; @@ -1763,3 +1714,259 @@ test "placement_get_multi null keys returns invalid_value" { var values = [_]?*anyopaque{@ptrCast(&id)}; try testing.expectEqual(Result.invalid_value, placement_get_multi(null, 1, null, &values, null)); } + +test "storage generation via get" { + if (comptime !build_options.kitty_graphics) return error.SkipZigTest; + + var t: terminal_c.Terminal = null; + try testing.expectEqual(Result.success, terminal_c.new( + &lib.alloc.test_allocator, + &t, + .{ .cols = 80, .rows = 24, .max_scrollback = 0 }, + )); + defer terminal_c.free(t); + + var graphics: KittyGraphics = undefined; + try testing.expectEqual(Result.success, terminal_c.get(t, .kitty_graphics, @ptrCast(&graphics))); + + // Fresh storage: generation zero. + var gen0: u64 = 99; + try testing.expectEqual(Result.success, get(graphics, .generation, @ptrCast(&gen0))); + try testing.expectEqual(0, gen0); + + // Transmit bumps the generation. + const transmit = "\x1b_Ga=t,t=d,f=24,i=1,s=1,v=2;////////\x1b\\"; + terminal_c.vt_write(t, transmit.ptr, transmit.len); + try testing.expectEqual(Result.success, terminal_c.get(t, .kitty_graphics, @ptrCast(&graphics))); + var gen1: u64 = 0; + try testing.expectEqual(Result.success, get(graphics, .generation, @ptrCast(&gen1))); + try testing.expect(gen1 > gen0); + + // Unrelated terminal writes (plain text) do not bump it. + const text = "hello world"; + terminal_c.vt_write(t, text.ptr, text.len); + try testing.expectEqual(Result.success, terminal_c.get(t, .kitty_graphics, @ptrCast(&graphics))); + var gen2: u64 = 0; + try testing.expectEqual(Result.success, get(graphics, .generation, @ptrCast(&gen2))); + try testing.expectEqual(gen1, gen2); + + // Placement bumps it. + const display = "\x1b_Ga=p,i=1,p=1,c=1,r=1;\x1b\\"; + terminal_c.vt_write(t, display.ptr, display.len); + try testing.expectEqual(Result.success, terminal_c.get(t, .kitty_graphics, @ptrCast(&graphics))); + var gen3: u64 = 0; + try testing.expectEqual(Result.success, get(graphics, .generation, @ptrCast(&gen3))); + try testing.expect(gen3 > gen2); + + // Delete bumps it. + const del = "\x1b_Ga=d,d=A\x1b\\"; + terminal_c.vt_write(t, del.ptr, del.len); + try testing.expectEqual(Result.success, terminal_c.get(t, .kitty_graphics, @ptrCast(&graphics))); + var gen4: u64 = 0; + try testing.expectEqual(Result.success, get(graphics, .generation, @ptrCast(&gen4))); + try testing.expect(gen4 > gen3); +} + +test "image generation detects same-sized retransmission" { + if (comptime !build_options.kitty_graphics) return error.SkipZigTest; + + var t: terminal_c.Terminal = null; + try testing.expectEqual(Result.success, terminal_c.new( + &lib.alloc.test_allocator, + &t, + .{ .cols = 80, .rows = 24, .max_scrollback = 0 }, + )); + defer terminal_c.free(t); + + // Transmit a 1x2 RGB image with id=1. + const transmit1 = "\x1b_Ga=t,t=d,f=24,i=1,s=1,v=2;////////\x1b\\"; + terminal_c.vt_write(t, transmit1.ptr, transmit1.len); + + var graphics: KittyGraphics = undefined; + try testing.expectEqual(Result.success, terminal_c.get(t, .kitty_graphics, @ptrCast(&graphics))); + + var gen1: u64 = 0; + var w1: u32 = 0; + var h1: u32 = 0; + var len1: usize = 0; + { + const img = image_get_handle(graphics, 1); + try testing.expect(img != null); + try testing.expectEqual(Result.success, image_get(img, .generation, @ptrCast(&gen1))); + try testing.expectEqual(Result.success, image_get(img, .width, @ptrCast(&w1))); + try testing.expectEqual(Result.success, image_get(img, .height, @ptrCast(&h1))); + try testing.expectEqual(Result.success, image_get(img, .data_len, @ptrCast(&len1))); + try testing.expect(gen1 > 0); + } + + // Retransmit the same ID with identical dimensions but different + // pixel bytes. All size heuristics match; only the generation + // reveals the change. + const transmit2 = "\x1b_Ga=t,t=d,f=24,i=1,s=1,v=2;AAAAAAAA\x1b\\"; + terminal_c.vt_write(t, transmit2.ptr, transmit2.len); + try testing.expectEqual(Result.success, terminal_c.get(t, .kitty_graphics, @ptrCast(&graphics))); + + { + const img = image_get_handle(graphics, 1); + try testing.expect(img != null); + var gen2: u64 = 0; + var w2: u32 = 0; + var h2: u32 = 0; + var len2: usize = 0; + try testing.expectEqual(Result.success, image_get(img, .generation, @ptrCast(&gen2))); + try testing.expectEqual(Result.success, image_get(img, .width, @ptrCast(&w2))); + try testing.expectEqual(Result.success, image_get(img, .height, @ptrCast(&h2))); + try testing.expectEqual(Result.success, image_get(img, .data_len, @ptrCast(&len2))); + + // Size heuristics are identical... + try testing.expectEqual(w1, w2); + try testing.expectEqual(h1, h2); + try testing.expectEqual(len1, len2); + + // ...but the generation changed. + try testing.expect(gen2 > gen1); + } +} + +test "image generation via image_get_multi" { + if (comptime !build_options.kitty_graphics) return error.SkipZigTest; + + var t: terminal_c.Terminal = null; + try testing.expectEqual(Result.success, terminal_c.new( + &lib.alloc.test_allocator, + &t, + .{ .cols = 80, .rows = 24, .max_scrollback = 0 }, + )); + defer terminal_c.free(t); + + const transmit = "\x1b_Ga=t,t=d,f=24,i=1,s=1,v=2;////////\x1b\\"; + terminal_c.vt_write(t, transmit.ptr, transmit.len); + + var graphics: KittyGraphics = undefined; + try testing.expectEqual(Result.success, terminal_c.get(t, .kitty_graphics, @ptrCast(&graphics))); + const img = image_get_handle(graphics, 1); + try testing.expect(img != null); + + var id: u32 = 0; + var generation: u64 = 0; + var written: usize = 0; + const keys = [_]ImageData{ .id, .generation }; + var values = [_]?*anyopaque{ @ptrCast(&id), @ptrCast(&generation) }; + try testing.expectEqual(Result.success, image_get_multi(img, keys.len, &keys, &values, &written)); + try testing.expectEqual(keys.len, written); + try testing.expectEqual(1, id); + try testing.expect(generation > 0); + + // The image stamp came from the same sequence as (and here, the + // same event as) the storage stamp. + var storage_gen: u64 = 0; + try testing.expectEqual(Result.success, get(graphics, .generation, @ptrCast(&storage_gen))); + try testing.expectEqual(storage_gen, generation); +} + +test "image compression and format always report decoded data" { + if (comptime !build_options.kitty_graphics) return error.SkipZigTest; + + var t: terminal_c.Terminal = null; + try testing.expectEqual(Result.success, terminal_c.new( + &lib.alloc.test_allocator, + &t, + .{ .cols = 80, .rows = 24, .max_scrollback = 0 }, + )); + defer terminal_c.free(t); + + // Transmit a zlib-compressed 1x1 RGB image (o=z). The payload is + // base64(zlib([0xFF, 0x00, 0x00])). + const compressed = comptime blk: { + // zlib stream for the 3 bytes FF 00 00 (stored block). + // 78 01: zlib header; 01: final stored block; 03 00 len; + // FC FF nlen; FF 00 00 data; adler32 = 0x03000100 (big endian). + const raw = [_]u8{ 0x78, 0x01, 0x01, 0x03, 0x00, 0xFC, 0xFF, 0xFF, 0x00, 0x00, 0x03, 0x00, 0x01, 0x00 }; + var buf: [std.base64.standard.Encoder.calcSize(raw.len)]u8 = undefined; + const enc = std.base64.standard.Encoder.encode(&buf, &raw); + break :blk enc[0..enc.len].*; + }; + const transmit = "\x1b_Ga=t,t=d,f=24,o=z,i=1,s=1,v=1;" ++ compressed ++ "\x1b\\"; + terminal_c.vt_write(t, transmit.ptr, transmit.len); + + var graphics: KittyGraphics = undefined; + try testing.expectEqual(Result.success, terminal_c.get(t, .kitty_graphics, @ptrCast(&graphics))); + const img = image_get_handle(graphics, 1); + try testing.expect(img != null); + + // Compression must report NONE: the data was inflated at + // transmission time. + var comp: ImageCompression = undefined; + try testing.expectEqual(Result.success, image_get(img, .compression, @ptrCast(&comp))); + try testing.expectEqual(.none, comp); + + // Data is the decoded pixels: width * height * bpp. + var data_len: usize = 0; + try testing.expectEqual(Result.success, image_get(img, .data_len, @ptrCast(&data_len))); + try testing.expectEqual(3, data_len); + + var data_ptr: [*]const u8 = undefined; + try testing.expectEqual(Result.success, image_get(img, .data_ptr, @ptrCast(&data_ptr))); + try testing.expectEqualSlices(u8, &.{ 0xFF, 0x00, 0x00 }, data_ptr[0..data_len]); +} + +test "generation never recurs across resets and screen switches" { + if (comptime !build_options.kitty_graphics) return error.SkipZigTest; + + var t: terminal_c.Terminal = null; + try testing.expectEqual(Result.success, terminal_c.new( + &lib.alloc.test_allocator, + &t, + .{ .cols = 80, .rows = 24, .max_scrollback = 0 }, + )); + defer terminal_c.free(t); + + var graphics: KittyGraphics = undefined; + var gen: u64 = 0; + + // Transmit on the main screen. + const transmit = "\x1b_Ga=t,t=d,f=24,i=1,s=1,v=2;////////\x1b\\"; + terminal_c.vt_write(t, transmit.ptr, transmit.len); + try testing.expectEqual(Result.success, terminal_c.get(t, .kitty_graphics, @ptrCast(&graphics))); + try testing.expectEqual(Result.success, get(graphics, .generation, @ptrCast(&gen))); + const gen_main = gen; + try testing.expect(gen_main > 0); + + // Switch to the alternate screen: its storage is untouched, so its + // generation is zero (which always means "empty"). + const alt_on = "\x1b[?1049h"; + terminal_c.vt_write(t, alt_on.ptr, alt_on.len); + try testing.expectEqual(Result.success, terminal_c.get(t, .kitty_graphics, @ptrCast(&graphics))); + try testing.expectEqual(Result.success, get(graphics, .generation, @ptrCast(&gen))); + try testing.expectEqual(0, gen); + + // A transmit on the alt screen draws from the same global sequence, + // so its stamp is strictly greater than anything seen on the main + // screen: an embedder keying a cache on the generation value alone + // can never confuse the two storages. + terminal_c.vt_write(t, transmit.ptr, transmit.len); + try testing.expectEqual(Result.success, terminal_c.get(t, .kitty_graphics, @ptrCast(&graphics))); + try testing.expectEqual(Result.success, get(graphics, .generation, @ptrCast(&gen))); + const gen_alt = gen; + try testing.expect(gen_alt > gen_main); + + // Back to the main screen: its generation is unchanged. + const alt_off = "\x1b[?1049l"; + terminal_c.vt_write(t, alt_off.ptr, alt_off.len); + try testing.expectEqual(Result.success, terminal_c.get(t, .kitty_graphics, @ptrCast(&graphics))); + try testing.expectEqual(Result.success, get(graphics, .generation, @ptrCast(&gen))); + try testing.expectEqual(gen_main, gen); + + // Reset zeroes the storage (empty), and the next mutation continues + // from the global sequence: past values are never reused for + // different content. + terminal_c.reset(t); + try testing.expectEqual(Result.success, terminal_c.get(t, .kitty_graphics, @ptrCast(&graphics))); + try testing.expectEqual(Result.success, get(graphics, .generation, @ptrCast(&gen))); + try testing.expectEqual(0, gen); + + terminal_c.vt_write(t, transmit.ptr, transmit.len); + try testing.expectEqual(Result.success, terminal_c.get(t, .kitty_graphics, @ptrCast(&graphics))); + try testing.expectEqual(Result.success, get(graphics, .generation, @ptrCast(&gen))); + try testing.expect(gen > gen_alt); +} diff --git a/vendor/libghostty-vt/src/terminal/c/main.zig b/vendor/libghostty-vt/src/terminal/c/main.zig index 648bdbe5..37dc5768 100644 --- a/vendor/libghostty-vt/src/terminal/c/main.zig +++ b/vendor/libghostty-vt/src/terminal/c/main.zig @@ -5,6 +5,7 @@ const buildpkg = @import("build_info.zig"); pub const allocator = @import("allocator.zig"); pub const cell = @import("cell.zig"); pub const color = @import("color.zig"); +pub const color_scheme = @import("color_scheme.zig"); pub const focus = @import("focus.zig"); pub const formatter = @import("formatter.zig"); pub const grid_ref = @import("grid_ref.zig"); @@ -43,6 +44,7 @@ pub const size_report = @import("size_report.zig"); pub const style = @import("style.zig"); pub const sys = @import("sys.zig"); pub const terminal = @import("terminal.zig"); +pub const unicode = @import("unicode.zig"); // The full C API, unexported. pub const build_info = buildpkg.get; @@ -56,6 +58,18 @@ pub const osc_command_type = osc.commandType; pub const osc_command_data = osc.commandData; pub const color_rgb_get = color.rgb_get; +pub const color_contrast = color.contrast; +pub const color_luminance = color.luminance; +pub const color_parse = color.parse; +pub const color_parse_palette_entry = color.parse_palette_entry; +pub const color_parse_x11 = color.parse_x11; +pub const color_palette_default = color.palette_default; +pub const color_palette_generate = color.palette_generate; +pub const color_perceived_luminance = color.perceived_luminance; +pub const color_x11_name_count = color.x11_name_count; +pub const color_x11_names = color.x11_names; + +pub const color_scheme_report_encode = color_scheme.report_encode; pub const focus_encode = focus.encode; @@ -69,6 +83,8 @@ pub const formatter_free = formatter.free; pub const render_state_new = render.new; 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_get = render.get; pub const render_state_get_multi = render.get_multi; pub const render_state_set = render.set; @@ -167,6 +183,8 @@ pub const terminal_resize = terminal.resize; pub const terminal_set = terminal.set; pub const terminal_vt_write = terminal.vt_write; pub const terminal_scroll_viewport = terminal.scroll_viewport; +pub const terminal_compression_activity = terminal.compression_activity; +pub const terminal_compress = terminal.compress; pub const terminal_mode_get = terminal.mode_get; pub const terminal_mode_set = terminal.mode_set; pub const terminal_get = terminal.get; @@ -198,6 +216,9 @@ pub const terminal_point_from_grid_ref = terminal.point_from_grid_ref; pub const type_json = types.get_json; +pub const unicode_codepoint_width = unicode.codepoint_width; +pub const unicode_grapheme_width = unicode.grapheme_width; + pub const grid_ref_cell = grid_ref.grid_ref_cell; pub const grid_ref_row = grid_ref.grid_ref_row; pub const grid_ref_graphemes = grid_ref.grid_ref_graphemes; @@ -214,6 +235,7 @@ test { _ = buildpkg; _ = cell; _ = color; + _ = color_scheme; _ = grid_ref; _ = grid_ref_tracked; _ = kitty_graphics; @@ -236,6 +258,7 @@ test { _ = sys; _ = terminal; _ = types; + _ = unicode; // We want to make sure we run the tests for the C allocator interface. _ = @import("../../lib/allocator.zig"); diff --git a/vendor/libghostty-vt/src/terminal/c/render.zig b/vendor/libghostty-vt/src/terminal/c/render.zig index 0f3b2e78..6cf78ec5 100644 --- a/vendor/libghostty-vt/src/terminal/c/render.zig +++ b/vendor/libghostty-vt/src/terminal/c/render.zig @@ -188,6 +188,25 @@ pub fn update( return .success; } +pub fn begin_update( + state_: RenderState, + terminal_: terminal_c.Terminal, +) callconv(lib.calling_conv) Result { + const state = state_ orelse return .invalid_value; + const t: *ZigTerminal = (terminal_ orelse return .invalid_value).terminal; + + state.state.beginUpdate(state.alloc, t) catch return .out_of_memory; + return .success; +} + +pub fn end_update( + state_: RenderState, +) callconv(lib.calling_conv) Result { + const state = state_ orelse return .invalid_value; + state.state.endUpdate(); + return .success; +} + pub fn get( state_: RenderState, data: Data, @@ -591,7 +610,7 @@ fn rowCellsGetGraphemesUtf8( if (!cell.hasText()) return .success; - var needed = std.unicode.utf8CodepointSequenceLength(cell.codepoint()) catch + var needed: usize = std.unicode.utf8CodepointSequenceLength(cell.codepoint()) catch return .invalid_value; for (extra) |cp| { needed += std.unicode.utf8CodepointSequenceLength(cp) catch @@ -787,6 +806,61 @@ test "render: update invalid value" { try testing.expectEqual(Result.invalid_value, update(state, null)); } +test "render: begin/end update invalid value" { + var state: RenderState = null; + try testing.expectEqual(Result.success, new( + &lib.alloc.test_allocator, + &state, + )); + defer free(state); + + try testing.expectEqual(Result.invalid_value, begin_update(null, null)); + try testing.expectEqual(Result.invalid_value, begin_update(state, null)); + try testing.expectEqual(Result.invalid_value, end_update(null)); + + // End without a begin is safe. + try testing.expectEqual(Result.success, end_update(state)); +} + +test "render: begin/end update" { + var terminal: terminal_c.Terminal = null; + try testing.expectEqual(Result.success, terminal_c.new( + &lib.alloc.test_allocator, + &terminal, + .{ + .cols = 10, + .rows = 3, + .max_scrollback = 10_000, + }, + )); + defer terminal_c.free(terminal); + + // Write some styled text so that the update has deferred work. + const t = terminal.?.terminal; + var s = t.vtStream(); + defer s.deinit(); + s.nextSlice("\x1b[1mAB"); // Bold + + var state: RenderState = null; + try testing.expectEqual(Result.success, new( + &lib.alloc.test_allocator, + &state, + )); + defer free(state); + + // Begin should record pending work, end should complete it. + try testing.expectEqual(Result.success, begin_update(state, terminal)); + try testing.expect(state.?.state.pending_styles.items.len > 0); + try testing.expectEqual(Result.success, end_update(state)); + try testing.expectEqual(0, state.?.state.pending_styles.items.len); + + // The cell styles should be complete. + const row_data = state.?.state.row_data.slice(); + const cells = row_data.items(.cells); + try testing.expect(cells[0].get(0).style.flags.bold); + try testing.expect(cells[0].get(1).style.flags.bold); +} + test "render: get invalid value" { var cols: size.CellCountInt = 0; try testing.expectEqual(Result.invalid_value, get(null, .cols, @ptrCast(&cols))); @@ -1293,6 +1367,82 @@ test "render: row cells get has_styling" { } test "render: row cells get graphemes utf8" { + const cases = [_]struct { + terminal_input: []const u8, + expected: []const u8, + }{ + .{ + .terminal_input = "e\u{301}", + .expected = "e\u{301}", + }, + .{ + .terminal_input = "\x1b[?2027h\u{1F1FA}\u{1F1F8}", + .expected = "\u{1F1FA}\u{1F1F8}", + }, + .{ + .terminal_input = "\x1b[?2027h\u{1F468}\u{200D}\u{1F469}\u{200D}\u{1F467}", + .expected = "\u{1F468}\u{200D}\u{1F469}\u{200D}\u{1F467}", + }, + }; + + for (cases) |case| { + var terminal: terminal_c.Terminal = null; + try testing.expectEqual(Result.success, terminal_c.new( + &lib.alloc.test_allocator, + &terminal, + .{ .cols = 10, .rows = 3, .max_scrollback = 10_000 }, + )); + defer terminal_c.free(terminal); + + terminal_c.vt_write(terminal, case.terminal_input.ptr, case.terminal_input.len); + + 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); + + 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, get(state, .row_iterator, @ptrCast(&it))); + try testing.expect(row_iterator_next(it)); + try testing.expectEqual(Result.success, row_get(it, .cells, @ptrCast(&cells))); + + try testing.expectEqual(Result.success, row_cells_select(cells, 0)); + + var text: lib.Buffer = .{}; + try testing.expectEqual(Result.out_of_space, row_cells_get(cells, .graphemes_utf8, @ptrCast(&text))); + try testing.expectEqual(case.expected.len, text.len); + + var small = [_]u8{'x'} ** 32; + const small_cap = case.expected.len - 1; + text = .{ .ptr = small[0..small_cap].ptr, .cap = small_cap }; + try testing.expectEqual(Result.out_of_space, row_cells_get(cells, .graphemes_utf8, @ptrCast(&text))); + try testing.expectEqual(case.expected.len, text.len); + try testing.expectEqualSlices(u8, &([_]u8{'x'} ** 32), &small); + + var buf: [32]u8 = undefined; + text = .{ .ptr = &buf, .cap = case.expected.len }; + try testing.expectEqual(Result.success, row_cells_get(cells, .graphemes_utf8, @ptrCast(&text))); + try testing.expectEqual(case.expected.len, text.len); + try testing.expectEqualStrings(case.expected, buf[0..text.len]); + } + var terminal: terminal_c.Terminal = null; try testing.expectEqual(Result.success, terminal_c.new( &lib.alloc.test_allocator, @@ -1331,26 +1481,9 @@ test "render: row cells get graphemes utf8" { try testing.expect(row_iterator_next(it)); try testing.expectEqual(Result.success, row_get(it, .cells, @ptrCast(&cells))); - try testing.expectEqual(Result.success, row_cells_select(cells, 0)); - - var text: lib.Buffer = .{}; - try testing.expectEqual(Result.out_of_space, row_cells_get(cells, .graphemes_utf8, @ptrCast(&text))); - try testing.expectEqual(@as(usize, input.len), text.len); - - var small = [_]u8{ 'x', 'x' }; - text = .{ .ptr = &small, .cap = small.len }; - try testing.expectEqual(Result.out_of_space, row_cells_get(cells, .graphemes_utf8, @ptrCast(&text))); - try testing.expectEqual(@as(usize, input.len), text.len); - try testing.expectEqualSlices(u8, &.{ 'x', 'x' }, &small); - - var buf: [8]u8 = undefined; - text = .{ .ptr = &buf, .cap = buf.len }; - try testing.expectEqual(Result.success, row_cells_get(cells, .graphemes_utf8, @ptrCast(&text))); - try testing.expectEqual(input.len, text.len); - try testing.expectEqualStrings(input, buf[0..text.len]); - try testing.expectEqual(Result.success, row_cells_select(cells, 1)); - text = .{ .ptr = &buf, .cap = buf.len }; + var buf: [8]u8 = undefined; + var text: lib.Buffer = .{ .ptr = &buf, .cap = buf.len }; try testing.expectEqual(Result.success, row_cells_get(cells, .graphemes_utf8, @ptrCast(&text))); try testing.expectEqual(@as(usize, 0), text.len); } diff --git a/vendor/libghostty-vt/src/terminal/c/terminal.zig b/vendor/libghostty-vt/src/terminal/c/terminal.zig index a7ad35a6..a896c0fd 100644 --- a/vendor/libghostty-vt/src/terminal/c/terminal.zig +++ b/vendor/libghostty-vt/src/terminal/c/terminal.zig @@ -5,6 +5,7 @@ const lib = @import("../lib.zig"); const CAllocator = lib.alloc.Allocator; pub const ZigTerminal = @import("../Terminal.zig"); const Stream = @import("../stream_terminal.zig").Stream; +const Screen = @import("../Screen.zig"); const ScreenSet = @import("../ScreenSet.zig"); const PageList = @import("../PageList.zig"); const apc = @import("../apc.zig"); @@ -23,6 +24,7 @@ const grid_ref_tracked_c = @import("grid_ref_tracked.zig"); const selection_c = @import("selection.zig"); const style_c = @import("style.zig"); const color = @import("../color.zig"); +const clipboard = @import("../clipboard.zig"); const Result = @import("result.zig").Result; const Handler = @import("../stream_terminal.zig").Handler; @@ -39,6 +41,24 @@ const TerminalWrapper = struct { tracked_grid_refs: std.AutoArrayHashMapUnmanaged(*grid_ref_tracked_c.TrackedGridRef, void) = .{}, }; +/// A single MIME representation in a clipboard write. +/// +/// C: GhosttyClipboardContent +pub const ClipboardContent = extern struct { + mime: lib.String, + data: lib.String, +}; + +/// A protocol-neutral request to replace or clear clipboard contents. +/// +/// C: GhosttyClipboardWrite +pub const ClipboardWrite = extern struct { + size: usize, + location: clipboard.Location, + contents: ?[*]const ClipboardContent, + contents_len: usize, +}; + /// C callback state for terminal effects. Trampolines are always /// installed on the stream handler; they check these fields and /// no-op when the corresponding callback is null. @@ -51,7 +71,9 @@ const Effects = struct { enquiry: ?EnquiryFn = null, xtversion: ?XtversionFn = null, title_changed: ?TitleChangedFn = null, + pwd_changed: ?PwdChangedFn = null, size_cb: ?SizeFn = null, + clipboard_write: ?ClipboardWriteFn = null, /// Scratch buffer for DA1 feature codes. The device attributes /// trampoline converts C feature codes into this buffer and returns @@ -82,9 +104,16 @@ const Effects = struct { /// (len=0) causes the default "libghostty" to be reported. 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; + /// C function pointer type for the title_changed callback. pub const TitleChangedFn = *const fn (Terminal, ?*anyopaque) callconv(lib.calling_conv) void; + /// C function pointer type for the pwd_changed callback. + pub const PwdChangedFn = *const fn (Terminal, ?*anyopaque) 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. @@ -133,6 +162,44 @@ const Effects = struct { func(@ptrCast(wrapper), wrapper.effects.userdata); } + fn clipboardWriteTrampoline(handler: *Handler, write: clipboard.Write) clipboard.WriteResult { + const stream_ptr: *Stream = @fieldParentPtr("handler", handler); + const wrapper: *TerminalWrapper = @fieldParentPtr("stream", stream_ptr); + const func = wrapper.effects.clipboard_write orelse return .unsupported; + + // Most protocols currently produce one representation, so keep that + // path allocation-free while supporting arbitrary multi-MIME writes. + var stack_contents: [4]ClipboardContent = undefined; + const contents: []ClipboardContent = if (write.contents.len <= stack_contents.len) + stack_contents[0..write.contents.len] + else + wrapper.terminal.gpa().alloc(ClipboardContent, write.contents.len) catch + return .io_error; + defer if (write.contents.len > stack_contents.len) + wrapper.terminal.gpa().free(contents); + + for (contents, write.contents) |*c_content, content| { + c_content.* = .{ + .mime = .{ + .ptr = content.mime.ptr, + .len = content.mime.len, + }, + .data = .{ + .ptr = content.data.ptr, + .len = content.data.len, + }, + }; + } + + const request: ClipboardWrite = .{ + .size = @sizeOf(ClipboardWrite), + .location = write.location, + .contents = if (contents.len > 0) contents.ptr else null, + .contents_len = contents.len, + }; + return func(@ptrCast(wrapper), wrapper.effects.userdata, &request); + } + fn colorSchemeTrampoline(handler: *Handler) ?device_status.ColorScheme { const stream_ptr: *Stream = @fieldParentPtr("handler", handler); const wrapper: *TerminalWrapper = @fieldParentPtr("stream", stream_ptr); @@ -198,6 +265,13 @@ const Effects = struct { func(@ptrCast(wrapper), wrapper.effects.userdata); } + fn pwdChangedTrampoline(handler: *Handler) void { + const stream_ptr: *Stream = @fieldParentPtr("handler", handler); + const wrapper: *TerminalWrapper = @fieldParentPtr("stream", stream_ptr); + const func = wrapper.effects.pwd_changed orelse return; + func(@ptrCast(wrapper), wrapper.effects.userdata); + } + fn sizeTrampoline(handler: *Handler) ?size_report.Size { const stream_ptr: *Stream = @fieldParentPtr("handler", handler); const wrapper: *TerminalWrapper = @fieldParentPtr("stream", stream_ptr); @@ -211,6 +285,12 @@ const Effects = struct { /// C: GhosttyTerminal pub const Terminal = ?*TerminalWrapper; +/// C: GhosttyTerminalCompressionMode +pub const CompressionMode = ZigTerminal.CompressionMode; + +/// C: GhosttyTerminalCompressionResult +pub const CompressionResult = ZigTerminal.CompressionResult; + pub fn zigTerminal(terminal_: Terminal) ?*ZigTerminal { return (terminal_ orelse return null).terminal; } @@ -283,7 +363,9 @@ fn new_( .enquiry = &Effects.enquiryTrampoline, .xtversion = &Effects.xtversionTrampoline, .title_changed = &Effects.titleChangedTrampoline, + .pwd_changed = &Effects.pwdChangedTrampoline, .size = &Effects.sizeTrampoline, + .clipboard_write = &Effects.clipboardWriteTrampoline, }; wrapper.* = .{ @@ -303,6 +385,30 @@ pub fn vt_write( wrapper.stream.nextSlice(ptr[0..len]); } +pub fn compression_activity( + terminal_: Terminal, + out_activity_: ?*u64, +) callconv(lib.calling_conv) Result { + const t: *ZigTerminal = (terminal_ orelse return .invalid_value).terminal; + const out_activity = out_activity_ orelse return .invalid_value; + out_activity.* = t.compressionActivity(); + return .success; +} + +pub fn compress( + terminal_: Terminal, + mode_: c_int, + out_result_: ?*CompressionResult, +) callconv(lib.calling_conv) Result { + const t: *ZigTerminal = (terminal_ orelse return .invalid_value).terminal; + const out_result = out_result_ orelse return .invalid_value; + const mode = std.meta.intToEnum(CompressionMode, mode_) catch + return .invalid_value; + + out_result.* = t.compress(mode); + return .success; +} + /// C: GhosttyTerminalOption pub const Option = enum(c_int) { userdata = 0, @@ -327,6 +433,11 @@ pub const Option = enum(c_int) { apc_max_bytes = 19, apc_max_bytes_kitty = 20, selection = 21, + default_cursor_style = 22, + default_cursor_blink = 23, + glyph_protocol = 24, + pwd_changed = 25, + clipboard_write = 26, /// Input type expected for setting the option. pub fn InType(comptime self: Option) type { @@ -339,7 +450,9 @@ pub const Option = enum(c_int) { .enquiry => ?Effects.EnquiryFn, .xtversion => ?Effects.XtversionFn, .title_changed => ?Effects.TitleChangedFn, + .pwd_changed => ?Effects.PwdChangedFn, .size_cb => ?Effects.SizeFn, + .clipboard_write => ?Effects.ClipboardWriteFn, .title, .pwd => ?*const lib.String, .color_foreground, .color_background, .color_cursor => ?*const color.RGB.C, .color_palette => ?*const color.PaletteC, @@ -347,9 +460,12 @@ pub const Option = enum(c_int) { .kitty_image_medium_file, .kitty_image_medium_temp_file, .kitty_image_medium_shared_mem, + .glyph_protocol, => ?*const bool, .apc_max_bytes, .apc_max_bytes_kitty => ?*const usize, .selection => ?*const selection_c.CSelection, + .default_cursor_style => ?*const TerminalCursorStyle, + .default_cursor_blink => ?*const bool, }; } }; @@ -391,7 +507,9 @@ fn setTyped( .enquiry => wrapper.effects.enquiry = value, .xtversion => wrapper.effects.xtversion = value, .title_changed => wrapper.effects.title_changed = value, + .pwd_changed => wrapper.effects.pwd_changed = value, .size_cb => wrapper.effects.size_cb = value, + .clipboard_write => wrapper.effects.clipboard_write = value, .title => { const str = if (value) |v| v.ptr[0..v.len] else ""; wrapper.terminal.setTitle(str) catch return .out_of_memory; @@ -457,6 +575,11 @@ fn setTyped( wrapper.stream.handler.apc_handler.max_bytes.remove(.kitty); } }, + .glyph_protocol => { + const enabled = (value orelse return .success).*; + wrapper.stream.handler.apc_handler.enable(.glyph, enabled); + if (!enabled) wrapper.terminal.glyph_glossary.clearAndFree(wrapper.terminal.gpa()); + }, .selection => { if (value) |ptr| { const sel = ptr.toZig() orelse return .invalid_value; @@ -465,10 +588,43 @@ fn setTyped( wrapper.terminal.screens.active.clearSelection(); } }, + .default_cursor_style => { + const style = (if (value) |ptr| ptr.* else TerminalCursorStyle.block).toZig() orelse return .invalid_value; + wrapper.stream.handler.default_cursor_style = style; + if (wrapper.stream.handler.default_cursor) { + wrapper.terminal.screens.active.cursor.cursor_style = style; + } + }, + .default_cursor_blink => { + const blink = if (value) |ptr| ptr.* else false; + wrapper.stream.handler.default_cursor_blink = blink; + if (wrapper.stream.handler.default_cursor) { + wrapper.terminal.modes.set(.cursor_blinking, blink); + } + }, } return .success; } +/// C: GhosttyTerminalCursorStyle +pub const TerminalCursorStyle = enum(c_int) { + bar = 0, + block = 1, + underline = 2, + block_hollow = 3, + _, + + fn toZig(self: TerminalCursorStyle) ?Screen.CursorStyle { + return switch (self) { + .bar => .bar, + .block => .block, + .underline => .underline, + .block_hollow => .block_hollow, + _ => null, + }; + } +}; + /// C: GhosttyDeviceAttributes pub const DeviceAttributes = Effects.CDeviceAttributes; @@ -484,6 +640,7 @@ pub fn scroll_viewport( .top => .top, .bottom => .bottom, .delta => .{ .delta = behavior.value.delta }, + .row => .{ .row = behavior.value.row }, }); } @@ -938,8 +1095,211 @@ test "scroll_viewport" { } } +test "scroll_viewport row" { + var t: Terminal = null; + try testing.expectEqual(Result.success, new( + &lib.alloc.test_allocator, + &t, + .{ + .cols = 5, + .rows = 2, + .max_scrollback = 10_000, + }, + )); + defer free(t); + + const zt = t.?.terminal; + + // Write 4 rows so that rows "1" and "2" are pushed into scrollback: + // total rows is 4, viewport length is 2. + vt_write(t, "1\r\n2\r\n3\r\n4", 10); + + var viewport_active: bool = false; + try testing.expectEqual(Result.success, get(t, .viewport_active, @ptrCast(&viewport_active))); + try testing.expect(viewport_active); + + // Row 0 is the top of the scrollback. + scroll_viewport(t, .{ .tag = .row, .value = .{ .row = 0 } }); + try testing.expectEqual(Result.success, get(t, .viewport_active, @ptrCast(&viewport_active))); + try testing.expect(!viewport_active); + { + const str = try zt.plainString(testing.allocator); + defer testing.allocator.free(str); + try testing.expectEqualStrings("1\n2", str); + } + + // An absolute row within the scrollback becomes the first visible + // row and round-trips through the scrollbar offset. + scroll_viewport(t, .{ .tag = .row, .value = .{ .row = 1 } }); + { + const str = try zt.plainString(testing.allocator); + defer testing.allocator.free(str); + try testing.expectEqualStrings("2\n3", str); + } + var scrollbar_data: TerminalScrollbar = undefined; + try testing.expectEqual(Result.success, get(t, .scrollbar, @ptrCast(&scrollbar_data))); + try testing.expectEqual(@as(u64, 4), scrollbar_data.total); + try testing.expectEqual(@as(u64, 1), scrollbar_data.offset); + try testing.expectEqual(@as(u64, 2), scrollbar_data.len); + + // A row past the end clamps to the active area. + scroll_viewport(t, .{ .tag = .row, .value = .{ .row = 9999 } }); + try testing.expectEqual(Result.success, get(t, .viewport_active, @ptrCast(&viewport_active))); + try testing.expect(viewport_active); + { + const str = try zt.plainString(testing.allocator); + defer testing.allocator.free(str); + try testing.expectEqualStrings("3\n4", str); + } + try testing.expectEqual(Result.success, get(t, .scrollbar, @ptrCast(&scrollbar_data))); + try testing.expectEqual(@as(u64, 2), scrollbar_data.offset); +} + +test "scroll_viewport row alt screen" { + var t: Terminal = null; + try testing.expectEqual(Result.success, new( + &lib.alloc.test_allocator, + &t, + .{ + .cols = 5, + .rows = 2, + .max_scrollback = 10_000, + }, + )); + defer free(t); + + // Enter the alternate screen, which has no scrollback. + vt_write(t, "\x1b[?1049h", 8); + var screen: TerminalScreen = undefined; + try testing.expectEqual(Result.success, get(t, .active_screen, @ptrCast(&screen))); + try testing.expectEqual(TerminalScreen.alternate, screen); + + // Scrolling to any row keeps the viewport on the active area. + var viewport_active: bool = false; + scroll_viewport(t, .{ .tag = .row, .value = .{ .row = 0 } }); + try testing.expectEqual(Result.success, get(t, .viewport_active, @ptrCast(&viewport_active))); + try testing.expect(viewport_active); + scroll_viewport(t, .{ .tag = .row, .value = .{ .row = 9999 } }); + try testing.expectEqual(Result.success, get(t, .viewport_active, @ptrCast(&viewport_active))); + try testing.expect(viewport_active); + + // With no scrollback the scrollbar covers exactly the active area. + var scrollbar_data: TerminalScrollbar = undefined; + try testing.expectEqual(Result.success, get(t, .scrollbar, @ptrCast(&scrollbar_data))); + try testing.expectEqual(@as(u64, 2), scrollbar_data.total); + try testing.expectEqual(@as(u64, 0), scrollbar_data.offset); + try testing.expectEqual(@as(u64, 2), scrollbar_data.len); +} + test "scroll_viewport null" { scroll_viewport(null, .{ .tag = .top, .value = undefined }); + scroll_viewport(null, .{ .tag = .row, .value = .{ .row = 1 } }); +} + +test "compression invalid arguments" { + var activity: u64 = undefined; + var compression_result: CompressionResult = undefined; + + try testing.expectEqual( + Result.invalid_value, + compression_activity(null, &activity), + ); + try testing.expectEqual( + Result.invalid_value, + compress(null, @intFromEnum(CompressionMode.incremental), &compression_result), + ); + + var t: Terminal = null; + try testing.expectEqual(Result.success, new( + &lib.alloc.test_allocator, + &t, + .{ + .cols = 80, + .rows = 24, + .max_scrollback = 10_000_000, + }, + )); + defer free(t); + + try testing.expectEqual( + Result.invalid_value, + compression_activity(t, null), + ); + try testing.expectEqual( + Result.invalid_value, + compress(t, @intFromEnum(CompressionMode.incremental), null), + ); + try testing.expectEqual( + Result.invalid_value, + compress(t, -1, &compression_result), + ); +} + +test "compression activity and incremental scheduling" { + var t: Terminal = null; + try testing.expectEqual(Result.success, new( + &lib.alloc.test_allocator, + &t, + .{ + .cols = 80, + .rows = 24, + .max_scrollback = 10_000_000, + }, + )); + defer free(t); + + var initial_activity: u64 = undefined; + try testing.expectEqual( + Result.success, + compression_activity(t, &initial_activity), + ); + + const line = "repeated and compressible terminal history\r\n"; + const repeat = 4_000; + const input = try testing.allocator.alloc(u8, line.len * repeat); + defer testing.allocator.free(input); + for (0..repeat) |i| + @memcpy(input[i * line.len ..][0..line.len], line); + vt_write(t, input.ptr, input.len); + + var activity: u64 = undefined; + try testing.expectEqual( + Result.success, + compression_activity(t, &activity), + ); + try testing.expect(activity != initial_activity); + + var compression_result: CompressionResult = undefined; + for (0..1_000) |_| { + try testing.expectEqual( + Result.success, + compress( + t, + @intFromEnum(CompressionMode.incremental), + &compression_result, + ), + ); + + switch (compression_result) { + .pending => continue, + .complete => break, + .unsupported => unreachable, + } + } else return error.TestUnexpectedResult; + + // Compression changes storage representation, not the activity token. + var final_activity: u64 = undefined; + try testing.expectEqual( + Result.success, + compression_activity(t, &final_activity), + ); + try testing.expectEqual(activity, final_activity); + + try testing.expectEqual( + Result.success, + compress(t, @intFromEnum(CompressionMode.full), &compression_result), + ); + try testing.expectEqual(CompressionResult.complete, compression_result); } test "reset" { @@ -1019,11 +1379,12 @@ test "resize shrinks both axes with cursor at bottom" { )); defer free(t); - // CSI 24;1H parks the cursor on the bottom row. + // CSI 24;1H -> park the cursor on the bottom row (1-based). const move = "\x1b[24;1H"; vt_write(t, move, move.len); // Shrink both axes; pre-resize cursor.y sits past the new bottom row. + // Previously this underflowed in PageList.resizeCols. try testing.expectEqual(Result.success, resize(t, 79, 23, 8, 16)); try testing.expectEqual(79, t.?.terminal.cols); try testing.expectEqual(23, t.?.terminal.rows); @@ -1401,6 +1762,45 @@ test "get invalid" { try testing.expectEqual(Result.invalid_value, get(t, .invalid, null)); } +test "set default cursor style and blink" { + var t: Terminal = null; + try testing.expectEqual(Result.success, new( + &lib.alloc.test_allocator, + &t, + .{ + .cols = 80, + .rows = 24, + .max_scrollback = 0, + }, + )); + defer free(t); + + var default_style: TerminalCursorStyle = .bar; + var default_blink = true; + try testing.expectEqual(Result.success, set(t, .default_cursor_style, @ptrCast(&default_style))); + try testing.expectEqual(Result.success, set(t, .default_cursor_blink, @ptrCast(&default_blink))); + + // Setting defaults applies them immediately while the cursor is still default. + try testing.expectEqual(Screen.CursorStyle.bar, t.?.terminal.screens.active.cursor.cursor_style); + try testing.expect(t.?.terminal.modes.get(.cursor_blinking)); + + // An explicit DECSCUSR style overrides the configured defaults. + vt_write(t, "\x1b[2 q", 5); + try testing.expectEqual(Screen.CursorStyle.block, t.?.terminal.screens.active.cursor.cursor_style); + try testing.expect(!t.?.terminal.modes.get(.cursor_blinking)); + + // Changing defaults does not override an explicit cursor style. + default_style = .underline; + try testing.expectEqual(Result.success, set(t, .default_cursor_style, @ptrCast(&default_style))); + try testing.expectEqual(Screen.CursorStyle.block, t.?.terminal.screens.active.cursor.cursor_style); + try testing.expect(!t.?.terminal.modes.get(.cursor_blinking)); + + // DECSCUSR reset restores the configured default style and blink. + vt_write(t, "\x1b[0 q", 5); + try testing.expectEqual(Screen.CursorStyle.underline, t.?.terminal.screens.active.cursor.cursor_style); + try testing.expect(t.?.terminal.modes.get(.cursor_blinking)); +} + test "set and get selection" { var t: Terminal = null; try testing.expectEqual(Result.success, new( @@ -1982,6 +2382,46 @@ test "set write_pty callback" { try testing.expectEqual(@as(?*anyopaque, @ptrCast(&sentinel)), S.last_userdata); } +test "write_pty receives OSC color query response" { + var t: Terminal = null; + try testing.expectEqual(Result.success, new( + &lib.alloc.test_allocator, + &t, + .{ + .cols = 80, + .rows = 24, + .max_scrollback = 0, + }, + )); + defer free(t); + + const S = struct { + var last_data: ?[]u8 = null; + + 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"); + } + }; + defer S.deinit(); + + try testing.expectEqual(Result.success, set(t, .write_pty, @ptrCast(&S.writePty))); + + const set_fg = "\x1B]10;rgb:01/02/03\x1B\\"; + vt_write(t, set_fg, set_fg.len); + try testing.expect(S.last_data == null); + + const query_fg = "\x1B]10;?\x1B\\"; + vt_write(t, query_fg, query_fg.len); + try testing.expect(S.last_data != null); + try testing.expectEqualStrings("\x1B]10;rgb:0101/0202/0303\x1B\\", S.last_data.?); +} + test "set write_pty without callback ignores queries" { var t: Terminal = null; try testing.expectEqual(Result.success, new( @@ -2279,6 +2719,248 @@ test "title_changed without callback is silent" { vt_write(t, "\x1B]2;Hello\x1B\\", 10); } +test "set pwd_changed callback" { + var t: Terminal = null; + try testing.expectEqual(Result.success, new( + &lib.alloc.test_allocator, + &t, + .{ + .cols = 80, + .rows = 24, + .max_scrollback = 0, + }, + )); + defer free(t); + + const S = struct { + var pwd_count: usize = 0; + var last_userdata: ?*anyopaque = null; + + fn pwdChanged(_: Terminal, ud: ?*anyopaque) callconv(lib.calling_conv) void { + pwd_count += 1; + last_userdata = ud; + } + }; + S.pwd_count = 0; + S.last_userdata = null; + + var sentinel: u8 = 88; + try testing.expectEqual(Result.success, set(t, .userdata, @ptrCast(&sentinel))); + try testing.expectEqual(Result.success, set(t, .pwd_changed, @ptrCast(&S.pwdChanged))); + + // OSC 7 ; file:///tmp ST — report pwd + const seq1 = "\x1B]7;file:///tmp\x1B\\"; + vt_write(t, seq1, seq1.len); + try testing.expectEqual(@as(usize, 1), S.pwd_count); + try testing.expectEqual(@as(?*anyopaque, @ptrCast(&sentinel)), S.last_userdata); + try testing.expectEqualStrings("file:///tmp", zigTerminal(t).?.getPwd().?); + + // Another pwd change + const seq2 = "\x1B]7;file:///home/user\x1B\\"; + vt_write(t, seq2, seq2.len); + try testing.expectEqual(@as(usize, 2), S.pwd_count); + try testing.expectEqualStrings("file:///home/user", zigTerminal(t).?.getPwd().?); +} + +test "set clipboard_write callback" { + var t: Terminal = null; + try testing.expectEqual(Result.success, new( + &lib.alloc.test_allocator, + &t, + .{ + .cols = 80, + .rows = 24, + .max_scrollback = 0, + }, + )); + defer free(t); + + const S = struct { + var count: usize = 0; + var last_terminal: Terminal = null; + var last_userdata: ?*anyopaque = null; + var last_size: usize = 0; + var last_location: clipboard.Location = .standard; + var last_contents_null: bool = false; + 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][64]u8 = undefined; + var last_data_lens: [8]usize = @splat(0); + var next_result: clipboard.WriteResult = .success; + + fn clipboardWrite( + terminal_: Terminal, + ud: ?*anyopaque, + request: *const ClipboardWrite, + ) callconv(lib.calling_conv) clipboard.WriteResult { + count += 1; + last_terminal = terminal_; + last_userdata = ud; + last_size = request.size; + last_location = request.location; + last_contents_null = request.contents == null; + 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]], + ); + } + } + + return next_result; + } + }; + S.count = 0; + S.last_terminal = null; + S.last_userdata = null; + S.next_result = .denied; + + var sentinel: u8 = 88; + try testing.expectEqual(Result.success, set(t, .userdata, @ptrCast(&sentinel))); + try testing.expectEqual(Result.success, set(t, .clipboard_write, @ptrCast(&S.clipboardWrite))); + + // Split OSC 52 write whose decoded payload contains an embedded NUL. + const seq1_a = "\x1B]52;c;aGVs"; + const seq1_b = "bG8Ad29ybGQ=\x1B\\"; + vt_write(t, seq1_a, seq1_a.len); + vt_write(t, seq1_b, seq1_b.len); + try testing.expectEqual(@as(usize, 1), S.count); + try testing.expectEqual(t, S.last_terminal); + try testing.expectEqual(@as(?*anyopaque, @ptrCast(&sentinel)), S.last_userdata); + try testing.expectEqual(@sizeOf(ClipboardWrite), S.last_size); + try testing.expectEqual(clipboard.Location.standard, S.last_location); + try testing.expect(!S.last_contents_null); + try testing.expectEqual(@as(usize, 1), S.last_contents_len); + 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 destinations are normalized rather than exposed as wire bytes. + const location_cases = [_]struct { + selector: u8, + expected: clipboard.Location, + }{ + .{ .selector = 's', .expected = .selection }, + .{ .selector = 'p', .expected = .primary }, + .{ .selector = 'q', .expected = .standard }, + }; + for (location_cases) |case| { + const seq = [_]u8{ '\x1B', ']', '5', '2', ';', case.selector, ';', 'e', 'A', '=', '=', '\x1B', '\\' }; + vt_write(t, &seq, seq.len); + try testing.expectEqual(case.expected, S.last_location); + } + try testing.expectEqual(@as(usize, 4), S.count); + + // An empty content list is a clear and retains a null descriptor pointer. + const clear = "\x1B]52;s;\x1B\\"; + vt_write(t, clear, clear.len); + try testing.expectEqual(@as(usize, 5), S.count); + try testing.expectEqual(clipboard.Location.selection, S.last_location); + try testing.expect(S.last_contents_null); + try testing.expectEqual(@as(usize, 0), S.last_contents_len); + + // Read requests and malformed base64 must never reach the callback. + const read = "\x1B]52;c;?\x1B\\"; + vt_write(t, read, read.len); + const malformed = "\x1B]52;c;%%%\x1B\\"; + vt_write(t, malformed, malformed.len); + try testing.expectEqual(@as(usize, 5), S.count); + + // iTerm2 Copy reaches the same normalized callback. + const iterm = "\x1B]1337;Copy=:aVRlcm0=\x1B\\"; + vt_write(t, iterm, iterm.len); + try testing.expectEqual(@as(usize, 6), S.count); + try testing.expectEqual(clipboard.Location.standard, S.last_location); + 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. + const internal_contents = [_]clipboard.Content{ + .{ .mime = "text/plain", .data = "plain" }, + .{ .mime = "application/octet-stream", .data = "a\x00b" }, + .{ .mime = "text/html", .data = "<b>plain</b>" }, + .{ .mime = "text/rtf", .data = "{\\rtf1 plain}" }, + .{ .mime = "image/png", .data = "\x89PNG" }, + }; + S.next_result = .busy; + const handler = &t.?.stream.handler; + const write_result = handler.effects.clipboard_write.?(handler, .{ + .location = .primary, + .contents = &internal_contents, + }); + try testing.expectEqual(clipboard.WriteResult.busy, write_result); + try testing.expectEqual(@as(usize, 7), S.count); + try testing.expectEqual(@as(usize, 5), S.last_contents_len); + try testing.expectEqualStrings( + "application/octet-stream", + S.last_mimes[1][0..S.last_mime_lens[1]], + ); + try testing.expectEqualSlices(u8, "a\x00b", S.last_data[1][0..S.last_data_lens[1]]); + 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. + try testing.expectEqual(Result.success, set(t, .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); +} + +test "clipboard_write without callback is unsupported and silent" { + var t: Terminal = null; + try testing.expectEqual(Result.success, new( + &lib.alloc.test_allocator, + &t, + .{ + .cols = 80, + .rows = 24, + .max_scrollback = 0, + }, + )); + defer free(t); + + // OSC 52 without a callback should not crash + 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); +} + +test "pwd_changed without callback is silent" { + var t: Terminal = null; + try testing.expectEqual(Result.success, new( + &lib.alloc.test_allocator, + &t, + .{ + .cols = 80, + .rows = 24, + .max_scrollback = 0, + }, + )); + defer free(t); + + // OSC 7 without a callback should not crash, but should still set the pwd + const seq = "\x1B]7;file:///tmp\x1B\\"; + vt_write(t, seq, seq.len); + try testing.expectEqualStrings("file:///tmp", zigTerminal(t).?.getPwd().?); +} + test "set size callback" { var t: Terminal = null; try testing.expectEqual(Result.success, new( @@ -3106,6 +3788,33 @@ test "set color sets dirty flag" { try testing.expect(zt.flags.dirty.palette); } +test "set glyph protocol disables APC handling and clears glossary" { + var t: Terminal = null; + try testing.expectEqual(Result.success, new( + &lib.alloc.test_allocator, + &t, + .{ .cols = 80, .rows = 24, .max_scrollback = 0 }, + )); + defer free(t); + + const register = "\x1B_25a1;r;cp=e0a0;AAAAAAAAAAAAAA==\x1B\\"; + vt_write(t, register, register.len); + try testing.expect(t.?.terminal.glyph_glossary.contains(0xE0A0)); + + const disabled = false; + try testing.expectEqual(Result.success, set(t, .glyph_protocol, @ptrCast(&disabled))); + try testing.expect(!t.?.stream.handler.apc_handler.enabled.contains(.glyph)); + try testing.expect(!t.?.terminal.glyph_glossary.contains(0xE0A0)); + + vt_write(t, register, register.len); + try testing.expect(!t.?.terminal.glyph_glossary.contains(0xE0A0)); + + const enabled = true; + try testing.expectEqual(Result.success, set(t, .glyph_protocol, @ptrCast(&enabled))); + vt_write(t, register, register.len); + try testing.expect(t.?.terminal.glyph_glossary.contains(0xE0A0)); +} + test "get_multi success" { var t: Terminal = null; try testing.expectEqual(Result.success, new( diff --git a/vendor/libghostty-vt/src/terminal/c/types.zig b/vendor/libghostty-vt/src/terminal/c/types.zig index de043a03..f160f385 100644 --- a/vendor/libghostty-vt/src/terminal/c/types.zig +++ b/vendor/libghostty-vt/src/terminal/c/types.zig @@ -7,6 +7,7 @@ const std = @import("std"); const lib = @import("../lib.zig"); const color = @import("../color.zig"); +const color_c = @import("color.zig"); const mouse_event = @import("mouse_event.zig"); const point = @import("../point.zig"); const size_report = @import("size_report.zig"); @@ -37,8 +38,12 @@ 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) }, @@ -209,9 +214,23 @@ test "json parses" { const root = parsed.value.object; // 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("GhosttyTerminalOptions")); try std.testing.expect(root.contains("GhosttyFormatterTerminalOptions")); + 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")); + + 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")); + // Verify GhosttyTerminalOptions fields const term_opts = root.get("GhosttyTerminalOptions").?.object; try std.testing.expect(term_opts.contains("size")); diff --git a/vendor/libghostty-vt/src/terminal/c/unicode.zig b/vendor/libghostty-vt/src/terminal/c/unicode.zig new file mode 100644 index 00000000..14451338 --- /dev/null +++ b/vendor/libghostty-vt/src/terminal/c/unicode.zig @@ -0,0 +1,85 @@ +const std = @import("std"); +const lib = @import("../lib.zig"); +const unicode_pkg = @import("../../unicode/main.zig"); + +pub fn codepoint_width(cp: u32) callconv(lib.calling_conv) u8 { + if (cp > 0x10FFFF) return 1; + return unicode_pkg.codepointWidth(@intCast(cp)); +} + +pub fn grapheme_width( + cps: ?[*]const u32, + len: usize, + width: ?*u8, +) callconv(lib.calling_conv) usize { + if (len == 0) { + if (width) |ptr| ptr.* = 0; + return 0; + } + + const ptr = cps orelse { + if (width) |out| out.* = 0; + return 0; + }; + const result = unicode_pkg.graphemeWidth(u32, ptr[0..len]); + if (width) |out| out.* = result.width; + return result.len; +} + +test "codepoint_width narrow" { + const testing = std.testing; + try testing.expectEqual(1, codepoint_width('a')); +} + +test "codepoint_width wide" { + const testing = std.testing; + try testing.expectEqual(2, codepoint_width(0x4E00)); +} + +test "codepoint_width zero" { + const testing = std.testing; + try testing.expectEqual(0, codepoint_width(0x0301)); +} + +test "codepoint_width out of range" { + const testing = std.testing; + try testing.expectEqual(1, codepoint_width(0x110000)); + try testing.expectEqual(1, codepoint_width(std.math.maxInt(u32))); +} + +test "grapheme_width empty" { + const testing = std.testing; + + var width: u8 = 42; + try testing.expectEqual(@as(usize, 0), grapheme_width(null, 0, &width)); + try testing.expectEqual(@as(u8, 0), width); +} + +test "grapheme_width null width" { + const testing = std.testing; + + const cps = [_]u32{ 0x2764, 0xFE0F }; + try testing.expectEqual(@as(usize, 2), grapheme_width(cps[0..].ptr, cps.len, null)); +} + +test "grapheme_width out of range" { + const testing = std.testing; + + var width: u8 = 0; + const invalid_first = [_]u32{ 0x110000, 0x0301 }; + try testing.expectEqual(@as(usize, 1), grapheme_width(invalid_first[0..].ptr, invalid_first.len, &width)); + try testing.expectEqual(@as(u8, 1), width); + + const invalid_second = [_]u32{ 'a', 0x110000 }; + try testing.expectEqual(@as(usize, 1), grapheme_width(invalid_second[0..].ptr, invalid_second.len, &width)); + try testing.expectEqual(@as(u8, 1), width); +} + +test "grapheme_width emoji sequence" { + const testing = std.testing; + + var width: u8 = 0; + const cps = [_]u32{ 0x1F468, 0x200D, 0x1F469, 0x200D, 0x1F467 }; + try testing.expectEqual(@as(usize, cps.len), grapheme_width(cps[0..].ptr, cps.len, &width)); + try testing.expectEqual(@as(u8, 2), width); +} diff --git a/vendor/libghostty-vt/src/terminal/clipboard.zig b/vendor/libghostty-vt/src/terminal/clipboard.zig new file mode 100644 index 00000000..554ca984 --- /dev/null +++ b/vendor/libghostty-vt/src/terminal/clipboard.zig @@ -0,0 +1,36 @@ +/// The clipboard destination for a write. +pub const Location = enum(c_int) { + standard = 0, + selection = 1, + primary = 2, + _, +}; + +/// A single representation of clipboard data. +/// +/// The MIME type and data are borrowed and only valid for the duration of a +/// clipboard write callback. Data is binary-safe. +pub const Content = struct { + mime: []const u8, + data: []const u8, +}; + +/// One atomic clipboard write. +/// +/// Contents are borrowed and only valid for the duration of a clipboard write +/// callback. An empty contents slice clears the destination. +pub const Write = struct { + location: Location, + contents: []const Content, +}; + +/// 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, + _, +}; diff --git a/vendor/libghostty-vt/src/terminal/color.zig b/vendor/libghostty-vt/src/terminal/color.zig index cb1511c3..92de3578 100644 --- a/vendor/libghostty-vt/src/terminal/color.zig +++ b/vendor/libghostty-vt/src/terminal/color.zig @@ -47,6 +47,81 @@ pub const default: Palette = default: { /// Palette is the 256 color palette. pub const Palette = [256]RGB; +/// A parsed palette entry from Ghostty's config "N=COLOR" syntax. +pub const PaletteEntry = struct { + index: u8, + color: RGB, +}; + +/// Parse a palette entry in Ghostty config syntax: "N=COLOR" where N is +/// a palette index 0-255 (decimal, or 0x/0o/0b-prefixed per Zig's +/// parseInt base-0 rules) and COLOR is anything RGB.parse accepts. +/// Whitespace (spaces/tabs) around N and COLOR is ignored. +pub fn parsePaletteEntry(value: []const u8) error{ InvalidFormat, Overflow }!PaletteEntry { + const eql_idx = std.mem.indexOfScalar(u8, value, '=') orelse + return error.InvalidFormat; + const index = std.fmt.parseInt( + u8, + std.mem.trim(u8, value[0..eql_idx], " \t"), + 0, + ) catch |err| switch (err) { + error.Overflow => return error.Overflow, + error.InvalidCharacter => return error.InvalidFormat, + }; + const rgb = try RGB.parse(value[eql_idx + 1 ..]); + return .{ .index = index, .color = rgb }; +} + +test "parsePaletteEntry" { + const testing = std.testing; + + { + const entry = try parsePaletteEntry("0=#AABBCC"); + try testing.expectEqual(@as(u8, 0), entry.index); + try testing.expectEqual(RGB{ .r = 170, .g = 187, .b = 204 }, entry.color); + } + { + const entry = try parsePaletteEntry("0b1=#014589"); + try testing.expectEqual(@as(u8, 1), entry.index); + try testing.expectEqual(RGB{ .r = 1, .g = 69, .b = 137 }, entry.color); + } + { + const entry = try parsePaletteEntry("0o7=#234567"); + try testing.expectEqual(@as(u8, 7), entry.index); + try testing.expectEqual(RGB{ .r = 35, .g = 69, .b = 103 }, entry.color); + } + { + const entry = try parsePaletteEntry("0xF=#ABCDEF"); + try testing.expectEqual(@as(u8, 15), entry.index); + try testing.expectEqual(RGB{ .r = 171, .g = 205, .b = 239 }, entry.color); + } + { + const entry = try parsePaletteEntry("0 = #AABBCC"); + try testing.expectEqual(@as(u8, 0), entry.index); + try testing.expectEqual(RGB{ .r = 170, .g = 187, .b = 204 }, entry.color); + } + { + const entry = try parsePaletteEntry(" 1= #DDEEFF "); + try testing.expectEqual(@as(u8, 1), entry.index); + try testing.expectEqual(RGB{ .r = 221, .g = 238, .b = 255 }, entry.color); + } + { + const entry = try parsePaletteEntry(" 2 = #123456 "); + try testing.expectEqual(@as(u8, 2), entry.index); + try testing.expectEqual(RGB{ .r = 18, .g = 52, .b = 86 }, entry.color); + } + { + const entry = try parsePaletteEntry("1=black"); + try testing.expectEqual(@as(u8, 1), entry.index); + try testing.expectEqual(RGB{ .r = 0, .g = 0, .b = 0 }, entry.color); + } + + try testing.expectError(error.InvalidFormat, parsePaletteEntry(" ")); + try testing.expectError(error.InvalidFormat, parsePaletteEntry("a")); + try testing.expectError(error.Overflow, parsePaletteEntry("256=#AABBCC")); + try testing.expectError(error.InvalidFormat, parsePaletteEntry("1=notacolor")); +} + /// C-compatible palette type using the extern RGB struct. pub const PaletteC = [256]RGB.C; @@ -441,6 +516,24 @@ pub const RGB = packed struct(u24) { return self.r == other.r and self.g == other.g and self.b == other.b; } + pub fn encodeRgb8(self: RGB, writer: *std.Io.Writer) !void { + try writer.print( + "rgb:{x:0>2}/{x:0>2}/{x:0>2}", + .{ self.r, self.g, self.b }, + ); + } + + pub fn encodeRgb16(self: RGB, writer: *std.Io.Writer) !void { + try writer.print( + "rgb:{x:0>4}/{x:0>4}/{x:0>4}", + .{ + @as(u16, self.r) * 257, + @as(u16, self.g) * 257, + @as(u16, self.b) * 257, + }, + ); + } + /// Calculates the contrast ratio between two colors. The contrast /// ration is a value between 1 and 21 where 1 is the lowest contrast /// and 21 is the highest contrast. @@ -541,6 +634,8 @@ pub const RGB = packed struct(u24) { /// Parse a color specification. /// + /// Leading and trailing spaces and tabs are ignored. + /// /// Any of the following forms are accepted: /// /// 1. rgb:<red>/<green>/<blue> @@ -554,38 +649,42 @@ pub const RGB = packed struct(u24) { /// where <red>, <green>, and <blue> are floating point values between /// 0.0 and 1.0 (inclusive). /// - /// 3. #rgb, #rrggbb, #rrrgggbbb #rrrrggggbbbb + /// 3. #rgb, #rrggbb, rgb, rrggbb, #rrrgggbbb, #rrrrggggbbbb /// - /// where `r`, `g`, and `b` are a single hexadecimal digit. - /// These specify a color with 4, 8, 12, and 16 bits of precision - /// per color channel. + /// where `r`, `g`, and `b` are hexadecimal digits. The forms with + /// a leading # specify a color with 4, 8, 12, and 16 bits of + /// precision per color channel. The forms without a leading # are + /// accepted for compatibility with Ghostty config/theme color values. + /// + /// 4. X11 color names pub fn parse(value: []const u8) error{InvalidFormat}!RGB { - if (value.len == 0) { + const input = std.mem.trim(u8, value, " \t"); + if (input.len == 0) { @branchHint(.cold); return error.InvalidFormat; } - if (value[0] == '#') { - switch (value.len) { + if (input[0] == '#') { + switch (input.len) { 4 => return RGB{ - .r = try RGB.fromHex(value[1..2]), - .g = try RGB.fromHex(value[2..3]), - .b = try RGB.fromHex(value[3..4]), + .r = try RGB.fromHex(input[1..2]), + .g = try RGB.fromHex(input[2..3]), + .b = try RGB.fromHex(input[3..4]), }, 7 => return RGB{ - .r = try RGB.fromHex(value[1..3]), - .g = try RGB.fromHex(value[3..5]), - .b = try RGB.fromHex(value[5..7]), + .r = try RGB.fromHex(input[1..3]), + .g = try RGB.fromHex(input[3..5]), + .b = try RGB.fromHex(input[5..7]), }, 10 => return RGB{ - .r = try RGB.fromHex(value[1..4]), - .g = try RGB.fromHex(value[4..7]), - .b = try RGB.fromHex(value[7..10]), + .r = try RGB.fromHex(input[1..4]), + .g = try RGB.fromHex(input[4..7]), + .b = try RGB.fromHex(input[7..10]), }, 13 => return RGB{ - .r = try RGB.fromHex(value[1..5]), - .g = try RGB.fromHex(value[5..9]), - .b = try RGB.fromHex(value[9..13]), + .r = try RGB.fromHex(input[1..5]), + .g = try RGB.fromHex(input[5..9]), + .b = try RGB.fromHex(input[9..13]), }, else => { @@ -595,24 +694,36 @@ pub const RGB = packed struct(u24) { } } - // Check for X11 named colors. We allow whitespace around the edges - // of the color because Kitty allows whitespace. This is not part of - // any spec I could find. - if (x11_color.map.get(std.mem.trim(u8, value, " "))) |rgb| return rgb; + // Check for X11 named colors. We allow whitespace around the edges. + if (x11_color.map.get(input)) |rgb| return rgb; + + switch (input.len) { + 3 => return RGB{ + .r = try RGB.fromHex(input[0..1]), + .g = try RGB.fromHex(input[1..2]), + .b = try RGB.fromHex(input[2..3]), + }, + 6 => return RGB{ + .r = try RGB.fromHex(input[0..2]), + .g = try RGB.fromHex(input[2..4]), + .b = try RGB.fromHex(input[4..6]), + }, + else => {}, + } - if (value.len < "rgb:a/a/a".len or !std.mem.eql(u8, value[0..3], "rgb")) { + if (input.len < "rgb:a/a/a".len or !std.mem.eql(u8, input[0..3], "rgb")) { @branchHint(.cold); return error.InvalidFormat; } var i: usize = 3; - const use_intensity = if (value[i] == 'i') blk: { + const use_intensity = if (input[i] == 'i') blk: { i += 1; break :blk true; } else false; - if (value[i] != ':') { + if (input[i] != ':') { @branchHint(.cold); return error.InvalidFormat; } @@ -620,8 +731,8 @@ pub const RGB = packed struct(u24) { i += 1; const r = r: { - const slice = if (std.mem.indexOfScalarPos(u8, value, i, '/')) |end| - value[i..end] + const slice = if (std.mem.indexOfScalarPos(u8, input, i, '/')) |end| + input[i..end] else { @branchHint(.cold); return error.InvalidFormat; @@ -636,8 +747,8 @@ pub const RGB = packed struct(u24) { }; const g = g: { - const slice = if (std.mem.indexOfScalarPos(u8, value, i, '/')) |end| - value[i..end] + const slice = if (std.mem.indexOfScalarPos(u8, input, i, '/')) |end| + input[i..end] else { @branchHint(.cold); return error.InvalidFormat; @@ -652,9 +763,9 @@ pub const RGB = packed struct(u24) { }; const b = if (use_intensity) - try RGB.fromIntensity(value[i..]) + try RGB.fromIntensity(input[i..]) else - try RGB.fromHex(value[i..]); + try RGB.fromHex(input[i..]); return RGB{ .r = r, @@ -780,6 +891,11 @@ test "RGB.parse" { try testing.expectEqual(RGB{ .r = 255, .g = 255, .b = 255 }, try RGB.parse("#fffffffff")); try testing.expectEqual(RGB{ .r = 255, .g = 255, .b = 255 }, try RGB.parse("#ffffffffffff")); try testing.expectEqual(RGB{ .r = 255, .g = 0, .b = 16 }, try RGB.parse("#ff0010")); + try testing.expectEqual(RGB{ .r = 10, .g = 11, .b = 12 }, try RGB.parse("0A0B0C")); + try testing.expectEqual(RGB{ .r = 255, .g = 255, .b = 255 }, try RGB.parse("FFFFFF")); + try testing.expectEqual(RGB{ .r = 255, .g = 255, .b = 255 }, try RGB.parse("FFF")); + try testing.expectEqual(RGB{ .r = 51, .g = 68, .b = 85 }, try RGB.parse("#345")); + try testing.expectEqual(RGB{ .r = 170, .g = 187, .b = 204 }, try RGB.parse(" #AABBCC ")); try testing.expectEqual(RGB{ .r = 0, .g = 0, .b = 0 }, try RGB.parse("black")); try testing.expectEqual(RGB{ .r = 255, .g = 0, .b = 0 }, try RGB.parse("red")); @@ -790,8 +906,11 @@ test "RGB.parse" { try testing.expectEqual(RGB{ .r = 124, .g = 252, .b = 0 }, try RGB.parse("LawnGreen")); try testing.expectEqual(RGB{ .r = 0, .g = 250, .b = 154 }, try RGB.parse("medium spring green")); try testing.expectEqual(RGB{ .r = 34, .g = 139, .b = 34 }, try RGB.parse(" Forest Green ")); + try testing.expectEqual(RGB{ .r = 34, .g = 139, .b = 34 }, try RGB.parse("\tForestGreen\t")); // Invalid format + try testing.expectError(error.InvalidFormat, RGB.parse("")); + try testing.expectError(error.InvalidFormat, RGB.parse(" ")); try testing.expectError(error.InvalidFormat, RGB.parse("rgb;")); try testing.expectError(error.InvalidFormat, RGB.parse("rgb:")); try testing.expectError(error.InvalidFormat, RGB.parse(":a/a/a")); @@ -807,6 +926,22 @@ test "RGB.parse" { try testing.expectError(error.InvalidFormat, RGB.parse("#ffff")); try testing.expectError(error.InvalidFormat, RGB.parse("#fffff")); try testing.expectError(error.InvalidFormat, RGB.parse("#gggggg")); + try testing.expectError(error.InvalidFormat, RGB.parse("#12345")); + try testing.expectError(error.InvalidFormat, RGB.parse("12345")); + try testing.expectError(error.InvalidFormat, RGB.parse("nosuchcolor")); +} + +test "RGB: encode" { + const rgb: RGB = .{ .r = 0x01, .g = 0x23, .b = 0xff }; + + var buf: [64]u8 = undefined; + var writer: std.Io.Writer = .fixed(&buf); + try rgb.encodeRgb8(&writer); + try std.testing.expectEqualStrings("rgb:01/23/ff", writer.buffered()); + + writer = .fixed(&buf); + try rgb.encodeRgb16(&writer); + try std.testing.expectEqualStrings("rgb:0101/2323/ffff", writer.buffered()); } test "DynamicPalette: init" { diff --git a/vendor/libghostty-vt/src/terminal/compress.zig b/vendor/libghostty-vt/src/terminal/compress.zig new file mode 100644 index 00000000..3b670ed7 --- /dev/null +++ b/vendor/libghostty-vt/src/terminal/compress.zig @@ -0,0 +1,15 @@ +//! Compression primitives used by the terminal. +//! +//! This namespace contains only the representation and codecs. Policy about +//! which pages to compress, when to decommit their resident memory, and when +//! to restore them belongs to `PageList`. + +/// The raw LZ4 block codec used for terminal page memory. +pub const lz4 = @import("compress/lz4.zig"); + +/// A compressed terminal page which retains its original virtual mapping. +pub const Page = @import("compress/Page.zig"); + +test { + @import("std").testing.refAllDecls(@This()); +} diff --git a/vendor/libghostty-vt/src/terminal/compress/AGENTS.md b/vendor/libghostty-vt/src/terminal/compress/AGENTS.md new file mode 100644 index 00000000..172891d9 --- /dev/null +++ b/vendor/libghostty-vt/src/terminal/compress/AGENTS.md @@ -0,0 +1,90 @@ +# Terminal Compression + +Guidance for the codecs and the compressed page representation +(`Page.zig`) in this directory. These compress terminal page backing +memory (`terminal.Page`). + +## Priorities + +When making tradeoffs, in order: + +1. **Compression ratio on page-shaped data.** Encoded bytes are retained + scrollback memory, and raw `terminal.Page` backing memory is the only + thing we actually compress. Ratio on text files or synthetic data is a + secondary signal. +2. **Decompression throughput.** Pages are compressed once when they go + cold but restored on demand (scrollback access, search, inspection), so + restore latency is felt directly. +3. **Compression throughput.** Runs on idle pages in the background; being + fast is nice, being slow is tolerable. + +## Testing + +- Targeted tests: `zig build test -Dtest-filter=<codec>` +- Prefer `zig build test-lib-vt -Dtest-filter=<codec>` when practical; + this code ships in libghostty-vt. +- Codecs must keep building for `wasm32-freestanding` (libghostty-vt): + no libc, no `src/simd` (Highway) dependencies. Verify with + `zig build -Demit-lib-vt -Dtarget=wasm32-freestanding -Doptimize=ReleaseSmall`. +- Every codec needs a differential property suite: round-trip identity, + an independent format walker, wrong-size output rejection, and + corruption/truncation decoding. Keep a light version in normal unit + tests and gate the exhaustive version behind an environment variable so + the default test suite stays fast. + +## Verifying Correctness + +- Decoders must be memory-safe for arbitrary input bytes. Every blind or + wide copy needs a stated margin argument bounding it by the output + buffer; keep those arguments in comments next to the code. +- Writing scratch bytes past a copy's logical end is safe only inside the + output buffer, because in-order decoding rewrites them before any match + can read them back. Do not weaken the exact-size output contract. +- When a change should not alter compressor output, prove it: compare + encoded sizes (or a sequence-count fingerprint) on the same corpus + before and after. Ratio drift is a functional change, not noise. + +## Benchmarking + +- Use `ghostty-bench +page-compression` (see `src/benchmark/AGENTS.md` + for the general workflow). Modes: `compress`, `decompress`, `store`, + and `report` for ratio. +- Build: `zig build -Demit-bench -Doptimize=ReleaseFast -Demit-macos-app=false` +- The most representative corpus is a raw dump of real page backing + memory, chunked at the page size (400 KiB on ReleaseFast targets). + Supplement with a text corpus and random bytes for worst cases, but + weigh page corpora highest per the priorities above. Keep corpora + outside the repository and reuse identical files across comparisons. +- `ghostty-bench +scrollback-compression` measures the PageList + transitions around the codec rather than the codec itself. +- For fast iteration, keep codecs dependent only on `std` so a standalone + harness can build them directly with `zig build-exe -O ReleaseFast` and + time the codec in-process (report min-of-N, verify round-trips). +- Measure one change at a time and re-measure the final state; run-to-run + noise is a few percent, so re-run before believing small deltas. + +## Performance Notes + +- Real page data decodes as millions of tiny operations (in LZ4: mostly + zero literals plus a 4-18 byte match). Per-item overhead dominates, so + branch-light fast paths with blind fixed-size copies win. +- Wide copies are the only SIMD that pays here. Vectorized compares and + other wide-stride tricks measured as net losses because matches are + short; prefer the simple word loop unless a measurement on page corpora + says otherwise. +- `@memcpy` beats stride loops only for long copies (roughly 64 bytes and + up); call overhead loses below that. + +## LZ4 Specific + +- The codec is `lz4.zig`, an allocation-free raw block (not frame) + implementation. Blocks do not carry their decoded size; callers supply + an exact-size output buffer. +- Tests: `zig build test -Dtest-filter=lz4`. The differential suite is + `lz4_differential.zig`; run the exhaustive version for any codec + change: + `GHOSTTY_LZ4_SLOW=1 zig build test -Dtest-filter="lz4 differential"` +- The compressor must keep the standard format restrictions (final five + bytes literal, matches start at least twelve bytes before the end) so + blocks stay consumable by optimized external decoders. The differential + walker checks this. diff --git a/vendor/libghostty-vt/src/terminal/compress/Page.zig b/vendor/libghostty-vt/src/terminal/compress/Page.zig new file mode 100644 index 00000000..b7ad8025 --- /dev/null +++ b/vendor/libghostty-vt/src/terminal/compress/Page.zig @@ -0,0 +1,398 @@ +//! A compressed terminal page which retains its resident virtual mapping. +//! +//! Terminal pages have two kinds of state: the large `Page.memory` allocation +//! and a comparatively small `Page` value containing offsets, dimensions, +//! dirty state, and allocator metadata. Not all of the latter state lives in +//! the backing memory, so preserving only the memory bytes is insufficient. +//! We preserve the complete `Page` value instead. This follows the same model +//! as `Page.cloneBuf`: page internals use offsets, so a shallow page copy remains +//! valid when its memory contents are restored at the same address. +//! +//! The resident memory is deliberately not freed by this type. `PageList` +//! keeps the virtual range allocated while asking the operating system to +//! discard its physical pages. Keeping the range has two useful properties: +//! the embedded page never contains a dangling pointer, and restoring the page +//! does not require a fallible allocation. +//! +//! The intended state transition is: +//! +//! 1. Create this value while the source page is resident. +//! 2. Ask the OS to decommit the source page's memory. +//! 3. Replace the PageList node's resident state with this value. +//! 4. To restore, recommit the retained range and call `restore`. +//! 5. After committing the resident state, call `deinit` to free the encoded +//! bytes. +//! +//! If decommit is unavailable or fails, the caller should deinitialize the +//! compressed candidate and leave the source page resident. This type does not +//! perform any virtual-memory operations itself. +//! +//! The planned native implementation uses `MADV_DONTNEED` on Linux and pairs +//! `MADV_FREE_REUSABLE` with `MADV_FREE_REUSE` on Darwin. Targets without a +//! reliable retained-mapping decommit operation should leave page compression +//! disabled rather than free and later reallocate the resident memory. +const Page = @This(); + +const std = @import("std"); +const assert = std.debug.assert; +const Allocator = std.mem.Allocator; +const TerminalPage = @import("../page.zig").Page; +const lz4 = @import("lz4.zig"); + +/// Complete page metadata together with the retained resident mapping. +/// +/// The bytes in `page.memory` may have been discarded by the OS while this +/// value is in compressed state. They must not be read until `restore` has +/// successfully decoded the page. +page: TerminalPage, + +/// Exact raw LZ4 block for `page.memory`. +encoded: []u8, + +/// Allocator which owns `encoded`. +/// +/// The allocator's backing state must outlive this compressed page. Storing +/// the allocator here lets a PageList node restore and discard its compressed +/// state without needing a reference back to the PageList. +alloc: Allocator, + +/// Return the largest scratch buffer that can produce a useful compressed +/// representation for `raw_len` bytes. +/// +/// This is deliberately smaller than the general LZ4 compression bound. The +/// compressed representation includes an additional slice compared to a +/// resident terminal page, so an encoded block which fills more than this +/// buffer cannot reduce resident memory. Limiting the output here lets a +/// PageList borrow a standard page-pool item as scratch instead of retaining a +/// larger, compression-bound allocation. +/// +/// Callers are expected to reuse the scratch memory when practical. The +/// scratch buffer and hash table are never retained by this type. +pub fn requiredScratch(raw_len: usize) lz4.CompressError!usize { + // Validate the codec's input limit before doing arithmetic based on the + // raw size. The bound itself is intentionally not returned; see above. + _ = try lz4.compressBound(raw_len); + + const representation_overhead = @sizeOf(Page) - @sizeOf(TerminalPage); + if (raw_len <= representation_overhead) return 0; + + // The savings comparison is strict, so reserve one fewer byte than the + // break-even encoded size. + return raw_len - representation_overhead - 1; +} + +/// Create a compressed representation of `source`. +/// +/// `source` is not modified and continues to own its backing allocation. The +/// scratch buffer must be at least `requiredScratch(source.memory.len)` bytes. +/// It must not overlap the source memory. On success, the returned value aliases +/// the source's resident mapping and owns only its exact-sized `encoded` +/// allocation. The allocator and its backing state must remain valid until +/// `deinit` is called. +/// +/// Returns null if retaining the compressed representation would not use less +/// memory than the resident representation. An `OutputTooSmall` result from +/// the codec also means the encoding crossed that break-even point and is +/// therefore returned as null. This comparison includes the in-memory size of +/// both representation structs but does not include allocator metadata or the +/// retained virtual address range. +pub fn init( + alloc: Allocator, + source: *const TerminalPage, + scratch: []u8, + table: *lz4.HashTable, +) (Allocator.Error || lz4.CompressError)!?Page { + const required = try requiredScratch(source.memory.len); + if (scratch.len < required) return error.OutputTooSmall; + if (required == 0) return null; + + const encoded_len = lz4.compress( + source.memory, + scratch[0..required], + table, + ) catch |err| switch (err) { + // The scratch limit is the largest representation worth retaining. + // Running out of room therefore means compression cannot save memory, + // rather than that the caller failed to provide the documented size. + error.OutputTooSmall => return null, + error.InputTooLarge => return err, + }; + + // requiredScratch already accounts for the representation structs. The + // retained virtual range contributes no resident bytes after PageList + // decommits it. + assert(@sizeOf(Page) + encoded_len < + @sizeOf(TerminalPage) + source.memory.len); + + return .{ + .page = source.*, + .encoded = try alloc.dupe(u8, scratch[0..encoded_len]), + .alloc = alloc, + }; +} + +/// Free the encoded block. +/// +/// This intentionally does not free `page.memory`. The PageList node which +/// supplied the source page continues to own that pool or heap allocation. +pub fn deinit(self: *Page) void { + self.alloc.free(self.encoded); + self.* = undefined; +} + +/// Restore the embedded terminal page into its retained resident mapping. +/// +/// The caller must recommit `page.memory` before calling this on platforms +/// which require an explicit recommit operation. The compressed value remains +/// valid whether decoding succeeds or fails, allowing the caller to retry or +/// discard it. On success the returned page aliases the same resident mapping; +/// it does not own a new allocation. +pub fn restore(self: *const Page) lz4.DecompressError!TerminalPage { + const result = self.page; + _ = try lz4.decompress(self.encoded, result.memory); + return result; +} + +/// Clone this page into caller-owned memory without restoring its mapping. +/// +/// `memory` must be at least as large as the retained page mapping. Decoding +/// writes only to that buffer, so both the discarded contents of `page.memory` +/// and this value's encoded representation remain unchanged. The returned Page +/// borrows `memory`; the caller must keep it alive for the Page's lifetime and +/// release it directly rather than calling `TerminalPage.deinit`. +pub fn cloneBuf( + self: *const Page, + memory: []align(std.heap.page_size_min) u8, +) lz4.DecompressError!TerminalPage { + assert(memory.len >= self.page.memory.len); + + // Page internals are offsets into the backing buffer, so all metadata can + // be copied verbatim when paired with an equally laid-out mapping. + var result = self.page; + result.memory = memory[0..self.page.memory.len]; + _ = try lz4.decompress(self.encoded, result.memory); + return result; +} + +test "compressed Page retained mapping round trip" { + const testing = std.testing; + + var resident = try TerminalPage.init(.{ + .cols = 12, + .rows = 9, + .styles = 8, + .grapheme_bytes = 128, + .string_bytes = 128, + }); + defer resident.deinit(); + resident.size = .{ .cols = 10, .rows = 7 }; + resident.dirty = true; + + // Put state in both the backing memory and the Page value. In particular, + // the style and hyperlink sets retain live counters outside Page.memory. + const rac = resident.getRowAndCell(2, 3); + rac.cell.* = .init('A'); + try resident.appendGrapheme(rac.row, rac.cell, 0x0301); + + const style_id = try resident.styles.add(resident.memory, .{ .flags = .{ + .bold = true, + } }); + rac.cell.style_id = style_id; + rac.row.styled = true; + + const hyperlink_id = try resident.insertHyperlink(.{ + .id = .{ .explicit = "compressed-page" }, + .uri = "https://ghostty.org/docs", + }); + try resident.setHyperlink(rac.row, rac.cell, hyperlink_id); + try resident.verifyIntegrity(testing.allocator); + + const expected = try testing.allocator.dupe(u8, resident.memory); + defer testing.allocator.free(expected); + const memory_ptr = resident.memory.ptr; + const memory_len = resident.memory.len; + + const scratch = try testing.allocator.alloc( + u8, + try requiredScratch(resident.memory.len), + ); + defer testing.allocator.free(scratch); + var table: lz4.HashTable = undefined; + var compressed = (try Page.init( + testing.allocator, + &resident, + scratch, + &table, + )).?; + defer compressed.deinit(); + + try testing.expectEqual(memory_ptr, compressed.page.memory.ptr); + try testing.expectEqual(memory_len, compressed.page.memory.len); + try testing.expect(compressed.encoded.len < resident.memory.len); + const expected_encoded = try testing.allocator.dupe(u8, compressed.encoded); + defer testing.allocator.free(expected_encoded); + + // Virtual-memory operations belong to PageList, so clearing the contents + // models a successful decommit: none of the resident bytes remain. + @memset(resident.memory, 0); + + // A clone decodes into independent storage for read-only consumers which + // must not change the compressed representation. In particular, it does + // not recommit or overwrite the retained source mapping. + const clone_memory = try testing.allocator.alignedAlloc( + u8, + .fromByteUnits(std.heap.page_size_min), + resident.memory.len, + ); + defer testing.allocator.free(clone_memory); + const cloned = try compressed.cloneBuf(clone_memory); + try testing.expect(cloned.memory.ptr != memory_ptr); + try testing.expect(std.mem.allEqual(u8, resident.memory, 0)); + try testing.expectEqualSlices(u8, expected_encoded, compressed.encoded); + try testing.expectEqualSlices(u8, expected, cloned.memory); + try testing.expectEqual(resident.size, cloned.size); + try testing.expect(cloned.dirty); + try cloned.verifyIntegrity(testing.allocator); + + const restored = try compressed.restore(); + try testing.expectEqual(memory_ptr, restored.memory.ptr); + try testing.expectEqual(memory_len, restored.memory.len); + try testing.expectEqualSlices(u8, expected, restored.memory); + try testing.expectEqual(resident.size, restored.size); + try testing.expect(restored.dirty); + try restored.verifyIntegrity(testing.allocator); + + const restored_rac = restored.getRowAndCell(2, 3); + try testing.expectEqualSlices( + u21, + &.{0x0301}, + restored.lookupGrapheme(restored_rac.cell).?, + ); + try testing.expect(restored.styles.get( + restored.memory, + restored_rac.cell.style_id, + ).flags.bold); + + const restored_hyperlink_id = restored.lookupHyperlink(restored_rac.cell).?; + const restored_hyperlink = restored.hyperlink_set.get( + restored.memory, + restored_hyperlink_id, + ); + try testing.expectEqualStrings( + "https://ghostty.org/docs", + restored_hyperlink.uri.slice(restored.memory), + ); +} + +test "compressed Page requires the maximum useful scratch" { + const testing = std.testing; + + var resident = try TerminalPage.init(.{ .cols = 4, .rows = 4 }); + defer resident.deinit(); + const expected = try testing.allocator.dupe(u8, resident.memory); + defer testing.allocator.free(expected); + + const required = try requiredScratch(resident.memory.len); + try testing.expectEqual( + resident.memory.len - (@sizeOf(Page) - @sizeOf(TerminalPage)) - 1, + required, + ); + try testing.expect(required < try lz4.compressBound(resident.memory.len)); + + const scratch = try testing.allocator.alloc(u8, required - 1); + defer testing.allocator.free(scratch); + var table: lz4.HashTable = undefined; + + try testing.expectError(error.OutputTooSmall, Page.init( + testing.allocator, + &resident, + scratch, + &table, + )); + try testing.expectEqualSlices(u8, expected, resident.memory); +} + +test "compressed Page rejects a representation without savings" { + const testing = std.testing; + + var resident = try TerminalPage.init(.{ + .cols = 4, + .rows = 4, + .styles = 0, + .grapheme_bytes = 0, + .string_bytes = 0, + .hyperlink_bytes = 0, + }); + defer resident.deinit(); + + var prng = std.Random.DefaultPrng.init(0x4C5A_3402); + prng.random().bytes(resident.memory); + + const scratch = try testing.allocator.alloc( + u8, + try requiredScratch(resident.memory.len), + ); + defer testing.allocator.free(scratch); + var table: lz4.HashTable = undefined; + var failing = testing.FailingAllocator.init(testing.allocator, .{ + .fail_index = 0, + }); + + try testing.expect((try Page.init( + failing.allocator(), + &resident, + scratch, + &table, + )) == null); + try testing.expect(!failing.has_induced_failure); +} + +test "compressed Page can retry after malformed encoded data" { + const testing = std.testing; + + var resident = try TerminalPage.init(.{ .cols = 8, .rows = 8 }); + defer resident.deinit(); + const expected = try testing.allocator.dupe(u8, resident.memory); + defer testing.allocator.free(expected); + + const scratch = try testing.allocator.alloc( + u8, + try requiredScratch(resident.memory.len), + ); + defer testing.allocator.free(scratch); + var table: lz4.HashTable = undefined; + var compressed = (try Page.init( + testing.allocator, + &resident, + scratch, + &table, + )).?; + defer compressed.deinit(); + + const full_encoded = compressed.encoded; + const first_byte = full_encoded[0]; + defer { + compressed.encoded = full_encoded; + compressed.encoded[0] = first_byte; + } + compressed.encoded = full_encoded[0..1]; + compressed.encoded[0] = 0xF0; + + const clone_memory = try testing.allocator.alignedAlloc( + u8, + .fromByteUnits(std.heap.page_size_min), + resident.memory.len, + ); + defer testing.allocator.free(clone_memory); + try testing.expectError( + error.TruncatedInput, + compressed.cloneBuf(clone_memory), + ); + try testing.expectError(error.TruncatedInput, compressed.restore()); + + compressed.encoded = full_encoded; + compressed.encoded[0] = first_byte; + @memset(resident.memory, 0); + const restored = try compressed.restore(); + try testing.expectEqualSlices(u8, expected, restored.memory); +} diff --git a/vendor/libghostty-vt/src/terminal/compress/lz4.zig b/vendor/libghostty-vt/src/terminal/compress/lz4.zig new file mode 100644 index 00000000..a6b07763 --- /dev/null +++ b/vendor/libghostty-vt/src/terminal/compress/lz4.zig @@ -0,0 +1,891 @@ +//! An allocation-free implementation of the raw LZ4 block format. +//! +//! LZ4 has two relevant layers: the block format describes the compressed +//! bytes, while the frame format adds headers, sizes, checksums, and support +//! for a stream of blocks. Terminal pages already have their own ownership +//! and metadata, so this implements only blocks. In particular, an encoded +//! block does not contain its decompressed size. The caller must store that +//! separately and provide an exactly sized buffer when decoding. +//! +//! A block is a series of sequences. Each non-final sequence has this shape: +//! +//! token | literal length extensions | literals | offset | match length extensions +//! +//! The token's high nibble contains the literal length and its low nibble +//! contains the match length minus four. A nibble value of 15 means that the +//! length continues in extension bytes at the corresponding point in the +//! sequence. Each extension byte adds to the length; a value of 255 means +//! another byte follows. The literal bytes are copied directly. The two-byte +//! little-endian offset then points backwards in the already decompressed +//! output to the match bytes. +//! +//! The last sequence is special: it contains literals only and ends directly +//! after them. The reference format also requires the last five input bytes to +//! be literals and the final match to begin at least twelve bytes before the +//! end of the input. The compressor observes these restrictions so its output +//! can be consumed by optimized LZ4 decoders which copy in larger units. +//! +//! Compression uses the fast LZ4 strategy: hash each four-byte input sequence +//! and test recent positions as match candidates. Two 16-bit positions fit in +//! each hash-table entry because the format cannot refer further than 64 KiB +//! backwards. Retaining the displaced position recovers useful matches after +//! hash collisions without increasing the fixed 16 KiB workspace. Long runs +//! without matches gradually skip input positions, while matching runs are +//! extended a machine word at a time. The implementation allocates nothing; +//! all input, output, and scratch memory is supplied by the caller. +//! +//! Format reference: +//! https://github.com/lz4/lz4/blob/dev/doc/lz4_Block_format.md +const std = @import("std"); +const assert = std.debug.assert; +const testing = std.testing; + +/// Maximum input accepted by the reference LZ4 block API. Keeping the same +/// limit means `compressBound` fits in the integer sizes used by LZ4 callers +/// and gives us the same compatibility boundary as other implementations. +pub const max_input_size: usize = 0x7E000000; + +/// Every LZ4 match represents at least four bytes. The token stores the number +/// of bytes beyond this minimum rather than the full match length. +const min_match = 4; + +/// Number of bytes at the end of a conforming block which must remain literals. +const last_literals = 5; + +/// A match may not begin in the final 12 bytes. This leaves enough room for the +/// minimum match and the required five trailing literals. +const match_find_limit = 12; + +/// We retain one input position for each 12-bit hash. LZ4 refers to this as +/// memory usage 14 because the 4096 entries are four bytes each (16 KiB). +const hash_log = 12; + +/// Multiplicative hash used by the reference LZ4 fast compressor. The high +/// `hash_log` bits provide the table index. +const hash_multiplier: u32 = 2_654_435_761; + +/// Scratch memory used while compressing one block. Each entry packs the low +/// 16 bits of the two most recent input positions for its hash. All-ones marks +/// an empty half; LZ4's 16-bit offset is enough to reconstruct the only useful +/// preceding address from the current position. +/// The table is reset by every call to `compress` and can be reused afterwards. +pub const HashTable = [1 << hash_log]u32; + +/// Errors which can occur while encoding a block. +pub const CompressError = error{ + /// The input exceeds the maximum size supported by the block compressor. + InputTooLarge, + + /// The provided output buffer cannot hold the encoded block. + OutputTooSmall, +}; + +/// Errors which can occur while decoding a block. +pub const DecompressError = error{ + /// The encoded block ended in the middle of a sequence. + TruncatedInput, + + /// A match offset was zero or pointed before the produced output. + InvalidOffset, + + /// A sequence would write beyond the provided output buffer. + OutputTooSmall, + + /// The block ended before filling the exact-size output buffer. + OutputSizeMismatch, +}; + +/// Return the maximum number of bytes needed to encode `input_len` bytes. +/// +/// Incompressible input is represented as one literal run. Every 255 literal +/// bytes can require one extension byte. The additional 16-byte margin covers +/// the token and the format's fixed overhead. Callers can allocate this amount +/// once and reuse it for any block no larger than `input_len`. +pub fn compressBound(input_len: usize) CompressError!usize { + if (input_len > max_input_size) return error.InputTooLarge; + return input_len + input_len / 255 + 16; +} + +/// Compress `input` into a raw LZ4 block in `output`. +/// +/// Returns the initialized length of `output`. The input and output buffers +/// must not overlap. `table` is scratch space and does not need to be +/// initialized by the caller; it is reset before use. +pub fn compress( + input: []const u8, + output: []u8, + table: *HashTable, +) CompressError!usize { + if (input.len > max_input_size) return error.InputTooLarge; + + // All-ones in either packed half means "no previous position". + @memset(table, std.math.maxInt(u32)); + + // `ip` is the current input position, `anchor` is the first literal not yet + // emitted, and `op` is the next output position. A successful match emits + // input[anchor..ip] as literals followed by the match, then moves both input + // positions to the end of that match. + var op: usize = 0; + var anchor: usize = 0; + + // LZ4's format leaves the final five input bytes as literals and starts + // the final match at least twelve bytes before the end. This is not + // required by our safe decoder, but makes blocks compatible with fast + // decoders that rely on the standard format restrictions. Inputs too + // short for any match are emitted below as one literal-only sequence. + if (input.len >= match_find_limit) { + const search_end = input.len - match_find_limit; + const match_end_limit = input.len - last_literals; + var ip: usize = 0; + var search_attempts: usize = 0; + + search: while (ip <= search_end) { + // Hash the next four bytes and replace the table entry + // immediately. Hash collisions are expected, so equality is + // checked below before accepting a saved position as a match. + const sequence = readU32(input, ip); + const hash = hashSequence(sequence); + const candidates = table[hash]; + rememberPosition(table, hash, ip); + + var match_pos = candidatePosition(ip, @truncate(candidates)) orelse + candidatePosition(ip, @truncate(candidates >> 16)) orelse + { + advanceSearch(&ip, anchor, &search_attempts); + continue :search; + }; + + if (readU32(input, match_pos) != sequence) { + // The nearest candidate collided. Fall back to the older + // one, which must additionally match one byte beyond the + // minimum so collision-prone minimum-length matches from + // the stale half are not emitted. + match_pos = older: { + if (candidatePosition( + ip, + @truncate(candidates >> 16), + )) |older| { + if (readU32(input, older) == sequence and + input[older + min_match] == input[ip + min_match]) + { + break :older older; + } + } + + advanceSearch(&ip, anchor, &search_attempts); + continue :search; + }; + } + + // Pull the match backwards into the current literal run. This is + // particularly helpful around aligned cell records. As with + // forward extension, compare words before locating the first + // differing byte. + const match_begin = matchBegin(input, ip, match_pos, anchor); + ip = match_begin.position; + match_pos = match_begin.candidate; + + // We already compared the first four bytes. Continue up to the + // point where the required last five literals begin. `matchEnd` + // compares a machine word at a time before locating the first + // differing byte. + const match_end = matchEnd( + input, + ip + min_match, + match_pos + min_match, + match_end_limit, + ); + + try emitSequence( + output, + &op, + input[anchor..ip], + @intCast(ip - match_pos), + match_end - ip, + ); + + // The main loop jumps over the matched bytes rather than hashing + // every position within them. Seed one position near the end so + // an adjacent repeated record can still refer back into this + // match. The next loop iteration will then seed `match_end` + // normally. + if (match_end >= 2 and match_end - 2 + min_match <= input.len) { + const seed = match_end - 2; + rememberPosition( + table, + hashSequence(readU32(input, seed)), + seed, + ); + } + + ip = match_end; + anchor = ip; + search_attempts = 0; + } + } + + // Whatever remains after the last match is the terminal literal-only + // sequence. For short inputs this is also the only sequence in the block. + try emitLastLiterals(output, &op, input[anchor..]); + return op; +} + +/// Decompress a raw LZ4 block into an exact-size output buffer. +/// +/// Returns `output.len` on success. Both consuming all input and filling all +/// output are required. Raw LZ4 blocks do not carry their decoded size, so this +/// exact-size contract validates the size metadata maintained by the caller. +/// The input and output buffers must not overlap. +pub fn decompress(input: []const u8, output: []u8) DecompressError!usize { + // `ip` and `op` always identify the next unread input byte and the next + // unwritten output byte respectively. + // + // The decoder is written around one observation: almost every sequence + // in real blocks has a short literal run and a short match. Both fast + // paths below copy a fixed number of bytes blindly and let the length + // arithmetic sort out how many of them were meaningful. Writing past a + // run's logical end is safe within the output buffer because decoding + // is strictly in order: every byte past `op` is either rewritten by a + // later copy before anything can read it, or lies beyond the block's + // final length and is never part of the result. The margin conditions + // on the fast paths also subsume the exact bounds checks they replace, + // which keeps decoding of malformed blocks memory-safe. + var ip: usize = 0; + var op: usize = 0; + + while (true) { + // A normal block ends after the literal bytes of its final sequence. + // This also accepts the empty block produced by our compressor, which + // consists of a zero token and no literals. + if (ip == input.len) { + if (op != output.len) return error.OutputSizeMismatch; + return op; + } + + const token = input[ip]; + ip += 1; + + // The high nibble and any extension bytes describe the literal run. + // The literals are copied as a side effect of computing the length. + const literal_len: usize = len: { + const nibble: usize = token >> 4; + if (nibble != 15 and + @min(input.len - ip, output.len - op) >= 16) + { + // A run below the extension threshold is at most 14 bytes, + // so with a 16-byte margin on both buffers one wide copy + // covers it. + copyIntAt(u128, output, op, input, ip); + break :len nibble; + } + + // Extended or margin-poor runs take the checked path. Bounds are + // verified before copying so malformed blocks never cause a + // partial read or write. + const len = try decodeLength(input, &ip, nibble); + if (len > input.len - ip) return error.TruncatedInput; + if (len > output.len - op) return error.OutputTooSmall; + @memcpy(output[op..][0..len], input[ip..][0..len]); + break :len len; + }; + ip += literal_len; + op += literal_len; + + // Ending immediately after the literals marks the final sequence. Any + // non-final sequence must continue with an offset and match length. + if (ip == input.len) { + if (op != output.len) return error.OutputSizeMismatch; + return op; + } + + if (input.len - ip < 2) return error.TruncatedInput; + const offset = readIntAt(u16, input, ip); + ip += 2; + if (offset == 0 or offset > op) return error.InvalidOffset; + + // The token stores the match length minus the four-byte minimum. As + // with literals, a low nibble of 15 is extended by following bytes. + const match_nibble: usize = token & 0x0F; + + // A match whose length fits its nibble spans at most 18 bytes, so + // three blind copies always cover it. They are overlap-safe when the + // offset is at least a word: each load lies a full word behind the + // store which could observe it, so repeating patterns propagate + // correctly. + if (match_nibble != 15 and offset >= 8 and output.len - op >= 18) { + const match = op - offset; + copyIntAt(u64, output, op, output, match); + copyIntAt(u64, output, op + 8, output, match + 8); + copyIntAt(u16, output, op + 16, output, match + 16); + op += match_nibble + min_match; + continue; + } + + const encoded_match_len = try decodeLength(input, &ip, match_nibble); + const match_len = std.math.add( + usize, + encoded_match_len, + min_match, + ) catch return error.OutputTooSmall; + if (match_len > output.len - op) return error.OutputTooSmall; + + // Match copies may overlap, so this cannot always be one memcpy. + // `copyMatch` expands the common small repeating periods into wide + // stores and uses word copies for larger offsets. + copyMatch(output, op, offset, match_len); + op += match_len; + } +} + +/// Emit one non-final sequence. +/// +/// A sequence starts with a token, followed by optional literal length bytes, +/// the literals themselves, the two-byte offset, and optional match length +/// bytes. This function computes the complete size first so `OutputTooSmall` +/// is reported without partially writing a sequence. +fn emitSequence( + output: []u8, + op: *usize, + literals: []const u8, + offset: u16, + match_len: usize, +) CompressError!void { + assert(match_len >= min_match); + assert(offset > 0); + + const encoded_match_len = match_len - min_match; + + // One byte is always needed for the token and two for the offset. Each + // length may additionally need extension bytes after its token nibble. + const required = 1 + + encodedLengthBytes(literals.len) + literals.len + + 2 + encodedLengthBytes(encoded_match_len); + if (required > output.len - op.*) return error.OutputTooSmall; + + const token_pos = op.*; + op.* += 1; + + // Lengths below 15 fit directly in their nibble. Larger values put 15 in + // the nibble and encode the remainder immediately after the token. + output[token_pos] = (@as(u8, @intCast(@min(literals.len, 15))) << 4) | + @as(u8, @intCast(@min(encoded_match_len, 15))); + + // Literal length extensions precede the literals they describe. + if (literals.len >= 15) writeLength(output, op, literals.len - 15); + @memcpy(output[op.*..][0..literals.len], literals); + op.* += literals.len; + + // Match length extensions follow the offset because this is where the + // decoder expects them in an LZ4 sequence. + writeIntAt(u16, output, op.*, offset); + op.* += 2; + if (encoded_match_len >= 15) + writeLength(output, op, encoded_match_len - 15); +} + +/// Emit the literal-only sequence which terminates every block. +/// +/// There is no offset or match length after these bytes. As with +/// `emitSequence`, capacity is checked before modifying the output. +fn emitLastLiterals( + output: []u8, + op: *usize, + literals: []const u8, +) CompressError!void { + const required = 1 + encodedLengthBytes(literals.len) + literals.len; + if (required > output.len - op.*) return error.OutputTooSmall; + + output[op.*] = @as(u8, @intCast(@min(literals.len, 15))) << 4; + op.* += 1; + if (literals.len >= 15) writeLength(output, op, literals.len - 15); + @memcpy(output[op.*..][0..literals.len], literals); + op.* += literals.len; +} + +/// Return the number of extension bytes needed when a length is represented by +/// a token nibble plus zero or more bytes. An extended length always ends with +/// a byte below 255, so an exact multiple of 255 requires a final zero byte. +fn encodedLengthBytes(encoded_len: usize) usize { + if (encoded_len < 15) return 0; + return (encoded_len - 15) / 255 + 1; +} + +/// Write the portion of a length which did not fit in the token nibble. +/// +/// Each 255 byte means "add 255 and continue". The final byte is always less +/// than 255 and may be zero. +fn writeLength(output: []u8, op: *usize, length_: usize) void { + var length = length_; + while (length >= 255) { + output[op.*] = 255; + op.* += 1; + length -= 255; + } + output[op.*] = @intCast(length); + op.* += 1; +} + +/// Decode a length from its token nibble and any following extension bytes. +/// `ip` is advanced past every consumed extension byte. +fn decodeLength( + input: []const u8, + ip: *usize, + nibble: usize, +) DecompressError!usize { + var length: usize = nibble; + if (nibble != 15) return length; + + while (true) { + if (ip.* >= input.len) return error.TruncatedInput; + const value = input[ip.*]; + ip.* += 1; + length = std.math.add(usize, length, value) catch + return error.TruncatedInput; + if (value != 255) return length; + } +} + +/// Read an unaligned little-endian integer at `position`. +inline fn readIntAt( + comptime Int: type, + input: []const u8, + position: usize, +) Int { + return std.mem.readInt( + Int, + input[position..][0..@sizeOf(Int)], + .little, + ); +} + +/// Write an unaligned little-endian integer at `position`. +inline fn writeIntAt( + comptime Int: type, + output: []u8, + position: usize, + value: Int, +) void { + std.mem.writeInt( + Int, + output[position..][0..@sizeOf(Int)], + value, + .little, + ); +} + +/// Copy one fixed-size integer between non-overlapping byte ranges. +inline fn copyIntAt( + comptime Int: type, + output: []u8, + output_position: usize, + input: []const u8, + input_position: usize, +) void { + writeIntAt( + Int, + output, + output_position, + readIntAt(Int, input, input_position), + ); +} + +/// Read the four-byte sequence used for match finding. Callers only use this +/// where at least four input bytes remain. +inline fn readU32(input: []const u8, pos: usize) u32 { + return readIntAt(u32, input, pos); +} + +/// Add a position to one hash slot and shift the previous newest position into +/// the fallback half. A position whose low bits are 0xFFFF conflicts with the +/// sentinel and is simply not stored. +inline fn rememberPosition(table: *HashTable, hash: usize, position: usize) void { + const low: u16 = @truncate(position); + if (low == std.math.maxInt(u16)) return; + table[hash] = (@as(u32, @truncate(table[hash])) << 16) | low; +} + +/// Recover the nearest preceding position from a stored low half. Modular +/// subtraction directly produces the LZ4 offset within the current window. +inline fn candidatePosition(ip: usize, stored: u16) ?usize { + if (stored == std.math.maxInt(u16)) return null; + const distance: u16 = @as(u16, @truncate(ip)) -% stored; + if (distance == 0) return null; + return ip - distance; +} + +/// Advance through a literal run. The first KiB inspects every byte without +/// maintaining a counter, which keeps ordinary terminal-page searches cheap. +/// Longer runs enable gradually increasing steps for incompressible data. +inline fn advanceSearch( + ip: *usize, + anchor: usize, + attempts: *usize, +) void { + if (attempts.* == 0) { + ip.* += 1; + if (ip.* - anchor == 1024) attempts.* = 1; + return; + } + + attempts.* += 1; + ip.* += 1 + attempts.* / 64; +} + +/// Extend a match backwards without crossing the current literal anchor or the +/// beginning of the candidate. The returned positions preserve their offset. +fn matchBegin( + input: []const u8, + position_: usize, + candidate_: usize, + anchor: usize, +) struct { position: usize, candidate: usize } { + var position = position_; + var candidate = candidate_; + + while (@min(position - anchor, candidate) >= @sizeOf(u64)) { + const position_word = position - @sizeOf(u64); + const candidate_word = candidate - @sizeOf(u64); + const difference = readIntAt(u64, input, position_word) ^ + readIntAt(u64, input, candidate_word); + if (difference != 0) { + const equal_bytes: usize = @intCast(@clz(difference) / 8); + position -= equal_bytes; + candidate -= equal_bytes; + return .{ .position = position, .candidate = candidate }; + } + + position = position_word; + candidate = candidate_word; + } + + while (position > anchor and candidate > 0 and + input[position - 1] == input[candidate - 1]) + { + position -= 1; + candidate -= 1; + } + return .{ .position = position, .candidate = candidate }; +} + +/// Return the first input position where two matching runs differ, or `limit` +/// when they remain equal. Both positions are known to have matched through +/// `min_match` before this is called. +fn matchEnd( + input: []const u8, + position_: usize, + candidate_: usize, + limit: usize, +) usize { + var position = position_; + var candidate = candidate_; + + // Reading as little endian makes the least-significant differing bit map + // to the earliest byte in memory on every target. Unaligned reads are + // lowered appropriately by Zig and require no target-specific intrinsics. + while (limit - position >= @sizeOf(u64)) { + const difference = readIntAt(u64, input, position) ^ + readIntAt(u64, input, candidate); + if (difference != 0) { + const equal_bytes: usize = @intCast(@ctz(difference) / 8); + return position + equal_bytes; + } + + position += @sizeOf(u64); + candidate += @sizeOf(u64); + } + + while (position < limit and input[position] == input[candidate]) { + position += 1; + candidate += 1; + } + return position; +} + +/// Copy one decoded match from `offset` bytes behind `op`. +/// +/// The caller has validated that the match fits: `op + match_len` never +/// exceeds `output.len`. Wide copies may write a few scratch bytes past the +/// match's logical end; as described in `decompress`, that is safe anywhere +/// the write stays inside the output buffer. Every path therefore bounds its +/// wide stores by both the match end and the buffer end, and the bytewise +/// loop at the bottom finishes whatever remains. +fn copyMatch(output: []u8, op_: usize, offset: usize, match_len: usize) void { + var op = op_; + const end = op_ + match_len; + + // A source which ends behind the copy can never overlap it. Long + // distant matches are common in structured pages (repeated rows and + // whole blank regions), and one exact memcpy moves them in cache-line + // units. Shorter matches are not worth the call overhead. + if (offset >= match_len and match_len >= 64) { + @memcpy( + output[op..][0..match_len], + output[op - offset ..][0..match_len], + ); + return; + } + + switch (offset) { + // A period which divides the word size expands into one repeated + // pattern word. Long runs (blank lines, repeated cells) then become + // independent stores, with no load waiting on a preceding store. + // Stores advance by whole words from `op`, which preserves the + // pattern's phase. + 1, 2, 4, 8 => { + const pattern: u64 = switch (offset) { + 1 => @as(u64, output[op - 1]) * 0x0101_0101_0101_0101, + 2 => @as(u64, readIntAt(u16, output, op - 2)) * + 0x0001_0001_0001_0001, + 4 => @as(u64, readIntAt(u32, output, op - 4)) * + 0x0000_0001_0000_0001, + 8 => readIntAt(u64, output, op - 8), + else => unreachable, + }; + + const limit = @min(end, output.len -| 7); + while (op < limit) : (op += 8) + writeIntAt(u64, output, op, pattern); + }, + + // Wide copies are overlap-safe for the remaining offsets of at + // least a copy unit: each load lies a full unit behind the store + // which could observe it. Offsets 3, 5, 6, and 7 fall through to + // the bytewise loop; they are rare in real data and word tricks + // for them cost more in complexity than they return. + else => if (offset >= 16) { + const limit = @min(end, output.len -| 15); + while (op < limit) : (op += 16) + copyIntAt(u128, output, op, output, op - offset); + } else if (offset >= 8) { + const limit = @min(end, output.len -| 7); + while (op < limit) : (op += 8) + copyIntAt(u64, output, op, output, op - offset); + }, + } + + while (op < end) : (op += 1) output[op] = output[op - offset]; +} + +/// Map a four-byte input sequence to its scratch-table slot. +inline fn hashSequence(sequence: u32) usize { + return @intCast((sequence *% hash_multiplier) >> (32 - hash_log)); +} + +/// Shared round-trip assertion used by the corpus-style tests below. +fn expectRoundTrip(input: []const u8) !void { + const bound = try compressBound(input.len); + const encoded = try testing.allocator.alloc(u8, bound); + defer testing.allocator.free(encoded); + const decoded = try testing.allocator.alloc(u8, input.len); + defer testing.allocator.free(decoded); + + var table: HashTable = undefined; + const encoded_len = try compress(input, encoded, &table); + try testing.expectEqual(input.len, try decompress( + encoded[0..encoded_len], + decoded, + )); + try testing.expectEqualSlices(u8, input, decoded); +} + +test "compressBound" { + try testing.expectEqual(@as(usize, 16), try compressBound(0)); + try testing.expectEqual(@as(usize, 272), try compressBound(255)); + try testing.expectError(error.InputTooLarge, compressBound(max_input_size + 1)); +} + +test "literal-only compatibility vectors" { + var empty: [0]u8 = .{}; + try testing.expectEqual(@as(usize, 0), try decompress(&.{0}, &empty)); + + var hello: [5]u8 = undefined; + try testing.expectEqual(@as(usize, 5), try decompress( + &.{ 0x50, 'h', 'e', 'l', 'l', 'o' }, + &hello, + )); + try testing.expectEqualStrings("hello", &hello); + + var fifteen: [15]u8 = undefined; + var encoded: [17]u8 = undefined; + encoded[0] = 0xF0; + encoded[1] = 0; + @memset(encoded[2..], 'x'); + _ = try decompress(&encoded, &fifteen); + try testing.expect(std.mem.allEqual(u8, &fifteen, 'x')); +} + +test "overlapping match compatibility vector" { + // One literal 'a', followed by a four-byte match at distance one. + var output: [5]u8 = undefined; + try testing.expectEqual(@as(usize, 5), try decompress( + &.{ 0x10, 'a', 0x01, 0x00 }, + &output, + )); + try testing.expectEqualStrings("aaaaa", &output); +} + +test "extended overlapping match compatibility vector" { + // One literal followed by a 274-byte match. The match extension is + // encoded as 255 + 0 after the low token nibble's initial 15 bytes. + var output: [275]u8 = undefined; + try testing.expectEqual(@as(usize, output.len), try decompress( + &.{ 0x1F, 'a', 0x01, 0x00, 0xFF, 0x00 }, + &output, + )); + try testing.expect(std.mem.allEqual(u8, &output, 'a')); +} + +test "short offset compatibility vectors" { + + // These blocks end immediately after their match. Besides covering the + // repeating-pattern paths, they verify that the decoder uses exact copies + // when the block does not provide the standard trailing-literal margin. + var offset_two: [6]u8 = undefined; + _ = try decompress(&.{ 0x20, 'a', 'b', 0x02, 0x00 }, &offset_two); + try testing.expectEqualStrings("ababab", &offset_two); + + var offset_three: [9]u8 = undefined; + _ = try decompress( + &.{ 0x32, 'a', 'b', 'c', 0x03, 0x00 }, + &offset_three, + ); + try testing.expectEqualStrings("abcabcabc", &offset_three); + + var offset_four: [8]u8 = undefined; + _ = try decompress( + &.{ 0x40, 'a', 'b', 'c', 'd', 0x04, 0x00 }, + &offset_four, + ); + try testing.expectEqualStrings("abcdabcd", &offset_four); +} + +test "bounded wild copies are overwritten by final literals" { + + // The first sequence's nine-byte match leaves the five final literals + // required by the LZ4 block format. Its logical one-byte tail is copied as + // a word and the following literal sequence overwrites the extra bytes. + var repeated_byte: [15]u8 = undefined; + _ = try decompress( + &.{ + 0x15, 'a', 0x01, 0x00, + 0x50, '1', '2', '3', + '4', '5', + }, + &repeated_byte, + ); + try testing.expectEqualStrings("aaaaaaaaaa12345", &repeated_byte); + + // Exercise the same bounded tail copy with a non-overlapping offset. + var word_offset: [22]u8 = undefined; + _ = try decompress( + &.{ + 0x85, 'a', 'b', 'c', 'd', 'e', 'f', 'g', 'h', 0x08, 0x00, + 0x50, '1', '2', '3', '4', '5', + }, + &word_offset, + ); + try testing.expectEqualStrings( + "abcdefghabcdefgha12345", + &word_offset, + ); +} + +test "maximum match offset compatibility vector" { + const literal_len = std.math.maxInt(u16); + const extension_len = (literal_len - 15) / 255 + 1; + const encoded = try testing.allocator.alloc( + u8, + 1 + extension_len + literal_len + 2, + ); + defer testing.allocator.free(encoded); + const output = try testing.allocator.alloc(u8, literal_len + min_match); + defer testing.allocator.free(output); + + var op: usize = 0; + encoded[op] = 0xF0; + op += 1; + writeLength(encoded, &op, literal_len - 15); + for (encoded[op..][0..literal_len], 0..) |*byte, i| + byte.* = @truncate(i); + op += literal_len; + std.mem.writeInt(u16, encoded[op..][0..2], std.math.maxInt(u16), .little); + op += 2; + + try testing.expectEqual(encoded.len, op); + try testing.expectEqual(output.len, try decompress(encoded, output)); + try testing.expectEqualSlices(u8, encoded[1 + extension_len ..][0..4], output[literal_len..]); +} + +test "round trips boundary-sized inputs" { + const lengths = [_]usize{ + 0, 1, 3, 4, 5, 12, 15, 16, 19, + 20, 254, 255, 256, 269, 270, 271, 510, 511, + 512, 65_535, 65_536, 65_537, + }; + + for (lengths) |len| { + const buf = try testing.allocator.alloc(u8, len); + defer testing.allocator.free(buf); + for (buf, 0..) |*byte, i| byte.* = @truncate(i *% 31); + try expectRoundTrip(buf); + } +} + +test "round trips compressible page-sized inputs" { + const page_len = 400 * 1024; + + const zeros = try testing.allocator.alloc(u8, page_len); + defer testing.allocator.free(zeros); + @memset(zeros, 0); + try expectRoundTrip(zeros); + + const structured = try testing.allocator.alloc(u8, page_len); + defer testing.allocator.free(structured); + @memset(structured, 0); + for (0..page_len / 8) |i| { + structured[i * 8] = @truncate(' ' + i % 95); + structured[i * 8 + 4] = @truncate((i / 80) % 16); + } + try expectRoundTrip(structured); +} + +test "round trips deterministic random inputs" { + var prng = std.Random.DefaultPrng.init(0x4C5A_3401); + const random = prng.random(); + + for (0..256) |_| { + const len = random.uintLessThan(usize, 32 * 1024); + const input = try testing.allocator.alloc(u8, len); + defer testing.allocator.free(input); + random.bytes(input); + try expectRoundTrip(input); + } +} + +test "compress reports short output" { + const input = "a terminal page needs enough output space"; + var table: HashTable = undefined; + var output: [4]u8 = undefined; + try testing.expectError( + error.OutputTooSmall, + compress(input, &output, &table), + ); +} + +test "decompress rejects malformed blocks" { + var output: [32]u8 = undefined; + + try testing.expectError(error.TruncatedInput, decompress(&.{0xF0}, &output)); + try testing.expectError(error.TruncatedInput, decompress(&.{ 0x10, 'a', 1 }, output[0..5])); + try testing.expectError(error.InvalidOffset, decompress(&.{ 0x10, 'a', 0, 0 }, output[0..5])); + try testing.expectError(error.InvalidOffset, decompress(&.{ 0x10, 'a', 2, 0 }, output[0..5])); + try testing.expectError(error.OutputTooSmall, decompress( + &.{ 0x10, 'a', 1, 0 }, + output[0..4], + )); + try testing.expectError(error.OutputSizeMismatch, decompress(&.{0}, output[0..1])); +} + +test { + _ = @import("lz4_differential.zig"); +} diff --git a/vendor/libghostty-vt/src/terminal/compress/lz4_differential.zig b/vendor/libghostty-vt/src/terminal/compress/lz4_differential.zig new file mode 100644 index 00000000..bbf3b276 --- /dev/null +++ b/vendor/libghostty-vt/src/terminal/compress/lz4_differential.zig @@ -0,0 +1,493 @@ +//! Differential and property tests for the LZ4 block codec. +//! +//! Every generated input must compress into a block which: +//! +//! 1. fits within `compressBound`, +//! 2. is structurally valid LZ4 with the stricter guarantees our +//! compressor documents (final five bytes are literals, matches start +//! at least twelve bytes before the end), verified by an independent +//! walker which shares no code with the codec, +//! 3. decompresses to exactly the original bytes, and +//! 4. is rejected when decompressed into a buffer of the wrong size. +//! +//! Valid blocks are additionally mutated (bit flips, splices, truncations) +//! and fed to the decompressor, which must fail cleanly or succeed, but +//! never read or write out of bounds. The unit-test build enables runtime +//! safety, so any out-of-bounds slice access fails the test. +//! +//! The light suite below runs as a normal unit test and finishes quickly. +//! The exhaustive suite multiplies the same properties across far more +//! sizes, periods, seeds, and mutations; it is slow and therefore skipped +//! unless the environment variable `GHOSTTY_LZ4_SLOW` is set: +//! +//! GHOSTTY_LZ4_SLOW=1 zig build test -Dtest-filter="lz4 differential" +const std = @import("std"); +const testing = std.testing; +const Allocator = std.mem.Allocator; +const lz4 = @import("lz4.zig"); + +/// Number of trailing block bytes which must be literals, mirroring the +/// documented guarantee of the compressor. Kept as an independent constant +/// so a codec regression cannot silently weaken the check. +const last_literals = 5; + +/// A match may not begin in the final twelve bytes. See `last_literals`. +const match_find_limit = 12; + +/// Sizes around every encoding boundary: token nibble limits (14/15), +/// minimum match and find limits (4/12), length-extension steps (15 + 255k), +/// and power-of-two neighborhoods. +const boundary_sizes = [_]usize{ + 0, 1, 2, 3, 4, 5, 6, 7, + 8, 9, 11, 12, 13, 14, 15, 16, + 17, 18, 19, 20, 31, 32, 33, 63, + 64, 65, 254, 255, 256, 269, 270, 271, + 272, 1023, 1024, 4095, 4096, 4097, +}; + +/// Input generators exercising distinct codec behaviors. Every generator is +/// deterministic for a given random state. +const Generator = enum { + /// Uniform random bytes; largely incompressible. + random_bytes, + + /// Runs of one repeated byte with random lengths; period-one matches. + runs, + + /// One repeating pattern; exercises a fixed match period end to end. + periodic, + + /// Eight-byte records with random small payloads and zero padding, with + /// some records repeated; resembles terminal cell memory. + cells, + + /// Dictionary words with separators; text-like literal/match mix. + words, + + /// Mostly zeros with scattered random bytes; long matches with isolated + /// literals. + sparse, + + /// Random segments of all other generators; exercises transitions. + mixed, + + fn fill(gen: Generator, random: std.Random, buf: []u8) void { + switch (gen) { + .random_bytes => random.bytes(buf), + + .runs => { + var i: usize = 0; + while (i < buf.len) { + const run = @min( + random.intRangeAtMost(usize, 1, 300), + buf.len - i, + ); + @memset(buf[i..][0..run], random.int(u8)); + i += run; + } + }, + + .periodic => fillPeriodic( + random, + buf, + random.intRangeAtMost(usize, 1, 40), + ), + + .cells => { + var i: usize = 0; + while (i + 8 <= buf.len) : (i += 8) { + const cell = buf[i..][0..8]; + if (i >= 8 and random.boolean()) { + // Repeat one of the last 256 records. + const back = 8 * random.intRangeAtMost( + usize, + 1, + @min(i / 8, 256), + ); + cell.* = buf[i - back ..][0..8].*; + } else { + @memset(cell, 0); + cell[0] = ' ' + random.uintLessThan(u8, 95); + cell[1] = random.uintLessThan(u8, 4); + } + } + random.bytes(buf[i..]); + }, + + .words => { + const words = [_][]const u8{ + "the", "terminal", "page", "compress", + "row", "cell", "style", "zig", + "lz4", "block", "offset", "match", + "a", "of", "and", "literal", + "0x00", "0xFF", " ", "\r\n", + "-----", "=", "pub fn", "const", + }; + var i: usize = 0; + while (i < buf.len) { + const word = words[random.uintLessThan(usize, words.len)]; + const n = @min(word.len, buf.len - i); + @memcpy(buf[i..][0..n], word[0..n]); + i += n; + if (i < buf.len) { + buf[i] = if (random.boolean()) ' ' else '\n'; + i += 1; + } + } + }, + + .sparse => { + @memset(buf, 0); + if (buf.len == 0) return; + for (0..buf.len / 32 + 1) |_| { + const at = random.uintLessThan(usize, buf.len); + buf[at] = random.int(u8); + } + }, + + .mixed => { + var i: usize = 0; + while (i < buf.len) { + const segment = @min( + random.intRangeAtMost(usize, 1, 2048), + buf.len - i, + ); + const sub = random.enumValue(Generator); + if (sub != .mixed) sub.fill(random, buf[i..][0..segment]); + i += segment; + } + }, + } + } +}; + +/// Fill `buf` with one repeating pattern of the given period. +fn fillPeriodic(random: std.Random, buf: []u8, period: usize) void { + if (buf.len == 0) return; + const head = @min(period, buf.len); + random.bytes(buf[0..head]); + for (head..buf.len) |i| buf[i] = buf[i - period]; +} + +/// Reusable buffers sized for the largest input a suite generates. +const Workspace = struct { + input: []u8, + encoded: []u8, + decoded: []u8, + table: *lz4.HashTable, + + fn init(alloc: Allocator, max_input: usize) !Workspace { + const input = try alloc.alloc(u8, max_input); + errdefer alloc.free(input); + const encoded = try alloc.alloc(u8, try lz4.compressBound(max_input)); + errdefer alloc.free(encoded); + // One extra byte so wrong-size decompression can be tested above + // the exact length as well as below it. + const decoded = try alloc.alloc(u8, max_input + 1); + errdefer alloc.free(decoded); + const table = try alloc.create(lz4.HashTable); + return .{ + .input = input, + .encoded = encoded, + .decoded = decoded, + .table = table, + }; + } + + fn deinit(ws: *Workspace, alloc: Allocator) void { + alloc.free(ws.input); + alloc.free(ws.encoded); + alloc.free(ws.decoded); + alloc.destroy(ws.table); + ws.* = undefined; + } +}; + +/// Compress one input and verify every property promised by the codec. +/// Returns the encoded length so callers can reuse the encoded block. +fn expectCodecProperties(ws: *Workspace, input: []const u8) !usize { + const encoded_len = try lz4.compress(input, ws.encoded, ws.table); + try testing.expect(encoded_len <= try lz4.compressBound(input.len)); + const encoded = ws.encoded[0..encoded_len]; + + try expectValidBlock(encoded, input.len); + + // Exact-size decompression must reproduce the input bit for bit. The + // output is poisoned first so unwritten bytes cannot pass as correct. + const output = ws.decoded[0..input.len]; + @memset(output, 0xAA); + try testing.expectEqual(input.len, try lz4.decompress(encoded, output)); + try testing.expectEqualSlices(u8, input, output); + + // The exact-size contract must reject both smaller and larger buffers. + if (input.len > 0) { + try testing.expectError( + error.OutputTooSmall, + lz4.decompress(encoded, ws.decoded[0 .. input.len - 1]), + ); + } + try testing.expectError( + error.OutputSizeMismatch, + lz4.decompress(encoded, ws.decoded[0 .. input.len + 1]), + ); + + return encoded_len; +} + +/// Structurally validate one encoded block against the LZ4 block format and +/// the stricter guarantees documented by our compressor. This deliberately +/// reimplements the format rather than reusing codec internals. +fn expectValidBlock(encoded: []const u8, raw_len: usize) !void { + var ip: usize = 0; + var op: usize = 0; + var last_match_end: usize = 0; + + while (true) { + // Every sequence, including the final one, starts with a token. + try testing.expect(ip < encoded.len); + const token = encoded[ip]; + ip += 1; + + var literal_len: usize = token >> 4; + if (literal_len == 15) literal_len += try readExtension(encoded, &ip); + try testing.expect(encoded.len - ip >= literal_len); + ip += literal_len; + op += literal_len; + + // The final sequence contains only literals and ends the block. + if (ip == encoded.len) { + try testing.expectEqual(raw_len, op); + if (last_match_end > 0) + try testing.expect(raw_len - last_match_end >= last_literals); + return; + } + + try testing.expect(encoded.len - ip >= 2); + const offset = std.mem.readInt(u16, encoded[ip..][0..2], .little); + ip += 2; + try testing.expect(offset >= 1); + try testing.expect(offset <= op); + + // Our compressor starts matches at least `match_find_limit` bytes + // before the end and never lets one run into the final literals. + try testing.expect(op + match_find_limit <= raw_len); + var match_len: usize = (token & 0x0F) + 4; + if (token & 0x0F == 15) match_len += try readExtension(encoded, &ip); + op += match_len; + try testing.expect(op + last_literals <= raw_len); + last_match_end = op; + } +} + +/// Read one length extension: bytes of 255 accumulate until a terminator +/// below 255, which is included in the sum. +fn readExtension(encoded: []const u8, ip: *usize) !usize { + var total: usize = 0; + while (true) { + try testing.expect(ip.* < encoded.len); + const value = encoded[ip.*]; + ip.* += 1; + total += value; + if (value != 255) return total; + } +} + +/// Decode deterministic corruptions of a valid block. Any result is +/// acceptable except memory unsafety, which the safety-checked test build +/// turns into a failure. Mutations may still form a valid block, so output +/// contents are intentionally not asserted. +fn expectMutationSafety( + ws: *Workspace, + random: std.Random, + encoded_len: usize, + raw_len: usize, + mutations: usize, +) !void { + const original = try testing.allocator.dupe(u8, ws.encoded[0..encoded_len]); + defer testing.allocator.free(original); + + for (0..mutations) |_| { + const block = ws.encoded[0..encoded_len]; + @memcpy(block, original); + + switch (random.uintLessThan(u8, 4)) { + // Flip up to eight random bits. + 0 => for (0..random.intRangeAtMost(usize, 1, 8)) |_| { + const at = random.uintLessThan(usize, block.len); + block[at] ^= @as(u8, 1) << random.int(u3); + }, + // Overwrite one random byte. Token and length bytes are the + // most interesting targets and small blocks are mostly tokens. + 1 => block[random.uintLessThan(usize, block.len)] = + random.int(u8), + // Splice random garbage over a random span. + 2 => { + const at = random.uintLessThan(usize, block.len); + const span = @min( + random.intRangeAtMost(usize, 1, 16), + block.len - at, + ); + random.bytes(block[at..][0..span]); + }, + // Decode a random prefix of the intact block. + else => {}, + } + + const len = if (random.boolean()) + random.uintAtMost(usize, block.len) + else + block.len; + _ = lz4.decompress(block[0..len], ws.decoded[0..raw_len]) catch {}; + } +} + +/// Workload knobs shared by the light and exhaustive suites. +const Budget = struct { + /// Upper bound and step of the contiguous small-size sweep applied to + /// every generator. + sweep_max: usize, + sweep_step: usize, + + /// Number and maximum size of random-parameter inputs. + random_inputs: usize, + random_max: usize, + + /// Explicit match periods checked with `fillPeriodic`. + periods: []const usize, + period_len: usize, + + /// Number of corrupted decode attempts per mutation base block. + mutations: usize, + + fn maxInput(budget: Budget) usize { + return @max( + budget.random_max, + @max(budget.period_len, boundary_sizes[boundary_sizes.len - 1]), + ); + } +}; + +const light_budget: Budget = .{ + .sweep_max = 96, + .sweep_step = 1, + .random_inputs = 24, + .random_max = 32 * 1024, + // One period per copy strategy: byte propagation (3), pattern words + // (1/2/4/8), word strides (9), wide strides (17), plus the 64 KiB + // window edge cases. + .periods = &.{ 1, 2, 3, 4, 8, 9, 17, 65534, 65535, 65536, 65537 }, + .period_len = 160 * 1024, + .mutations = 64, +}; + +const exhaustive_budget: Budget = .{ + .sweep_max = 2048, + .sweep_step = 1, + .random_inputs = 512, + .random_max = 512 * 1024, + .periods = &.{ + 1, 2, 3, 4, 5, 6, 7, 8, + 9, 10, 11, 12, 13, 14, 15, 16, + 17, 18, 19, 23, 24, 31, 32, 33, + 48, 63, 64, 65, 127, 128, 255, 256, + 257, 4095, 4096, 32768, 65533, 65534, 65535, 65536, + 65537, 65538, + }, + .period_len = 320 * 1024, + .mutations = 4096, +}; + +fn runSuite(budget: Budget, seed: u64) !void { + var prng = std.Random.DefaultPrng.init(seed); + const random = prng.random(); + + var ws: Workspace = try .init(testing.allocator, budget.maxInput()); + defer ws.deinit(testing.allocator); + + // Every generator across every boundary size and the small-size sweep. + // Small inputs hit the literal-only format edges: below the minimum + // match sizes, around nibble limits, and around extension steps. + inline for (@typeInfo(Generator).@"enum".fields) |field| { + const gen: Generator = @enumFromInt(field.value); + + for (boundary_sizes) |size| { + const input = ws.input[0..size]; + gen.fill(random, input); + _ = try expectCodecProperties(&ws, input); + } + + var size: usize = 0; + while (size <= budget.sweep_max) : (size += budget.sweep_step) { + const input = ws.input[0..size]; + gen.fill(random, input); + _ = try expectCodecProperties(&ws, input); + } + } + + // Random generator and size pairs, biased toward interesting sizes by + // squaring so both tiny and large inputs appear. + for (0..budget.random_inputs) |_| { + const gen = random.enumValue(Generator); + const scale = random.float(f64); + const size: usize = @intFromFloat( + scale * scale * @as(f64, @floatFromInt(budget.random_max)), + ); + const input = ws.input[0..size]; + gen.fill(random, input); + const encoded_len = try expectCodecProperties(&ws, input); + + // Reuse a handful of these encodings as corruption bases. + try expectMutationSafety( + &ws, + random, + encoded_len, + input.len, + budget.mutations / budget.random_inputs + 1, + ); + } + + // Directed match periods, including the 64 KiB window edge where the + // compressor's 16-bit position arithmetic wraps. + for (budget.periods) |period| { + const input = ws.input[0..budget.period_len]; + fillPeriodic(random, input, period); + _ = try expectCodecProperties(&ws, input); + } + + // Dedicated corruption run over a text-like block, plus an exhaustive + // truncation sweep: every prefix of a valid block must decode cleanly + // or fail cleanly. + { + const input = ws.input[0..@min(16 * 1024, budget.random_max)]; + Generator.words.fill(random, input); + const encoded_len = try expectCodecProperties(&ws, input); + try expectMutationSafety( + &ws, + random, + encoded_len, + input.len, + budget.mutations, + ); + + for (0..encoded_len) |prefix| { + _ = lz4.decompress( + ws.encoded[0..prefix], + ws.decoded[0..input.len], + ) catch {}; + } + } +} + +test "lz4 differential light" { + try runSuite(light_budget, 0x4C5A_3403); +} + +test "lz4 differential exhaustive" { + // Slow. Enable explicitly, ideally together with a test filter: + // GHOSTTY_LZ4_SLOW=1 zig build test -Dtest-filter="lz4 differential" + if (!std.process.hasEnvVarConstant("GHOSTTY_LZ4_SLOW")) + return error.SkipZigTest; + + // Several independent seeds; the suite is deterministic per seed. + for (0..4) |seed| try runSuite(exhaustive_budget, 0x4C5A_4000 + seed); +} diff --git a/vendor/libghostty-vt/src/terminal/device_status.zig b/vendor/libghostty-vt/src/terminal/device_status.zig index 42b4bf01..fd1a9b7e 100644 --- a/vendor/libghostty-vt/src/terminal/device_status.zig +++ b/vendor/libghostty-vt/src/terminal/device_status.zig @@ -7,6 +7,32 @@ pub const ColorScheme = lib.Enum(lib.target, &.{ "dark", }); +/// Maximum number of bytes that `encodeColorSchemeReport` will write. +pub const max_color_scheme_report_encode_size = max: { + var result: usize = 0; + for (@typeInfo(ColorScheme).@"enum".fields) |field| { + var discarding: std.Io.Writer.Discarding = .init(&.{}); + encodeColorSchemeReport( + &discarding.writer, + @enumFromInt(field.value), + ) catch unreachable; + result = @max(result, @as(usize, @intCast(discarding.count))); + } + + break :max result; +}; + +/// Encode a color scheme report response for CSI ? 996 n queries. +pub fn encodeColorSchemeReport( + writer: *std.Io.Writer, + scheme: ColorScheme, +) std.Io.Writer.Error!void { + try writer.writeAll(switch (scheme) { + .dark => "\x1B[?997;1n", + .light => "\x1B[?997;2n", + }); +} + /// An enum(u16) of the available device status requests. pub const Request = dsr_enum: { const EnumField = std.builtin.Type.EnumField; @@ -72,3 +98,19 @@ const entries: []const Entry = &.{ .{ .name = "cursor_position", .value = 6 }, .{ .name = "color_scheme", .value = 996, .question = true }, }; + +test "encode color scheme report dark" { + try std.testing.expectEqual(@as(usize, 9), max_color_scheme_report_encode_size); + + var buf: [max_color_scheme_report_encode_size]u8 = undefined; + var writer: std.Io.Writer = .fixed(&buf); + try encodeColorSchemeReport(&writer, .dark); + try std.testing.expectEqualStrings("\x1B[?997;1n", writer.buffered()); +} + +test "encode color scheme report light" { + var buf: [max_color_scheme_report_encode_size]u8 = undefined; + var writer: std.Io.Writer = .fixed(&buf); + try encodeColorSchemeReport(&writer, .light); + try std.testing.expectEqualStrings("\x1B[?997;2n", writer.buffered()); +} diff --git a/vendor/libghostty-vt/src/terminal/formatter.zig b/vendor/libghostty-vt/src/terminal/formatter.zig index a375b4dd..553ea28d 100644 --- a/vendor/libghostty-vt/src/terminal/formatter.zig +++ b/vendor/libghostty-vt/src/terminal/formatter.zig @@ -747,7 +747,7 @@ pub const PageListFormatter = struct { assert(chunk.start < chunk.end); assert(chunk.end > 0); - var formatter: PageFormatter = .init(&chunk.node.data, self.opts); + var formatter: PageFormatter = .init(chunk.node.page(), self.opts); formatter.start_y = chunk.start; formatter.end_y = chunk.end - 1; formatter.trailing_state = page_state; @@ -1612,7 +1612,7 @@ test "Page plain single line" { try testing.expect(pages.pages.first == pages.pages.last); // Create the formatter - const page = &pages.pages.last.?.data; + const page = pages.pages.last.?.page(); var formatter: PageFormatter = .init(page, .plain); // Test our point map. @@ -1659,7 +1659,7 @@ test "Page plain single line soft-wrapped unwrapped" { try testing.expect(pages.pages.first == pages.pages.last); // Create the formatter - const page = &pages.pages.last.?.data; + const page = pages.pages.last.?.page(); var formatter: PageFormatter = .init(page, .{ .emit = .plain, .unwrap = true, @@ -1729,7 +1729,7 @@ test "Page plain single wide char" { try testing.expect(pages.pages.first == pages.pages.last); // Create the formatter - const page = &pages.pages.last.?.data; + const page = pages.pages.last.?.page(); var formatter: PageFormatter = .init(page, .plain); // Test our point map. @@ -1820,7 +1820,7 @@ test "Page plain single wide char soft-wrapped unwrapped" { try testing.expect(pages.pages.first == pages.pages.last); // Create the formatter - const page = &pages.pages.last.?.data; + const page = pages.pages.last.?.page(); var formatter: PageFormatter = .init(page, .plain); formatter.opts.unwrap = true; @@ -1937,7 +1937,7 @@ test "Page plain multiline" { try testing.expect(pages.pages.first == pages.pages.last); // Create the formatter - const page = &pages.pages.last.?.data; + const page = pages.pages.last.?.page(); var formatter: PageFormatter = .init(page, .plain); var point_map: std.ArrayList(Coordinate) = .empty; @@ -1988,7 +1988,7 @@ test "Page plain multiline rectangle" { try testing.expect(pages.pages.first == pages.pages.last); // Create the formatter - const page = &pages.pages.last.?.data; + const page = pages.pages.last.?.page(); var formatter: PageFormatter = .init(page, .plain); formatter.start_x = 1; formatter.end_x = 3; @@ -2042,7 +2042,7 @@ test "Page plain multi blank lines" { try testing.expect(pages.pages.first == pages.pages.last); // Create the formatter - const page = &pages.pages.last.?.data; + const page = pages.pages.last.?.page(); var formatter: PageFormatter = .init(page, .plain); var point_map: std.ArrayList(Coordinate) = .empty; @@ -2095,7 +2095,7 @@ test "Page plain trailing blank lines" { try testing.expect(pages.pages.first == pages.pages.last); // Create the formatter - const page = &pages.pages.last.?.data; + const page = pages.pages.last.?.page(); var formatter: PageFormatter = .init(page, .plain); var point_map: std.ArrayList(Coordinate) = .empty; @@ -2148,7 +2148,7 @@ test "Page plain trailing whitespace" { try testing.expect(pages.pages.first == pages.pages.last); // Create the formatter - const page = &pages.pages.last.?.data; + const page = pages.pages.last.?.page(); var formatter: PageFormatter = .init(page, .plain); var point_map: std.ArrayList(Coordinate) = .empty; @@ -2201,7 +2201,7 @@ test "Page plain trailing whitespace no trim" { try testing.expect(pages.pages.first == pages.pages.last); // Create the formatter - const page = &pages.pages.last.?.data; + const page = pages.pages.last.?.page(); var formatter: PageFormatter = .init(page, .{ .emit = .plain, .trim = false, @@ -2255,7 +2255,7 @@ test "Page plain with prior trailing state rows" { try testing.expect(pages.pages.first != null); try testing.expect(pages.pages.first == pages.pages.last); - const page = &pages.pages.last.?.data; + const page = pages.pages.last.?.page(); var formatter: PageFormatter = .init(page, .plain); formatter.trailing_state = .{ .rows = 2, .cells = 0 }; @@ -2301,7 +2301,7 @@ test "Page plain with prior trailing state cells no wrapped line" { try testing.expect(pages.pages.first != null); try testing.expect(pages.pages.first == pages.pages.last); - const page = &pages.pages.last.?.data; + const page = pages.pages.last.?.page(); var formatter: PageFormatter = .init(page, .plain); formatter.trailing_state = .{ .rows = 0, .cells = 3 }; @@ -2346,7 +2346,7 @@ test "Page plain with prior trailing state cells with wrap continuation" { try testing.expect(pages.pages.first != null); try testing.expect(pages.pages.first == pages.pages.last); - const page = &pages.pages.last.?.data; + const page = pages.pages.last.?.page(); // Surgically modify the first row to be a wrap continuation const row = page.getRow(0); @@ -2400,7 +2400,7 @@ test "Page plain soft-wrapped without unwrap" { try testing.expect(pages.pages.first != null); try testing.expect(pages.pages.first == pages.pages.last); - const page = &pages.pages.last.?.data; + const page = pages.pages.last.?.page(); var formatter: PageFormatter = .init(page, .plain); var point_map: std.ArrayList(Coordinate) = .empty; @@ -2449,7 +2449,7 @@ test "Page plain soft-wrapped with unwrap" { try testing.expect(pages.pages.first != null); try testing.expect(pages.pages.first == pages.pages.last); - const page = &pages.pages.last.?.data; + const page = pages.pages.last.?.page(); var formatter: PageFormatter = .init(page, .{ .emit = .plain, .unwrap = true }); var point_map: std.ArrayList(Coordinate) = .empty; @@ -2497,7 +2497,7 @@ test "Page plain soft-wrapped 3 lines without unwrap" { try testing.expect(pages.pages.first != null); try testing.expect(pages.pages.first == pages.pages.last); - const page = &pages.pages.last.?.data; + const page = pages.pages.last.?.page(); var formatter: PageFormatter = .init(page, .plain); var point_map: std.ArrayList(Coordinate) = .empty; @@ -2551,7 +2551,7 @@ test "Page plain soft-wrapped 3 lines with unwrap" { try testing.expect(pages.pages.first != null); try testing.expect(pages.pages.first == pages.pages.last); - const page = &pages.pages.last.?.data; + const page = pages.pages.last.?.page(); var formatter: PageFormatter = .init(page, .{ .emit = .plain, .unwrap = true }); var point_map: std.ArrayList(Coordinate) = .empty; @@ -2600,7 +2600,7 @@ test "Page plain start_y subset" { s.nextSlice("hello\r\nworld\r\ntest"); const pages = &t.screens.active.pages; - const page = &pages.pages.last.?.data; + const page = pages.pages.last.?.page(); var formatter: PageFormatter = .init(page, .plain); formatter.start_y = 1; @@ -2647,7 +2647,7 @@ test "Page plain end_y subset" { s.nextSlice("hello\r\nworld\r\ntest"); const pages = &t.screens.active.pages; - const page = &pages.pages.last.?.data; + const page = pages.pages.last.?.page(); var formatter: PageFormatter = .init(page, .plain); formatter.end_y = 1; @@ -2694,7 +2694,7 @@ test "Page plain start_y and end_y range" { s.nextSlice("hello\r\nworld\r\ntest\r\nfoo"); const pages = &t.screens.active.pages; - const page = &pages.pages.last.?.data; + const page = pages.pages.last.?.page(); var formatter: PageFormatter = .init(page, .plain); formatter.start_y = 1; @@ -2742,7 +2742,7 @@ test "Page plain start_y out of bounds" { s.nextSlice("hello"); const pages = &t.screens.active.pages; - const page = &pages.pages.last.?.data; + const page = pages.pages.last.?.page(); var formatter: PageFormatter = .init(page, .plain); formatter.start_y = 30; @@ -2780,7 +2780,7 @@ test "Page plain end_y greater than rows" { s.nextSlice("hello"); const pages = &t.screens.active.pages; - const page = &pages.pages.last.?.data; + const page = pages.pages.last.?.page(); var formatter: PageFormatter = .init(page, .plain); formatter.end_y = 30; @@ -2823,7 +2823,7 @@ test "Page plain end_y less than start_y" { s.nextSlice("hello"); const pages = &t.screens.active.pages; - const page = &pages.pages.last.?.data; + const page = pages.pages.last.?.page(); var formatter: PageFormatter = .init(page, .plain); formatter.start_y = 5; @@ -2862,7 +2862,7 @@ test "Page plain start_x on first row only" { s.nextSlice("hello world"); const pages = &t.screens.active.pages; - const page = &pages.pages.last.?.data; + const page = pages.pages.last.?.page(); var formatter: PageFormatter = .init(page, .plain); formatter.start_x = 6; @@ -2904,7 +2904,7 @@ test "Page plain end_x on last row only" { s.nextSlice("first line\r\nsecond line\r\nthird line"); const pages = &t.screens.active.pages; - const page = &pages.pages.last.?.data; + const page = pages.pages.last.?.page(); var formatter: PageFormatter = .init(page, .plain); formatter.end_y = 2; @@ -2957,7 +2957,7 @@ test "Page plain start_x and end_x multiline" { s.nextSlice("hello world\r\ntest case\r\nfoo bar"); const pages = &t.screens.active.pages; - const page = &pages.pages.last.?.data; + const page = pages.pages.last.?.page(); var formatter: PageFormatter = .init(page, .plain); formatter.start_x = 6; @@ -3014,7 +3014,7 @@ test "Page plain start_x out of bounds" { s.nextSlice("hello"); const pages = &t.screens.active.pages; - const page = &pages.pages.last.?.data; + const page = pages.pages.last.?.page(); var formatter: PageFormatter = .init(page, .plain); formatter.start_x = 100; @@ -3052,7 +3052,7 @@ test "Page plain end_x greater than cols" { s.nextSlice("hello"); const pages = &t.screens.active.pages; - const page = &pages.pages.last.?.data; + const page = pages.pages.last.?.page(); var formatter: PageFormatter = .init(page, .plain); formatter.end_x = 100; @@ -3094,7 +3094,7 @@ test "Page plain end_x less than start_x single row" { s.nextSlice("hello"); const pages = &t.screens.active.pages; - const page = &pages.pages.last.?.data; + const page = pages.pages.last.?.page(); var formatter: PageFormatter = .init(page, .plain); formatter.start_x = 10; @@ -3134,7 +3134,7 @@ test "Page plain start_y non-zero ignores trailing state" { s.nextSlice("hello\r\nworld"); const pages = &t.screens.active.pages; - const page = &pages.pages.last.?.data; + const page = pages.pages.last.?.page(); var formatter: PageFormatter = .init(page, .plain); formatter.start_y = 1; @@ -3178,7 +3178,7 @@ test "Page plain start_x non-zero ignores trailing state" { s.nextSlice("hello world"); const pages = &t.screens.active.pages; - const page = &pages.pages.last.?.data; + const page = pages.pages.last.?.page(); var formatter: PageFormatter = .init(page, .plain); formatter.start_x = 6; @@ -3222,7 +3222,7 @@ test "Page plain start_y and start_x zero uses trailing state" { s.nextSlice("hello"); const pages = &t.screens.active.pages; - const page = &pages.pages.last.?.data; + const page = pages.pages.last.?.page(); var formatter: PageFormatter = .init(page, .plain); formatter.start_y = 0; @@ -3274,7 +3274,7 @@ test "Page plain single line with styling" { try testing.expect(pages.pages.first == pages.pages.last); // Create the formatter - const page = &pages.pages.last.?.data; + const page = pages.pages.last.?.page(); var formatter: PageFormatter = .init(page, .plain); var point_map: std.ArrayList(Coordinate) = .empty; @@ -3315,7 +3315,7 @@ test "Page VT single line plain text" { s.nextSlice("hello"); const pages = &t.screens.active.pages; - const page = &pages.pages.last.?.data; + const page = pages.pages.last.?.page(); var formatter: PageFormatter = .init(page, .vt); @@ -3354,7 +3354,7 @@ test "Page VT single line with bold" { s.nextSlice("\x1b[1mhello\x1b[0m"); const pages = &t.screens.active.pages; - const page = &pages.pages.last.?.data; + const page = pages.pages.last.?.page(); var formatter: PageFormatter = .init(page, .vt); @@ -3400,7 +3400,7 @@ test "Page VT multiple styles" { s.nextSlice("\x1b[1mhello \x1b[3mworld\x1b[0m"); const pages = &t.screens.active.pages; - const page = &pages.pages.last.?.data; + const page = pages.pages.last.?.page(); var formatter: PageFormatter = .init(page, .vt); @@ -3435,7 +3435,7 @@ test "Page VT with foreground color" { s.nextSlice("\x1b[31mred\x1b[0m"); const pages = &t.screens.active.pages; - const page = &pages.pages.last.?.data; + const page = pages.pages.last.?.page(); var formatter: PageFormatter = .init(page, .vt); @@ -3481,7 +3481,7 @@ test "Page VT with background and foreground colors" { s.nextSlice("hello"); const pages = &t.screens.active.pages; - const page = &pages.pages.last.?.data; + const page = pages.pages.last.?.page(); var formatter: PageFormatter = .init(page, .{ .emit = .vt, @@ -3518,7 +3518,7 @@ test "Page VT multi-line with styles" { s.nextSlice("\x1b[1mfirst\x1b[0m\r\n\x1b[3msecond\x1b[0m"); const pages = &t.screens.active.pages; - const page = &pages.pages.last.?.data; + const page = pages.pages.last.?.page(); var formatter: PageFormatter = .init(page, .vt); @@ -3555,7 +3555,7 @@ test "Page VT duplicate style not emitted twice" { s.nextSlice("\x1b[1mhel\x1b[1mlo\x1b[0m"); const pages = &t.screens.active.pages; - const page = &pages.pages.last.?.data; + const page = pages.pages.last.?.page(); var formatter: PageFormatter = .init(page, .vt); @@ -3624,7 +3624,7 @@ test "PageList plain spanning two pages" { defer s.deinit(); const pages = &t.screens.active.pages; - const first_page_rows = pages.pages.first.?.data.capacity.rows; + const first_page_rows = pages.pages.first.?.capacity().rows; // Fill the first page almost completely for (0..first_page_rows - 1) |_| s.nextSlice("\r\n"); @@ -3697,7 +3697,7 @@ test "PageList soft-wrapped line spanning two pages without unwrap" { defer s.deinit(); const pages = &t.screens.active.pages; - const first_page_rows = pages.pages.first.?.data.capacity.rows; + const first_page_rows = pages.pages.first.?.capacity().rows; // Fill the first page with soft-wrapped content for (0..first_page_rows - 1) |_| s.nextSlice("\r\n"); @@ -3761,7 +3761,7 @@ test "PageList soft-wrapped line spanning two pages with unwrap" { defer s.deinit(); const pages = &t.screens.active.pages; - const first_page_rows = pages.pages.first.?.data.capacity.rows; + const first_page_rows = pages.pages.first.?.capacity().rows; // Fill the first page with soft-wrapped content for (0..first_page_rows - 1) |_| s.nextSlice("\r\n"); @@ -3822,7 +3822,7 @@ test "PageList VT spanning two pages" { defer s.deinit(); const pages = &t.screens.active.pages; - const first_page_rows = pages.pages.first.?.data.capacity.rows; + const first_page_rows = pages.pages.first.?.capacity().rows; // Fill the first page almost completely for (0..first_page_rows - 1) |_| s.nextSlice("\r\n"); @@ -3928,7 +3928,7 @@ test "PageList plain with x offset spanning two pages" { defer s.deinit(); const pages = &t.screens.active.pages; - const first_page_rows = pages.pages.first.?.data.capacity.rows; + const first_page_rows = pages.pages.first.?.capacity().rows; // Fill first page almost completely for (0..first_page_rows - 1) |_| s.nextSlice("\r\n"); @@ -3950,7 +3950,7 @@ test "PageList plain with x offset spanning two pages" { defer pin_map.deinit(alloc); var formatter: PageListFormatter = .init(pages, .plain); - formatter.top_left = .{ .node = first_node, .y = first_node.data.size.rows - 1, .x = 6 }; + formatter.top_left = .{ .node = first_node, .y = first_node.rows() - 1, .x = 6 }; formatter.bottom_right = .{ .node = last_node, .y = 1, .x = 2 }; formatter.pin_map = .{ .alloc = alloc, .map = &pin_map }; @@ -5175,7 +5175,7 @@ test "Page html with multiple styles" { s.nextSlice("\x1b[1mbold\x1b[3mitalic\x1b[0mnormal"); const pages = &t.screens.active.pages; - const page = &pages.pages.last.?.data; + const page = pages.pages.last.?.page(); var formatter: PageFormatter = .init(page, .{ .emit = .html }); try formatter.format(&builder.writer); @@ -5210,7 +5210,7 @@ test "Page html plain text" { s.nextSlice("hello, world"); const pages = &t.screens.active.pages; - const page = &pages.pages.last.?.data; + const page = pages.pages.last.?.page(); var formatter: PageFormatter = .init(page, .{ .emit = .html }); try formatter.format(&builder.writer); @@ -5243,7 +5243,7 @@ test "Page html with colors" { s.nextSlice("\x1b[31;44mcolored"); const pages = &t.screens.active.pages; - const page = &pages.pages.last.?.data; + const page = pages.pages.last.?.page(); var formatter: PageFormatter = .init(page, .{ .emit = .html }); try formatter.format(&builder.writer); @@ -5313,7 +5313,7 @@ test "Page html with background and foreground colors" { s.nextSlice("hello"); const pages = &t.screens.active.pages; - const page = &pages.pages.last.?.data; + const page = pages.pages.last.?.page(); var formatter: PageFormatter = .init(page, .{ .emit = .html, .background = .{ .r = 0x12, .g = 0x34, .b = 0x56 }, @@ -5348,7 +5348,7 @@ test "Page html with escaping" { s.nextSlice("<tag>&\"'text"); const pages = &t.screens.active.pages; - const page = &pages.pages.last.?.data; + const page = pages.pages.last.?.page(); var formatter: PageFormatter = .init(page, .{ .emit = .html }); var point_map: std.ArrayList(Coordinate) = .empty; @@ -5419,7 +5419,7 @@ test "Page html with unicode as numeric entities" { s.nextSlice("╰─ ❯"); const pages = &t.screens.active.pages; - const page = &pages.pages.last.?.data; + const page = pages.pages.last.?.page(); var formatter: PageFormatter = .init(page, .{ .emit = .html }); try formatter.format(&builder.writer); @@ -5452,7 +5452,7 @@ test "Page html ascii characters unchanged" { s.nextSlice("hello world"); const pages = &t.screens.active.pages; - const page = &pages.pages.last.?.data; + const page = pages.pages.last.?.page(); var formatter: PageFormatter = .init(page, .{ .emit = .html }); try formatter.format(&builder.writer); @@ -5484,7 +5484,7 @@ test "Page html mixed ascii and unicode" { s.nextSlice("test ╰─❯ ok"); const pages = &t.screens.active.pages; - const page = &pages.pages.last.?.data; + const page = pages.pages.last.?.page(); var formatter: PageFormatter = .init(page, .{ .emit = .html }); try formatter.format(&builder.writer); @@ -5518,7 +5518,7 @@ test "Page VT with palette option emits RGB" { s.nextSlice("\x1b[31mred"); const pages = &t.screens.active.pages; - const page = &pages.pages.last.?.data; + const page = pages.pages.last.?.page(); // Without palette option - should emit palette index { @@ -5562,7 +5562,7 @@ test "Page html with palette option emits RGB" { s.nextSlice("\x1b[31mred"); const pages = &t.screens.active.pages; - const page = &pages.pages.last.?.data; + const page = pages.pages.last.?.page(); // Without palette option - should emit CSS variable { @@ -5615,7 +5615,7 @@ test "Page VT style reset properly closes styles" { s.nextSlice("\x1b[1mbold\x1b[0mnormal"); const pages = &t.screens.active.pages; - const page = &pages.pages.last.?.data; + const page = pages.pages.last.?.page(); builder.clearRetainingCapacity(); var formatter: PageFormatter = .init(page, .vt); @@ -5645,7 +5645,7 @@ test "Page codepoint_map single replacement" { s.nextSlice("hello world"); const pages = &t.screens.active.pages; - const page = &pages.pages.last.?.data; + const page = pages.pages.last.?.page(); // Replace 'o' with 'x' var map: std.MultiArrayList(CodepointMap) = .{}; @@ -5704,7 +5704,7 @@ test "Page codepoint_map conflicting replacement prefers last" { s.nextSlice("hello"); const pages = &t.screens.active.pages; - const page = &pages.pages.last.?.data; + const page = pages.pages.last.?.page(); // Replace 'o' with 'x', then with 'y' - should prefer last var map: std.MultiArrayList(CodepointMap) = .{}; @@ -5746,7 +5746,7 @@ test "Page codepoint_map replace with string" { s.nextSlice("hello"); const pages = &t.screens.active.pages; - const page = &pages.pages.last.?.data; + const page = pages.pages.last.?.page(); // Replace 'o' with a multi-byte string var map: std.MultiArrayList(CodepointMap) = .{}; @@ -5802,7 +5802,7 @@ test "Page codepoint_map range replacement" { s.nextSlice("abcdefg"); const pages = &t.screens.active.pages; - const page = &pages.pages.last.?.data; + const page = pages.pages.last.?.page(); // Replace 'b' through 'e' with 'X' var map: std.MultiArrayList(CodepointMap) = .{}; @@ -5840,7 +5840,7 @@ test "Page codepoint_map multiple ranges" { s.nextSlice("hello world"); const pages = &t.screens.active.pages; - const page = &pages.pages.last.?.data; + const page = pages.pages.last.?.page(); // Replace 'a'-'m' with 'A' and 'n'-'z' with 'Z' var map: std.MultiArrayList(CodepointMap) = .{}; @@ -5884,7 +5884,7 @@ test "Page codepoint_map unicode replacement" { s.nextSlice("hello ⚡ world"); const pages = &t.screens.active.pages; - const page = &pages.pages.last.?.data; + const page = pages.pages.last.?.page(); // Replace lightning bolt with fire emoji var map: std.MultiArrayList(CodepointMap) = .{}; @@ -5949,7 +5949,7 @@ test "Page codepoint_map with styled formats" { s.nextSlice("\x1b[31mred text\x1b[0m"); const pages = &t.screens.active.pages; - const page = &pages.pages.last.?.data; + const page = pages.pages.last.?.page(); // Replace 'e' with 'X' in styled text var map: std.MultiArrayList(CodepointMap) = .{}; @@ -5990,7 +5990,7 @@ test "Page codepoint_map empty map" { s.nextSlice("hello world"); const pages = &t.screens.active.pages; - const page = &pages.pages.last.?.data; + const page = pages.pages.last.?.page(); // Empty map should not change anything var map: std.MultiArrayList(CodepointMap) = .{}; @@ -6032,7 +6032,7 @@ test "Page VT background color on trailing blank cells" { s.nextSlice("\x1b[0m\r\nline2"); const pages = &t.screens.active.pages; - const page = &pages.pages.last.?.data; + const page = pages.pages.last.?.page(); var formatter: PageFormatter = .init(page, .vt); formatter.opts.trim = false; // Don't trim so we can see the trailing behavior @@ -6079,7 +6079,7 @@ test "Page HTML with hyperlinks" { s.nextSlice("\x1b]8;;https://example.com\x1b\\link text\x1b]8;;\x1b\\ normal"); const pages = &t.screens.active.pages; - const page = &pages.pages.last.?.data; + const page = pages.pages.last.?.page(); var formatter: PageFormatter = .init(page, .{ .emit = .html }); try formatter.format(&builder.writer); @@ -6114,7 +6114,7 @@ test "Page HTML with multiple hyperlinks" { s.nextSlice("\x1b]8;;https://second.com\x1b\\second\x1b]8;;\x1b\\"); const pages = &t.screens.active.pages; - const page = &pages.pages.last.?.data; + const page = pages.pages.last.?.page(); var formatter: PageFormatter = .init(page, .{ .emit = .html }); try formatter.format(&builder.writer); @@ -6150,7 +6150,7 @@ test "Page HTML with hyperlink escaping" { s.nextSlice("\x1b]8;;https://example.com?a=1&b=2\x1b\\link\x1b]8;;\x1b\\"); const pages = &t.screens.active.pages; - const page = &pages.pages.last.?.data; + const page = pages.pages.last.?.page(); var formatter: PageFormatter = .init(page, .{ .emit = .html }); try formatter.format(&builder.writer); @@ -6184,7 +6184,7 @@ test "Page HTML with styled hyperlink" { s.nextSlice("\x1b]8;;https://example.com\x1b\\\x1b[1mbold link\x1b[0m\x1b]8;;\x1b\\"); const pages = &t.screens.active.pages; - const page = &pages.pages.last.?.data; + const page = pages.pages.last.?.page(); var formatter: PageFormatter = .init(page, .{ .emit = .html }); try formatter.format(&builder.writer); @@ -6219,7 +6219,7 @@ test "Page HTML hyperlink closes style before anchor" { s.nextSlice("\x1b]8;;https://example.com\x1b\\\x1b[1mbold\x1b[0m plain"); const pages = &t.screens.active.pages; - const page = &pages.pages.last.?.data; + const page = pages.pages.last.?.page(); var formatter: PageFormatter = .init(page, .{ .emit = .html }); try formatter.format(&builder.writer); @@ -6253,7 +6253,7 @@ test "Page HTML hyperlink point map maps closing to previous cell" { s.nextSlice("\x1b]8;;https://example.com\x1b\\link\x1b]8;;\x1b\\ normal"); const pages = &t.screens.active.pages; - const page = &pages.pages.last.?.data; + const page = pages.pages.last.?.page(); var formatter: PageFormatter = .init(page, .{ .emit = .html }); var point_map: std.ArrayList(Coordinate) = .empty; diff --git a/vendor/libghostty-vt/src/terminal/hash_map.zig b/vendor/libghostty-vt/src/terminal/hash_map.zig index 5a38cc17..d8feddcd 100644 --- a/vendor/libghostty-vt/src/terminal/hash_map.zig +++ b/vendor/libghostty-vt/src/terminal/hash_map.zig @@ -23,31 +23,49 @@ //! because our terminal page representation is backed by a single large //! allocation so we can give the HashMap a slice of memory to operate in. //! -//! I haven't carefully benchmarked this implementation against other hash -//! map implementations. It's possible using some of the newer variants out -//! there would be better. However, I trust the built-in version is pretty good -//! and its more important to get the terminal page representation working -//! first then we can measure and improve this later if we find it to be a -//! bottleneck. +//! This fork diverges from the stdlib in one significant way: removal uses +//! backward-shift deletion (Knuth vol. 3, section 6.4, algorithm R) rather +//! than tombstones. A fixed-capacity map cannot outgrow tombstone buildup +//! the way an allocating map does, so tombstones require either unbounded +//! probe lengths or periodic in-place rebuilds with subtle bookkeeping. +//! Backward-shift deletion instead restores the table after every removal +//! to the exact state it would be in had the removed key never been +//! inserted. Probe chains are therefore always minimal for the insertion +//! order, there is no fragmentation to repair, and lookup cost depends only +//! on the current load factor. +//! +//! Pointer stability: insertion never moves existing entries, but removal +//! may move *other* entries within a probe cluster. Any key or value +//! pointers previously returned by the map must be considered invalidated +//! by any removal. const std = @import("std"); const assert = @import("../quirks.zig").inlineAssert; -const autoHash = std.hash.autoHash; -const math = std.math; const mem = std.mem; const Allocator = mem.Allocator; -const Wyhash = std.hash.Wyhash; const Offset = @import("size.zig").Offset; const OffsetBuf = @import("size.zig").OffsetBuf; const getOffset = @import("size.zig").getOffset; -pub fn AutoOffsetHashMap(comptime K: type, comptime V: type) type { - return OffsetHashMap(K, V, AutoContext(K)); +/// The default allows every raw slot to be occupied. Callers whose maps see +/// removal-heavy churn should choose a lower value to bound probe lengths. +pub const default_max_load_percentage: u8 = 100; + +pub fn AutoOffsetHashMap( + comptime K: type, + comptime V: type, + comptime max_load_percentage: u8, +) type { + return OffsetHashMap(K, V, AutoContext(K), max_load_percentage); } -fn AutoHashMapUnmanaged(comptime K: type, comptime V: type) type { - return HashMapUnmanaged(K, V, AutoContext(K)); +fn AutoHashMapUnmanaged( + comptime K: type, + comptime V: type, + comptime max_load_percentage: u8, +) type { + return HashMapUnmanaged(K, V, AutoContext(K), max_load_percentage); } fn AutoContext(comptime K: type) type { @@ -64,12 +82,18 @@ pub fn OffsetHashMap( comptime K: type, comptime V: type, comptime Context: type, + comptime max_load_percentage: u8, ) type { return struct { const Self = @This(); /// This is the pointer-based map that we're wrapping. - pub const Unmanaged = HashMapUnmanaged(K, V, Context); + pub const Unmanaged = HashMapUnmanaged( + K, + V, + Context, + max_load_percentage, + ); pub const Layout = Unmanaged.Layout; /// This is the alignment that the base pointer must have. @@ -81,7 +105,7 @@ pub fn OffsetHashMap( /// HashMap with the given capacity. The base ptr must also be /// aligned to base_align. pub fn layout(cap: Unmanaged.Size) Layout { - return Unmanaged.layoutForCapacity(cap); + return Unmanaged.layoutForSize(cap); } /// Initialize a new HashMap with the given capacity and backing @@ -105,19 +129,22 @@ pub fn OffsetHashMap( } /// Fork of stdlib.HashMap as of Zig 0.12 modified to use offsets for -/// the key/values pointer. The metadata is still a pointer to limit -/// the amount of arithmetic required to access it. See the file comment -/// for full details. +/// the key/values pointer, and backward-shift deletion in place of +/// tombstones. The metadata is still a pointer to limit the amount of +/// arithmetic required to access it. See the file comment for full details. fn HashMapUnmanaged( comptime K: type, comptime V: type, comptime Context: type, + comptime max_load_percentage: u8, ) type { return struct { const Self = @This(); comptime { assert(@alignOf(Metadata) == 1); + assert(max_load_percentage > 0); + assert(max_load_percentage <= 100); } const header_align = @alignOf(Header); @@ -139,10 +166,6 @@ fn HashMapUnmanaged( /// Pointer to the metadata. metadata: ?[*]Metadata = null, - // This is purely empirical and not a /very smart magic constant™/. - /// Capacity of the first grow when bootstrapping the hashmap. - const minimal_capacity = 8; - // This hashmap is specially designed for sizes that fit in a u32. pub const Size = u32; @@ -168,11 +191,9 @@ fn HashMapUnmanaged( size: Size, }; - /// Metadata for a slot. It can be in three states: empty, used or - /// tombstone. Tombstones indicate that an entry was previously used, - /// they are a simple way to handle removal. - /// To this state, we add 7 bits from the slot's key hash. These are - /// used as a fast way to disambiguate between entries without + /// Metadata for a slot. It can be in two states: free or used. + /// To the used state, we add 7 bits from the slot's key hash. These + /// are used as a fast way to disambiguate between entries without /// having to use the equality function. If two fingerprints are /// different, we know that we don't have to compare the keys at all. /// The 7 bits are the highest ones from a 64 bit hash. This way, not @@ -182,28 +203,23 @@ fn HashMapUnmanaged( /// Not using the equality function means we don't have to read into /// the entries array, likely avoiding a cache miss and a potentially /// costly function call. - const Metadata = packed struct { + const Metadata = packed struct(u8) { const FingerPrint = u7; - const free: FingerPrint = 0; - const tombstone: FingerPrint = 1; - - fingerprint: FingerPrint = free, + fingerprint: FingerPrint = 0, used: u1 = 0, - const slot_free = @as(u8, @bitCast(Metadata{ .fingerprint = free })); - const slot_tombstone = @as(u8, @bitCast(Metadata{ .fingerprint = tombstone })); - pub fn isUsed(self: Metadata) bool { return self.used == 1; } - pub fn isTombstone(self: Metadata) bool { - return @as(u8, @bitCast(self)) == slot_tombstone; - } - pub fn isFree(self: Metadata) bool { - return @as(u8, @bitCast(self)) == slot_free; + // A free slot is always the all-zero byte: `fill` sets the + // used bit and removal zeroes the whole byte. Comparing the + // full byte (rather than testing the used bit) lets the + // optimizer fuse this with the fingerprint comparison in + // probe loops into single-byte compares. + return @as(u8, @bitCast(self)) == 0; } pub fn takeFingerprint(hash: Hash) FingerPrint { @@ -216,11 +232,6 @@ fn HashMapUnmanaged( self.used = 1; self.fingerprint = fp; } - - pub fn remove(self: *Metadata) void { - self.used = 0; - self.fingerprint = tombstone; - } }; comptime { @@ -228,6 +239,9 @@ fn HashMapUnmanaged( assert(@alignOf(Metadata) == 1); } + /// Iterates the entries of the map. Any mutation of the map + /// invalidates the iterator: removal may move entries across the + /// iteration cursor. pub const Iterator = struct { hm: *const Self, index: Size = 0, @@ -310,7 +324,7 @@ fn HashMapUnmanaged( pub fn ensureTotalCapacity(self: *Self, new_size: Size) Allocator.Error!void { if (new_size > self.header().size) { - try self.growIfNeeded(new_size - self.header().size); + try self.checkCapacity(new_size - self.header().size); } } @@ -347,6 +361,13 @@ fn HashMapUnmanaged( return self.header().capacity; } + /// Maximum number of entries the map will hold. This is less than + /// capacity when max_load_percentage is below 100, which keeps free + /// slots in every probe chain and bounds probe lengths. + pub fn maxLoad(self: *const Self) Size { + return maxLoadForCapacity(self.capacity()); + } + pub fn iterator(self: *const Self) Iterator { return .{ .hm = self }; } @@ -391,7 +412,7 @@ fn HashMapUnmanaged( } pub fn putNoClobberContext(self: *Self, key: K, value: V, ctx: Context) Allocator.Error!void { assert(!self.containsContext(key, ctx)); - try self.growIfNeeded(1); + try self.checkCapacity(1); self.putAssumeCapacityNoClobberContext(key, value, ctx); } @@ -419,6 +440,9 @@ fn HashMapUnmanaged( pub fn putAssumeCapacityNoClobberContext(self: *Self, key: K, value: V, ctx: Context) void { assert(!self.containsContext(key, ctx)); + // A free slot must exist for the probe below to terminate. + assert(self.header().size < self.capacity()); + const hash = ctx.hash(key); const mask = self.capacity() - 1; var idx = @as(usize, @truncate(hash & mask)); @@ -429,8 +453,7 @@ fn HashMapUnmanaged( metadata = self.metadata.? + idx; } - const fingerprint = Metadata.takeFingerprint(hash); - metadata[0].fill(fingerprint); + metadata[0].fill(Metadata.takeFingerprint(hash)); self.keys()[idx] = key; self.values()[idx] = value; self.header().size += 1; @@ -476,31 +499,22 @@ fn HashMapUnmanaged( } /// If there is an `Entry` with a matching key, it is deleted from - /// the hash map, and then returned from this function. + /// the hash map, and then returned from this function. Removal may + /// move other entries: any previously returned key or value + /// pointers are invalidated. pub fn fetchRemove(self: *Self, key: K) ?KV { if (@sizeOf(Context) != 0) @compileError("Cannot infer context " ++ @typeName(Context) ++ ", call fetchRemoveContext instead."); return self.fetchRemoveContext(key, undefined); } pub fn fetchRemoveContext(self: *Self, key: K, ctx: Context) ?KV { - return self.fetchRemoveAdapted(key, ctx); - } - pub fn fetchRemoveAdapted(self: *Self, key: anytype, ctx: anytype) ?KV { - if (self.getIndex(key, ctx)) |idx| { - const old_key = &self.keys()[idx]; - const old_val = &self.values()[idx]; - const result = KV{ - .key = old_key.*, - .value = old_val.*, - }; - self.metadata.?[idx].remove(); - old_key.* = undefined; - old_val.* = undefined; - self.header().size -= 1; - return result; - } - - return null; + const idx = self.getIndex(key, ctx) orelse return null; + const result = KV{ + .key = self.keys()[idx], + .value = self.values()[idx], + }; + self.removeByIndexContext(idx, ctx); + return result; } /// Find the index containing the data for the given key. @@ -524,7 +538,7 @@ fn HashMapUnmanaged( } const mask = self.capacity() - 1; const fingerprint = Metadata.takeFingerprint(hash); - // Don't loop indefinitely when there are no empty slots. + // Don't loop indefinitely when there are no free slots. var limit = self.capacity(); var idx = @as(usize, @truncate(hash & mask)); @@ -664,10 +678,10 @@ fn HashMapUnmanaged( return self.getOrPutContextAdapted(key, key_ctx); } pub fn getOrPutContextAdapted(self: *Self, key: anytype, key_ctx: anytype) Allocator.Error!GetOrPutResult { - self.growIfNeeded(1) catch |err| { - // If allocation fails, try to do the lookup anyway. - // If we find an existing item, we can return it. - // Otherwise return the error, we could not add another. + self.checkCapacity(1) catch |err| { + // The map is full. Try to do the lookup anyway; if we find + // an existing item, we can return it. Otherwise return the + // error, we could not add another. const index = self.getIndex(key, key_ctx) orelse return err; return GetOrPutResult{ .key_ptr = &self.keys()[index], @@ -704,7 +718,6 @@ fn HashMapUnmanaged( var limit = self.capacity(); var idx = @as(usize, @truncate(hash & mask)); - var first_tombstone_idx: usize = self.capacity(); // invalid index var metadata = self.metadata.? + idx; while (!metadata[0].isFree() and limit != 0) { if (metadata[0].isUsed() and metadata[0].fingerprint == fingerprint) { @@ -724,8 +737,6 @@ fn HashMapUnmanaged( .found_existing = true, }; } - } else if (first_tombstone_idx == self.capacity() and metadata[0].isTombstone()) { - first_tombstone_idx = idx; } limit -= 1; @@ -733,11 +744,11 @@ fn HashMapUnmanaged( metadata = self.metadata.? + idx; } - if (first_tombstone_idx < self.capacity()) { - // Cheap try to lower probing lengths after deletions. Recycle a tombstone. - idx = first_tombstone_idx; - metadata = self.metadata.? + idx; - } + // The caller guaranteed capacity for at least one new entry, so + // the probe must have ended at a free slot. Anything else means + // the assume-capacity contract was violated and we would be + // silently overwriting a live entry. + assert(metadata[0].isFree()); metadata[0].fill(fingerprint); const new_key = &self.keys()[idx]; @@ -780,37 +791,74 @@ fn HashMapUnmanaged( return self.getIndex(key, ctx) != null; } - fn removeByIndex(self: *Self, idx: usize) void { - self.metadata.?[idx].remove(); - self.keys()[idx] = undefined; - self.values()[idx] = undefined; + /// Remove the entry at the given index using backward-shift deletion + /// (Knuth vol. 3, section 6.4, algorithm R): rather than marking the + /// slot with a tombstone, restore the table to the state it would be + /// in had the removed key never been inserted. Any entry whose probe + /// sequence passes over the hole is moved into it, which moves the + /// hole further along the cluster, until the cluster ends at a free + /// slot. + fn removeByIndexContext(self: *Self, idx: usize, ctx: Context) void { + const mask: usize = self.capacity() - 1; + const metadata = self.metadata.?; + const keys_ptr = self.keys(); + const values_ptr = self.values(); + + // A completely full table has no free slot to terminate the + // scan, so bound it to one full cycle. That is sufficient: the + // hole only ever moves forward to slots the scan has already + // visited, so each entry needs to be considered exactly once. + var hole = idx; + var j = idx; + var limit = self.capacity() - 1; + while (limit != 0) : (limit -= 1) { + j = (j + 1) & mask; + if (metadata[j].isFree()) break; + + // The entry at `j` may move into the hole only if the hole + // lies on its probe path, i.e. cyclically within [home, j). + // Otherwise the move would place it before its home slot + // and lookups could no longer find it. + const home: usize = @truncate(ctx.hash(keys_ptr[j]) & mask); + if (((hole -% home) & mask) < ((j -% home) & mask)) { + metadata[hole] = metadata[j]; + keys_ptr[hole] = keys_ptr[j]; + values_ptr[hole] = values_ptr[j]; + hole = j; + } + } + + metadata[hole] = .{}; + keys_ptr[hole] = undefined; + values_ptr[hole] = undefined; self.header().size -= 1; } /// If there is an `Entry` with a matching key, it is deleted from /// the hash map, and this function returns true. Otherwise this - /// function returns false. + /// function returns false. Removal may move other entries: any + /// previously returned key or value pointers are invalidated. pub fn remove(self: *Self, key: K) bool { if (@sizeOf(Context) != 0) @compileError("Cannot infer context " ++ @typeName(Context) ++ ", call removeContext instead."); return self.removeContext(key, undefined); } pub fn removeContext(self: *Self, key: K, ctx: Context) bool { - return self.removeAdapted(key, ctx); - } - pub fn removeAdapted(self: *Self, key: anytype, ctx: anytype) bool { - if (self.getIndex(key, ctx)) |idx| { - self.removeByIndex(idx); - return true; - } - - return false; + const idx = self.getIndex(key, ctx) orelse return false; + self.removeByIndexContext(idx, ctx); + return true; } /// Delete the entry with key pointed to by key_ptr from the hash map. /// key_ptr is assumed to be a valid pointer to a key that is present - /// in the hash map. + /// in the hash map. Removal may move other entries: any previously + /// returned key or value pointers are invalidated. pub fn removeByPtr(self: *Self, key_ptr: *K) void { + if (@sizeOf(Context) != 0) + @compileError("Cannot infer context " ++ @typeName(Context) ++ ", call removeByPtrContext instead."); + return self.removeByPtrContext(key_ptr, undefined); + } + pub fn removeByPtrContext(self: *Self, key_ptr: *K, ctx: Context) void { // TODO: replace with pointer subtraction once supported by zig // if @sizeOf(K) == 0 then there is at most one item in the hash // map, which is assumed to exist as key_ptr must be valid. This @@ -820,18 +868,29 @@ fn HashMapUnmanaged( else 0; - self.removeByIndex(idx); + self.removeByIndexContext(idx, ctx); } fn initMetadatas(self: *Self) void { @memset(@as([*]u8, @ptrCast(self.metadata.?))[0 .. @sizeOf(Metadata) * self.capacity()], 0); } - fn growIfNeeded(self: *Self, new_count: Size) Allocator.Error!void { - const available = self.capacity() - self.header().size; + /// Returns an error if the map cannot hold `new_count` more entries. + /// This map is fixed-capacity so nothing can be done to make room; + /// the caller must grow the backing memory and rebuild the map. + fn checkCapacity(self: *Self, new_count: Size) Allocator.Error!void { + const available = self.maxLoad() - self.header().size; if (new_count > available) return error.OutOfMemory; } + fn maxLoadForCapacity(cap: Size) Size { + if (cap == 0) return 0; + return @intCast(@divFloor( + @as(u64, cap) * max_load_percentage, + 100, + )); + } + /// The memory layout for the underlying buffer for a given capacity. const Layout = struct { /// The total size of the buffer required. The buffer is expected @@ -888,6 +947,37 @@ fn HashMapUnmanaged( .capacity = new_capacity, }; } + + /// Returns a layout with enough raw slots to hold `new_size` entries + /// at the configured maximum load factor. + pub fn layoutForSize(new_size: Size) Layout { + if (new_size == 0) return layoutForCapacity(0); + + // Scale the requested number of entries up to the raw slot count + // required by the load factor. Widen first so `new_size * 100` + // cannot overflow Size. + const minimum_capacity = std.math.divCeil( + u64, + @as(u64, new_size) * 100, + max_load_percentage, + ) catch unreachable; + + // Capacities must be powers of two, so the largest capacity that + // fits in Size is the highest bit rather than maxInt(Size). + const max_capacity = @as(u64, 1) << + (@typeInfo(Size).int.bits - 1); + if (minimum_capacity > max_capacity) { + return layoutForCapacity(@intCast(max_capacity)); + } + + // Linear probing uses a mask for wraparound, which requires the + // final raw capacity to be rounded up to a power of two. + const raw_capacity = std.math.ceilPowerOfTwo( + u64, + minimum_capacity, + ) catch unreachable; + return layoutForCapacity(@intCast(raw_capacity)); + } }; } @@ -895,8 +985,28 @@ const testing = std.testing; const expect = std.testing.expect; const expectEqual = std.testing.expectEqual; +/// Verify the canonical placement invariant that backward-shift deletion +/// maintains: every used entry is reachable from its home slot without +/// crossing a free slot. This is exactly the property lookups depend on. +fn expectCanonical(map: anytype, ctx: anytype) !void { + const cap = map.capacity(); + const mask = cap - 1; + var used: usize = 0; + for (0..cap) |idx| { + const metadata = map.metadata.?[idx]; + if (!metadata.isUsed()) continue; + used += 1; + + var probe: usize = @truncate(ctx.hash(map.keys()[idx]) & mask); + while (probe != idx) : (probe = (probe + 1) & mask) { + try expect(map.metadata.?[probe].isUsed()); + } + } + try expectEqual(map.count(), used); +} + test "HashMap basic usage" { - const Map = AutoHashMapUnmanaged(u32, u32); + const Map = AutoHashMapUnmanaged(u32, u32, default_max_load_percentage); const alloc = testing.allocator; const cap = 16; @@ -931,7 +1041,7 @@ test "HashMap basic usage" { } test "HashMap ensureTotalCapacity" { - const Map = AutoHashMapUnmanaged(i32, i32); + const Map = AutoHashMapUnmanaged(i32, i32, default_max_load_percentage); const cap = 32; const alloc = testing.allocator; @@ -950,8 +1060,8 @@ test "HashMap ensureTotalCapacity" { try testing.expect(initial_capacity == map.capacity()); } -test "HashMap ensureUnusedCapacity with tombstones" { - const Map = AutoHashMapUnmanaged(i32, i32); +test "HashMap ensureUnusedCapacity with removals" { + const Map = AutoHashMapUnmanaged(i32, i32, default_max_load_percentage); const cap = 32; const alloc = testing.allocator; @@ -969,7 +1079,7 @@ test "HashMap ensureUnusedCapacity with tombstones" { } test "HashMap clearRetainingCapacity" { - const Map = AutoHashMapUnmanaged(u32, u32); + const Map = AutoHashMapUnmanaged(u32, u32, default_max_load_percentage); const cap = 16; const alloc = testing.allocator; @@ -1000,8 +1110,8 @@ test "HashMap clearRetainingCapacity" { } test "HashMap ensureTotalCapacity with existing elements" { - const Map = AutoHashMapUnmanaged(u32, u32); - const cap = Map.minimal_capacity; + const Map = AutoHashMapUnmanaged(u32, u32, default_max_load_percentage); + const cap = 8; const alloc = testing.allocator; const layout = Map.layoutForCapacity(cap); @@ -1011,15 +1121,15 @@ test "HashMap ensureTotalCapacity with existing elements" { try map.put(0, 0); try expectEqual(map.count(), 1); - try expectEqual(map.capacity(), Map.minimal_capacity); + try expectEqual(map.capacity(), cap); try testing.expectError(error.OutOfMemory, map.ensureTotalCapacity(65)); try expectEqual(map.count(), 1); - try expectEqual(map.capacity(), Map.minimal_capacity); + try expectEqual(map.capacity(), cap); } test "HashMap remove" { - const Map = AutoHashMapUnmanaged(u32, u32); + const Map = AutoHashMapUnmanaged(u32, u32, default_max_load_percentage); const cap = 32; const alloc = testing.allocator; @@ -1057,7 +1167,7 @@ test "HashMap remove" { } test "HashMap reverse removes" { - const Map = AutoHashMapUnmanaged(u32, u32); + const Map = AutoHashMapUnmanaged(u32, u32, default_max_load_percentage); const cap = 32; const alloc = testing.allocator; @@ -1085,7 +1195,7 @@ test "HashMap reverse removes" { } test "HashMap multiple removes on same metadata" { - const Map = AutoHashMapUnmanaged(u32, u32); + const Map = AutoHashMapUnmanaged(u32, u32, default_max_load_percentage); const cap = 32; const alloc = testing.allocator; @@ -1128,7 +1238,7 @@ test "HashMap multiple removes on same metadata" { } test "HashMap put and remove loop in random order" { - const Map = AutoHashMapUnmanaged(u32, u32); + const Map = AutoHashMapUnmanaged(u32, u32, default_max_load_percentage); const cap = 64; const alloc = testing.allocator; @@ -1166,7 +1276,7 @@ test "HashMap put and remove loop in random order" { } test "HashMap put" { - const Map = AutoHashMapUnmanaged(u32, u32); + const Map = AutoHashMapUnmanaged(u32, u32, default_max_load_percentage); const cap = 32; const alloc = testing.allocator; @@ -1197,7 +1307,7 @@ test "HashMap put" { } test "HashMap put full load" { - const Map = AutoHashMapUnmanaged(usize, usize); + const Map = AutoHashMapUnmanaged(usize, usize, default_max_load_percentage); const cap = 16; const alloc = testing.allocator; @@ -1213,7 +1323,7 @@ test "HashMap put full load" { } test "HashMap putAssumeCapacity" { - const Map = AutoHashMapUnmanaged(u32, u32); + const Map = AutoHashMapUnmanaged(u32, u32, default_max_load_percentage); const cap = 32; const alloc = testing.allocator; @@ -1248,7 +1358,7 @@ test "HashMap putAssumeCapacity" { } test "HashMap repeat putAssumeCapacity/remove" { - const Map = AutoHashMapUnmanaged(u32, u32); + const Map = AutoHashMapUnmanaged(u32, u32, default_max_load_percentage); const cap = 32; const alloc = testing.allocator; @@ -1283,8 +1393,191 @@ test "HashMap repeat putAssumeCapacity/remove" { try expectEqual(map.count(), limit); } +test "HashMap no-clobber move after remove at max load" { + const Context = struct { + pub fn hash(_: @This(), key: u32) u64 { + return key; + } + + pub fn eql(_: @This(), a: u32, b: u32) bool { + return a == b; + } + }; + const Map = HashMapUnmanaged(u32, u32, Context, 80); + const cap = 16; + + const alloc = testing.allocator; + const layout = Map.layoutForCapacity(cap); + const buf = try alloc.alignedAlloc(u8, Map.base_align, layout.total_size); + defer alloc.free(buf); + var map = Map.init(.init(buf), layout); + + // Fill the map to its maximum load. + const max_load = map.maxLoad(); + for (0..max_load) |i| { + map.putAssumeCapacityNoClobberContext( + @intCast(i), + @intCast(i), + .{}, + ); + } + + // Model a managed-cell move: remove the source and insert the value at + // a destination known to be absent. This must work at maximum load for + // any number of moves since removal genuinely frees a slot. + for (0..100) |i| { + const src: u32 = @intCast(i); + const dst: u32 = @intCast(i + max_load); + try expect(map.removeContext(src, .{})); + map.putAssumeCapacityNoClobberContext(dst, dst, .{}); + + try expectEqual(max_load, map.count()); + try expectEqual(dst, map.getContext(dst, .{}).?); + try expectCanonical(&map, Context{}); + } +} + +test "HashMap removal keeps colliding clusters findable" { + // All keys hash to the same home slot near the end of the table so + // that clusters wrap around the index mask. This exercises the cyclic + // arithmetic in backward-shift deletion. + const Context = struct { + pub fn hash(_: @This(), _: u32) u64 { + return 14; + } + + pub fn eql(_: @This(), a: u32, b: u32) bool { + return a == b; + } + }; + const Map = HashMapUnmanaged( + u32, + u32, + Context, + default_max_load_percentage, + ); + const cap = 16; + + const alloc = testing.allocator; + const layout = Map.layoutForCapacity(cap); + const buf = try alloc.alignedAlloc(u8, Map.base_align, layout.total_size); + defer alloc.free(buf); + var map = Map.init(.init(buf), layout); + + // Fill half the table: the cluster spans the wraparound point. + for (0..cap / 2) |i| { + map.putAssumeCapacityNoClobberContext(@intCast(i), @intCast(i), .{}); + } + + // Remove from the middle of the cluster and verify all remaining + // entries stay findable after every removal. + var removed: usize = 0; + for ([_]u32{ 3, 0, 7, 4, 1, 6, 2, 5 }) |key| { + try expect(map.removeContext(key, .{})); + removed += 1; + + for (0..cap / 2) |i| { + const k: u32 = @intCast(i); + const v = map.getContext(k, .{}); + if (map.containsContext(k, .{})) { + try expectEqual(k, v.?); + } + } + try expectEqual(cap / 2 - removed, map.count()); + try expectCanonical(&map, Context{}); + } +} + +test "HashMap removal from a completely full table" { + const Map = AutoHashMapUnmanaged(u32, u32, default_max_load_percentage); + const cap = 64; + + const alloc = testing.allocator; + const layout = Map.layoutForCapacity(cap); + const buf = try alloc.alignedAlloc(u8, Map.base_align, layout.total_size); + defer alloc.free(buf); + var map = Map.init(.init(buf), layout); + + // A 100% load factor allows filling every raw slot, so removal cannot + // rely on a free slot to terminate its cluster scan. + for (0..cap) |i| { + map.putAssumeCapacityNoClobber(@intCast(i), @intCast(i)); + } + try expectEqual(cap, map.count()); + + // Remove every other key, verifying everything else stays findable. + var expected: usize = cap; + for (0..cap) |i| { + if (i % 2 != 0) continue; + try expect(map.remove(@intCast(i))); + expected -= 1; + try expectEqual(expected, map.count()); + } + + for (0..cap) |i| { + if (i % 2 == 0) { + try expectEqual(null, map.get(@intCast(i))); + } else { + try expectEqual(i, map.get(@intCast(i)).?); + } + } + try expectCanonical(&map, AutoContext(u32){}); +} + +test "HashMap random operations against an oracle" { + const Map = AutoHashMapUnmanaged(u32, u32, default_max_load_percentage); + const cap = 64; + + const alloc = testing.allocator; + const layout = Map.layoutForCapacity(cap); + const buf = try alloc.alignedAlloc(u8, Map.base_align, layout.total_size); + defer alloc.free(buf); + var map = Map.init(.init(buf), layout); + + var oracle: std.AutoHashMapUnmanaged(u32, u32) = .empty; + defer oracle.deinit(alloc); + + var prng = std.Random.DefaultPrng.init(0xdeadbeef); + const random = prng.random(); + + // A small key space forces frequent hits, misses, and re-insertions + // at every load factor from empty to completely full. + const key_space = cap + cap / 2; + for (0..20_000) |_| { + const key = random.uintLessThan(u32, key_space); + switch (random.uintLessThan(u8, 4)) { + 0, 1 => { + const value = random.int(u32); + if (map.put(key, value)) { + try oracle.put(alloc, key, value); + } else |_| { + // Map is full: the oracle must not know this key + // (put on an existing key always succeeds). + try expect(!oracle.contains(key)); + try expectEqual(map.count(), map.capacity()); + } + }, + 2 => try expectEqual( + oracle.remove(key), + map.remove(key), + ), + 3 => try expectEqual(oracle.get(key), map.get(key)), + else => unreachable, + } + + try expectEqual(oracle.count(), map.count()); + } + + // Final full comparison plus the canonical placement invariant. + var it = oracle.iterator(); + while (it.next()) |entry| { + try expectEqual(entry.value_ptr.*, map.get(entry.key_ptr.*).?); + } + try expectCanonical(&map, AutoContext(u32){}); +} + test "HashMap getOrPut" { - const Map = AutoHashMapUnmanaged(u32, u32); + const Map = AutoHashMapUnmanaged(u32, u32, default_max_load_percentage); const cap = 32; const alloc = testing.allocator; @@ -1313,7 +1606,7 @@ test "HashMap getOrPut" { } test "HashMap basic hash map usage" { - const Map = AutoHashMapUnmanaged(i32, i32); + const Map = AutoHashMapUnmanaged(i32, i32, default_max_load_percentage); const cap = 32; const alloc = testing.allocator; @@ -1364,7 +1657,7 @@ test "HashMap basic hash map usage" { } test "HashMap ensureUnusedCapacity" { - const Map = AutoHashMapUnmanaged(u64, u64); + const Map = AutoHashMapUnmanaged(u64, u64, default_max_load_percentage); const cap = 64; const alloc = testing.allocator; @@ -1378,7 +1671,7 @@ test "HashMap ensureUnusedCapacity" { } test "HashMap removeByPtr" { - const Map = AutoHashMapUnmanaged(i32, u64); + const Map = AutoHashMapUnmanaged(i32, u64, default_max_load_percentage); const cap = 64; const alloc = testing.allocator; @@ -1409,7 +1702,7 @@ test "HashMap removeByPtr" { } test "HashMap removeByPtr 0 sized key" { - const Map = AutoHashMapUnmanaged(i32, u64); + const Map = AutoHashMapUnmanaged(i32, u64, default_max_load_percentage); const cap = 64; const alloc = testing.allocator; @@ -1433,7 +1726,7 @@ test "HashMap removeByPtr 0 sized key" { } test "HashMap repeat fetchRemove" { - const Map = AutoHashMapUnmanaged(u64, void); + const Map = AutoHashMapUnmanaged(u64, void, default_max_load_percentage); const cap = 64; const alloc = testing.allocator; @@ -1461,7 +1754,11 @@ test "HashMap repeat fetchRemove" { } test "OffsetHashMap basic usage" { - const OffsetMap = AutoOffsetHashMap(u32, u32); + const OffsetMap = AutoOffsetHashMap( + u32, + u32, + default_max_load_percentage, + ); const cap = 16; const alloc = testing.allocator; @@ -1496,7 +1793,11 @@ test "OffsetHashMap basic usage" { } test "OffsetHashMap remake map" { - const OffsetMap = AutoOffsetHashMap(u32, u32); + const OffsetMap = AutoOffsetHashMap( + u32, + u32, + default_max_load_percentage, + ); const cap = 16; const alloc = testing.allocator; @@ -1516,12 +1817,44 @@ test "OffsetHashMap remake map" { } } +test "OffsetHashMap maximum load leaves probe headroom" { + const OffsetMap = AutoOffsetHashMap(u32, u32, 80); + const alloc = testing.allocator; + const requested_size = 16; + const layout = OffsetMap.layout(requested_size); + const buf = try alloc.alignedAlloc( + u8, + OffsetMap.base_align, + layout.total_size, + ); + defer alloc.free(buf); + + const offset_map = OffsetMap.init(.init(buf), layout); + var map = offset_map.map(buf); + + try testing.expect(map.capacity() > requested_size); + try testing.expect(map.maxLoad() >= requested_size); + try testing.expect(map.maxLoad() < map.capacity()); + + for (0..requested_size) |i| try map.put(@intCast(i), @intCast(i)); + for (0..100) |_| { + for (0..requested_size) |i| { + try testing.expect(map.remove(@intCast(i))); + try map.put(@intCast(i), @intCast(i)); + } + } + + for (0..requested_size) |i| { + try testing.expectEqual(@as(u32, @intCast(i)), map.get(@intCast(i))); + } +} + test "layoutForCapacity no overflow for large capacity" { // Test that layoutForCapacity correctly handles large capacities without overflow. // Prior to the fix, new_capacity (u32) was multiplied before widening to usize, // causing overflow when new_capacity * @sizeOf(K) exceeded 2^32. // See: https://github.com/ghostty-org/ghostty/issues/9862 - const Map = AutoHashMapUnmanaged(u64, u64); + const Map = AutoHashMapUnmanaged(u64, u64, default_max_load_percentage); // Use 2^30 capacity - this would overflow in u32 when multiplied by @sizeOf(u64)=8 // 0x40000000 * 8 = 0x2_0000_0000 which wraps to 0 in u32 diff --git a/vendor/libghostty-vt/src/terminal/hyperlink.zig b/vendor/libghostty-vt/src/terminal/hyperlink.zig index 94f86466..35a16a2a 100644 --- a/vendor/libghostty-vt/src/terminal/hyperlink.zig +++ b/vendor/libghostty-vt/src/terminal/hyperlink.zig @@ -20,7 +20,7 @@ pub const Id = size.HyperlinkCountInt; // The mapping of cell to hyperlink. We use an offset hash map to save space // since its very unlikely a cell is a hyperlink, so its a waste to store // the hyperlink ID in the cell itself. -pub const Map = AutoOffsetHashMap(Offset(Cell), Id); +pub const Map = AutoOffsetHashMap(Offset(Cell), Id, 80); /// A fully decoded hyperlink that may or may not have its /// memory within a page. The memory location of this is dependent diff --git a/vendor/libghostty-vt/src/terminal/kitty/color.zig b/vendor/libghostty-vt/src/terminal/kitty/color.zig index c1072c39..3d2e950c 100644 --- a/vendor/libghostty-vt/src/terminal/kitty/color.zig +++ b/vendor/libghostty-vt/src/terminal/kitty/color.zig @@ -51,6 +51,23 @@ pub const Kind = union(enum) { return .{ .palette = std.fmt.parseUnsigned(u8, key, 10) catch return null }; } + /// Returns true when a terminal has built-in state for this key. + /// + /// Unsupported special colors may still be valid Kitty protocol keys, but + /// libghostty-vt cannot report them because Terminal does not store them. + pub fn hasTerminalQueryColor(self: Kind) bool { + return switch (self) { + .palette => true, + .special => |special| switch (special) { + .foreground, + .background, + .cursor, + => true, + else => false, + }, + }; + } + pub fn format( self: Kind, writer: *std.Io.Writer, @@ -74,4 +91,8 @@ test "OSC: kitty color protocol kind string" { const actual = try std.fmt.bufPrint(&buf, "{f}", .{Kind{ .palette = 42 }}); try testing.expectEqualStrings("42", actual); } + + try testing.expect((Kind{ .palette = 42 }).hasTerminalQueryColor()); + try testing.expect((Kind{ .special = .foreground }).hasTerminalQueryColor()); + try testing.expect(!(Kind{ .special = .selection_background }).hasTerminalQueryColor()); } diff --git a/vendor/libghostty-vt/src/terminal/kitty/graphics.zig b/vendor/libghostty-vt/src/terminal/kitty/graphics.zig index 6659cd31..1f639fab 100644 --- a/vendor/libghostty-vt/src/terminal/kitty/graphics.zig +++ b/vendor/libghostty-vt/src/terminal/kitty/graphics.zig @@ -29,6 +29,7 @@ pub const LoadingImage = image.LoadingImage; pub const ImageStorage = storage.ImageStorage; pub const RenderPlacement = render.Placement; pub const Response = command.Response; +pub const nextGeneration = storage.nextGeneration; pub const execute = exec.execute; diff --git a/vendor/libghostty-vt/src/terminal/kitty/graphics_command.zig b/vendor/libghostty-vt/src/terminal/kitty/graphics_command.zig index aa35fbeb..bc0ffb5b 100644 --- a/vendor/libghostty-vt/src/terminal/kitty/graphics_command.zig +++ b/vendor/libghostty-vt/src/terminal/kitty/graphics_command.zig @@ -149,6 +149,26 @@ pub const Parser = struct { } } + /// Feed a slice of bytes to the parser. This is equivalent to + /// calling feed for each byte in order, but once we're in the data + /// state the remainder of the slice is appended in bulk, avoiding + /// per-byte overhead for large payloads. + pub fn feedSlice(self: *Parser, bytes: []const u8) !void { + var rem = bytes; + while (rem.len > 0) { + if (self.state == .data) { + if (self.data.items.len + rem.len > self.max_bytes) { + return error.OutOfMemory; + } + try self.data.appendSlice(self.arena.child_allocator, rem); + return; + } + + try self.feed(rem[0]); + rem = rem[1..]; + } + } + /// Complete the parsing. This must be called after all the /// bytes have been fed to the parser. /// @@ -987,6 +1007,57 @@ test "transmission command" { try testing.expectEqual(@as(u32, 20), v.height); } +test "feedSlice matches per-byte feed" { + const testing = std.testing; + const alloc = testing.allocator; + + const input = "f=24,s=10,v=20;aGVsbG8gd29ybGQ="; + + var p1 = Parser.init(alloc, 1024 * 1024); + defer p1.deinit(); + for (input) |c| try p1.feed(c); + const c1 = try p1.complete(alloc); + defer c1.deinit(alloc); + + var p2 = Parser.init(alloc, 1024 * 1024); + defer p2.deinit(); + try p2.feedSlice(input); + const c2 = try p2.complete(alloc); + defer c2.deinit(alloc); + + try testing.expect(c1.control == .transmit); + try testing.expect(c2.control == .transmit); + try testing.expectEqualStrings(c1.data, c2.data); +} + +test "feedSlice across slice boundaries" { + const testing = std.testing; + const alloc = testing.allocator; + var p = Parser.init(alloc, 1024 * 1024); + defer p.deinit(); + + try p.feedSlice("f=24,s=10"); + try p.feedSlice(",v=20;aGVsbG8g"); + try p.feedSlice("d29ybGQ="); + const command = try p.complete(alloc); + defer command.deinit(alloc); + + try testing.expect(command.control == .transmit); + + // The payload is base64-decoded on completion. + try testing.expectEqualStrings("hello world", command.data); +} + +test "feedSlice respects max_bytes" { + const testing = std.testing; + const alloc = testing.allocator; + var p = Parser.init(alloc, 4); + defer p.deinit(); + + try p.feedSlice("f=24;ab"); + try testing.expectError(error.OutOfMemory, p.feedSlice("cde")); +} + test "transmission ignores 'm' if medium is not direct" { const testing = std.testing; const alloc = testing.allocator; diff --git a/vendor/libghostty-vt/src/terminal/kitty/graphics_exec.zig b/vendor/libghostty-vt/src/terminal/kitty/graphics_exec.zig index a6a879e5..6c72ec4e 100644 --- a/vendor/libghostty-vt/src/terminal/kitty/graphics_exec.zig +++ b/vendor/libghostty-vt/src/terminal/kitty/graphics_exec.zig @@ -380,7 +380,6 @@ const EncodeableError = Image.Error || Allocator.Error; fn encodeError(r: *Response, err: EncodeableError) void { switch (err) { error.OutOfMemory => r.message = "ENOMEM: out of memory", - error.InternalError => r.message = "EINVAL: internal error", error.InvalidData => r.message = "EINVAL: invalid data", error.DecompressionFailed => r.message = "EINVAL: decompression failed", error.FilePathTooLong => r.message = "EINVAL: file path too long", @@ -573,3 +572,87 @@ test "kittygfx no response with no image ID or number load and display" { try testing.expect(resp == null); } } + +test "kittygfx retransmit same id gets fresh image generation" { + const testing = std.testing; + const alloc = testing.allocator; + + var t = try Terminal.init(alloc, .{ .rows = 5, .cols = 5 }); + defer t.deinit(alloc); + const storage = &t.screens.active.kitty_images; + + // Transmit a 1x2 RGB image with id=1. + { + const cmd = try command.Parser.parseString( + alloc, + "a=t,t=d,f=24,i=1,s=1,v=2;////////", + ); + defer cmd.deinit(alloc); + const resp = execute(alloc, &t, &cmd).?; + try testing.expect(resp.ok()); + } + const gen1 = storage.imageById(1).?.generation; + try testing.expect(gen1 > 0); + try testing.expectEqual(gen1, storage.generation); + + // Retransmit the same id with identical dimensions/length. The + // (width, height, format, len) tuple is identical, so only the + // generation can reveal that the contents were replaced. + { + const cmd = try command.Parser.parseString( + alloc, + "a=t,t=d,f=24,i=1,s=1,v=2;AAAAAAAA", + ); + defer cmd.deinit(alloc); + const resp = execute(alloc, &t, &cmd).?; + try testing.expect(resp.ok()); + } + const gen2 = storage.imageById(1).?.generation; + try testing.expect(gen2 > gen1); + try testing.expectEqual(gen2, storage.generation); +} + +test "kittygfx delete then retransmit same id gets fresh generation" { + const testing = std.testing; + const alloc = testing.allocator; + + var t = try Terminal.init(alloc, .{ .rows = 5, .cols = 5 }); + defer t.deinit(alloc); + const storage = &t.screens.active.kitty_images; + + // Transmit and display, then delete everything (including image + // data), then retransmit the same ID. + { + const cmd = try command.Parser.parseString( + alloc, + "a=T,t=d,f=24,i=1,s=1,v=2,c=1,r=1;////////", + ); + defer cmd.deinit(alloc); + const resp = execute(alloc, &t, &cmd).?; + try testing.expect(resp.ok()); + } + const gen1 = storage.imageById(1).?.generation; + + { + const cmd = try command.Parser.parseString(alloc, "a=d,d=A"); + defer cmd.deinit(alloc); + const resp = execute(alloc, &t, &cmd); + try testing.expect(resp == null); + } + try testing.expect(storage.imageById(1) == null); + const gen_delete = storage.generation; + try testing.expect(gen_delete > gen1); + + { + const cmd = try command.Parser.parseString( + alloc, + "a=t,t=d,f=24,i=1,s=1,v=2;////////", + ); + defer cmd.deinit(alloc); + const resp = execute(alloc, &t, &cmd).?; + try testing.expect(resp.ok()); + } + const gen2 = storage.imageById(1).?.generation; + try testing.expect(gen2 > gen1); + try testing.expect(gen2 > gen_delete); +} diff --git a/vendor/libghostty-vt/src/terminal/kitty/graphics_image.zig b/vendor/libghostty-vt/src/terminal/kitty/graphics_image.zig index 8243a632..b6f44fac 100644 --- a/vendor/libghostty-vt/src/terminal/kitty/graphics_image.zig +++ b/vendor/libghostty-vt/src/terminal/kitty/graphics_image.zig @@ -401,12 +401,6 @@ pub const LoadingImage = struct { return error.InvalidData; } - // Set our time - self.image.transmit_time = std.time.Instant.now() catch |err| { - log.warn("failed to get time: {}", .{err}); - return error.InternalError; - }; - // Everything looks good, copy the image data over. var result = self.image; result.data = try self.data.toOwnedSlice(alloc); @@ -504,6 +498,12 @@ pub const LoadingImage = struct { }; /// Image represents a single fully loaded image. +/// +/// The image data is always fully decoded raw pixels: loading inflates +/// any zlib-compressed payload and decodes PNG into RGBA before an image +/// is completed, so `compression` is always `.none` and `format` is +/// never `.png` for a stored image, and `data.len` always equals +/// `width * height * bytes-per-pixel`. pub const Image = struct { id: u32 = 0, number: u32 = 0, @@ -512,7 +512,14 @@ pub const Image = struct { format: command.Transmission.Format = .rgb, compression: command.Transmission.Compression = .none, data: []const u8 = "", - transmit_time: std.time.Instant = undefined, + + /// Unique, monotonically increasing stamp assigned each time an + /// image is added to (or replaced in) an ImageStorage. A changed + /// generation for a given image ID means the image contents may + /// 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". + generation: u64 = 0, /// Set this to true if this image was loaded by a command that /// doesn't specify an ID or number, since such commands should @@ -521,7 +528,6 @@ pub const Image = struct { implicit_id: bool = false, pub const Error = error{ - InternalError, InvalidData, DecompressionFailed, DimensionsRequired, diff --git a/vendor/libghostty-vt/src/terminal/kitty/graphics_storage.zig b/vendor/libghostty-vt/src/terminal/kitty/graphics_storage.zig index e017d5f7..ba82ea40 100644 --- a/vendor/libghostty-vt/src/terminal/kitty/graphics_storage.zig +++ b/vendor/libghostty-vt/src/terminal/kitty/graphics_storage.zig @@ -16,6 +16,53 @@ const Command = command.Command; const log = std.log.scoped(.kitty_gfx); +/// Process-global counter backing all generation stamps (see +/// ImageStorage.generation and Image.generation). This is global rather +/// than per-storage so that stamps are unique across every storage in +/// the process: two mutation events never produce the same value, even +/// across separate screens (main vs. alt), storage resets, or separate +/// terminals. This lets consumers use a generation value alone as a +/// cache key without any ambiguity. +/// +/// Thread-safe because separate terminals may mutate their storages +/// from different threads. On single-threaded targets this lowers to +/// plain operations. +var generation_counter: GenerationCounter = .{}; + +/// Returns the next generation stamp. Stamps are unique and strictly +/// monotonically increasing process-wide, starting at 1 (0 is reserved +/// to mean "never stamped"). +pub fn nextGeneration() u64 { + return generation_counter.next(); +} + +/// Backing implementation for the generation counter. We use a +/// lock-free atomic counter where we can, but not all targets support +/// 64-bit atomic operations (e.g. 32-bit ARM Android), so we fall back +/// to a mutex-protected counter on those. This is a cold path (only +/// invoked on content mutations) so the mutex cost is irrelevant. +/// +/// The pointer-width check is a conservative proxy for 64-bit atomic +/// support: every 64-bit target supports 64-bit atomics, while 32-bit +/// targets may not (per the compiler's atomic operand validation). +const GenerationCounter = if (@bitSizeOf(usize) >= 64) struct { + value: std.atomic.Value(u64) = .init(0), + + fn next(self: *@This()) u64 { + return self.value.fetchAdd(1, .monotonic) + 1; + } +} else struct { + mutex: std.Thread.Mutex = .{}, + value: u64 = 0, + + fn next(self: *@This()) u64 { + self.mutex.lock(); + defer self.mutex.unlock(); + self.value += 1; + return self.value; + } +}; + /// An image storage is associated with a terminal screen (i.e. main /// screen, alt screen) and contains all the transmitted images and /// placements. @@ -27,8 +74,32 @@ pub const ImageStorage = struct { /// purely informational for the renderer and doesn't affect the /// correctness of the program. The renderer must set this to false /// if it cares about this value. + /// + /// Note that dirty is also set by scrolling and resizing (outside + /// of this struct) because those move placement pins, even though + /// the set of images/placements itself is unchanged. See generation + /// for a signal that only tracks content mutations. + /// + /// Invariant: dirty is always set when generation changes + /// (markMutated sets both); dirty set without a generation change + /// means a geometry-only event. dirty: bool = false, + /// Generation stamp of the last content mutation to this storage: + /// any image transmit/replace, placement add, or delete of either. + /// Zero means the storage has never been mutated (and is therefore + /// empty). + /// + /// Unlike dirty, this is NOT updated by scrolling/resizing, so an + /// unchanged generation means the placement set and all image data + /// are identical; only placement geometry (pins) may have moved. + /// Values come from a process-global monotonic counter, so a value + /// observed from any storage never recurs for different content, + /// even across screen switches or storage resets. + /// + /// 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 @@ -80,6 +151,19 @@ pub const ImageStorage = struct { return self.total_limit != 0; } + /// 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). + /// + /// 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) void { + self.dirty = true; + self.generation = nextGeneration(); + } + /// Sets the limit in bytes for the total amount of image data that /// can be loaded. If this limit is lower, this will do an eviction /// if necessary. If the value is zero, then Kitty image protocol will @@ -95,6 +179,7 @@ pub const ImageStorage = struct { const image_limits = self.image_limits; self.deinit(alloc, s); self.* = .{ .image_limits = image_limits }; + self.markMutated(); } // If we re lowering our limit, check if we need to evict. @@ -144,7 +229,12 @@ pub const ImageStorage = struct { gop.value_ptr.* = img; self.total_bytes += img.data.len; - self.dirty = true; + // Stamp the stored image with a fresh generation. This gives + // every add/replace a unique stamp even when the same image ID + // is retransmitted with identical dimensions, so consumers + // (e.g. renderer texture caches) can detect content changes. + self.markMutated(); + gop.value_ptr.generation = self.generation; } /// Add a placement for a given image. The caller must verify in advance @@ -185,7 +275,7 @@ pub const ImageStorage = struct { const gop = try self.placements.getOrPut(alloc, key); gop.value_ptr.* = p; - self.dirty = true; + self.markMutated(); } fn clearPlacements(self: *ImageStorage, s: *terminal.Screen) void { @@ -207,7 +297,7 @@ pub const ImageStorage = struct { while (it.next()) |kv| { if (kv.value_ptr.number == image_number) { if (newest == null or - kv.value_ptr.transmit_time.order(newest.?.transmit_time) == .gt) + kv.value_ptr.generation > newest.?.generation) { newest = kv.value_ptr.*; } @@ -224,6 +314,17 @@ pub const ImageStorage = struct { t: *terminal.Terminal, cmd: command.Delete, ) 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. + 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(); + switch (cmd) { .all => |delete_images| { var it = self.placements.iterator(); @@ -245,8 +346,6 @@ pub const ImageStorage = struct { var image_it = self.images.iterator(); while (image_it.next()) |kv| self.deleteIfUnused(alloc, kv.key_ptr.*); } - - self.dirty = true; }, .id => |v| self.deleteById( @@ -341,9 +440,6 @@ pub const ImageStorage = struct { if (v.delete) self.deleteIfUnused(alloc, img.id); } } - - // Mark dirty to force redraw - self.dirty = true; }, .row => |v| row: { @@ -373,9 +469,6 @@ pub const ImageStorage = struct { if (v.delete) self.deleteIfUnused(alloc, img.id); } } - - // Mark dirty to force redraw - self.dirty = true; }, .z => |v| { @@ -396,9 +489,6 @@ pub const ImageStorage = struct { if (v.delete) self.deleteIfUnused(alloc, image_id); } } - - // Mark dirty to force redraw - self.dirty = true; }, .range => |v| range: { @@ -420,9 +510,6 @@ pub const ImageStorage = struct { if (v.delete) self.deleteIfUnused(alloc, image_id); } } - - // Mark dirty to force redraw - self.dirty = true; }, // We don't support animation frames yet so they are successfully @@ -461,9 +548,6 @@ pub const ImageStorage = struct { // 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); - - // Mark dirty to force redraw - self.dirty = true; } /// Delete an image if it is unused. @@ -507,9 +591,6 @@ pub const ImageStorage = struct { if (delete_unused) self.deleteIfUnused(alloc, img.id); } } - - // Mark dirty to force redraw - self.dirty = true; } /// Evict image to make space. This will evict the oldest image, @@ -526,7 +607,7 @@ pub const ImageStorage = struct { // bit is fine compared to the megabytes we're looking to save. const Candidate = struct { id: u32, - time: std.time.Instant, + generation: u64, used: bool, }; @@ -554,7 +635,7 @@ pub const ImageStorage = struct { try candidates.append(alloc, .{ .id = img.id, - .time = img.transmit_time, + .generation = img.generation, .used = used, }); } @@ -572,12 +653,14 @@ pub const ImageStorage = struct { ) bool { _ = ctx; - // If they're usage matches, then its based on time. - if (lhs.used == rhs.used) return switch (lhs.time.order(rhs.time)) { - .lt => true, - .gt => false, - .eq => lhs.id < rhs.id, - }; + // If their usage matches, then it's based on the + // generation stamp, which orders by transmit time. + // (Stamps are unique but tie-break by ID anyway to + // stay deterministic for hand-built test images.) + if (lhs.used == rhs.used) return if (lhs.generation == rhs.generation) + lhs.id < rhs.id + else + lhs.generation < rhs.generation; // If not used, then its a better candidate return !lhs.used; @@ -585,6 +668,11 @@ pub const ImageStorage = struct { }.lessThan, ); + // Evicting anything is a content mutation. This matters for the + // setLimit path in particular, which doesn't otherwise mark it. + var any_evicted = false; + defer if (any_evicted) self.markMutated(); + // They're in order of best to evict. var evicted: usize = 0; for (candidates.items) |c| { @@ -593,6 +681,7 @@ pub const ImageStorage = struct { while (p_it.next()) |entry| { if (entry.key_ptr.image_id == c.id) { self.placements.removeByPtr(entry.key_ptr); + any_evicted = true; } } @@ -604,6 +693,7 @@ pub const ImageStorage = struct { entry.value_ptr.deinit(alloc); self.images.removeByPtr(entry.key_ptr); + any_evicted = true; if (evicted > req) return true; } @@ -1362,3 +1452,150 @@ test "storage: aspect ratio calculation when only columns or rows specified" { try testing.expectEqual(@as(u32, 100), calc_size.height); } } + +test "storage: generation stamps on image add and replace" { + const testing = std.testing; + const alloc = testing.allocator; + var t = try terminal.Terminal.init(alloc, .{ .rows = 3, .cols = 3 }); + defer t.deinit(alloc); + + var s: ImageStorage = .{}; + defer s.deinit(alloc, t.screens.active); + + // Fresh storage has generation zero (never mutated). + try testing.expectEqual(@as(u64, 0), s.generation); + + try s.addImage(alloc, .{ .id = 1, .width = 1, .height = 1 }); + const gen1 = s.generation; + try testing.expect(gen1 > 0); + + const img1 = s.imageById(1).?; + try testing.expectEqual(gen1, img1.generation); + + // A second image gets a strictly greater stamp. + try s.addImage(alloc, .{ .id = 2, .width = 1, .height = 1 }); + const gen2 = s.generation; + try testing.expect(gen2 > gen1); + try testing.expectEqual(gen2, s.imageById(2).?.generation); + + // Retransmitting the same image ID (identical dimensions) gets a + // fresh stamp: this is what makes same-sized retransmissions + // detectable by renderers. + try s.addImage(alloc, .{ .id = 1, .width = 1, .height = 1 }); + const gen3 = s.generation; + try testing.expect(gen3 > gen2); + try testing.expectEqual(gen3, s.imageById(1).?.generation); + + // Image 2 kept its stamp. + try testing.expectEqual(gen2, s.imageById(2).?.generation); +} + +test "storage: generation bumps on placement and delete" { + const testing = std.testing; + const alloc = testing.allocator; + var t = try terminal.Terminal.init(alloc, .{ .rows = 3, .cols = 3 }); + defer t.deinit(alloc); + + var s: ImageStorage = .{}; + defer s.deinit(alloc, t.screens.active); + try s.addImage(alloc, .{ .id = 1 }); + const gen_add = s.generation; + + try s.addPlacement(alloc, 1, 1, .{ .location = .{ .pin = try trackPin(&t, .{ .x = 1, .y = 1 }) } }); + const gen_place = s.generation; + try testing.expect(gen_place > gen_add); + + // Reads don't change the generation. + _ = s.imageById(1); + _ = s.imageByNumber(1); + try testing.expectEqual(gen_place, s.generation); + + s.delete(alloc, &t, .{ .all = true }); + try testing.expect(s.generation > gen_place); +} + +test "storage: generation bumps when setLimit evicts or disables" { + const testing = std.testing; + const alloc = testing.allocator; + var t = try terminal.Terminal.init(alloc, .{ .rows = 3, .cols = 3 }); + defer t.deinit(alloc); + + var s: ImageStorage = .{}; + defer s.deinit(alloc, t.screens.active); + + const data = try alloc.dupe(u8, "1234"); + try s.addImage(alloc, .{ .id = 1, .width = 1, .height = 1, .data = data }); + const gen_add = s.generation; + + // Lowering the limit evicts the image and must mark a mutation. + s.dirty = false; + try s.setLimit(alloc, t.screens.active, 1); + try testing.expect(s.dirty); + try testing.expect(s.generation > gen_add); + try testing.expectEqual(@as(usize, 0), s.images.count()); + const gen_evict = s.generation; + + // Disabling (limit=0) resets the storage and must mark a mutation. + s.dirty = false; + try s.setLimit(alloc, t.screens.active, 0); + try testing.expect(s.dirty); + try testing.expect(s.generation > gen_evict); +} + +test "storage: imageByNumber returns most recently transmitted" { + const testing = std.testing; + const alloc = testing.allocator; + var t = try terminal.Terminal.init(alloc, .{ .rows = 3, .cols = 3 }); + defer t.deinit(alloc); + + var s: ImageStorage = .{}; + defer s.deinit(alloc, t.screens.active); + + // Two images sharing a number: the newest transmission wins, + // regardless of insertion order or clock resolution. + try s.addImage(alloc, .{ .id = 1, .number = 7 }); + try s.addImage(alloc, .{ .id = 2, .number = 7 }); + try testing.expectEqual(@as(u32, 2), s.imageByNumber(7).?.id); + + // Retransmit the first: it becomes the newest. + try s.addImage(alloc, .{ .id = 1, .number = 7 }); + try testing.expectEqual(@as(u32, 1), s.imageByNumber(7).?.id); +} + +test "storage: nextGeneration is unique and monotonic" { + const testing = std.testing; + const a = nextGeneration(); + const b = nextGeneration(); + try testing.expect(b > a); + try testing.expect(a > 0); +} + +test "storage: no-op delete does not mark a mutation" { + const testing = std.testing; + const alloc = testing.allocator; + var t = try terminal.Terminal.init(alloc, .{ .rows = 3, .cols = 3 }); + defer t.deinit(alloc); + + var s: ImageStorage = .{}; + defer s.deinit(alloc, t.screens.active); + + // A delete-all on an empty storage (this runs on every screen + // clear) must not dirty the state or bump the generation. + s.delete(alloc, &t, .{ .all = true }); + try testing.expect(!s.dirty); + try testing.expectEqual(@as(u64, 0), s.generation); + + // Same for a delete that matches nothing. + try s.addImage(alloc, .{ .id = 1 }); + try s.addPlacement(alloc, 1, 1, .{ .location = .{ .pin = try trackPin(&t, .{ .x = 1, .y = 1 }) } }); + const gen = s.generation; + s.dirty = false; + s.delete(alloc, &t, .{ .id = .{ .image_id = 42 } }); + try testing.expect(!s.dirty); + try testing.expectEqual(gen, s.generation); + + // But a delete that removes something does mark a mutation. + s.delete(alloc, &t, .{ .id = .{ .image_id = 1 } }); + try testing.expect(s.dirty); + try testing.expect(s.generation > gen); +} diff --git a/vendor/libghostty-vt/src/terminal/main.zig b/vendor/libghostty-vt/src/terminal/main.zig index 53491a00..af277f97 100644 --- a/vendor/libghostty-vt/src/terminal/main.zig +++ b/vendor/libghostty-vt/src/terminal/main.zig @@ -10,6 +10,7 @@ pub const dcs = @import("dcs.zig"); pub const osc = @import("osc.zig"); pub const point = @import("point.zig"); pub const color = @import("color.zig"); +pub const clipboard = @import("clipboard.zig"); pub const device_attributes = @import("device_attributes.zig"); pub const device_status = @import("device_status.zig"); pub const focus = @import("focus.zig"); @@ -75,6 +76,9 @@ pub const Attribute = sgr.Attribute; pub const Options = @import("build_options.zig").Options; pub const options = @import("terminal_options"); +/// Whether this target supports terminal page compression. +pub const compression_enabled = @import("mem.zig").canReclaim(.strict); + /// This is set to true when we're building the C library. pub const c_api = if (options.c_abi) @import("c/main.zig") else void; @@ -83,7 +87,9 @@ test { // Internals _ = @import("bitmap_allocator.zig"); + _ = @import("compress.zig"); _ = @import("hash_map.zig"); + _ = @import("mem.zig"); _ = @import("ref_counted_set.zig"); _ = @import("size.zig"); } diff --git a/vendor/libghostty-vt/src/terminal/mem.zig b/vendor/libghostty-vt/src/terminal/mem.zig new file mode 100644 index 00000000..7fa31480 --- /dev/null +++ b/vendor/libghostty-vt/src/terminal/mem.zig @@ -0,0 +1,222 @@ +//! Virtual-memory operations shared by terminal page owners. +//! +//! Terminal pages use page-aligned, page-multiple mappings. This module can +//! discard the physical pages behind one of those mappings without releasing +//! its virtual address range, then prepare the same range for reuse. It does +//! not allocate memory or decide which terminal pages should be discarded. +const std = @import("std"); +const builtin = @import("builtin"); +const assert = @import("../quirks.zig").inlineAssert; + +const log = std.log.scoped(.terminal_mem); + +/// What guarantee decommit must provide when the OS cannot discard a mapping. +pub const DecommitMode = enum { + /// The dirty prefix must read as zero after this call, even when physical + /// reclamation is unavailable. Bytes after the prefix must already be zero. + zero, + + /// Physical-memory reclamation is required. Do not touch the mapping when + /// reclamation is unsupported or fails; report the failure to the caller. + strict, +}; + +/// Return whether this target can reclaim physical memory for `mode` while +/// retaining the mapping's virtual address range. +/// +/// Test builds support both modes because `decommit` simulates reclamation by +/// clearing the supplied range. Runtime reclamation is intentionally limited +/// to 64-bit Linux and Darwin. Other targets must leave strict callers' memory +/// resident; zero mode still provides its documented memset fallback through +/// `decommit` even when this function returns false. +pub inline fn canReclaim(comptime mode: DecommitMode) bool { + // Both modes use the same retained-mapping primitives. Keeping the switch + // exhaustive makes additions to DecommitMode choose target support + // explicitly rather than inheriting it accidentally. + return switch (mode) { + .zero, .strict => supported: { + // Tests never call into the OS because their allocator ranges can + // share mappings with unrelated allocations. `decommit` simulates + // successful reclamation by zeroing the requested range instead, + // so both modes are always available to tests on every target. + if (builtin.is_test) break :supported true; + + // Compression currently retains complete page mappings for its + // lifetime. Limit the initial runtime support to 64-bit address + // spaces where that virtual-memory cost is negligible and where + // the retained-mapping behavior has been validated. + if (builtin.target.ptrBitWidth() != 64) break :supported false; + + // Linux provides MADV_DONTNEED, which immediately discards pages + // from a private anonymous mapping and faults them back as zeroes. + // Zig reaches this through the raw syscall path without libc. + if (builtin.target.os.tag == .linux) break :supported true; + + // Darwin provides the paired MADV_FREE_REUSABLE/FREE_REUSE + // operations used below. Darwin requires libc independently of + // this feature, so using its madvise entry point adds no new + // dependency to libghostty-vt. + if (builtin.target.os.tag.isDarwin()) break :supported true; + + // Other targets have no retained-mapping reclamation contract in + // this module. Zero mode can still clear through its memset + // fallback, but strict callers must leave their mapping resident. + break :supported false; + }, + }; +} + +/// Discard physical pages while retaining a mapping's virtual address range. +/// +/// The complete mapping must be page-aligned and a multiple of the minimum +/// system page size. `dirty_len` identifies the prefix whose contents may be +/// nonzero. Strict mode requires the complete mapping to be dirty because a +/// successful discard invalidates all of its contents. +/// +/// The return value reports whether the OS accepted the reclamation request. +/// Test builds return true after simulating reclamation by zeroing dirty bytes. +/// In zero mode, the requested bytes are guaranteed to be zero regardless of +/// the return value. +pub fn decommit( + comptime mode: DecommitMode, + memory: []align(std.heap.page_size_min) u8, + dirty_len: usize, +) bool { + assert(memory.len > 0); + assert(@intFromPtr(memory.ptr) % std.heap.page_size_min == 0); + assert(memory.len % std.heap.page_size_min == 0); + assert(dirty_len <= memory.len); + if (comptime mode == .strict) assert(dirty_len == memory.len); + + // Testing allocator ranges may share an OS mapping with unrelated memory, + // so madvise is not safe. Zeroing models the only content guarantee callers + // have after a successful discard. + if (comptime builtin.is_test) { + @memset(memory[0..dirty_len], 0); + return true; + } + + // DONTNEED immediately reclaims private anonymous pages on Linux and + // faults them back as zero-filled pages. We deliberately avoid MADV_FREE: + // it does not reduce RSS until memory pressure and does not guarantee that + // the next read is zero. + if (comptime builtin.os.tag == .linux) { + if (std.posix.madvise( + memory.ptr, + memory.len, + std.posix.MADV.DONTNEED, + )) |_| return true else |err| { + log.warn("madvise(DONTNEED) failed err={}", .{err}); + if (comptime mode == .strict) return false; + // Zero mode falls through to the memset below. + } + } + + // FREE_REUSABLE removes the range from the Darwin process footprint while + // retaining its mapping. Zero mode clears its dirty prefix first because + // the kernel may preserve the contents. Strict mode avoids that write + // because its caller will replace the entire mapping after recommit. + if (comptime builtin.os.tag.isDarwin()) { + if (comptime mode == .zero) @memset(memory[0..dirty_len], 0); + + if (std.posix.madvise( + memory.ptr, + memory.len, + std.posix.MADV.FREE_REUSABLE, + )) |_| return true else |err| { + switch (mode) { + .strict => { + log.warn("madvise(FREE_REUSABLE) failed err={}", .{err}); + return false; + }, + + .zero => { + // Plain FREE can still reclaim the already-zero mapping + // under pressure and does not require a reuse pairing. + std.posix.madvise( + memory.ptr, + memory.len, + std.posix.MADV.FREE, + ) catch {}; + return false; + }, + } + } + } + + if (comptime mode == .zero) @memset(memory[0..dirty_len], 0); + return false; +} + +/// Prepare a mapping previously passed to decommit for reuse. +/// +/// Linux and test builds need no explicit operation. Darwin pairs +/// FREE_REUSABLE with FREE_REUSE so pages touched by the caller are accounted +/// to the process again. Failure does not invalidate the retained mapping, so +/// reuse can continue after logging the accounting failure. +pub fn recommit(memory: []align(std.heap.page_size_min) u8) void { + assert(memory.len > 0); + assert(@intFromPtr(memory.ptr) % std.heap.page_size_min == 0); + assert(memory.len % std.heap.page_size_min == 0); + + if (comptime builtin.is_test) return; + if (comptime builtin.os.tag.isDarwin()) { + std.posix.madvise( + memory.ptr, + memory.len, + std.posix.MADV.FREE_REUSE, + ) catch |err| { + log.warn("madvise(FREE_REUSE) failed err={}", .{err}); + }; + } +} + +test "decommit with zero fallback clears the dirty prefix" { + const testing = std.testing; + const memory_len = 2 * std.heap.page_size_min; + const memory = try testing.allocator.alignedAlloc( + u8, + .fromByteUnits(std.heap.page_size_min), + memory_len, + ); + defer testing.allocator.free(memory); + + @memset(memory, 0xAA); + _ = decommit(.zero, memory, memory.len); + try testing.expect(std.mem.allEqual(u8, memory, 0)); + + // The tail is already zero by contract, so a partially dirty mapping only + // needs its dirty prefix cleared. + @memset(memory[0..1024], 0xAA); + _ = decommit(.zero, memory, 1024); + try testing.expect(std.mem.allEqual(u8, memory, 0)); +} + +test "strict decommit retains the mapping for recommit" { + const testing = std.testing; + const memory_len = 2 * std.heap.page_size_min; + const memory = try testing.allocator.alignedAlloc( + u8, + .fromByteUnits(std.heap.page_size_min), + memory_len, + ); + defer testing.allocator.free(memory); + @memset(memory, 0xAA); + + const original_ptr = memory.ptr; + const original_len = memory.len; + try testing.expect(decommit(.strict, memory, memory.len)); + try testing.expectEqual(original_ptr, memory.ptr); + try testing.expectEqual(original_len, memory.len); + try testing.expect(std.mem.allEqual(u8, memory, 0)); + + recommit(memory); + @memset(memory, 0xBB); + try testing.expect(std.mem.allEqual(u8, memory, 0xBB)); +} + +test "test builds can reclaim retained mappings" { + const testing = std.testing; + try testing.expect(canReclaim(.zero)); + try testing.expect(canReclaim(.strict)); +} diff --git a/vendor/libghostty-vt/src/terminal/osc.zig b/vendor/libghostty-vt/src/terminal/osc.zig index 36cfd7f8..26ce1267 100644 --- a/vendor/libghostty-vt/src/terminal/osc.zig +++ b/vendor/libghostty-vt/src/terminal/osc.zig @@ -156,6 +156,9 @@ pub const Command = union(Key) { kitty_clipboard_protocol: KittyClipboardProtocol, + /// Kitty drag and drop protocol (OSC 72) + kitty_dnd_protocol: KittyDndProtocol, + /// OSC 3008. Hierarchical context signalling (UAPI spec). /// https://uapi-group.org/specifications/specs/osc_context/ context_signal: parsers.context_signal.Command, @@ -164,6 +167,8 @@ pub const Command = union(Key) { pub const KittyClipboardProtocol = parsers.kitty_clipboard_protocol.OSC; + pub const KittyDndProtocol = parsers.kitty_dnd_protocol.OSC; + pub const Key = LibEnum( lib.target, // NOTE: Order matters, see LibEnum documentation. @@ -192,6 +197,7 @@ pub const Command = union(Key) { "conemu_comment", "kitty_text_sizing", "kitty_clipboard_protocol", + "kitty_dnd_protocol", "context_signal", }, ); @@ -344,6 +350,7 @@ pub const Parser = struct { @"52", @"55", @"66", + @"72", @"77", @"104", @"110", @@ -398,10 +405,12 @@ pub const Parser = struct { .kitty_color_protocol => |*v| kitty_color_protocol: { v.deinit(self.alloc orelse break :kitty_color_protocol); }, + .color_operation => |*v| color_operation: { + v.requests.deinit(self.alloc orelse break :color_operation); + }, .change_window_icon, .change_window_title, .clipboard_contents, - .color_operation, .conemu_change_tab_title, .conemu_comment, .conemu_guimacro, @@ -421,6 +430,7 @@ pub const Parser = struct { .show_desktop_notification, .kitty_text_sizing, .kitty_clipboard_protocol, + .kitty_dnd_protocol, .context_signal, => {}, } @@ -691,10 +701,16 @@ pub const Parser = struct { .@"7" => switch (c) { ';' => self.captureTrailing(.fixed), + '2' => self.state = .@"72", '7' => self.state = .@"77", else => self.state = .invalid, }, + .@"72" => switch (c) { + ';' => self.captureTrailing(.allocating), + else => self.state = .invalid, + }, + .@"77" => switch (c) { '7' => self.state = .@"777", else => self.state = .invalid, @@ -805,6 +821,8 @@ pub const Parser = struct { .@"66" => parsers.kitty_text_sizing.parse(self, terminator_ch), + .@"72" => parsers.kitty_dnd_protocol.parse(self, terminator_ch), + .@"77" => null, .@"133" => parsers.semantic_prompt.parse(self, terminator_ch), diff --git a/vendor/libghostty-vt/src/terminal/osc/parsers.zig b/vendor/libghostty-vt/src/terminal/osc/parsers.zig index a56b38e8..8cb91fb9 100644 --- a/vendor/libghostty-vt/src/terminal/osc/parsers.zig +++ b/vendor/libghostty-vt/src/terminal/osc/parsers.zig @@ -9,6 +9,7 @@ pub const hyperlink = @import("parsers/hyperlink.zig"); 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_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/kitty_dnd_protocol.zig b/vendor/libghostty-vt/src/terminal/osc/parsers/kitty_dnd_protocol.zig new file mode 100644 index 00000000..35d23906 --- /dev/null +++ b/vendor/libghostty-vt/src/terminal/osc/parsers/kitty_dnd_protocol.zig @@ -0,0 +1,363 @@ +//! Kitty's drag and drop protocol (OSC 72) +//! Specification: https://sw.kovidgoyal.net/kitty/drag-and-drop-protocol/ + +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 log = std.log.scoped(.kitty_dnd_protocol); + +pub const OSC = struct { + /// The raw metadata that was received. Parse individual values with `readOption`. + metadata: []const u8, + /// The raw payload. Its meaning and encoding depend on the event type (`t` key). + 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, + + 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, + }; + } +}; + +/// 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); + + 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; + + // 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; + + while (pos < metadata.len and std.ascii.isWhitespace(metadata[pos])) pos += 1; + if (pos >= metadata.len) return null; + if (metadata[pos] != '=') return null; + + 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; + }; + + return switch (key) { + .t => .init(value), + .m, .i, .o, .x, .y, .X, .Y => std.fmt.parseInt(i32, value, 10) catch null, + }; + } +}; + +test "OSC 72: metadata only, no payload" { + 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.expectEqualStrings("t=a", cmd.kitty_dnd_protocol.metadata); + try testing.expect(cmd.kitty_dnd_protocol.payload == null); +} + +test "OSC 72: metadata and empty payload" { + 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.expectEqualStrings("t=a", cmd.kitty_dnd_protocol.metadata); + try testing.expectEqualStrings("", cmd.kitty_dnd_protocol.payload.?); +} + +test "OSC 72: metadata and non-empty payload" { + const testing = std.testing; + + var p: Parser = .init(testing.allocator); + defer p.deinit(); + + const input = "72;t=a:i=5;text/plain text/uri-list"; + for (input) |ch| p.next(ch); + + const cmd = p.end('\x1b').?.*; + try testing.expect(cmd == .kitty_dnd_protocol); + try testing.expectEqualStrings("t=a:i=5", cmd.kitty_dnd_protocol.metadata); + try testing.expectEqualStrings("text/plain text/uri-list", cmd.kitty_dnd_protocol.payload.?); +} + +test "OSC 72: readOption .t valid event types" { + 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"; + 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); +} + +test "OSC 72: BEL terminator recorded" { + const testing = std.testing; + + var p: Parser = .init(testing.allocator); + defer p.deinit(); + + const input = "72;t=q"; + for (input) |ch| p.next(ch); + + const cmd = p.end(0x07).?.*; + 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/osc/parsers/semantic_prompt.zig b/vendor/libghostty-vt/src/terminal/osc/parsers/semantic_prompt.zig index 25152d7c..97212fbb 100644 --- a/vendor/libghostty-vt/src/terminal/osc/parsers/semantic_prompt.zig +++ b/vendor/libghostty-vt/src/terminal/osc/parsers/semantic_prompt.zig @@ -60,6 +60,16 @@ pub const Command = struct { } }; +// ClickEvents can either be a click_events=1 or click_events=2. +// The click_events=1 sends a click event with the absolute coordinates +// of the click. +// The click_events=2 sends a click event with the coordinates of the click +// relative to the prompt area. +// See https://github.com/ghostty-org/ghostty/issues/10865 and +// https://github.com/kovidgoyal/kitty/issues/9500 +// for further details. +pub const ClickEvents = enum { absolute, relative }; + pub const Option = enum { aid, cl, @@ -102,7 +112,7 @@ pub const Option = enum { .err => []const u8, .redraw => Redraw, .special_key => bool, - .click_events => bool, + .click_events => ClickEvents, .cmdline => []const u8, .cmdline_url => []const u8, .exit_code => i32, @@ -200,7 +210,12 @@ pub const Option = enum { .last else null, - .special_key, .click_events => if (value.len == 1) switch (value[0]) { + .click_events => if (value.len == 1) switch (value[0]) { + '1' => .absolute, + '2' => .relative, + else => null, + } else null, + .special_key => if (value.len == 1) switch (value[0]) { '0' => false, '1' => true, else => null, @@ -1249,9 +1264,10 @@ test "Option.read special_key" { test "Option.read click_events" { const testing = std.testing; - try testing.expect(Option.click_events.read("click_events=1").? == true); - try testing.expect(Option.click_events.read("click_events=0").? == false); try testing.expect(Option.click_events.read("click_events=yes") == null); + try testing.expect(Option.click_events.read("click_events=0") == null); + try testing.expect(Option.click_events.read("click_events=1").? == .absolute); + try testing.expect(Option.click_events.read("click_events=2").? == .relative); } test "Option.read exit_code" { diff --git a/vendor/libghostty-vt/src/terminal/page.zig b/vendor/libghostty-vt/src/terminal/page.zig index 0942e260..ca6d9854 100644 --- a/vendor/libghostty-vt/src/terminal/page.zig +++ b/vendor/libghostty-vt/src/terminal/page.zig @@ -92,7 +92,11 @@ const grapheme_chunk = grapheme_chunk_len * @sizeOf(u21); const GraphemeAlloc = BitmapAllocator(grapheme_chunk); const grapheme_count_default = GraphemeAlloc.bitmap_bit_size; pub const grapheme_bytes_default = grapheme_count_default * grapheme_chunk; -const GraphemeMap = AutoOffsetHashMap(Offset(Cell), Offset(u21).Slice); +const GraphemeMap = AutoOffsetHashMap( + Offset(Cell), + Offset(u21).Slice, + hash_map.default_max_load_percentage, +); /// The allocator used for shared utf8-encoded strings within a page. /// Note the chunk size below is the minimum size of a single allocation @@ -145,6 +149,15 @@ pub const Page = struct { @alignOf(Cell), StyleSet.base_align.toByteUnits(), ) == 0); + + // The PageList memory pool requires that initBuf overwrites at + // least the first pointer-size bytes of the backing buffer: + // std.heap.MemoryPool stores its free list node there when a + // page buffer is returned to it, and pool reuse skips zeroing + // in release builds. This holds because the rows array is at + // offset 0 (see layout), a page always has at least one row, + // and initBuf fully rewrites every row. + assert(@sizeOf(Row) >= @sizeOf(usize)); } /// The backing memory for the page. A page is always made up of a @@ -238,6 +251,13 @@ pub const Page = struct { /// It is up to the caller to not call deinit on these pages. pub inline fn initBuf(buf: OffsetBuf, l: Layout) Page { const cap = l.capacity; + + // A page must always have at least one row. Aside from being + // useless otherwise, the row initialization below must always + // overwrite the start of the buffer for pool reuse. See the + // comptime assert at the top of Page. + assert(cap.rows > 0); + const rows = buf.member(Row, l.rows_start); const cells = buf.member(Cell, l.cells_start); @@ -754,11 +774,11 @@ pub const Page = struct { } } - // The hyperlink_map capacity in layout() is computed as: - // hyperlink_count * hyperlink_cell_multiplier (rounded to power of 2) - // We need enough hyperlink_bytes so that when layout() computes - // the map capacity, it can accommodate all hyperlink cells. This - // is unit tested. + // layout() requests `hyperlink_count * hyperlink_cell_multiplier` + // usable map entries. The map layout adds load-factor headroom and + // rounds the raw slot count to a power of two. We need enough + // hyperlink_bytes for that requested entry count to accommodate all + // hyperlink cells. This is unit tested. const hyperlink_cap = cap: { const hyperlink_count = id_set.count(); const hyperlink_set_cap = hyperlink.Set.capacityForCount(hyperlink_count); @@ -1461,7 +1481,7 @@ pub const Page = struct { const entry = map.getEntry(src_offset).?; const value = entry.value_ptr.*; map.removeByPtr(entry.key_ptr); - map.putAssumeCapacity(dst_offset, value); + map.putAssumeCapacityNoClobber(dst_offset, value); // NOTE: We must not set src/dst.hyperlink here because this // function is used in various cases where we swap cell contents @@ -1478,7 +1498,7 @@ pub const Page = struct { /// Returns the hyperlink capacity for the page. This isn't the byte /// size but the number of unique cells that can have hyperlink data. pub inline fn hyperlinkCapacity(self: *const Page) usize { - return self.hyperlink_map.map(self.memory).capacity(); + return self.hyperlink_map.map(self.memory).maxLoad(); } /// Set the graphemes for the given cell. This asserts that the cell @@ -1609,7 +1629,7 @@ pub const Page = struct { const entry = map.getEntry(src_offset).?; const value = entry.value_ptr.*; map.removeByPtr(entry.key_ptr); - map.putAssumeCapacity(dst_offset, value); + map.putAssumeCapacityNoClobber(dst_offset, value); } /// Clear the graphemes for a given cell. @@ -1703,6 +1723,11 @@ pub const Page = struct { /// and rows size. pub inline fn layout(cap: Capacity) Layout { const rows_count: usize = @intCast(cap.rows); + + // The rows array must stay at offset 0: the PageList memory + // pool relies on initBuf overwriting the first bytes of a + // reused page buffer, which hold the pool's free list node. + // See the comptime assert at the top of Page. const rows_start = 0; const rows_end: usize = rows_start + (rows_count * @sizeOf(Row)); @@ -1744,7 +1769,7 @@ pub const Page = struct { u32, hyperlink_count * hyperlink_cell_multiplier, ) orelse break :count std.math.maxInt(u32); - break :count std.math.ceilPowerOfTwoAssert(u32, mult); + break :count mult; }; const hyperlink_map_layout = hyperlink.Map.layout(hyperlink_map_count); const hyperlink_map_start = alignForward(usize, hyperlink_set_end, hyperlink.Map.base_align.toByteUnits()); @@ -1988,6 +2013,11 @@ pub const Row = packed struct(u64) { prompt_continuation = 2, }; + /// The backing integer of this packed struct. Prefer this over + /// hardcoding the integer type so that code is resilient to the + /// size changing. + pub const Backing = @typeInfo(Row).@"struct".backing_integer.?; + /// C ABI type. pub const C = u64; @@ -2100,6 +2130,11 @@ pub const Cell = packed struct(u64) { prompt = 2, }; + /// The backing integer of this packed struct. Prefer this over + /// hardcoding the integer type so that code is resilient to the + /// size changing. + pub const Backing = @typeInfo(Cell).@"struct".backing_integer.?; + /// C ABI type. pub const C = u64; @@ -2194,6 +2229,270 @@ pub const Cell = packed struct(u64) { } }; +/// Returns a mask with all bits set for the given fields of the packed +/// struct T, used for masked compares of raw backing-integer values +/// (e.g. `Row.Backing`, `Cell.Backing`). This is an implementation +/// detail of `Mask`, which is the public API built on top of this. +fn fieldMask( + comptime T: type, + comptime fields: []const []const u8, +) @typeInfo(T).@"struct".backing_integer.? { + // Backing int of the packed struct + const Int = @typeInfo(T).@"struct".backing_integer.?; + + var mask: Int = 0; + inline for (fields) |field| { + // The type that fits all the bits we need to set. + const Ones = std.meta.Int( + .unsigned, + @bitSizeOf(@FieldType(T, field)), + ); + + // Mask out the ones + mask |= @as(Int, std.math.maxInt(Ones)) << @bitOffsetOf(T, field); + } + + return mask; +} + +/// A comptime-generated helper for classifying and comparing packed +/// struct values (e.g. Row, Cell) in bulk, using masked compares of +/// their raw backing integers. +/// +/// Masked compares are the key to making bulk row/cell processing fast. +/// Rows and cells are small packed structs specifically so that a single +/// integer load observes every field at once. A masked compare can then +/// answer a multi-field question with one AND and one compare, instead +/// of extracting and branching on each field individually (each packed +/// field access compiles to its own shift/mask). Just as importantly, +/// the integer form vectorizes trivially: `@splat` the mask and expected +/// value, and whole groups of rows or cells can be classified with a +/// few SIMD instructions. +/// +/// Some real examples of this in use: +/// +/// - Terminal print fast path: a cell can be overwritten by the +/// simple/fast path only if its content tag is a plain codepoint, +/// it has no style, isn't wide, and isn't a hyperlink. Masking +/// with those fields and comparing against a template answers all +/// four questions in one compare per cell. +/// +/// - Render state updates: a cell whose masked +/// `{content_tag, style_id}` bits are zero is a plain cell that +/// needs no managed-memory handling, so a vector OR-reduce can +/// skip entire groups of plain cells at once. Similarly, cells +/// whose masked bits equal the first cell's form a run sharing one +/// style, letting the update record one style lookup per run +/// rather than per cell. +/// +/// - Dirty scans: OR-reducing groups of rows against the `dirty` +/// field mask finds whether any row in the group needs a rebuild +/// without touching each row's flag individually. +/// +/// T is the packed struct type, fields are the fields covered by the +/// mask, and group_len is the number of values processed at once by +/// the group (vectorized) operations. Callers typically scan a slice +/// with the group operations and fall back to the scalar variants for +/// the remainder and for pinpointing values within a matched group. +pub fn Mask( + comptime T: type, + comptime fields: []const []const u8, + comptime group_len_param: comptime_int, +) type { + return struct { + const Backing = @typeInfo(T).@"struct".backing_integer.?; + const mask: Backing = fieldMask(T, fields); + + /// The number of values processed at once by group operations. + pub const group_len = group_len_param; + + /// A group of raw values for the vectorized operations. + const Group = @Vector(group_len, Backing); + + /// Load a group of values from the slice starting at index i. + /// Asserts that at least group_len values are available. + inline fn load(values: []const T, i: usize) Group { + return @bitCast(values[i..][0..group_len].*); + } + + /// Returns the raw backing bits of a single value. + pub inline fn bits(v: T) Backing { + return @bitCast(v); + } + + /// Returns the masked bits of a single value: the bits of the + /// masked fields with all other fields zeroed. Use this to + /// build the expected value for the eql functions. + pub inline fn pattern(v: T) Backing { + return bits(v) & mask; + } + + /// Returns the backing bits of a single value with the masked + /// fields zeroed: the complement of `pattern`. Use this to + /// compare values while ignoring the masked fields. + pub inline fn strip(v: T) Backing { + return bits(v) & ~mask; + } + + /// Returns true if every value in the group of group_len + /// values starting at index i matches, where a value matches + /// when none of the masked fields have any bits set: false + /// for bools, zero for ints, the zero tag for enums, and so + /// on. Asserts that at least group_len values are available. + pub inline fn match(values: []const T, i: usize) bool { + return @reduce(.Or, load(values, i)) & mask == 0; + } + + /// Scalar variant of `match` for a single value. + pub inline fn matchScalar(v: T) bool { + return bits(v) & mask == 0; + } + + /// Returns true if the masked fields of every value in the + /// group of group_len values starting at index i equal the + /// expected pattern (see `pattern`). + /// + /// This is a masked compare: fields outside the mask may vary + /// freely. Use this to detect runs of values that share the + /// masked field contents while other fields differ, e.g. a run + /// of cells with the same style ID but different codepoints. + /// If the result you derive from a run depends on fields + /// outside the mask, use `eqlExact` instead. + pub inline fn eql( + values: []const T, + i: usize, + expected: Backing, + ) bool { + const masked = load(values, i) & @as(Group, @splat(mask)); + return @reduce(.And, masked == @as(Group, @splat(expected))); + } + + /// Scalar variant of `eql` for a single value. + pub inline fn eqlScalar(v: T, expected: Backing) bool { + return pattern(v) == expected; + } + + /// Like `eql` but returns the number of leading values whose + /// masked fields equal the expected pattern, i.e. group_len if + /// the entire group matches. This is useful for early-exit run + /// scans that need to pinpoint exactly where a run ends rather + /// than only whether the whole group matches. + pub inline fn eqlPrefix( + values: []const T, + i: usize, + expected: Backing, + ) usize { + const masked = load(values, i) & @as(Group, @splat(mask)); + const ok = masked == @as(Group, @splat(expected)); + + // Test the whole group before extracting the prefix + // count: turning a vector compare into a scalar bitmask + // is expensive on some targets (e.g. NEON has no movemask + // instruction) and run scans overwhelmingly see fully + // matching groups, so we only pay for the extraction on + // the final group of a run. + if (@reduce(.And, ok)) { + @branchHint(.likely); + return group_len; + } + + const ok_bits: std.meta.Int( + .unsigned, + group_len, + ) = @bitCast(ok); + return @ctz(~ok_bits); + } + + /// Returns true if every value in the group of group_len + /// values starting at index i is bit-identical to the expected + /// value. Note: this compares entire values; it is NOT + /// affected by the field mask. + /// + /// This exists alongside `eql` for run detection where the + /// derived result depends on fields outside the mask, so a + /// masked compare would incorrectly extend the run. For + /// example, the background color of a bg-color cell lives in + /// the content field: two such cells only share a background + /// if their content bits are identical, not merely their + /// content tag. Values in such runs are typically produced by + /// bulk fills (e.g. erase with a pending background) and are + /// bit-identical in practice, so exact equality is both + /// correct and cheap. + pub inline fn eqlExact( + values: []const T, + i: usize, + expected: Backing, + ) bool { + const group = load(values, i); + return @reduce(.And, group == @as(Group, @splat(expected))); + } + }; +} + +test "Mask" { + const M = Mask(Cell, &.{ "content_tag", "style_id" }, 4); + + const plain: Cell = .init('A'); + var styled: Cell = .init('B'); + styled.style_id = 5; + var styled2: Cell = .init('C'); + styled2.style_id = 5; + var other: Cell = .init('D'); + other.style_id = 6; + + // match: plain cells only + { + var cells: [4]Cell = .{ plain, plain, plain, plain }; + try testing.expect(M.match(&cells, 0)); + try testing.expect(M.matchScalar(plain)); + + cells[2] = styled; + try testing.expect(!M.match(&cells, 0)); + try testing.expect(!M.matchScalar(styled)); + } + + // eql: runs of matching masked fields, other fields may vary + { + const expected = M.pattern(styled); + var cells: [4]Cell = .{ styled, styled2, styled, styled2 }; + try testing.expect(M.eql(&cells, 0, expected)); + try testing.expect(M.eqlScalar(styled2, expected)); + + cells[1] = other; + try testing.expect(!M.eql(&cells, 0, expected)); + try testing.expect(!M.eqlScalar(other, expected)); + } + + // eqlPrefix: count of leading values matching the pattern + { + const expected = M.pattern(styled); + var cells: [4]Cell = .{ styled, styled2, other, styled }; + try testing.expectEqual(2, M.eqlPrefix(&cells, 0, expected)); + + cells[2] = styled; + try testing.expectEqual(4, M.eqlPrefix(&cells, 0, expected)); + } + + // eqlExact: bit-identical values only + { + const expected = M.bits(styled); + var cells: [4]Cell = .{ styled, styled, styled, styled }; + try testing.expect(M.eqlExact(&cells, 0, expected)); + + // Same masked fields but different codepoint is not exact. + cells[3] = styled2; + try testing.expect(!M.eqlExact(&cells, 0, expected)); + } + + // strip: compare values while ignoring the masked fields + { + var styled_other: Cell = .init('B'); + styled_other.style_id = 6; + try testing.expectEqual(M.strip(styled), M.strip(styled_other)); + try testing.expect(M.strip(styled) != M.strip(styled2)); + } +} + // Uncomment this when you want to do some math. // test "Page size calculator" { // const total_size = alignForward( @@ -3916,3 +4215,20 @@ test "Page exactRowCapacity hyperlink map capacity for many cells" { try testing.expect(cloned_cell.hyperlink); } } + +test "Page layout avoids double rounding hyperlink map capacity" { + const hyperlink_count = 3; + const layout = Page.layout(.{ + .cols = 1, + .rows = 1, + .hyperlink_bytes = hyperlink_count * @sizeOf(hyperlink.Set.Item), + }); + + // Three set entries request 48 usable map entries. Scaling that for the + // 80% load factor needs 60 raw slots, which rounds once to 64. Rounding + // the request before applying the load factor would allocate 128 slots. + try std.testing.expectEqual( + @as(u32, 64), + layout.hyperlink_map_layout.capacity, + ); +} diff --git a/vendor/libghostty-vt/src/terminal/ref_counted_set.zig b/vendor/libghostty-vt/src/terminal/ref_counted_set.zig index 8040039a..dd76079f 100644 --- a/vendor/libghostty-vt/src/terminal/ref_counted_set.zig +++ b/vendor/libghostty-vt/src/terminal/ref_counted_set.zig @@ -497,6 +497,15 @@ pub fn RefCountedSet( return self.lookupContext(base, value, self.context); } pub fn lookupContext(self: *const Self, base: anytype, value: T, ctx: Context) ?Id { + // A zero-capacity set (a valid special case of Layout.init) + // contains nothing and has a zero-size table, so we can't + // probe it: table[0] would read whatever memory follows the + // set in the backing buffer. + if (self.layout.table_cap == 0) { + @branchHint(.cold); + return null; + } + const table = self.table.ptr(base); const items = self.items.ptr(base); diff --git a/vendor/libghostty-vt/src/terminal/render.zig b/vendor/libghostty-vt/src/terminal/render.zig index 98e14224..04f0c967 100644 --- a/vendor/libghostty-vt/src/terminal/render.zig +++ b/vendor/libghostty-vt/src/terminal/render.zig @@ -41,6 +41,29 @@ const Terminal = @import("Terminal.zig"); /// defer state.deinit(alloc); /// state.update(alloc, &terminal); /// +/// ## Two-Phase Updates +/// +/// For callers that synchronize terminal access (e.g. a renderer thread +/// sharing a lock with an IO thread), the update can be split into two +/// phases to minimize the time the terminal must be held exclusively: +/// `beginUpdate` requires terminal access, while `endUpdate` completes +/// any deferred work using only memory owned by the render state. +/// +/// { +/// mutex.lock(); +/// defer mutex.unlock(); +/// try state.beginUpdate(alloc, &terminal); +/// } +/// +/// // The IO thread is free to modify the terminal while we +/// // complete the update. +/// state.endUpdate(); +/// +/// The render state must be treated as incomplete between the two calls. +/// `update` is a convenience that performs both phases in one call. +/// +/// ## Memory +/// /// Note: the render state retains as much memory as possible between updates /// to prevent future allocations. If a very large frame is rendered once, /// the render state will retain that much memory until deinit. To avoid @@ -91,6 +114,16 @@ pub const RenderState = struct { /// if possible. selection_cache: ?SelectionCache = null, + /// The pending style runs requiring an endUpdate call, in the + /// order they were recorded. If multiple begins happen without an + /// endUpdate call, runs accumulate; rows rebuilt more than once + /// may then have superseded (stale) runs in this list, which is + /// harmless: newer runs are appended later so they win, and cells + /// not covered by newer runs have a default style ID in their raw + /// data so their style is undefined by contract anyway. See + /// beginUpdate. + pending_styles: std.ArrayList(StyleRun) = .empty, + /// Initial state. pub const empty: RenderState = .{ .rows = 0, @@ -176,10 +209,16 @@ pub const RenderState = struct { /// change often. arena: ArenaAllocator.State, - /// The page pin. This is not safe to read unless you can guarantee - /// the terminal state hasn't changed since the last `update` call. + /// The page pin. Its copied values may be compared, but its node must + /// not be dereferenced unless the terminal state is protected from + /// changes since the last `update` call. pin: PageList.Pin, + /// The page node generation captured alongside `pin`. This lets + /// consumers validate the pin without dereferencing its node after + /// the terminal lock has been released. + serial: u64, + /// Raw row data. raw: page.Row, @@ -244,6 +283,23 @@ pub const RenderState = struct { br_pin: PageList.Pin, }; + /// A run of cells within one row sharing one style, pending + /// denormalization into the per-cell data. This is populated by + /// `beginUpdate` and consumed by `endUpdate`. This exists so that + /// the (potentially large) denormalization of styles into cells + /// can happen outside of any terminal locks. See `beginUpdate`. + pub const StyleRun = struct { + /// The viewport row. + y: size.CellCountInt, + + /// Start (inclusive) and end (exclusive) x coordinates. + start: size.CellCountInt, + end: size.CellCountInt, + + /// The style for this cell range. + style: Style, + }; + pub fn deinit(self: *RenderState, alloc: Allocator) void { for ( self.row_data.items(.arena), @@ -254,10 +310,17 @@ pub const RenderState = struct { cells.deinit(alloc); } self.row_data.deinit(alloc); + self.pending_styles.deinit(alloc); } /// Update the render state to the latest terminal state. /// + /// This is a convenience function that performs a full update in + /// one call, equivalent to `beginUpdate` immediately followed by + /// `endUpdate`. Callers that hold a lock over the terminal state + /// should prefer calling the two phases directly so that the lock + /// is only held for `beginUpdate`. + /// /// This will reset the terminal dirty state since it is consumed /// by this render state update. pub fn update( @@ -265,6 +328,36 @@ pub const RenderState = struct { alloc: Allocator, t: *Terminal, ) Allocator.Error!void { + try self.beginUpdate(alloc, t); + self.endUpdate(); + } + + /// Begin an update of the render state to the latest terminal + /// state. Every begin must be completed with an `endUpdate` call + /// before the render state is read. + /// + /// This two-phase structure exists for callers that lock the + /// terminal state: only this function requires terminal access, so + /// a caller can hold its lock for this call only and then call + /// `endUpdate` after releasing it. `endUpdate` exclusively reads + /// and writes memory owned by the render state. + /// + /// Work that doesn't require terminal access may be deferred to + /// `endUpdate` to keep this call (and therefore lock hold time) as + /// short as possible. At the time of writing, the deferred work is + /// the per-cell style denormalization, so between this call and + /// `endUpdate` the per-cell `style` data of any updated rows is + /// stale and must not be read. More work may be deferred in the + /// future; callers should treat the render state as incomplete + /// until `endUpdate` is called. + /// + /// This will reset the terminal dirty state since it is consumed + /// by this render state update. + pub fn beginUpdate( + self: *RenderState, + alloc: Allocator, + t: *Terminal, + ) Allocator.Error!void { const s: *Screen = t.screens.active; const viewport_pin = s.pages.getTopLeft(.viewport); const redraw = redraw: { @@ -322,7 +415,14 @@ pub const RenderState = struct { // Colors. self.colors.cursor = t.colors.cursor.get(); - self.colors.palette = t.colors.palette.current; + + // The palette is a relatively large copy (768 bytes at the time + // of writing) so we only copy it when it could have changed. All + // palette modifications set a terminal-level dirty flag (see + // Terminal.Dirty.palette), and any terminal-level dirty flag + // forces a redraw, so checking redraw is sufficient. + if (redraw) self.colors.palette = t.colors.palette.current; + bg_fg: { // Background/foreground can be unset initially which would // depend on "default" background/foreground. The expected use @@ -358,6 +458,7 @@ pub const RenderState = struct { row_data.set(y, .{ .arena = .{}, .pin = undefined, + .serial = undefined, .raw = undefined, .cells = .empty, .dirty = true, @@ -383,33 +484,62 @@ pub const RenderState = struct { const row_data = self.row_data.slice(); const row_arenas = row_data.items(.arena); const row_pins = row_data.items(.pin); + const row_serials = row_data.items(.serial); const row_rows = row_data.items(.raw); const row_cells = row_data.items(.cells); const row_sels = row_data.items(.selection); const row_highlights = row_data.items(.highlights); const row_dirties = row_data.items(.dirty); - // Track the last page that we know was dirty. This lets us - // more quickly do the full-page dirty check. - var last_dirty_page: ?*page.Page = null; - - // Go through and setup our rows. - var row_it = s.pages.rowIterator( - .right_down, - .{ .viewport = .{} }, - null, - ); - var y: size.CellCountInt = 0; + // If we're redrawing then every row will be rebuilt, superseding + // any pending style runs from prior updates. Clearing also + // guarantees pending runs always match the current dimensions + // (dimension changes force a redraw). + if (redraw) self.pending_styles.clearRetainingCapacity(); + + // Go through and setup our rows. We iterate page chunks rather + // than individual rows so that per-page work (dirty flags, cursor + // detection, memory pointers) is hoisted out of the row loop. This + // makes the common case of a clean (or mostly clean) frame very + // cheap: a contiguous scan of row dirty flags. + const builder: RowBuilder = .{ + .alloc = alloc, + .cols = self.cols, + .arenas = row_arenas, + .raws = row_rows, + .cells = row_cells, + .sels = row_sels, + .highlights = row_highlights, + .dirties = row_dirties, + .pending_styles = &self.pending_styles, + }; + var y: usize = 0; var any_dirty: bool = false; - while (row_it.next()) |row_pin| : (y = y + 1) { + var page_it = viewport_pin.pageIterator(.right_down, null); + while (y < self.rows) { + const chunk = page_it.next() orelse break; + const node = chunk.node; + const node_serial = node.serial; + const p: *page.Page = node.page(); + + // The number of rows we consume from this chunk. The chunk + // may extend beyond the viewport (the viewport is always + // exactly `rows` tall) so we clamp. + const take: usize = @min( + @as(usize, chunk.end - chunk.start), + self.rows - y, + ); + // Find our cursor if we haven't found it yet. We do this even - // if the row is not dirty because the cursor is unrelated. + // if rows are not dirty because the cursor is unrelated. We + // can check the chunk bounds once rather than every row. if (self.cursor.viewport == null and - row_pin.node == s.cursor.page_pin.node and - row_pin.y == s.cursor.page_pin.y) - { + node == s.cursor.page_pin.node) + cursor: { + const cy = s.cursor.page_pin.y; + if (cy < chunk.start or cy >= chunk.start + take) break :cursor; self.cursor.viewport = .{ - .y = y, + .y = @intCast(y + (cy - chunk.start)), .x = s.cursor.x, // Future: we should use our own state here to look this @@ -421,135 +551,82 @@ pub const RenderState = struct { }; } - // Store our pin. We have to store these even if we're not dirty - // because dirty is only a renderer optimization. It doesn't - // apply to memory movement. This will let us remap any cell - // pins back to an exact entry in our RenderState. - row_pins[y] = row_pin; - - // Get all our cells in the page. - const p: *page.Page = &row_pin.node.data; - const page_rac = row_pin.rowAndCell(); - - dirty: { - // If we're redrawing then we're definitely dirty. - if (redraw) break :dirty; - - // If our page is the same as last time then its dirty. - if (p == last_dirty_page) break :dirty; - if (p.dirty) { - // If this page is dirty then clear the dirty flag - // of the last page and then store this one. This benchmarks - // faster than iterating pages again later. - if (last_dirty_page) |last_p| last_p.dirty = false; - last_dirty_page = p; - break :dirty; + // The page-level dirty flag applies to every row in the chunk. + // We consume (clear) it now; each node appears at most once in + // this iteration and we're the only consumer of dirty state. + const page_dirty = p.dirty; + if (page_dirty) p.dirty = false; + + // Get our contiguous rows for this chunk. + const page_rows: []page.Row = p.rows.ptr(p.memory)[chunk.start..][0..take]; + assert(p.size.cols == self.cols); + + // Store our pins and their node generations. We have to store + // these even for rows that aren't dirty because dirty is only a + // renderer optimization; it doesn't apply to memory movement. + // This lets us remap any cell pins back to an exact entry in our + // RenderState and validate them later without dereferencing a + // potentially stale node. + // + // We can skip the writes when the pins and serials are unchanged: + // if we're not redrawing, every value was stored by a prior update + // (row count changes force a redraw). Within a single update a + // node appears at most once and its stored pins have consecutive + // y values, so if the first and last entries of this chunk's range + // already match then every entry in between matches too. + if (redraw or + row_pins[y].node != node or + row_pins[y].y != chunk.start or + row_serials[y] != node_serial or + row_pins[y + take - 1].node != node or + row_pins[y + take - 1].y != chunk.start + take - 1 or + row_serials[y + take - 1] != node_serial) + { + for ( + row_pins[y..][0..take], + row_serials[y..][0..take], + chunk.start.., + ) |*pin, *serial, py| { + pin.* = .{ .node = node, .y = @intCast(py) }; + serial.* = node_serial; } - - // If our row is dirty then we're dirty. - if (page_rac.row.dirty) break :dirty; - - // Not dirty! - continue; } - // Set that at least one row was dirty. - any_dirty = true; - - // Clear our row dirty, we'll clear our page dirty later. - // We can't clear it now because we have more rows to go through. - page_rac.row.dirty = false; - - // Promote our arena. State is copied by value so we need to - // restore it on all exit paths so we don't leak memory. - var arena = row_arenas[y].promote(alloc); - defer row_arenas[y] = arena.state; - - // Reset our cells if we're rebuilding this row. - if (row_cells[y].len > 0) { - _ = arena.reset(.retain_capacity); - row_cells[y].clearRetainingCapacity(); - row_sels[y] = null; - row_highlights[y] = .empty; - } - row_dirties[y] = true; - - // Get all our cells in the page. - const page_cells: []const page.Cell = p.getCells(page_rac.row); - assert(page_cells.len == self.cols); - - // Copy our raw row data - row_rows[y] = page_rac.row.*; - - // Note: our cells MultiArrayList uses our general allocator. - // We do this on purpose because as rows become dirty, we do - // not want to reallocate space for cells (which are large). This - // was a source of huge slowdown. - // - // Our per-row arena is only used for temporary allocations - // pertaining to cells directly (e.g. graphemes, hyperlinks). - const cells: *std.MultiArrayList(Cell) = &row_cells[y]; - try cells.resize(alloc, self.cols); - - // We always copy our raw cell data. In the case we have no - // managed memory, we can skip setting any other fields. - // - // This is an important optimization. For plain-text screens - // this ends up being something around 300% faster based on - // the `screen-clone` benchmark. - const cells_slice = cells.slice(); - fastmem.copy( - page.Cell, - cells_slice.items(.raw), - page_cells, - ); - if (!page_rac.row.managedMemory()) continue; - - const arena_alloc = arena.allocator(); - const cells_grapheme = cells_slice.items(.grapheme); - const cells_style = cells_slice.items(.style); - for (page_cells, 0..) |*page_cell, x| { - // Append assuming its a single-codepoint, styled cell - // (most common by far). - if (page_cell.style_id > 0) cells_style[x] = p.styles.get( - p.memory, - page_cell.style_id, - ).*; - - // Switch on our content tag to handle less likely cases. - switch (page_cell.content_tag) { - .codepoint => { + if (!redraw and !page_dirty) { + // Only dirty rows (usually none) need a rebuild. Scan the + // dirty flags a group at a time; the dirty bit is directly + // testable on the packed row representation. + var i: usize = 0; + while (take - i >= RowDirtyMask.group_len) : (i += RowDirtyMask.group_len) { + if (RowDirtyMask.match(page_rows, i)) { @branchHint(.likely); - // Primary codepoint goes into `raw` field. - }, - - // If we have a multi-codepoint grapheme, look it up and - // set our content type. - .codepoint_grapheme => { - @branchHint(.unlikely); - cells_grapheme[x] = try arena_alloc.dupe( - u21, - p.lookupGrapheme(page_cell) orelse &.{}, - ); - }, - - .bg_color_rgb => { - @branchHint(.unlikely); - cells_style[x] = .{ .bg_color = .{ .rgb = .{ - .r = page_cell.content.color_rgb.r, - .g = page_cell.content.color_rgb.g, - .b = page_cell.content.color_rgb.b, - } } }; - }, + continue; + } - .bg_color_palette => { - @branchHint(.unlikely); - cells_style[x] = .{ .bg_color = .{ - .palette = page_cell.content.color_palette, - } }; - }, + for (page_rows[i..][0..RowDirtyMask.group_len], i..) |*page_row, j| { + if (!page_row.dirty) continue; + page_row.dirty = false; + any_dirty = true; + try builder.row(p, page_row, y + j); + } + } + while (i < take) : (i += 1) { + const page_row = &page_rows[i]; + if (!page_row.dirty) continue; + page_row.dirty = false; + any_dirty = true; + try builder.row(p, page_row, y + i); + } + } else { + // Rebuild every row in the chunk. + any_dirty = true; + for (page_rows, 0..) |*page_row, i| { + page_row.dirty = false; + try builder.row(p, page_row, y + i); } } + + y += take; } assert(y == self.rows); @@ -640,14 +717,40 @@ pub const RenderState = struct { self.dirty = .partial; } - // Finalize our final dirty page - if (last_dirty_page) |last_p| last_p.dirty = false; - // Clear our dirty flags t.flags.dirty = .{}; s.dirty = .{}; } + /// Complete a prior `beginUpdate` call by performing any deferred + /// work. At the time of writing, this denormalizes the pending + /// style runs into the per-cell style data. + /// + /// This only reads and writes memory owned by the render state, so + /// it is safe to call while the terminal is being modified (no + /// terminal lock is required). + pub fn endUpdate(self: *RenderState) void { + // Common case: no styled rows were rebuilt. + if (self.pending_styles.items.len == 0) return; + + const row_data = self.row_data.slice(); + const row_cells = row_data.items(.cells); + for (self.pending_styles.items) |run| { + // 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); + + @memset(styles[start..end], run.style); + } + self.pending_styles.clearRetainingCapacity(); + } + /// 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 @@ -678,22 +781,28 @@ pub const RenderState = struct { const row_arenas = row_data.items(.arena); const row_dirties = row_data.items(.dirty); const row_pins = row_data.items(.pin); + const row_serials = row_data.items(.serial); const row_highlights_slice = row_data.items(.highlights); for ( row_arenas, row_pins, + row_serials, row_highlights_slice, row_dirties, - ) |*row_arena, row_pin, *row_highlights, *dirty| { + ) |*row_arena, row_pin, row_serial, *row_highlights, *dirty| { for (hls) |hl| { const chunks_slice = hl.chunks.slice(); const nodes = chunks_slice.items(.node); + const serials = chunks_slice.items(.serial); const starts = chunks_slice.items(.start); const ends = chunks_slice.items(.end); for (0.., nodes) |i, node| { - // If this node doesn't match or we're not within - // the row range, skip it. + // If this node generation doesn't match or we're not + // within the row range, skip it. Both serials are copied + // values, so this never dereferences a node outside the + // terminal lock. if (node != row_pin.node or + serials[i] != row_serial or row_pin.y < starts[i] or row_pin.y >= ends[i]) continue; @@ -825,7 +934,7 @@ pub const RenderState = struct { // Grab our link ID const link_pin: PageList.Pin = row_pins[viewport_point.y]; - const link_page: *page.Page = &link_pin.node.data; + const link_page: *page.Page = link_pin.node.page(); const link = link: { const rac = link_page.getRowAndCell( viewport_point.x, @@ -854,7 +963,7 @@ pub const RenderState = struct { for (0.., cells.items(.raw)) |x, cell| { if (!cell.hyperlink) continue; - const other_page: *page.Page = &pin.node.data; + const other_page: *page.Page = pin.node.page(); const other = link: { const rac = other_page.getRowAndCell(x, pin.y); const link_id = other_page.lookupHyperlink(rac.cell) orelse continue; @@ -879,6 +988,231 @@ pub const RenderState = struct { } }; +/// The number of rows/cells we scan as a single group when looking for +/// dirty rows or special cells. Rows and cells are small packed structs +/// so a group is scanned with a handful of vector operations. +const scan_group_len = 8; + +/// Group scan helper for the row dirty flag. A row that matches has +/// its dirty flag unset. +const RowDirtyMask = page.Mask( + page.Row, + &.{"dirty"}, + scan_group_len, +); + +/// Group scan helper for the cell fields that require managed memory +/// handling. A cell that matches is a plain (possibly zero) codepoint +/// with a default style, requiring no work beyond the raw copy. See +/// RowBuilder.row. +const CellSpecialMask = page.Mask(page.Cell, &.{ + "content_tag", + "style_id", +}, scan_group_len); + +/// Internal helper for RenderState.update that rebuilds a single row of +/// the render state from the current page contents. +const RowBuilder = struct { + alloc: Allocator, + cols: usize, + arenas: []ArenaAllocator.State, + raws: []page.Row, + cells: []std.MultiArrayList(RenderState.Cell), + sels: []?[2]size.CellCountInt, + highlights: []std.ArrayList(RenderState.Highlight), + dirties: []bool, + pending_styles: *std.ArrayList(RenderState.StyleRun), + + fn row( + b: *const RowBuilder, + p: *page.Page, + page_row: *const page.Row, + vy: usize, + ) Allocator.Error!void { + // Promote our arena. State is copied by value so we need to + // restore it on all exit paths so we don't leak memory. + var arena = b.arenas[vy].promote(b.alloc); + defer b.arenas[vy] = arena.state; + + // Reset our per-row state if we're rebuilding this row. A + // non-zero cell length means the row was populated by a prior + // update. + if (b.cells[vy].len > 0) { + _ = arena.reset(.retain_capacity); + b.sels[vy] = null; + b.highlights[vy] = .empty; + } + b.dirties[vy] = true; + + // Get all our cells in the page. + const page_cells: []const page.Cell = page_row.cells.ptr(p.memory)[0..b.cols]; + + // Copy our raw row data + b.raws[vy] = page_row.*; + + // Note: our cells MultiArrayList uses our general allocator. + // We do this on purpose because as rows become dirty, we do + // not want to reallocate space for cells (which are large). This + // was a source of huge slowdown. + // + // 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); + + // We always copy our raw cell data. In the case we have no + // managed memory, we can skip setting any other fields. + // + // This is an important optimization. For plain-text screens + // this ends up being something around 300% faster based on + // the `screen-clone` benchmark. + const cells_slice = cells.slice(); + fastmem.copy( + page.Cell, + cells_slice.items(.raw), + page_cells, + ); + if (!page_row.managedMemory()) return; + + const arena_alloc = arena.allocator(); + const cells_grapheme = cells_slice.items(.grapheme); + const n = page_cells.len; + var x: usize = 0; + scan: while (x < n) { + // Skip runs of plain cells a group at a time. Cells that + // need managed handling are often rare even within rows that + // have managed memory (e.g. a row is "styled" if a single + // cell has a style) so groups are skipped with a few vector + // operations. + while (n - x >= CellSpecialMask.group_len) { + if (!CellSpecialMask.match(page_cells, x)) break; + x += CellSpecialMask.group_len; + } + + // Scalar scan to the next special cell. + while (true) { + if (x >= n) break :scan; + if (!CellSpecialMask.matchScalar(page_cells[x])) break; + x += 1; + } + + const page_cell = &page_cells[x]; + + switch (page_cell.content_tag) { + // Single-codepoint styled cells are by far the most + // common special cells, and they usually come in long + // runs sharing one style ID (e.g. a fully styled row + // usually uses a single style). Find the run and record + // it: this does one style lookup per run and defers the + // (large) per-cell fill to endUpdate, outside of any + // terminal locks. + .codepoint => { + @branchHint(.likely); + const sid = page_cell.style_id; + assert(sid > 0); // special + codepoint implies styled + const style_val: Style = p.styles.get(p.memory, sid).*; + + // A cell continues the run if its masked special + // bits are exactly the style ID of the run (in + // particular the content tag must be a plain + // codepoint). We can check groups of cells at a + // time this way. + const pattern = CellSpecialMask.pattern(page_cell.*); + const start = x; + x += 1; + while (n - x >= CellSpecialMask.group_len) { + if (!CellSpecialMask.eql( + page_cells, + x, + pattern, + )) break; + x += CellSpecialMask.group_len; + } + while (x < n) : (x += 1) { + if (!CellSpecialMask.eqlScalar( + page_cells[x], + pattern, + )) break; + } + + try b.pending_styles.append(b.alloc, .{ + .y = @intCast(vy), + .start = @intCast(start), + .end = @intCast(x), + .style = style_val, + }); + }, + + // If we have a multi-codepoint grapheme, look it up and + // set our content type. Note grapheme cells may also + // be styled. The style must be recorded as a run (rather + // than written directly) so that it is ordered correctly + // relative to possibly-stale runs from prior updates. + .codepoint_grapheme => { + if (page_cell.style_id > 0) { + try b.pending_styles.append(b.alloc, .{ + .y = @intCast(vy), + .start = @intCast(x), + .end = @intCast(x + 1), + .style = p.styles.get( + p.memory, + page_cell.style_id, + ).*, + }); + } + cells_grapheme[x] = try arena_alloc.dupe( + u21, + p.lookupGrapheme(page_cell) orelse &.{}, + ); + x += 1; + }, + + // Background-color-only cells. The style is derived + // entirely from the cell contents. Consecutive cleared + // cells with the same background are bit-identical, so + // we run-detect on full equality (e.g. a line cleared + // with a background color pending is one run). + .bg_color_rgb, .bg_color_palette => { + const style_val: Style = switch (page_cell.content_tag) { + .bg_color_rgb => .{ .bg_color = .{ .rgb = .{ + .r = page_cell.content.color_rgb.r, + .g = page_cell.content.color_rgb.g, + .b = page_cell.content.color_rgb.b, + } } }, + .bg_color_palette => .{ .bg_color = .{ + .palette = page_cell.content.color_palette, + } }, + else => unreachable, + }; + + const first_bits = CellSpecialMask.bits(page_cell.*); + const start = x; + x += 1; + while (n - x >= CellSpecialMask.group_len) { + if (!CellSpecialMask.eqlExact( + page_cells, + x, + first_bits, + )) break; + x += CellSpecialMask.group_len; + } + while (x < n) : (x += 1) { + if (CellSpecialMask.bits(page_cells[x]) != first_bits) + break; + } + + try b.pending_styles.append(b.alloc, .{ + .y = @intCast(vy), + .start = @intCast(start), + .end = @intCast(x), + .style = style_val, + }); + }, + } + } + } +}; + test "styled" { const testing = std.testing; const alloc = testing.allocator; @@ -979,6 +1313,331 @@ test "styled text" { try testing.expectEqual(0, cells[0].get(3).raw.codepoint()); } +/// Verifies that an incrementally updated render state has identical +/// contents to a from-scratch rebuild. This is the load-bearing check +/// for our dirty tracking: if any terminal operation changes row +/// contents without setting a dirty signal that `update` honors +/// (terminal dirty, screen dirty, page dirty, row dirty, viewport pin, +/// or dimensions), the incremental state will contain stale rows and +/// this comparison will fail. +fn testCompareStates( + incremental: *const RenderState, + fresh: *const RenderState, +) !void { + const testing = std.testing; + + // Row metadata that is allowed to be stale in an incremental + // update. Dirty tracking only guarantees that VISUAL changes are + // flagged (see page.Row.dirty); these fields are non-visual + // metadata that the terminal may change without dirtying the row + // (e.g. Screen.cursorResetWrap clears wrap flags without a dirty + // mark). This staleness predates the chunked update + // implementation; it is present in the row-iterator implementation + // as well. + const StaleOkMask = page.Mask(page.Row, &.{ + "wrap", + "wrap_continuation", + "semantic_prompt", + "dirty", + }, 1); + + try testing.expectEqual(fresh.rows, incremental.rows); + try testing.expectEqual(fresh.cols, incremental.cols); + try testing.expectEqual(fresh.cursor.active, incremental.cursor.active); + try testing.expectEqual(fresh.cursor.viewport, incremental.cursor.viewport); + try testing.expectEqual( + @as(page.Cell.Backing, @bitCast(fresh.cursor.cell)), + @as(page.Cell.Backing, @bitCast(incremental.cursor.cell)), + ); + + const inc_data = incremental.row_data.slice(); + const new_data = fresh.row_data.slice(); + try testing.expectEqual(new_data.len, inc_data.len); + for (0..new_data.len) |y| { + errdefer std.log.warn("mismatch on row y={}", .{y}); + + // Pins must match exactly. + const inc_pin = inc_data.items(.pin)[y]; + const new_pin = new_data.items(.pin)[y]; + try testing.expectEqual(new_pin.node, inc_pin.node); + try testing.expectEqual(new_pin.y, inc_pin.y); + + // Raw row data must match, except for non-visual metadata + // fields which may legitimately be stale (see StaleOkMask). + const inc_row = inc_data.items(.raw)[y]; + const new_row = new_data.items(.raw)[y]; + try testing.expectEqual( + StaleOkMask.strip(new_row), + StaleOkMask.strip(inc_row), + ); + + const inc_cells = inc_data.items(.cells)[y].slice(); + const new_cells = new_data.items(.cells)[y].slice(); + try testing.expectEqual(new_cells.len, inc_cells.len); + const managed = new_row.managedMemory(); + for (0..new_cells.len) |x| { + errdefer std.log.warn("mismatch on cell x={}", .{x}); + + // Raw cell contents must match. + const inc_cell = inc_cells.items(.raw)[x]; + const new_cell = new_cells.items(.raw)[x]; + try testing.expectEqual( + @as(page.Cell.Backing, @bitCast(new_cell)), + @as(page.Cell.Backing, @bitCast(inc_cell)), + ); + + // The style is only defined if the cell is styled or is + // a bg-color cell within a row that has managed memory. + if (new_cell.style_id != 0 or + (managed and switch (new_cell.content_tag) { + .bg_color_rgb, .bg_color_palette => true, + else => false, + })) + { + try testing.expect(std.meta.eql( + new_cells.items(.style)[x], + inc_cells.items(.style)[x], + )); + } + + // Graphemes are only defined for grapheme cells. + if (new_cell.content_tag == .codepoint_grapheme) { + try testing.expectEqualSlices( + u21, + new_cells.items(.grapheme)[x], + inc_cells.items(.grapheme)[x], + ); + } + } + } +} + +test "incremental updates match full rebuild" { + const testing = std.testing; + const alloc = testing.allocator; + + // Deterministic so failures are reproducible. + var prng = std.Random.DefaultPrng.init(0xB0BA_CAFE); + const rand = prng.random(); + + var t = try Terminal.init(alloc, .{ + .cols = 20, + .rows = 8, + .max_scrollback = 500, + }); + defer t.deinit(alloc); + + var s = t.vtStream(); + defer s.deinit(); + + var inc: RenderState = .empty; + defer inc.deinit(alloc); + + var buf: [64]u8 = undefined; + for (0..300) |_| { + // Perform a random batch of operations between updates. + for (0..rand.intRangeAtMost(usize, 1, 6)) |_| { + switch (rand.intRangeAtMost(u8, 0, 18)) { + // Plain text (possibly wrapping and scrolling). + 0, 1, 2 => for (0..rand.intRangeAtMost(usize, 1, 30)) |_| { + s.nextSlice(&.{rand.intRangeAtMost(u8, 'A', 'Z')}); + }, + + // Newlines to build scrollback and trigger pruning. + 3, 4 => for (0..rand.intRangeAtMost(usize, 1, 10)) |_| { + s.nextSlice("x\r\n"); + }, + + // Cursor movement. + 5 => s.nextSlice(try std.fmt.bufPrint(&buf, "\x1b[{};{}H", .{ + rand.intRangeAtMost(u16, 1, 8), + rand.intRangeAtMost(u16, 1, 20), + })), + + // Styling: bold, truecolor bg, palette fg, reset. + 6 => s.nextSlice(switch (rand.intRangeAtMost(u8, 0, 3)) { + 0 => "\x1b[1m", + 1 => "\x1b[48;2;30;60;90m", + 2 => "\x1b[38;5;120m", + else => "\x1b[0m", + }), + + // Erase ops (EL, ED variants including scrollback). + 7 => s.nextSlice(switch (rand.intRangeAtMost(u8, 0, 4)) { + 0 => "\x1b[K", + 1 => "\x1b[1K", + 2 => "\x1b[J", + 3 => "\x1b[2J", + else => "\x1b[3J", + }), + + // Insert/delete lines (row rotations within regions). + 8 => s.nextSlice(try std.fmt.bufPrint(&buf, "\x1b[{}L", .{ + rand.intRangeAtMost(u16, 1, 4), + })), + 9 => s.nextSlice(try std.fmt.bufPrint(&buf, "\x1b[{}M", .{ + rand.intRangeAtMost(u16, 1, 4), + })), + + // Scroll up/down (page-dirty row rotations). + 10 => s.nextSlice(try std.fmt.bufPrint(&buf, "\x1b[{}S", .{ + rand.intRangeAtMost(u16, 1, 4), + })), + 11 => s.nextSlice(try std.fmt.bufPrint(&buf, "\x1b[{}T", .{ + rand.intRangeAtMost(u16, 1, 4), + })), + + // Set/reset scroll regions to exercise bounded scrolls. + 12 => { + const top = rand.intRangeAtMost(u16, 1, 4); + const bot = rand.intRangeAtMost(u16, top + 1, 8); + s.nextSlice(try std.fmt.bufPrint( + &buf, + "\x1b[{};{}r", + .{ top, bot }, + )); + }, + + // Insert/delete/erase chars within a row. + 13 => s.nextSlice(try std.fmt.bufPrint(&buf, "\x1b[{}@", .{ + rand.intRangeAtMost(u16, 1, 5), + })), + 14 => s.nextSlice(try std.fmt.bufPrint(&buf, "\x1b[{}P", .{ + rand.intRangeAtMost(u16, 1, 5), + })), + + // Reverse index (scroll down at top). + 15 => s.nextSlice("\x1bM"), + + // Wide chars and multi-codepoint graphemes. + 16 => s.nextSlice("字👨👩👧"), + + // Alternate screen switching (screen key redraw path). + 17 => s.nextSlice(if (rand.boolean()) + "\x1b[?1049h" + else + "\x1b[?1049l"), + + // DECALN full-screen fill. + 18 => s.nextSlice("\x1b#8"), + + else => unreachable, + } + } + + // Occasionally scroll the viewport into scrollback and back. + switch (rand.intRangeAtMost(u8, 0, 9)) { + 0 => t.scrollViewport(.{ .delta = -3 }), + 1 => t.scrollViewport(.{ .delta = 2 }), + 2 => t.scrollViewport(.bottom), + 3 => t.scrollViewport(.top), + else => {}, + } + + // Update our incremental state first: it must consume the dirty + // state. The fresh state always fully rebuilds (its dimensions + // start empty so it always redraws) and so does not depend on + // any dirty flags. + try inc.update(alloc, &t); + + var fresh: RenderState = .empty; + defer fresh.deinit(alloc); + try fresh.update(alloc, &t); + + try testCompareStates(&inc, &fresh); + } +} + +test "begin and end update" { + const testing = std.testing; + const alloc = testing.allocator; + + var t = try Terminal.init(alloc, .{ + .cols = 10, + .rows = 3, + }); + defer t.deinit(alloc); + + var s = t.vtStream(); + defer s.deinit(); + s.nextSlice("\x1b[1mAB"); // Bold + s.nextSlice("\x1b[0;3mC"); // Italic + + var state: RenderState = .empty; + defer state.deinit(alloc); + try state.beginUpdate(alloc, &t); + + // We should have pending style runs on row 0: one for the bold + // run and one for the italic run. + { + const runs = state.pending_styles.items; + try testing.expectEqual(2, runs.len); + try testing.expectEqual(0, runs[0].y); + try testing.expectEqual(0, runs[0].start); + try testing.expectEqual(2, runs[0].end); + try testing.expect(runs[0].style.flags.bold); + try testing.expectEqual(0, runs[1].y); + try testing.expectEqual(2, runs[1].start); + try testing.expectEqual(3, runs[1].end); + try testing.expect(runs[1].style.flags.italic); + } + + // End our update. This should denormalize the runs into cells + // and clear the pending runs. + state.endUpdate(); + { + try testing.expectEqual(0, state.pending_styles.items.len); + + const row_data = state.row_data.slice(); + const cells = row_data.items(.cells); + try testing.expect(cells[0].get(0).style.flags.bold); + try testing.expect(cells[0].get(1).style.flags.bold); + try testing.expect(cells[0].get(2).style.flags.italic); + } +} + +test "bg color cells" { + const testing = std.testing; + const alloc = testing.allocator; + + var t = try Terminal.init(alloc, .{ + .cols = 10, + .rows = 3, + }); + defer t.deinit(alloc); + + var s = t.vtStream(); + defer s.deinit(); + + // Write a styled cell (so the row has managed memory) then erase + // the rest of the line with a palette background pending. The + // erase produces bg_color content cells rather than styled cells. + s.nextSlice("\x1b[1mA\x1b[48;5;1m\x1b[K"); + + var state: RenderState = .empty; + defer state.deinit(alloc); + try state.update(alloc, &t); + + const row_data = state.row_data.slice(); + const cells = row_data.items(.cells); + { + const cell = cells[0].get(0); + try testing.expectEqual('A', cell.raw.codepoint()); + try testing.expect(cell.style.flags.bold); + } + for (1..10) |x| { + const cell = cells[0].get(x); + try testing.expectEqual( + page.Cell.ContentTag.bg_color_palette, + cell.raw.content_tag, + ); + try testing.expectEqual( + Style.Color{ .palette = 1 }, + cell.style.bg_color, + ); + } +} + test "grapheme" { const testing = std.testing; const alloc = testing.allocator; @@ -1343,7 +2002,7 @@ test "linkCells with scrollback spanning pages" { defer s.deinit(); const pages = &t.screens.active.pages; - const first_page_cap = pages.pages.first.?.data.capacity.rows; + const first_page_cap = pages.pages.first.?.capacity().rows; // Fill first page for (0..first_page_cap - 1) |_| s.nextSlice("\r\n"); @@ -1405,6 +2064,73 @@ test "linkCells with invalid viewport point" { } } +test "flattened highlights require matching page serial" { + const testing = std.testing; + const alloc = testing.allocator; + + var t = try Terminal.init(alloc, .{ + .cols = 10, + .rows = 3, + }); + defer t.deinit(alloc); + + // Capture the live generation while terminal-owned state is in scope so + // we can also verify beginUpdate copies it into the render row. + const live_pin = t.screens.active.pages.getTopLeft(.viewport); + const live_serial = live_pin.node.serial; + + var state: RenderState = .empty; + defer state.deinit(alloc); + try state.update(alloc, &t); + + const pin: PageList.Pin = pin: { + const row_data = state.row_data.slice(); + @memset(row_data.items(.dirty), false); + state.dirty = .false; + break :pin row_data.items(.pin)[0]; + }; + const row_serial = state.row_data.items(.serial)[0]; + try testing.expectEqual(live_pin.node, pin.node); + try testing.expectEqual(live_serial, row_serial); + + // Use the exact node pointer and row captured by the render state, but a + // different generation. A reused node address must not make this stale + // flattened highlight match. + var hl: highlight.Flattened = .{ + .chunks = .empty, + .top_x = 2, + .bot_x = 4, + }; + defer hl.deinit(alloc); + try hl.chunks.append(alloc, .{ + .node = pin.node, + .serial = live_serial ^ 1, + .start = pin.y, + .end = pin.y + 1, + }); + + try state.updateHighlightsFlattened(alloc, 42, &.{hl}); + { + const row_data = state.row_data.slice(); + try testing.expectEqual(0, row_data.items(.highlights)[0].items.len); + try testing.expect(!row_data.items(.dirty)[0]); + try testing.expectEqual(.false, state.dirty); + } + + // The same chunk is accepted once its copied serial also matches. + hl.chunks.items(.serial)[0] = live_serial; + try state.updateHighlightsFlattened(alloc, 42, &.{hl}); + { + const row_data = state.row_data.slice(); + const row_highlights = row_data.items(.highlights)[0].items; + try testing.expectEqual(1, row_highlights.len); + try testing.expectEqual(42, row_highlights[0].tag); + try testing.expectEqual([2]size.CellCountInt{ 2, 4 }, row_highlights[0].range); + try testing.expect(row_data.items(.dirty)[0]); + try testing.expectEqual(.partial, state.dirty); + } +} + test "dirty row resets highlights" { const testing = std.testing; const alloc = testing.allocator; diff --git a/vendor/libghostty-vt/src/terminal/search/Thread.zig b/vendor/libghostty-vt/src/terminal/search/Thread.zig index fa09af5f..d7f0d562 100644 --- a/vendor/libghostty-vt/src/terminal/search/Thread.zig +++ b/vendor/libghostty-vt/src/terminal/search/Thread.zig @@ -121,7 +121,11 @@ pub fn deinit(self: *Thread) void { // Nothing can possibly access the mailbox anymore, destroy it. self.mailbox.destroy(self.alloc); - if (self.search) |*s| s.deinit(); + if (self.search) |*s| { + self.opts.mutex.lock(); + defer self.opts.mutex.unlock(); + s.deinit(self.opts.terminal); + } } /// The main entrypoint for the thread. @@ -252,11 +256,15 @@ fn drainMailbox(self: *Thread) !void { fn select(self: *Thread, sel: ScreenSearch.Select) !void { const s = if (self.search) |*s| s else return; - const screen_search = s.screens.getPtr(s.last_screen.key) orelse return; self.opts.mutex.lock(); defer self.opts.mutex.unlock(); + // 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); @@ -308,7 +316,11 @@ fn changeNeedle(self: *Thread, needle: []const u8) !void { // If our search is unchanged, do nothing. if (std.ascii.eqlIgnoreCase(s.viewport.needle(), needle)) return; - s.deinit(); + { + self.opts.mutex.lock(); + defer self.opts.mutex.unlock(); + s.deinit(self.opts.terminal); + } self.search = null; // When the search changes then we need to emit that it stopped. @@ -497,6 +509,11 @@ const Search = struct { /// 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), + /// All state related to screen switches, collected so that when /// we switch screens it makes everything related stale, too. last_screen: ScreenState, @@ -537,16 +554,35 @@ const Search = struct { return .{ .viewport = vp, .screens = .init(.{}), + .screen_generations = .init(.{}), .last_screen = .{ .key = .primary }, .last_complete = false, .stale_viewport_matches = true, }; } - pub fn deinit(self: *Search) void { + pub fn deinit(self: *Search, t: *Terminal) void { self.viewport.deinit(); var it = self.screens.iterator(); - while (it.next()) |entry| entry.value.deinit(); + 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. @@ -629,18 +665,16 @@ const Search = struct { // Remove screens we have that no longer exist or changed. var it = self.screens.iterator(); while (it.next()) |entry| { - const remove: bool = remove: { - // If the screen doesn't exist at all, remove it. - const actual = t.screens.all.get(entry.key) orelse break :remove true; - - // If the screen pointer changed, remove it, the screen - // was totally reinitialized. - break :remove actual != entry.value.screen; - }; + const remove = !self.screenIsValid( + &t.screens, + entry.key, + entry.value, + ); if (remove) { - entry.value.deinit(); + entry.value.deinitScreenInvalid(); _ = self.screens.remove(entry.key); + _ = self.screen_generations.remove(entry.key); } } } @@ -649,7 +683,7 @@ const Search = struct { var it = t.screens.all.iterator(); while (it.next()) |entry| { if (self.screens.contains(entry.key)) continue; - self.screens.put(entry.key, ScreenSearch.init( + const screen_search = ScreenSearch.init( alloc, entry.value.*, self.viewport.needle(), @@ -664,7 +698,12 @@ const Search = struct { ); continue; }, - }); + }; + self.screens.put(entry.key, screen_search); + self.screen_generations.put( + entry.key, + t.screens.generation(entry.key), + ); } } @@ -903,3 +942,32 @@ test { } }, t.screens.active.pages.pointFromPin(.screen, sel.end).?); } } + +test "select after active screen removal" { + const alloc = testing.allocator; + var mutex: std.Thread.Mutex = .{}; + var t: Terminal = try .init(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/active.zig b/vendor/libghostty-vt/src/terminal/search/active.zig index 692a10e1..b99b2357 100644 --- a/vendor/libghostty-vt/src/terminal/search/active.zig +++ b/vendor/libghostty-vt/src/terminal/search/active.zig @@ -56,6 +56,10 @@ pub const ActiveSearch = struct { // Clear our previous sliding window self.window.clearAndRetainCapacity(); + // An empty needle represents an inactive search and has no overlap + // or history to load. + if (self.window.needle.len == 0) return null; + // First up, add enough pages to cover the active area. var rem: usize = list.rows; var node_ = list.pages.last; @@ -68,26 +72,22 @@ pub const ActiveSearch = struct { // page that contains the active area. We go to the previous // page once more since its the first page of our required // overlap. - if (rem <= node.data.size.rows) { + if (rem <= node.rows()) { node_ = node.prev; break; } - rem -= node.data.size.rows; + rem -= node.rows(); } // Next, add enough overlap to cover needle.len - 1 bytes (if it // exists) so we can cover the overlap. while (node_) |node| : (node_ = node.prev) { - // If the last row of this node isn't wrapped we can't overlap. - const row = node.data.getRow(node.data.size.rows - 1); - if (!row.wrap) break; - // We could be more accurate here and count bytes since the // last wrap but its complicated and unlikely multiple pages // wrap so this should be fine. - const added = try self.window.append(node); - if (added >= self.window.needle.len - 1) break; + const appended = try self.window.appendIfWrapped(node) orelse break; + if (appended.content_len >= self.window.needle.len - 1) break; } // Return the last node we added to our window. diff --git a/vendor/libghostty-vt/src/terminal/search/pagelist.zig b/vendor/libghostty-vt/src/terminal/search/pagelist.zig index f76ad4e4..98a24c18 100644 --- a/vendor/libghostty-vt/src/terminal/search/pagelist.zig +++ b/vendor/libghostty-vt/src/terminal/search/pagelist.zig @@ -3,6 +3,7 @@ const Allocator = std.mem.Allocator; const testing = std.testing; const terminal = @import("../main.zig"); const point = terminal.point; +const size = terminal.size; const FlattenedHighlight = @import("../highlight.zig").Flattened; const Page = terminal.Page; const PageList = terminal.PageList; @@ -56,8 +57,8 @@ pub const PageListSearch = struct { // be moved somewhere safe. const pin = try list.trackPin(.{ .node = start, - .y = start.data.size.rows - 1, - .x = start.data.size.cols - 1, + .y = start.rows() - 1, + .x = start.cols() - 1, }); errdefer list.untrackPin(pin); @@ -84,6 +85,13 @@ pub const PageListSearch = struct { self.list.untrackPin(self.pin); } + /// Release owned search buffers after the PageList itself has already + /// been destroyed. Its pin belonged to the PageList pool and was freed + /// with that list, so it must not be untracked here. + pub fn deinitListInvalid(self: *PageListSearch) void { + self.window.deinit(); + } + /// Return the next match in the loaded page nodes. If this returns /// null then the PageList search needs to be fed the next node(s). /// Call, `feed` to do this. @@ -121,10 +129,13 @@ pub const PageListSearch = struct { // get our desired amount of data. var node_: ?*PageList.List.Node = self.pin.node.prev; while (node_) |node| : (node_ = node.prev) { - rem -|= try self.window.append(node); + rem -|= (try self.window.append(node)).content_len; // Move our tracked pin to the new node. self.pin.node = node; + self.pin.y = node.rows() - 1; + self.pin.x = node.cols() - 1; + self.pin.garbage = false; if (rem == 0) break; } @@ -190,7 +201,7 @@ test "feed multiple pages with matches" { defer s.deinit(); // Fill up first page - const first_page_rows = t.screens.active.pages.pages.first.?.data.capacity.rows; + const first_page_rows = t.screens.active.pages.pages.first.?.capacity().rows; for (0..first_page_rows - 1) |_| s.nextSlice("\r\n"); s.nextSlice("Fizz"); try testing.expect(t.screens.active.pages.pages.first == t.screens.active.pages.pages.last); @@ -234,7 +245,7 @@ test "feed multiple pages no matches" { defer s.deinit(); // Fill up first page - const first_page_rows = t.screens.active.pages.pages.first.?.data.capacity.rows; + const first_page_rows = t.screens.active.pages.pages.first.?.capacity().rows; for (0..first_page_rows - 1) |_| s.nextSlice("\r\n"); s.nextSlice("Hello"); @@ -272,7 +283,7 @@ test "feed iteratively through multiple matches" { var s = t.vtStream(); defer s.deinit(); - const first_page_rows = t.screens.active.pages.pages.first.?.data.capacity.rows; + const first_page_rows = t.screens.active.pages.pages.first.?.capacity().rows; // Fill first page with a match at the end for (0..first_page_rows - 1) |_| s.nextSlice("\r\n"); @@ -313,7 +324,7 @@ test "feed with match spanning page boundary" { var s = t.vtStream(); defer s.deinit(); - const first_page_rows = t.screens.active.pages.pages.first.?.data.capacity.rows; + const first_page_rows = t.screens.active.pages.pages.first.?.capacity().rows; // Fill first page ending with "Te" for (0..first_page_rows - 1) |_| s.nextSlice("\r\n"); @@ -359,6 +370,76 @@ test "feed with match spanning page boundary" { try testing.expect(!try search.feed()); } +test "compressed history match spanning page boundary remains compressed" { + const alloc = testing.allocator; + var t: Terminal = try .init(alloc, .{ + .cols = 80, + .rows = 24, + .max_scrollback = 10 * 1024 * 1024, + }); + defer t.deinit(alloc); + + var stream = t.vtStream(); + defer stream.deinit(); + + const pages = &t.screens.active.pages; + const first = pages.pages.first.?; + const first_page_rows = first.capacity().rows; + + // Put half the needle on either side of a soft-wrapped page boundary. + for (0..first_page_rows - 1) |_| stream.nextSlice("\r\n"); + for (0..pages.cols - 2) |_| stream.nextSlice("x"); + stream.nextSlice("Test"); + const second = first.next.?; + try testing.expectEqual(second, pages.pages.last.?); + + // Move both matching pages completely into history. The third page holds + // the active area, making the first two eligible for compression. + while (pages.pages.last.? == second) stream.nextSlice("\r\n"); + for (0..pages.rows) |_| stream.nextSlice("\r\n"); + try testing.expect(pages.getTopLeft(.active).node != second); + + _ = pages.compress(.full); + try testing.expectEqual(PageList.List.Node.Storage.compressed, first.storage()); + try testing.expectEqual(PageList.List.Node.Storage.compressed, second.storage()); + const compressed = pages.memoryStats(); + try testing.expect(compressed.compressed_pages >= 2); + + var search: PageListSearch = try .init( + alloc, + "Test", + pages, + pages.pages.last.?, + ); + defer search.deinit(); + + // Drain and feed incrementally until the boundary match is available. The + // exact number of feeds depends on how much blank-page text the formatter + // trims, so bound progress by the number of pages rather than encoding + // that implementation detail in the test. + var found: ?FlattenedHighlight = null; + for (0..pages.totalPages() + 1) |_| { + if (search.next()) |match| { + found = match; + break; + } + if (!try search.feed()) break; + } + try testing.expect(found != null); + + const match = found.?.untracked(); + try testing.expectEqual(first, match.start.node); + try testing.expectEqual(second, match.end.node); + try testing.expectEqual(@as(size.CellCountInt, pages.cols - 2), match.start.x); + try testing.expectEqual(@as(size.CellCountInt, 1), match.end.x); + + // Search owns formatted text and coordinate maps, not page snapshots. The + // original raw mappings therefore remain discarded after matching. + try testing.expectEqual(compressed, pages.memoryStats()); + try testing.expectEqual(PageList.List.Node.Storage.compressed, first.storage()); + try testing.expectEqual(PageList.List.Node.Storage.compressed, second.storage()); +} + test "feed with match spanning page boundary with newline" { const alloc = testing.allocator; var t: Terminal = try .init(alloc, .{ .cols = 80, .rows = 24 }); @@ -367,7 +448,7 @@ test "feed with match spanning page boundary with newline" { var s = t.vtStream(); defer s.deinit(); - const first_page_rows = t.screens.active.pages.pages.first.?.data.capacity.rows; + const first_page_rows = t.screens.active.pages.pages.first.?.capacity().rows; // Fill first page ending with "Te" for (0..first_page_rows - 1) |_| s.nextSlice("\r\n"); @@ -404,14 +485,14 @@ test "feed with pruned page" { // Grow to capacity const page1_node = p.pages.last.?; - const page1 = page1_node.data; + const page1 = page1_node.page(); for (0..page1.capacity.rows - page1.size.rows) |_| { try testing.expect(try p.grow() == null); } // Grow and allocate one more page. Then fill that page up. const page2_node = (try p.grow()).?; - const page2 = page2_node.data; + const page2 = page2_node.page(); for (0..page2.capacity.rows - page2.size.rows) |_| { try testing.expect(try p.grow() == null); } @@ -439,3 +520,31 @@ test "feed with pruned page" { // Feed should still do nothing try testing.expect(!try search.feed()); } + +test "feed keeps its tracked pin within a shorter page" { + const alloc = testing.allocator; + var pages: PageList = try .init(alloc, 10, 2, null); + defer pages.deinit(); + + const first = pages.pages.first.?; + while (first.rows() < first.capacity().rows) _ = try pages.grow(); + + const second = (try pages.grow()).?; + while (second.rows() < second.capacity().rows) _ = try pages.grow(); + + try pages.split(.{ .node = first, .y = 1, .x = 0 }); + const shorter = first.next.?; + try testing.expect(shorter.rows() < second.rows()); + + var search: PageListSearch = try .init( + alloc, + "x", + &pages, + second, + ); + defer search.deinit(); + + try testing.expect(try search.feed()); + try testing.expectEqual(shorter, search.pin.node); + try testing.expect(pages.pinIsValid(search.pin.*)); +} diff --git a/vendor/libghostty-vt/src/terminal/search/screen.zig b/vendor/libghostty-vt/src/terminal/search/screen.zig index 3e7e316f..e0cd9911 100644 --- a/vendor/libghostty-vt/src/terminal/search/screen.zig +++ b/vendor/libghostty-vt/src/terminal/search/screen.zig @@ -157,10 +157,29 @@ pub const ScreenSearch = struct { } pub fn deinit(self: *ScreenSearch) void { + self.deinitInternal(true); + } + + /// Release owned search state after the underlying Screen has already + /// been destroyed. Tracked pins belonged to that Screen's PageList pool + /// and were freed with it, so only independently owned memory is freed. + pub fn deinitScreenInvalid(self: *ScreenSearch) void { + self.deinitInternal(false); + } + + fn deinitInternal(self: *ScreenSearch, screen_valid: bool) void { const alloc = self.allocator(); self.active.deinit(); - if (self.history) |*h| h.deinit(self.screen); - if (self.selected) |*m| m.deinit(self.screen); + if (self.history) |*h| { + if (screen_valid) { + h.deinit(self.screen); + } else { + h.searcher.deinitListInvalid(); + } + } + if (screen_valid) { + if (self.selected) |*m| m.deinit(self.screen); + } for (self.active_results.items) |*hl| hl.deinit(alloc); self.active_results.deinit(alloc); for (self.history_results.items) |*hl| hl.deinit(alloc); @@ -182,6 +201,33 @@ pub const ScreenSearch = struct { return self.active_results.items.len + self.history_results.items.len; } + /// Reinitialize cached search state after a screen resize. Reflow can + /// replace every PageList node, so even flattened highlights that weren't + /// selected contain invalid node pointers once either dimension changes. + /// + /// Returns true when the reset occurred. The replacement search has + /// already loaded its active area through `init` in that case. + fn resetIfDimensionsChanged(self: *ScreenSearch) Allocator.Error!bool { + if (self.screen.pages.rows == self.rows and + self.screen.pages.cols == self.cols) + { + return false; + } + + const new: ScreenSearch = try .init( + self.allocator(), + self.screen, + self.needle(), + ); + + self.deinit(); + self.* = new; + + assert(self.screen.pages.rows == self.rows); + assert(self.screen.pages.cols == self.cols); + return true; + } + /// Returns all matches as an owned slice (caller must free). /// The matches are ordered from most recent to oldest (e.g. bottom /// of the screen to top of the screen). @@ -260,28 +306,9 @@ pub const ScreenSearch = struct { /// Feed on a complete screen search will perform some cleanup of /// potentially stale history results (pruned) and reclaim some memory. pub fn feed(self: *ScreenSearch) Allocator.Error!void { - // If the screen resizes, we have to reset our entire search. That - // isn't ideal but we don't have a better way right now to handle - // reflowing the search results beyond putting a tracked pin for - // every single result. - if (self.screen.pages.rows != self.rows or - self.screen.pages.cols != self.cols) - { - // Reinit - const new: ScreenSearch = try .init( - self.allocator(), - self.screen, - self.needle(), - ); - - // Deinit/reinit - self.deinit(); - self.* = new; - - // New result should have matching dimensions - assert(self.screen.pages.rows == self.rows); - assert(self.screen.pages.cols == self.cols); - } + // Resize/reflow invalidates every flattened result, not just search + // state that needs another history feed. + _ = try self.resetIfDimensionsChanged(); const history: *PageListSearch = if (self.history) |*h| &h.searcher else { // No history to feed, search is complete. @@ -317,22 +344,88 @@ pub const ScreenSearch = struct { } } + fn historyResultIsValid( + self: *const ScreenSearch, + hl: *const FlattenedHighlight, + ) bool { + const chunks = hl.chunks.slice(); + const nodes = chunks.items(.node); + const serials = chunks.items(.serial); + for (nodes, serials) |node, serial| { + if (!self.screen.pages.nodeIsValid(node, serial)) return false; + } + + return true; + } + + /// Clear a selected history match if its flattened page coordinates are + /// stale. reloadActive only inspects the selected history result, so this + /// avoids validating every cached result on each active-area refresh. + fn validateSelectedHistory(self: *ScreenSearch) void { + const m = if (self.selected) |*m| m else return; + const active_len = self.active_results.items.len; + if (m.idx < active_len) return; + + const history_idx = m.idx - active_len; + if (history_idx < self.history_results.items.len and + self.historyResultIsValid(&self.history_results.items[history_idx])) + { + return; + } + + m.deinit(self.screen); + self.selected = null; + } + fn pruneHistory(self: *ScreenSearch) void { // Go through our history results in order (newest to oldest) to find - // any result that contains an invalid serial. Prune up to that - // point. - for (0..self.history_results.items.len) |i| { + // any result that contains an invalid serial. + var i: usize = 0; + while (i < self.history_results.items.len) { const hl = &self.history_results.items[i]; - const serials = hl.chunks.items(.serial); - const lowest = serials[0]; - if (lowest < self.screen.pages.page_serial_min) { - // Everything from here forward we assume is invalid because - // our history results only get older. + const chunks = hl.chunks.slice(); + const serials = chunks.items(.serial); + const first_serial = serials[0]; + if (first_serial < self.screen.pages.page_serial_epoch) { + // Only a whole-list reset advances the epoch. Results are + // newest to oldest, so this result and the entire remaining + // suffix predate that reset. Drop them without scanning the + // live page list for every captured chunk. const alloc = self.allocator(); + if (self.selected) |*m| { + const first_pruned = self.active_results.items.len + i; + if (m.idx >= first_pruned) { + m.deinit(self.screen); + self.selected = null; + } + } for (self.history_results.items[i..]) |*prune_hl| prune_hl.deinit(alloc); self.history_results.shrinkAndFree(alloc, i); return; } + + // Ordinary pruning, layout changes, and replacements do not + // advance the epoch. Validate their pointer-plus-generation pairs + // against the live list. + if (self.historyResultIsValid(hl)) { + i += 1; + continue; + } + + // Only this result is known to be invalid. Older results may live + // on unrelated pages and remain usable, so remove it individually. + const result_idx = self.active_results.items.len + i; + if (self.selected) |*m| { + if (m.idx == result_idx) { + m.deinit(self.screen); + self.selected = null; + } else if (m.idx > result_idx) { + m.idx -= 1; + } + } + + var removed = self.history_results.orderedRemove(i); + removed.deinit(self.allocator()); } } @@ -392,6 +485,14 @@ pub const ScreenSearch = struct { /// /// The caller must hold the necessary locks to access the screen state. pub fn reloadActive(self: *ScreenSearch) Allocator.Error!void { + // This check must precede all inspection of cached highlights and + // searchers: column reflow may have freed every node they reference. + if (try self.resetIfDimensionsChanged()) return; + + // reloadActive only inspects the selected history result, so validate + // that result without scanning every cached match on this hot path. + self.validateSelectedHistory(); + const tw = reloadActive_tw; // If our selection pin became garbage it means we scrolled off @@ -696,9 +797,14 @@ pub const ScreenSearch = struct { /// access to the underlying screen, since we utilize tracked pins to /// ensure our selection sticks with contents changing. pub fn select(self: *ScreenSearch, to: Select) Allocator.Error!bool { - // All selection requires valid pins so we prune history and - // reload our active area immediately. This ensures all search - // results point to valid nodes. + // A resize can replace any page node, including nodes retained by + // flattened history results. Reset before reloadActive or tracking a + // selected result so no pre-resize pointer is ever dereferenced. + _ = try self.resetIfDimensionsChanged(); + + // Reload validates the selected history result before inspecting its + // cached coordinates. Prune the remaining results afterward so every + // candidate is valid before selection tracks it. try self.reloadActive(); self.pruneHistory(); @@ -798,7 +904,7 @@ pub const ScreenSearch = struct { const active_len = self.active_results.items.len; const history_len = self.history_results.items.len; - const next_idx = if (prev.idx != 0) prev.idx - 1 else active_len - 1 + history_len; + const next_idx = if (prev.idx != 0) prev.idx - 1 else active_len + history_len - 1; const hl: FlattenedHighlight = if (next_idx < active_len) self.active_results.items[active_len - 1 - next_idx] @@ -1090,6 +1196,40 @@ test "select next" { } } +test "select after resize resets stale flattened results" { + const alloc = testing.allocator; + var t: Terminal = try .init(alloc, .{ + .cols = 10, + .rows = 3, + .max_scrollback = std.math.maxInt(usize), + }); + defer t.deinit(alloc); + + var stream = t.vtStream(); + defer stream.deinit(); + stream.nextSlice("Fizz\r\nBuzz"); + + var search: ScreenSearch = try .init(alloc, t.screens.active, "Fizz"); + defer search.deinit(); + try search.searchAll(); + try testing.expect(try search.select(.next)); + try testing.expect(search.selectedMatch() != null); + try testing.expectEqual(@as(size.CellCountInt, 10), search.cols); + + // Column reflow replaces the nodes referenced by the cached flattened + // active result. select() calls reloadActive(), so dimension invalidation + // must run before either path dereferences those old nodes. + try t.screens.active.pages.resize(.{ .cols = 5 }); + try testing.expectEqual(@as(size.CellCountInt, 10), search.cols); + + try testing.expect(try search.select(.next)); + try testing.expectEqual(@as(size.CellCountInt, 5), search.cols); + + const selected = search.selectedMatch().?.untracked(); + try testing.expectEqual(@as(size.CellCountInt, 5), selected.start.node.cols()); + try testing.expectEqual(@as(size.CellCountInt, 5), selected.end.node.cols()); +} + test "select in active changes contents completely" { const alloc = testing.allocator; var t: Terminal = try .init(alloc, .{ .cols = 10, .rows = 5 }); @@ -1380,6 +1520,276 @@ test "select prev with history" { } } +test "select prev wraps when all matches are in history" { + // Regression test: when every match is in scrollback (the active area + // has none, so active_len == 0), selecting prev from index 0 must wrap + // to the last result without underflowing `active_len - 1`. + const alloc = testing.allocator; + var t: Terminal = try .init(alloc, .{ + .cols = 10, + .rows = 2, + .max_scrollback = std.math.maxInt(usize), + }); + defer t.deinit(alloc); + const list: *PageList = &t.screens.active.pages; + + var s = t.vtStream(); + defer s.deinit(); + + // Put the only match in scrollback, then scroll the active area to all + // blank lines so it contains no match (active_len == 0, history_len == 1). + s.nextSlice("Fizz\r\n"); + while (list.totalPages() < 3) s.nextSlice("\r\n"); + for (0..list.rows) |_| s.nextSlice("\r\n"); + + var search: ScreenSearch = try .init(alloc, t.screens.active, "Fizz"); + defer search.deinit(); + try search.searchAll(); + try testing.expectEqual(0, search.active_results.items.len); + + // Select the first match (idx 0), then wrap backwards. This must not + // panic and must keep a valid selection. + _ = try search.select(.next); + _ = try search.select(.prev); + try testing.expect(search.selectedMatch() != null); +} + +test "select after all matches disappear drops the selection" { + // The wrap arithmetic in selectPrev (active_len + history_len - 1) would + // underflow if a selection were ever live while both result lists are + // empty. This guards the invariant that makes that unreachable: when a + // reload/prune empties the results, the selection is dropped, so the next + // select() hits the "no matches" guard instead of the wrap arithmetic. + const alloc = testing.allocator; + var t: Terminal = try .init(alloc, .{ .cols = 10, .rows = 2 }); + defer t.deinit(alloc); + + var s = t.vtStream(); + defer s.deinit(); + s.nextSlice("Fizz"); + + var search: ScreenSearch = try .init(alloc, t.screens.active, "Fizz"); + defer search.deinit(); + try search.searchAll(); + try testing.expectEqual(1, search.active_results.items.len); + + // Take a selection, then overwrite the only match so a reload finds none + // (active and history both empty). + _ = try search.select(.next); + try testing.expect(search.selectedMatch() != null); + s.nextSlice("\x1b[1;1H "); + + // Must not underflow; the selection is dropped and nothing is selected. + _ = try search.select(.prev); + try testing.expect(search.selectedMatch() == null); + try testing.expectEqual(0, search.active_results.items.len); + try testing.expectEqual(0, search.history_results.items.len); +} + +test "select after partial history erase drops a pruned selection" { + const alloc = testing.allocator; + var t: Terminal = try .init(alloc, .{ + .cols = 10, + .rows = 2, + .max_scrollback = std.math.maxInt(usize), + }); + defer t.deinit(alloc); + const list: *PageList = &t.screens.active.pages; + + var stream = t.vtStream(); + defer stream.deinit(); + + stream.nextSlice("error\r\n"); + const first = list.pages.first.?; + while (list.totalPages() < 3) stream.nextSlice("\r\n"); + const first_rows = first.rows(); + + var search: ScreenSearch = try .init(alloc, t.screens.active, "error"); + defer search.deinit(); + try search.searchAll(); + try testing.expectEqual(0, search.active_results.items.len); + try testing.expectEqual(1, search.history_results.items.len); + try testing.expect(try search.select(.next)); + + list.eraseHistory(.{ .history = .{ .y = first_rows - 1 } }); + try testing.expect(!search.selected.?.highlight.start.garbage); + + try testing.expect(!try search.select(.next)); + try testing.expect(search.selected == null); +} + +test "select after history compaction ignores replaced results" { + const alloc = testing.allocator; + var t: Terminal = try .init(alloc, .{ + .cols = 10, + .rows = 2, + .max_scrollback = std.math.maxInt(usize), + }); + defer t.deinit(alloc); + const list: *PageList = &t.screens.active.pages; + + var stream = t.vtStream(); + defer stream.deinit(); + + stream.nextSlice("error\r\n"); + const first = list.pages.first.?; + while (list.totalPages() < 3) stream.nextSlice("\r\n"); + try list.split(.{ + .node = first, + .y = first.rows() / 2, + .x = 0, + }); + + var search: ScreenSearch = try .init(alloc, t.screens.active, "error"); + defer search.deinit(); + try search.searchAll(); + try testing.expectEqual(1, search.history_results.items.len); + + const replacement = (try list.compact(first)).?; + try testing.expect(replacement != first); + + try testing.expect(!try search.select(.next)); + try testing.expect(search.selected == null); +} + +test "select after partial history page erase ignores shifted results" { + const alloc = testing.allocator; + var t: Terminal = try .init(alloc, .{ + .cols = 10, + .rows = 2, + .max_scrollback = std.math.maxInt(usize), + }); + defer t.deinit(alloc); + const list: *PageList = &t.screens.active.pages; + + var stream = t.vtStream(); + defer stream.deinit(); + + const first = list.pages.first.?; + while (first.rows() < first.capacity().rows) stream.nextSlice("\r\n"); + stream.nextSlice("error"); + for (0..list.rows + 1) |_| stream.nextSlice("\r\n"); + try testing.expect(first != list.pages.last.?); + + var search: ScreenSearch = try .init(alloc, t.screens.active, "error"); + defer search.deinit(); + try search.searchAll(); + try testing.expectEqual(0, search.active_results.items.len); + try testing.expectEqual(1, search.history_results.items.len); + + const old_rows = first.rows(); + list.eraseHistory(.{ .history = .{ .y = 0 } }); + try testing.expectEqual(old_rows - 1, first.rows()); + + try testing.expect(!try search.select(.next)); + try testing.expect(search.selected == null); +} + +test "reload defers pruning unselected history results" { + const alloc = testing.allocator; + var t: Terminal = try .init(alloc, .{ + .cols = 10, + .rows = 2, + .max_scrollback = std.math.maxInt(usize), + }); + defer t.deinit(alloc); + const list: *PageList = &t.screens.active.pages; + + var stream = t.vtStream(); + defer stream.deinit(); + + const first = list.pages.first.?; + while (first.rows() < first.capacity().rows) stream.nextSlice("\r\n"); + stream.nextSlice("error"); + for (0..list.rows + 1) |_| stream.nextSlice("\r\n"); + + var search: ScreenSearch = try .init(alloc, t.screens.active, "error"); + defer search.deinit(); + try search.searchAll(); + try testing.expectEqual(1, search.history_results.items.len); + + list.eraseHistory(.{ .history = .{ .y = 0 } }); + + // Routine active refreshes don't inspect unselected history results. + try search.reloadActive(); + try testing.expectEqual(1, search.history_results.items.len); + + // Selection prunes every candidate before attempting to track it. + try testing.expect(!try search.select(.next)); + try testing.expectEqual(0, search.history_results.items.len); +} + +test "reload after partial history page erase drops shifted selection first" { + const alloc = testing.allocator; + var t: Terminal = try .init(alloc, .{ + .cols = 10, + .rows = 2, + .max_scrollback = std.math.maxInt(usize), + }); + defer t.deinit(alloc); + const list: *PageList = &t.screens.active.pages; + + var stream = t.vtStream(); + defer stream.deinit(); + + const first = list.pages.first.?; + while (first.rows() < first.capacity().rows) stream.nextSlice("\r\n"); + stream.nextSlice("error"); + for (0..list.rows + 1) |_| stream.nextSlice("\r\n"); + + var search: ScreenSearch = try .init(alloc, t.screens.active, "error"); + defer search.deinit(); + try search.searchAll(); + try testing.expect(try search.select(.next)); + try testing.expect(search.selected != null); + + list.eraseHistory(.{ .history = .{ .y = 0 } }); + + // Advance the active boundary into another node and add matches so + // reloadActive updates the history result offsets. It must discard the + // stale selected result before comparing any of its captured coordinates. + const active_node = list.getTopLeft(.active).node; + while (list.getTopLeft(.active).node == active_node) { + stream.nextSlice("error\r\n"); + } + + try search.reloadActive(); + try testing.expect(search.selected == null); +} + +test "select after history page split ignores moved results" { + const alloc = testing.allocator; + var t: Terminal = try .init(alloc, .{ + .cols = 10, + .rows = 2, + .max_scrollback = std.math.maxInt(usize), + }); + defer t.deinit(alloc); + const list: *PageList = &t.screens.active.pages; + + var stream = t.vtStream(); + defer stream.deinit(); + + const first = list.pages.first.?; + while (first.rows() < first.capacity().rows) stream.nextSlice("\r\n"); + stream.nextSlice("error"); + for (0..list.rows + 1) |_| stream.nextSlice("\r\n"); + + var search: ScreenSearch = try .init(alloc, t.screens.active, "error"); + defer search.deinit(); + try search.searchAll(); + try testing.expectEqual(1, search.history_results.items.len); + + try list.split(.{ + .node = first, + .y = first.rows() / 2, + .x = 0, + }); + + try testing.expect(!try search.select(.next)); + try testing.expect(search.selected == null); +} + test "screen search no scrollback has no history" { const alloc = testing.allocator; var t: Terminal = try .init(alloc, .{ diff --git a/vendor/libghostty-vt/src/terminal/search/sliding_window.zig b/vendor/libghostty-vt/src/terminal/search/sliding_window.zig index 93a606fd..2f858ec8 100644 --- a/vendor/libghostty-vt/src/terminal/search/sliding_window.zig +++ b/vendor/libghostty-vt/src/terminal/search/sliding_window.zig @@ -89,6 +89,7 @@ pub const SlidingWindow = struct { const Meta = struct { node: *PageList.List.Node, serial: u64, + rows: size.CellCountInt, cell_map: std.ArrayList(point.Coordinate), pub fn deinit(self: *Meta, alloc: Allocator) void { @@ -96,6 +97,17 @@ pub const SlidingWindow = struct { } }; + /// Information copied from a page while appending it to the window. + /// + /// Both values remain safe after the node's preserved page is released. + /// In particular, callers use `last_row_wrapped` to decide whether an + /// adjacent page can contribute to a cross-page match without reading the + /// node again. + pub const AppendResult = struct { + content_len: usize, + last_row_wrapped: bool, + }; + pub fn init( alloc: Allocator, direction: Direction, @@ -164,6 +176,11 @@ pub const SlidingWindow = struct { /// or `append()`. If the caller wants to retain the flattened highlight /// then they should clone it. pub fn next(self: *SlidingWindow) ?FlattenedHighlight { + // An empty needle represents an inactive search. Searching for it + // would otherwise produce a zero-length match, which highlight() + // cannot represent because its end offset is inclusive. + if (self.needle.len == 0) return null; + const slices = slices: { // If we have less data then the needle then we can't possibly match const data_len = self.data.len(); @@ -309,6 +326,12 @@ pub const SlidingWindow = struct { self.chunk_buf.clearRetainingCapacity(); var result: terminal.highlight.Flattened = .empty; + // A reverse cross-page match needs the row count of the last meta in + // search order after the chunks themselves are reversed. Snapshot it + // while traversing Meta rather than dereferencing a PageList node after + // the terminal lock has been released. + var cross_page_end_rows: ?size.CellCountInt = null; + // Go through the meta nodes to find our start. const tl: struct { /// If non-null, we need to continue searching for the bottom-right. @@ -375,7 +398,7 @@ pub const SlidingWindow = struct { .node = meta.node, .serial = meta.serial, .start = @intCast(map.y), - .end = meta.node.data.size.rows, + .end = meta.rows, }); break :tl .{ @@ -410,7 +433,7 @@ pub const SlidingWindow = struct { .node = meta.node, .serial = meta.serial, .start = 0, - .end = meta.node.data.size.rows, + .end = meta.rows, }); meta_consumed += meta.cell_map.items.len; @@ -420,6 +443,7 @@ pub const SlidingWindow = struct { // We found it const map = meta.cell_map.items[meta_i]; result.bot_x = map.x; + cross_page_end_rows = meta.rows; self.chunk_buf.appendAssumeCapacity(.{ .node = meta.node, .serial = meta.serial, @@ -465,12 +489,14 @@ pub const SlidingWindow = struct { .reverse => { const slice = self.chunk_buf.slice(); const nodes = slice.items(.node); + const serials = slice.items(.serial); const starts = slice.items(.start); const ends = slice.items(.end); if (self.chunk_buf.len > 1) { // Reverse all our chunks. This should be pretty obvious why. std.mem.reverse(*PageList.List.Node, nodes); + std.mem.reverse(u64, serials); std.mem.reverse(size.CellCountInt, starts); std.mem.reverse(size.CellCountInt, ends); @@ -491,7 +517,7 @@ pub const SlidingWindow = struct { // order. assert(nodes.len >= 2); starts[0] = ends[0] - 1; - ends[0] = nodes[0].data.size.rows; + ends[0] = cross_page_end_rows.?; ends[nodes.len - 1] = starts[nodes.len - 1] + 1; starts[nodes.len - 1] = 0; } else { @@ -526,11 +552,49 @@ pub const SlidingWindow = struct { pub fn append( self: *SlidingWindow, node: *PageList.List.Node, - ) Allocator.Error!usize { + ) Allocator.Error!AppendResult { + var preserved = try node.pagePreservingState(self.alloc); + defer preserved.deinit(); + + const page = preserved.page(); + const last_row_wrapped = page.getRow(page.size.rows - 1).wrap; + return self.appendPage(node, page, last_row_wrapped); + } + + /// Append a node only when its last row is soft wrapped. + /// + /// This acquires one preserved page for both the wrap check and formatting + /// so a compressed node is decoded at most once. It is used when loading + /// the overlap around an otherwise complete search region. + pub fn appendIfWrapped( + self: *SlidingWindow, + node: *PageList.List.Node, + ) Allocator.Error!?AppendResult { + var preserved = try node.pagePreservingState(self.alloc); + defer preserved.deinit(); + + const page = preserved.page(); + const last_row_wrapped = page.getRow(page.size.rows - 1).wrap; + if (!last_row_wrapped) return null; + return try self.appendPage(node, page, last_row_wrapped); + } + + /// Copy one preserved page into the window's owned search buffers. + /// + /// No pointer into `page` may escape this function: compressed preserved + /// pages own temporary decode storage, while resident values borrow node + /// memory. + fn appendPage( + self: *SlidingWindow, + node: *PageList.List.Node, + page: *const terminal.Page, + last_row_wrapped: bool, + ) Allocator.Error!AppendResult { // Initialize our metadata for the node. var meta: Meta = .{ .node = node, .serial = node.serial, + .rows = page.size.rows, .cell_map = .empty, }; errdefer meta.deinit(self.alloc); @@ -544,7 +608,7 @@ pub const SlidingWindow = struct { // Encode the page into the buffer. const formatter: PageFormatter = formatter: { - var formatter: PageFormatter = .init(&meta.node.data, .{ + var formatter: PageFormatter = .init(page, .{ .emit = .plain, .unwrap = true, }); @@ -563,8 +627,7 @@ pub const SlidingWindow = struct { // If the node we're adding isn't soft-wrapped, we add the // trailing newline. - const row = node.data.getRow(node.data.size.rows - 1); - if (!row.wrap) { + if (!last_row_wrapped) { encoded.writer.writeByte('\n') catch return error.OutOfMemory; try meta.cell_map.append( self.alloc, @@ -580,7 +643,10 @@ pub const SlidingWindow = struct { const written = encoded.written(); if (written.len == 0) { self.assertIntegrity(); - return 0; + return .{ + .content_len = 0, + .last_row_wrapped = last_row_wrapped, + }; } // Get our written data. If we're doing a reverse search then we @@ -603,7 +669,10 @@ pub const SlidingWindow = struct { self.meta.appendAssumeCapacity(meta); self.assertIntegrity(); - return written.len; + return .{ + .content_len = written.len, + .last_row_wrapped = last_row_wrapped, + }; } /// Only for tests! @@ -642,6 +711,25 @@ test "SlidingWindow empty on init" { try testing.expectEqual(0, w.meta.len()); } +test "SlidingWindow empty needle has no matches" { + const testing = std.testing; + const alloc = testing.allocator; + + var w: SlidingWindow = try .init(alloc, .forward, ""); + defer w.deinit(); + + var s = try Screen.init(alloc, .{ + .cols = 80, + .rows = 24, + .max_scrollback = 0, + }); + defer s.deinit(); + try s.testWriteString("hello"); + + _ = try w.append(s.pages.pages.first.?); + try testing.expectEqual(null, w.next()); +} + test "SlidingWindow single append" { const testing = std.testing; const alloc = testing.allocator; @@ -813,7 +901,7 @@ test "SlidingWindow two pages" { // Fill up the first page. The final bytes in the first page // are "boo!" - const first_page_rows = s.pages.pages.first.?.data.capacity.rows; + const first_page_rows = s.pages.pages.first.?.capacity().rows; for (0..first_page_rows - 1) |_| try s.testWriteString("\n"); for (0..s.pages.cols - 4) |_| try s.testWriteString("x"); try s.testWriteString("boo!"); @@ -868,7 +956,7 @@ test "SlidingWindow two pages single char" { // Fill up the first page. The final bytes in the first page // are "boo!" - const first_page_rows = s.pages.pages.first.?.data.capacity.rows; + const first_page_rows = s.pages.pages.first.?.capacity().rows; for (0..first_page_rows - 1) |_| try s.testWriteString("\n"); for (0..s.pages.cols - 4) |_| try s.testWriteString("x"); try s.testWriteString("boo!"); @@ -923,7 +1011,7 @@ test "SlidingWindow two pages match across boundary" { // Fill up the first page. The final bytes in the first page // are "boo!" - const first_page_rows = s.pages.pages.first.?.data.capacity.rows; + const first_page_rows = s.pages.pages.first.?.capacity().rows; for (0..first_page_rows - 1) |_| try s.testWriteString("\n"); for (0..s.pages.cols - 4) |_| try s.testWriteString("x"); try s.testWriteString("hell"); @@ -968,7 +1056,7 @@ test "SlidingWindow two pages no match across boundary with newline" { // Fill up the first page. The final bytes in the first page // are "boo!" - const first_page_rows = s.pages.pages.first.?.data.capacity.rows; + const first_page_rows = s.pages.pages.first.?.capacity().rows; for (0..first_page_rows - 1) |_| try s.testWriteString("\n"); for (0..s.pages.cols - 4) |_| try s.testWriteString("x"); try s.testWriteString("hell"); @@ -1001,7 +1089,7 @@ test "SlidingWindow two pages no match across boundary with newline reverse" { // Fill up the first page. The final bytes in the first page // are "boo!" - const first_page_rows = s.pages.pages.first.?.data.capacity.rows; + const first_page_rows = s.pages.pages.first.?.capacity().rows; for (0..first_page_rows - 1) |_| try s.testWriteString("\n"); for (0..s.pages.cols - 4) |_| try s.testWriteString("x"); try s.testWriteString("hell"); @@ -1031,7 +1119,7 @@ test "SlidingWindow two pages no match prunes first page" { // Fill up the first page. The final bytes in the first page // are "boo!" - const first_page_rows = s.pages.pages.first.?.data.capacity.rows; + const first_page_rows = s.pages.pages.first.?.capacity().rows; for (0..first_page_rows - 1) |_| try s.testWriteString("\n"); for (0..s.pages.cols - 4) |_| try s.testWriteString("x"); try s.testWriteString("boo!"); @@ -1063,7 +1151,7 @@ test "SlidingWindow two pages no match keeps both pages" { // Fill up the first page. The final bytes in the first page // are "boo!" - const first_page_rows = s.pages.pages.first.?.data.capacity.rows; + const first_page_rows = s.pages.pages.first.?.capacity().rows; for (0..first_page_rows - 1) |_| try s.testWriteString("\n"); for (0..s.pages.cols - 4) |_| try s.testWriteString("x"); try s.testWriteString("boo!"); @@ -1164,7 +1252,7 @@ test "SlidingWindow single append match on boundary" { // We need to surgically modify the last row to be soft-wrapped try testing.expect(s.pages.pages.first == s.pages.pages.last); const node: *PageList.List.Node = s.pages.pages.first.?; - node.data.getRow(node.data.size.rows - 1).wrap = true; + node.page().getRow(node.rows() - 1).wrap = true; // We are trying to break a circular buffer boundary so the way we // do this is to duplicate the data then do a failing search. This @@ -1290,7 +1378,7 @@ test "SlidingWindow two pages reversed" { // Fill up the first page. The final bytes in the first page // are "boo!" - const first_page_rows = s.pages.pages.first.?.data.capacity.rows; + const first_page_rows = s.pages.pages.first.?.capacity().rows; for (0..first_page_rows - 1) |_| try s.testWriteString("\n"); for (0..s.pages.cols - 4) |_| try s.testWriteString("x"); try s.testWriteString("boo!"); @@ -1345,7 +1433,7 @@ test "SlidingWindow two pages match across boundary reversed" { // Fill up the first page. The final bytes in the first page // are "hell" - const first_page_rows = s.pages.pages.first.?.data.capacity.rows; + const first_page_rows = s.pages.pages.first.?.capacity().rows; for (0..first_page_rows - 1) |_| try s.testWriteString("\n"); for (0..s.pages.cols - 4) |_| try s.testWriteString("x"); try s.testWriteString("hell"); @@ -1361,6 +1449,15 @@ test "SlidingWindow two pages match across boundary reversed" { // Search should find a match { const h = w.next().?; + const chunks = h.chunks.slice(); + const nodes = chunks.items(.node); + const serials = chunks.items(.serial); + try testing.expectEqual(2, chunks.len); + try testing.expectEqual(node, nodes[0]); + try testing.expectEqual(node.serial, serials[0]); + try testing.expectEqual(node.next.?, nodes[1]); + try testing.expectEqual(node.next.?.serial, serials[1]); + const sel = h.untracked(); try testing.expectEqual(point.Point{ .active = .{ .x = 76, @@ -1391,7 +1488,7 @@ test "SlidingWindow two pages no match prunes first page reversed" { // Fill up the first page. The final bytes in the first page // are "boo!" - const first_page_rows = s.pages.pages.first.?.data.capacity.rows; + const first_page_rows = s.pages.pages.first.?.capacity().rows; for (0..first_page_rows - 1) |_| try s.testWriteString("\n"); for (0..s.pages.cols - 4) |_| try s.testWriteString("x"); try s.testWriteString("boo!"); @@ -1423,7 +1520,7 @@ test "SlidingWindow two pages no match keeps both pages reversed" { // Fill up the first page. The final bytes in the first page // are "boo!" - const first_page_rows = s.pages.pages.first.?.data.capacity.rows; + const first_page_rows = s.pages.pages.first.?.capacity().rows; for (0..first_page_rows - 1) |_| try s.testWriteString("\n"); for (0..s.pages.cols - 4) |_| try s.testWriteString("x"); try s.testWriteString("boo!"); @@ -1525,7 +1622,7 @@ test "SlidingWindow single append match on boundary reversed" { // We need to surgically modify the last row to be soft-wrapped try testing.expect(s.pages.pages.first == s.pages.pages.last); const node: *PageList.List.Node = s.pages.pages.first.?; - node.data.getRow(node.data.size.rows - 1).wrap = true; + node.page().getRow(node.rows() - 1).wrap = true; // We are trying to break a circular buffer boundary so the way we // do this is to duplicate the data then do a failing search. This @@ -1665,7 +1762,7 @@ test "SlidingWindow append whitespace only node" { // This is invasive but its otherwise hard to reproduce naturally // without creating a slow test. const node: *PageList.List.Node = s.pages.pages.first.?; - const last_row = node.data.getRow(node.data.size.rows - 1); + const last_row = node.page().getRow(node.rows() - 1); last_row.wrap = true; try testing.expect(s.pages.pages.first == s.pages.pages.last); diff --git a/vendor/libghostty-vt/src/terminal/search/viewport.zig b/vendor/libghostty-vt/src/terminal/search/viewport.zig index 35dd9331..09d202be 100644 --- a/vendor/libghostty-vt/src/terminal/search/viewport.zig +++ b/vendor/libghostty-vt/src/terminal/search/viewport.zig @@ -24,7 +24,7 @@ const Terminal = @import("../Terminal.zig"); /// lives in the same page. pub const ViewportSearch = struct { window: SlidingWindow, - fingerprint: ?Fingerprint, + fingerprint: Fingerprint = .{}, /// If this is null, then active dirty tracking is disabled and if the /// viewport overlaps the active area we always re-search. If this is @@ -43,21 +43,19 @@ pub const ViewportSearch = struct { errdefer window.deinit(); return .{ .window = window, - .fingerprint = null, .active_dirty = null, }; } pub fn deinit(self: *ViewportSearch) void { - if (self.fingerprint) |*fp| fp.deinit(self.window.alloc); + self.fingerprint.deinit(self.window.alloc); self.window.deinit(); } /// Reset our fingerprint and results so that the next update will /// always re-search. pub fn reset(self: *ViewportSearch) void { - if (self.fingerprint) |*fp| fp.deinit(self.window.alloc); - self.fingerprint = null; + self.fingerprint.clearAndRetainCapacity(); self.window.clearAndRetainCapacity(); } @@ -80,48 +78,37 @@ pub const ViewportSearch = struct { self: *ViewportSearch, list: *PageList, ) Allocator.Error!bool { - // See if our viewport changed - var fingerprint: Fingerprint = try .init(self.window.alloc, list); - if (self.fingerprint) |*old| { - if (old.eql(fingerprint)) match: { - // Determine if we need to check if we overlap the active - // area. If we have dirty tracking on we also set it to - // false here. - const check_active: bool = active: { - const dirty = self.active_dirty orelse break :active true; - if (!dirty) break :active false; - self.active_dirty = false; - break :active true; - }; - - if (check_active) { - // If our fingerprint contains the active area, then we always - // re-search since the active area is mutable. - const active_tl = list.getTopLeft(.active); - const active_br = list.getBottomRight(.active).?; - - // If our viewport contains the start or end of the active area, - // we are in the active area. We purposely do this first - // because our viewport is always larger than the active area. - for (old.nodes) |node| { - if (node == active_tl.node) break :match; - if (node == active_br.node) break :match; - } + // See if our viewport changed. We retain the fingerprint buffer so + // unchanged viewports don't allocate and changed viewports usually + // reuse the previous capacity. + const fingerprint = &self.fingerprint; + if (!try fingerprint.update(self.window.alloc, list)) match: { + // Determine if we need to check if we overlap the active area. + // If we have dirty tracking on we also set it to false here. + const check_active: bool = active: { + const dirty = self.active_dirty orelse break :active true; + if (!dirty) break :active false; + self.active_dirty = false; + break :active true; + }; + + if (check_active) { + // If our fingerprint contains the active area, then we always + // re-search since the active area is mutable. + const active_tl = list.getTopLeft(.active); + const active_br = list.getBottomRight(.active).?; + + // If our viewport contains the start or end of the active area, + // we are in the active area. We purposely do this first because + // our viewport is always larger than the active area. + for (fingerprint.entries.items) |entry| { + if (entry.node == active_tl.node) break :match; + if (entry.node == active_br.node) break :match; } - - // No change - fingerprint.deinit(self.window.alloc); - return false; } - old.deinit(self.window.alloc); - self.fingerprint = null; - } - assert(self.fingerprint == null); - self.fingerprint = fingerprint; - errdefer { - fingerprint.deinit(self.window.alloc); - self.fingerprint = null; + // No change + return false; } // If our active area was set as dirty, we always unset it here @@ -134,40 +121,40 @@ pub const ViewportSearch = struct { // Add enough overlap to cover needle.len - 1 bytes (if it // exists) so we can cover the overlap. We only do this for the // soft-wrapped prior pages. - var node_ = fingerprint.nodes[0].prev; + const overlap_len = self.window.needle.len -| 1; + const entries = fingerprint.entries.items; + var node_ = entries[0].node.prev; var added: usize = 0; while (node_) |node| : (node_ = node.prev) { - // If the last row of this node isn't wrapped we can't overlap. - const row = node.data.getRow(node.data.size.rows - 1); - if (!row.wrap) break; - // We could be more accurate here and count bytes since the // last wrap but its complicated and unlikely multiple pages // wrap so this should be fine. - added += try self.window.append(node); - if (added >= self.window.needle.len - 1) break; + const appended = try self.window.appendIfWrapped(node) orelse break; + added += appended.content_len; + if (added >= overlap_len) break; } // We can use our fingerprint nodes to initialize our sliding // window, since we already traversed the viewport once. - for (fingerprint.nodes) |node| { - _ = try self.window.append(node); + var end_append: SlidingWindow.AppendResult = undefined; + for (entries) |entry| { + end_append = try self.window.append(entry.node); } // Add any trailing overlap as well. trailing: { - const end: *PageList.List.Node = fingerprint.nodes[fingerprint.nodes.len - 1]; - if (!end.data.getRow(end.data.size.rows - 1).wrap) break :trailing; + const end: *PageList.List.Node = entries[entries.len - 1].node; + if (!end_append.last_row_wrapped) break :trailing; node_ = end.next; added = 0; while (node_) |node| : (node_ = node.next) { - added += try self.window.append(node); - if (added >= self.window.needle.len - 1) break; + const appended = try self.window.append(node); + added += appended.content_len; + if (added >= overlap_len) break; // If this row doesn't wrap, then we can quit - const row = node.data.getRow(node.data.size.rows - 1); - if (!row.wrap) break; + if (!appended.last_row_wrapped) break; } } @@ -182,36 +169,59 @@ pub const ViewportSearch = struct { /// Viewport fingerprint so we can detect when the viewport moves. const Fingerprint = struct { - /// The nodes that make up the viewport. We need to flatten this - /// to a single list because we can't safely traverse the cached values - /// because the page nodes may be invalid. All that is safe is comparing - /// the actual pointer values. - nodes: []const *PageList.List.Node, - - pub fn init(alloc: Allocator, pages: *PageList) Allocator.Error!Fingerprint { - var list: std.ArrayList(*PageList.List.Node) = .empty; - defer list.deinit(alloc); - - // Get our viewport area. Bottom right of a viewport can never - // fail. - const tl = pages.getTopLeft(.viewport); - const br = pages.getBottomRight(.viewport).?; - - var it = tl.pageIterator(.right_down, br); - while (it.next()) |chunk| try list.append(alloc, chunk.node); - return .{ .nodes = try list.toOwnedSlice(alloc) }; - } + /// The node addresses and generations that make up the viewport. We + /// can't safely dereference cached node pointers because the nodes may + /// be invalid, so equality only compares these captured values. + entries: std.ArrayListUnmanaged(Entry) = .empty, + + const Entry = struct { + node: *PageList.List.Node, + serial: u64, + }; pub fn deinit(self: *Fingerprint, alloc: Allocator) void { - alloc.free(self.nodes); + self.entries.deinit(alloc); + } + + pub fn clearAndRetainCapacity(self: *Fingerprint) void { + self.entries.clearRetainingCapacity(); } - pub fn eql(self: Fingerprint, other: Fingerprint) bool { - if (self.nodes.len != other.nodes.len) return false; - for (self.nodes, other.nodes) |a, b| { - if (a != b) return false; + /// Update this fingerprint to match the current viewport. + /// + /// Returns true if the viewport changed. The viewport can have at + /// most one entry per visible row, so we reserve that upper bound + /// before mutating the retained buffer. This lets us rebuild in one + /// pass while still preserving the old fingerprint if allocation + /// fails. + pub fn update(self: *Fingerprint, alloc: Allocator, pages: *PageList) Allocator.Error!bool { + try self.entries.ensureTotalCapacity(alloc, pages.rows); + + const old_len = self.entries.items.len; + var changed = false; + + var i: usize = 0; + var it = pages.pageIterator(.right_down, .{ .viewport = .{} }, null); + while (it.next()) |chunk| : (i += 1) { + const entry: Entry = .{ + .node = chunk.node, + .serial = chunk.node.serial, + }; + if (!changed) { + changed = i >= old_len or + self.entries.items[i].node != entry.node or + self.entries.items[i].serial != entry.serial; + } + + if (i < self.entries.items.len) { + self.entries.items[i] = entry; + } else { + self.entries.appendAssumeCapacity(entry); + } } - return true; + + self.entries.shrinkRetainingCapacity(i); + return changed or i != old_len; } }; }; @@ -259,6 +269,55 @@ test "simple search" { try testing.expect(search.next() == null); } +test "fingerprint detects node generation changes" { + const alloc = testing.allocator; + var t: Terminal = try .init(alloc, .{ .cols = 10, .rows = 10 }); + defer t.deinit(alloc); + + var search: ViewportSearch = try .init(alloc, "needle"); + defer search.deinit(); + search.active_dirty = false; + + try testing.expect(try search.update(&t.screens.active.pages)); + try testing.expect(!try search.update(&t.screens.active.pages)); + + const old_entry = search.fingerprint.entries.items[0]; + const node = t.screens.active.pages.pages.first.?; + try testing.expectEqual(node, old_entry.node); + const original_serial = node.serial; + defer node.serial = original_serial; + node.serial += 1; + + // The viewport still contains the same pointer, but its generation has + // changed, so the cached window must be rebuilt. + try testing.expect(try search.update(&t.screens.active.pages)); + const new_entry = search.fingerprint.entries.items[0]; + try testing.expectEqual(old_entry.node, new_entry.node); + try testing.expect(old_entry.serial != new_entry.serial); +} + +test "fingerprint only allocates on changes" { + const alloc = testing.allocator; + var t: Terminal = try .init(alloc, .{ .cols = 10, .rows = 10 }); + defer t.deinit(alloc); + + var failing = testing.FailingAllocator.init(alloc, .{}); + const failing_alloc = failing.allocator(); + + var search: ViewportSearch = try .init(failing_alloc, "needle"); + defer search.deinit(); + search.active_dirty = false; + + try testing.expect(try search.update(&t.screens.active.pages)); + + // Fail the next allocation. An unchanged viewport with active dirty + // tracking disabled should reuse the existing fingerprint and return + // unchanged before rebuilding the search window, so this update should + // succeed without reporting a viewport change. + failing.fail_index = failing.alloc_index; + try testing.expect(!try search.update(&t.screens.active.pages)); +} + test "clear screen and search" { const alloc = testing.allocator; var t: Terminal = try .init(alloc, .{ .cols = 10, .rows = 10 }); @@ -348,7 +407,7 @@ test "history search, no active area" { defer s.deinit(); // Fill up first page - const first_page_rows = t.screens.active.pages.pages.first.?.data.capacity.rows; + const first_page_rows = t.screens.active.pages.pages.first.?.capacity().rows; s.nextSlice("Fizz\r\n"); for (1..first_page_rows - 1) |_| s.nextSlice("\r\n"); try testing.expect(t.screens.active.pages.pages.first == t.screens.active.pages.pages.last); diff --git a/vendor/libghostty-vt/src/terminal/stream.zig b/vendor/libghostty-vt/src/terminal/stream.zig index 9771334f..efb06ecd 100644 --- a/vendor/libghostty-vt/src/terminal/stream.zig +++ b/vendor/libghostty-vt/src/terminal/stream.zig @@ -33,6 +33,7 @@ const debug = false; /// function for handling. pub const Action = union(Key) { print: Print, + print_slice: PrintSlice, print_repeat: usize, bell, backspace, @@ -110,6 +111,7 @@ pub const Action = union(Key) { apc_start, apc_end, apc_put: u8, + apc_put_slice: ApcPutSlice, end_hyperlink, active_status_display: ansi.StatusDisplay, decaln, @@ -130,6 +132,7 @@ pub const Action = union(Key) { lib.target, &.{ "print", + "print_slice", "print_repeat", "bell", "backspace", @@ -207,6 +210,7 @@ pub const Action = union(Key) { "apc_start", "apc_end", "apc_put", + "apc_put_slice", "end_hyperlink", "active_status_display", "decaln", @@ -252,6 +256,40 @@ pub const Action = union(Key) { } }; + /// A run of printable codepoints. This is emitted instead of + /// individual print actions when the stream can decode multiple + /// printable codepoints at once, so handlers can process them in + /// batch with per-run rather than per-codepoint overhead (see + /// Terminal.printSlice). A naive handler can simply loop and + /// handle each codepoint like a print action. + /// + /// The slice is only valid for the duration of the handler call. + pub const PrintSlice = struct { + cps: []const u32, + + pub const C = extern struct { + cps: [*]const u32, + len: usize, + }; + + pub fn cval(self: PrintSlice) PrintSlice.C { + return .{ .cps = self.cps.ptr, .len = self.cps.len }; + } + }; + + pub const ApcPutSlice = struct { + bytes: []const u8, + + pub const C = extern struct { + bytes: [*]const u8, + len: usize, + }; + + pub fn cval(self: ApcPutSlice) ApcPutSlice.C { + return .{ .bytes = self.bytes.ptr, .len = self.bytes.len }; + } + }; + pub const InvokeCharset = lib.Struct(lib.target, struct { bank: charsets.ActiveSlot, charset: charsets.Slots, @@ -411,6 +449,11 @@ pub const Action = union(Key) { /// about in its pursuit of implementing a terminal emulator or other /// functionality. /// +/// Note that printable text is delivered via `print_slice` actions +/// (runs of codepoints) whenever the stream can decode multiple +/// codepoints at once, and via `print` actions otherwise. Handlers +/// that care about text must handle both. +/// /// The Handler type must also have a `deinit` function. /// /// The "comptime" key is on purpose (vs. a standard Zig tagged union) @@ -521,12 +564,25 @@ pub fn Stream(comptime H: type) type { // up to that point are just UTF-8. while (self.parser.state == .ground and offset < input.len) { const res = simd.vt.utf8DecodeUntilControlSeq(input[offset..], cp_buf); - for (cp_buf[0..res.decoded]) |cp| { + const cps = cp_buf[0..res.decoded]; + + // Hand runs of printable codepoints to the handler as + // print_slice actions so it can process them with + // per-run rather than per-codepoint overhead. + var i: usize = 0; + while (i < cps.len) { + const cp = cps[i]; if (cp <= 0xF) { + @branchHint(.unlikely); self.execute(@intCast(cp)); - } else { - self.print(@intCast(cp)); + i += 1; + continue; } + + var end = i + 1; + while (end < cps.len and cps[end] > 0xF) end += 1; + self.handler.vt(.print_slice, .{ .cps = cps[i..end] }); + i = end; } // Consume the bytes we just processed. offset += res.consumed; @@ -570,12 +626,202 @@ pub fn Stream(comptime H: type) type { var offset: usize = 0; while (self.parser.state != .ground) { if (offset >= input.len) return input.len; + + // Fast path for CSI entry: "ESC [" is by far the most + // common escape sequence prefix, so handle the '[' and + // the byte that follows it here rather than paying a + // nextNonUtf8 call for each. + if (self.parser.state == .escape and input[offset] == '[') { + self.parser.state = .csi_entry; + offset += 1; + continue; + } + + if (comptime !@hasDecl(T, "vtRaw")) { + if (self.parser.state == .csi_entry) { + if (self.csiEntryByte(input[offset])) { + offset += 1; + continue; + } + } + + // Bulk-consume CSI parameter bytes. This can't be + // used for handlers with a vtRaw hook because it + // dispatches the CSI directly (see nextNonUtf8). + if (self.parser.state == .csi_param) { + offset += self.consumeCsiParams(input[offset..]); + if (offset >= input.len) return input.len; + // If we're still in csi_param then the next byte + // isn't a parameter byte; let nextNonUtf8 below + // handle it. Otherwise re-check our state. + if (self.parser.state != .csi_param) continue; + } + + // Bulk-consume APC string bytes into a single slice. + // APC payloads (e.g. Kitty graphics) 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 it dispatches the slice directly. + if (self.parser.state == .sos_pm_apc_string) { + offset += self.consumeApcString(input[offset..]); + if (offset >= input.len) return input.len; + // The next byte exits the string state; let + // nextNonUtf8 below handle it. + } + } + self.nextNonUtf8(input[offset]); offset += 1; } return offset; } + /// Fast path for a byte in the csi_entry state, the state right + /// after "ESC [". Virtually every CSI sequence spends exactly + /// one byte in this state, on either a digit, a private marker, + /// or a final byte. Returns true if the byte was fully handled; + /// false means the caller must process it through the general + /// state machine. + /// + /// Must not be used by handlers with a vtRaw hook because the + /// final byte case dispatches the CSI directly. + inline fn csiEntryByte(self: *Self, c: u8) bool { + comptime assert(!@hasDecl(T, "vtRaw")); + assert(self.parser.state == .csi_entry); + switch (c) { + // First parameter digit. + '0'...'9' => { + self.parser.state = .csi_param; + // param_acc is zero (cleared on escape entry) + // so accumulating is just the digit value. + self.parser.param_acc = c - '0'; + self.parser.param_acc_idx = 1; + }, + // An empty first parameter. + ';' => { + self.parser.state = .csi_param; + self.parser.params[0] = 0; + self.parser.params_idx = 1; + }, + // Private marker (e.g. '?' in "ESC [ ? 2004 h"). + 0x3C...0x3F => { + self.parser.state = .csi_param; + self.parser.collect(c); + }, + // A final byte: a parameterless CSI. + 0x40...0x7E => self.csiDispatchFinal(c), + // Defer to the state machine for anything else + // (C0 controls, intermediates, colon). + else => return false, + } + return true; + } + + /// Bulk-consume CSI parameter bytes (digits and separators) + /// and, if reached, the final byte (dispatching the CSI). + /// Returns the number of bytes consumed. Stops at the first + /// byte that isn't handled here, leaving the parser in the + /// csi_param state so the caller can process that byte. + fn consumeCsiParams(self: *Self, input: []const u8) usize { + const p = &self.parser; + assert(p.state == .csi_param); + + // Accumulate parser state in locals for the hot loop. + var acc = p.param_acc; + var acc_idx = p.param_acc_idx; + var idx = p.params_idx; + + var offset: usize = 0; + while (offset < input.len) { + const c = input[offset]; + switch (c) { + // A parameter digit. + '0'...'9' => { + if (idx < Parser.MAX_PARAMS) { + acc *|= 10; + acc +|= c - '0'; + acc_idx |= 1; + } + offset += 1; + }, + + // A parameter separator. + ':', ';' => { + if (idx < Parser.MAX_PARAMS) { + p.params[idx] = acc; + if (c == ':') p.params_sep.set(idx); + idx += 1; + acc = 0; + acc_idx = 0; + } + offset += 1; + }, + + // A final byte: dispatch the CSI. + 0x40...0x7E => { + p.param_acc = acc; + p.param_acc_idx = acc_idx; + p.params_idx = idx; + self.csiDispatchFinal(c); + return offset + 1; + }, + + // Anything else (C0 controls, intermediates, etc.) + // is handled by the caller. + else => break, + } + } + + p.param_acc = acc; + p.param_acc_idx = acc_idx; + p.params_idx = idx; + return offset; + } + + /// Bulk-consume APC string bytes and dispatch them as a single + /// apc_put_slice action. Returns the number of bytes consumed. + /// Stops at the first byte that is not an apc_put byte in the + /// parse table, leaving it for the caller to process through + /// the state machine. CAN, SUB, ESC, and most C1 bytes exit + /// or abort the string state; 0xA0-0xFF are ignored by the + /// table (not payload), so they can't be bulk-consumed either. + /// + /// Must not be used by handlers with a vtRaw hook because it + /// dispatches the slice directly. + fn consumeApcString(self: *Self, input: []const u8) usize { + comptime assert(!@hasDecl(T, "vtRaw")); + assert(self.parser.state == .sos_pm_apc_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 invalid = (bytes == @as(ByteVector, @splat(0x18))) | + (bytes == @as(ByteVector, @splat(0x1A))) | + (bytes == @as(ByteVector, @splat(0x1B))) | + (bytes >= @as(ByteVector, @splat(0x80))); + if (@reduce(.Or, invalid)) break; + end += vector_len; + } + } + while (end < input.len) { + switch (input[end]) { + // Not apc_put bytes: CAN/SUB/ESC and most C1 exit + // or abort the state; 0xA0-0xFF are ignored by it. + 0x18, 0x1A, 0x1B, 0x80...0xFF => break, + // Everything else is an apc_put byte. + else => end += 1, + } + } + + if (end > 0) self.handler.vt( + .apc_put_slice, + .{ .bytes = 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. @@ -654,6 +900,13 @@ pub fn Stream(comptime H: type) type { self.parser.state = .csi_entry; return; } + + // The fast paths below dispatch actions directly rather than + // going through Parser.next, so they'd bypass a handler's + // vtRaw hook. Handlers with vtRaw (e.g. the inspector) use + // the general path for anything that produces an action. + const has_vt_raw = comptime @hasDecl(T, "vtRaw"); + // Fast path for CSI params. if (self.parser.state == .csi_param) csi_param: { // csi_param is the most common parser state @@ -690,6 +943,10 @@ pub fn Stream(comptime H: type) type { self.parser.param_acc = 0; self.parser.param_acc_idx = 0; }, + // A final byte: dispatch the CSI directly. + 0x40...0x7E => if (comptime !has_vt_raw) { + self.csiDispatchFinal(c); + } else break :csi_param, // Explicitly ignored: 0x7F => {}, // Defer to the state machine to @@ -699,6 +956,13 @@ pub fn Stream(comptime H: type) type { return; } + // Fast path for CSI entry, the state right after "ESC [". + if (comptime !has_vt_raw) { + if (self.parser.state == .csi_entry) { + if (self.csiEntryByte(c)) return; + } + } + // We explicitly inline this call here for performance reasons. // // We do this rather than mark Parser.next as inline because doing @@ -744,6 +1008,48 @@ pub fn Stream(comptime H: type) type { } } + /// Finalize and dispatch a CSI directly from parser state for + /// the fast paths in nextNonUtf8, without going through + /// Parser.next. This must match the behavior of the parser's + /// csi_dispatch action. + fn csiDispatchFinal(self: *Self, c: u8) void { + const p = &self.parser; + p.state = .ground; + + // Ignore sequences with too many parameters, matching the + // parser's behavior of dropping the dispatch entirely. + if (p.params_idx >= Parser.MAX_PARAMS) { + @branchHint(.unlikely); + return; + } + + // Finalize the last parameter if we have one. + if (p.param_acc_idx > 0) { + p.params[p.params_idx] = p.param_acc; + p.params_idx += 1; + } + + const action: Parser.Action.CSI = .{ + .intermediates = p.intermediates[0..p.intermediates_idx], + .params = p.params[0..p.params_idx], + .params_sep = p.params_sep, + .final = c, + }; + + // We only allow colon or mixed separators for the 'm' command. + if (c != 'm' and p.params_sep.count() > 0) { + @branchHint(.cold); + log.warn( + "CSI colon or mixed separators only allowed for 'm' command, got: {f}", + .{action}, + ); + return; + } + + if (comptime debug) log.info("action: {f}", .{Parser.Action{ .csi_dispatch = action }}); + self.csiDispatch(action); + } + inline fn print(self: *Self, c: u21) void { self.handler.vt(.print, .{ .cp = c }); } @@ -776,7 +1082,7 @@ pub fn Stream(comptime H: type) type { .SO => self.handler.vt(.invoke_charset, .{ .bank = .GL, .charset = .G1, .locking = false }), .SI => self.handler.vt(.invoke_charset, .{ .bank = .GL, .charset = .G0, .locking = false }), - else => log.warn("invalid C0 character, ignoring: 0x{x}", .{c}), + else => logUnsupportedOnce("invalid C0 character, ignoring: 0x{x}", .{c}, c), } } @@ -1294,7 +1600,11 @@ pub fn Stream(comptime H: type) type { if (req) |r| { self.handler.vt(.device_attributes, r); } else { - log.warn("invalid device attributes command: {f}", .{input}); + logUnsupportedOnce( + "invalid device attributes command: {f}", + .{input}, + if (input.params.len > 0) input.params[0] else 0, + ); return; } }, @@ -1380,7 +1690,7 @@ pub fn Stream(comptime H: type) type { if (modes.modeFromInt(mode_int, ansi_mode)) |mode| { self.handler.vt(.set_mode, .{ .mode = mode }); } else { - log.warn("unimplemented mode: {}", .{mode_int}); + logUnsupportedOnce("unimplemented mode: {}", .{mode_int}, mode_int); } } }, @@ -1401,7 +1711,7 @@ pub fn Stream(comptime H: type) type { if (modes.modeFromInt(mode_int, ansi_mode)) |mode| { self.handler.vt(.reset_mode, .{ .mode = mode }); } else { - log.warn("unimplemented mode: {}", .{mode_int}); + logUnsupportedOnce("unimplemented mode: {}", .{mode_int}, mode_int); } } }, @@ -1470,9 +1780,10 @@ pub fn Stream(comptime H: type) type { self.handler.vt(.modify_key_format, format); }, - else => log.warn( + else => logUnsupportedOnce( "unknown CSI m with intermediate: {}", .{input.intermediates[0]}, + input.intermediates[0], ), }, @@ -1804,13 +2115,15 @@ pub fn Stream(comptime H: type) type { 23 => self.handler.vt(.title_pop, index), else => @compileError("unreachable"), } - } else log.warn( + } else logUnsupportedOnce( "ignoring CSI 22/23 t with extra parameters: {f}", .{input}, + input.params[0], ), - else => log.warn( + else => logUnsupportedOnce( "ignoring CSI t with unimplemented parameter: {f}", .{input}, + input.params[0], ), } } else log.err( @@ -1985,7 +2298,11 @@ pub fn Stream(comptime H: type) type { .change_window_icon => |icon| { @branchHint(.likely); - log.info("OSC 1 (change icon) received and ignored icon={s}", .{icon}); + logUnsupportedOnce( + "OSC 1 (change icon) received and ignored icon={s}", + .{icon}, + 0, + ); }, .clipboard_contents => |clip| { @@ -2058,6 +2375,7 @@ pub fn Stream(comptime H: type) type { .conemu_run_process, .kitty_text_sizing, .kitty_clipboard_protocol, + .kitty_dnd_protocol, .context_signal, => { log.debug("unimplemented OSC callback: {}", .{cmd}); @@ -2176,7 +2494,11 @@ pub fn Stream(comptime H: type) type { else => {}, // fall through } - log.warn("unimplemented ESC action: {f}", .{action}); + logUnsupportedOnce( + "unimplemented ESC action: {f}", + .{action}, + action.final, + ); }, // IND - Index @@ -2370,12 +2692,72 @@ pub fn Stream(comptime H: type) type { @branchHint(.likely); }, - else => log.warn("unimplemented ESC action: {f}", .{action}), + else => logUnsupportedOnce( + "unimplemented ESC action: {f}", + .{action}, + action.final, + ), } } }; } +/// Logs an unsupported-input message at most once per distinct key +/// per process. +/// +/// These messages are emitted in response to input that the terminal +/// application controls, so a misbehaving (or merely chatty) program +/// can trigger the same message millions of times, e.g. by toggling +/// an unimplemented mode on every frame. Each log call has a real +/// throughput cost (formatting plus a blocking write per message) +/// while adding no diagnostic value beyond the first occurrence. +/// +/// The keys seen so far are tracked in a small fixed table (64 bytes) +/// instantiated per (format, argument type) tuple, i.e. roughly per +/// call site. Real streams only ever produce a handful of distinct +/// unsupported values per site, so if the table ever fills, messages +/// for further new values are suppressed as well: by that point the +/// log already shows this class of problem and unbounded distinct +/// values would flood it anyway. +fn logUnsupportedOnce( + comptime format: []const u8, + args: anytype, + key: u16, +) void { + // u32 slots so every u16 key is representable alongside an empty + // sentinel and so 32-bit targets (e.g. wasm32) have native + // atomics. + const empty = std.math.maxInt(u32); + const Static = struct { + var seen: [16]u32 = @splat(empty); + }; + + // The atomics make concurrent streams safe: slots are only ever + // claimed, never changed, so the scan can stop at the first empty + // slot. The worst case race is a benign duplicate message. + for (&Static.seen) |*slot| { + const cur = @atomicLoad(u32, slot, .acquire); + if (cur == key) return; // already logged + if (cur != empty) continue; // other key, keep scanning + + // Empty slot: claim it for this key and log below. + const actual = @cmpxchgStrong( + u32, + slot, + empty, + key, + .acq_rel, + .acquire, + ) orelse break; + + // Lost the race: suppress if it was to the same key, keep + // scanning otherwise. + if (actual == key) return; + } else return; // table full: suppress new values too + + log.warn(format, args); +} + test Action { // Forces the C type to be reified when the target is C, ensuring // all our types are C ABI compatible. @@ -2414,6 +2796,7 @@ test "simd: print invalid utf-8" { ) void { switch (action) { .print => self.c = value.cp, + .print_slice => self.c = @intCast(value.cps[value.cps.len - 1]), else => {}, } } @@ -2435,6 +2818,7 @@ test "simd: complete incomplete utf-8" { ) void { switch (action) { .print => self.c = value.cp, + .print_slice => self.c = @intCast(value.cps[value.cps.len - 1]), else => {}, } } @@ -3447,3 +3831,130 @@ test "stream: tab clear with overflowing param" { // CSI with a huge numeric param that saturates to 65535, followed by 'g'. s.nextSlice("\x1b[388888888888888888888888888888888888g\x1b[0m"); } + +/// A test handler that accumulates APC bytes regardless of whether they +/// arrive per-byte (apc_put) or in bulk (apc_put_slice). +const ApcTestHandler = struct { + buf: [256]u8 = undefined, + len: usize = 0, + slices: usize = 0, + puts: usize = 0, + started: usize = 0, + ended: usize = 0, + + pub fn vt( + self: *@This(), + comptime action: Action.Tag, + value: Action.Value(action), + ) void { + switch (action) { + .apc_start => self.started += 1, + .apc_end => self.ended += 1, + .apc_put => { + self.buf[self.len] = value; + self.len += 1; + self.puts += 1; + }, + .apc_put_slice => { + @memcpy(self.buf[self.len..][0..value.bytes.len], value.bytes); + self.len += value.bytes.len; + self.slices += 1; + }, + else => {}, + } + } +}; + +test "stream: apc bulk slice" { + var s: Stream(ApcTestHandler) = .init(.{}); + s.nextSlice("\x1b_Gf=24,s=10,v=20;aGVsbG8=\x1b\\"); + + try testing.expectEqual(@as(usize, 1), s.handler.started); + try testing.expectEqual(@as(usize, 1), s.handler.ended); + try testing.expectEqualStrings( + "Gf=24,s=10,v=20;aGVsbG8=", + s.handler.buf[0..s.handler.len], + ); + + // With SIMD enabled the body must arrive as a single slice. + if (comptime build_options.simd and !debug) { + try testing.expectEqual(@as(usize, 1), s.handler.slices); + try testing.expectEqual(@as(usize, 0), s.handler.puts); + } +} + +test "stream: apc bulk slice split across inputs" { + var s: Stream(ApcTestHandler) = .init(.{}); + s.nextSlice("\x1b_Gf=24,s=10"); + s.nextSlice(",v=20;aGVs"); + s.nextSlice("bG8=\x1b\\"); + + try testing.expectEqual(@as(usize, 1), s.handler.started); + try testing.expectEqual(@as(usize, 1), s.handler.ended); + try testing.expectEqualStrings( + "Gf=24,s=10,v=20;aGVsbG8=", + s.handler.buf[0..s.handler.len], + ); +} + +test "stream: apc bulk slice keeps C0 bytes as data" { + var s: Stream(ApcTestHandler) = .init(.{}); + // BEL does not terminate an APC string; it is payload data. + s.nextSlice("\x1b_Gx\x07y\x1b\\"); + + try testing.expectEqual(@as(usize, 1), s.handler.ended); + try testing.expectEqualStrings("Gx\x07y", s.handler.buf[0..s.handler.len]); +} + +test "stream: apc aborted by CAN" { + var s: Stream(ApcTestHandler) = .init(.{}); + // CAN (0x18) aborts the APC string via the anywhere => ground + // transition. Exiting the sos_pm_apc_string state emits apc_end, + // and the trailing bytes are printed, not treated as APC data. + s.nextSlice("\x1b_Gabcdefghijklmnopqrstuvwxyz0123456789\x18def"); + + try testing.expectEqual(@as(usize, 1), s.handler.started); + try testing.expectEqual(@as(usize, 1), s.handler.ended); + try testing.expectEqualStrings( + "Gabcdefghijklmnopqrstuvwxyz0123456789", + s.handler.buf[0..s.handler.len], + ); +} + +test "stream: apc scalar path matches" { + var s: Stream(ApcTestHandler) = .init(.{}); + for ("\x1b_Gf=24;aGVsbG8=\x1b\\") |c| s.next(c); + + try testing.expectEqual(@as(usize, 1), s.handler.started); + try testing.expectEqual(@as(usize, 1), s.handler.ended); + try testing.expectEqualStrings( + "Gf=24;aGVsbG8=", + s.handler.buf[0..s.handler.len], + ); +} + +test "stream: apc vector boundaries match scalar path" { + const positions = [_]usize{ 15, 16, 17, 31, 32, 33, 63, 64, 65 }; + const controls = [_]u8{ 0x18, 0x1A, 0x1B, 0x80, 0xFF }; + + for (positions) |position| for (controls) |control| { + var input: [96]u8 = undefined; + input[0..3].* = "\x1b_G".*; + @memset(input[3 .. 3 + position], 'a'); + input[3 + position] = control; + input[4 + position] = '\\'; + const bytes = input[0 .. 5 + position]; + + var bulk: Stream(ApcTestHandler) = .init(.{}); + bulk.nextSlice(bytes); + var scalar: Stream(ApcTestHandler) = .init(.{}); + for (bytes) |byte| scalar.next(byte); + + try testing.expectEqual(scalar.handler.started, bulk.handler.started); + try testing.expectEqual(scalar.handler.ended, bulk.handler.ended); + try testing.expectEqualStrings( + scalar.handler.buf[0..scalar.handler.len], + bulk.handler.buf[0..bulk.handler.len], + ); + }; +} diff --git a/vendor/libghostty-vt/src/terminal/stream_terminal.zig b/vendor/libghostty-vt/src/terminal/stream_terminal.zig index f68f088b..91cfb21c 100644 --- a/vendor/libghostty-vt/src/terminal/stream_terminal.zig +++ b/vendor/libghostty-vt/src/terminal/stream_terminal.zig @@ -2,13 +2,16 @@ const std = @import("std"); const build_options = @import("terminal_options"); const testing = std.testing; const apc = @import("apc.zig"); +const clipboard = @import("clipboard.zig"); const csi = @import("csi.zig"); const device_attributes = @import("device_attributes.zig"); const device_status = @import("device_status.zig"); const stream = @import("stream.zig"); const Action = stream.Action; const Screen = @import("Screen.zig"); +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_color = @import("kitty/color.zig"); const size_report = @import("size_report.zig"); @@ -43,6 +46,11 @@ pub const Handler = struct { /// the kitty graphics protocol. apc_handler: apc.Handler = .{}, + /// Default cursor style used by DECSCUSR reset (CSI 0 q). + default_cursor: bool = true, + default_cursor_style: Screen.CursorStyle = .block, + default_cursor_blink: bool = false, + pub const Effects = struct { /// Called when the terminal needs to write data back to the pty, /// e.g. in response to a DECRQM query. The data is only valid @@ -78,6 +86,25 @@ pub const Handler = struct { /// handler.terminal.getTitle(). title_changed: ?*const fn (*Handler) void, + /// Called when the terminal pwd changes via escape sequences + /// (e.g. OSC 7). The new pwd can be queried via + /// handler.terminal.getPwd(). + pwd_changed: ?*const fn (*Handler) 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. + /// + /// A write with no contents clears the destination. A content entry + /// with empty data is a distinct empty representation. + /// + /// 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, + /// Called in response to an XTVERSION query. Returns the version /// string to report (e.g. "ghostty 1.2.3"). The returned memory /// must be valid for the lifetime of the call. The maximum length @@ -89,11 +116,13 @@ pub const Handler = struct { /// effects beyond that. pub const readonly: Effects = .{ .bell = null, + .clipboard_write = null, .color_scheme = null, .device_attributes = null, .enquiry = null, .size = null, .title_changed = null, + .pwd_changed = null, .write_pty = null, .xtversion = null, }; @@ -126,6 +155,7 @@ pub const Handler = struct { ) !void { switch (action) { .print => try self.terminal.print(value.cp), + .print_slice => try self.terminal.printSlice(value.cps), .print_repeat => try self.terminal.printRepeat(value), .backspace => self.terminal.backspace(), .carriage_return => self.terminal.carriageReturn(), @@ -152,12 +182,19 @@ pub const Handler = struct { self.terminal.screens.active.cursor.x + 1, ), .cursor_style => { + self.default_cursor = false; + const blink = switch (value) { - .default, .steady_block, .steady_bar, .steady_underline => false, + .default => self.default_cursor_blink, + .steady_block, .steady_bar, .steady_underline => false, .blinking_block, .blinking_bar, .blinking_underline => true, }; const style: Screen.CursorStyle = switch (value) { - .default, .blinking_block, .steady_block => .block, + .default => style: { + self.default_cursor = true; + break :style self.default_cursor_style; + }, + .blinking_block, .steady_block => .block, .blinking_bar, .steady_bar => .bar, .blinking_underline, .steady_underline => .underline, }; @@ -228,17 +265,23 @@ 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(); + self.default_cursor = true; + self.terminal.modes.set(.cursor_blinking, self.default_cursor_blink); + self.terminal.screens.active.cursor.cursor_style = self.default_cursor_style; + }, .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), .mouse_shape => self.terminal.mouse_shape = value, - .color_operation => try self.colorOperation(value.op, &value.requests), + .color_operation => try self.colorOperation(&value.requests, value.terminator), .kitty_color_report => try self.kittyColorOperation(value), // APC .apc_start => self.apc_handler.start(), .apc_put => self.apc_handler.feed(self.terminal.gpa(), value), + .apc_put_slice => self.apc_handler.feedSlice(self.terminal.gpa(), value.bytes), .apc_end => self.apcEnd(), // Effect-based handlers @@ -251,7 +294,9 @@ pub const Handler = struct { .request_mode_unknown => self.requestModeUnknown(value.mode, value.ansi), .size_report => self.reportSize(value), .window_title => self.windowTitle(value.title), + .report_pwd => self.reportPwd(value.url), .xtversion => self.reportXtversion(), + .clipboard_contents => try self.clipboardContents(value.kind, value.data), // No supported DCS commands have any terminal-modifying effects, // but they may in the future. For now we just ignore it. @@ -261,10 +306,8 @@ pub const Handler = struct { => {}, // Have no terminal-modifying effect - .report_pwd, .show_desktop_notification, .progress_report, - .clipboard_contents, .title_push, .title_pop, => {}, @@ -281,6 +324,44 @@ pub const Handler = struct { func(self); } + 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; + + const location: clipboard.Location = switch (kind) { + 's' => .selection, + 'p' => .primary, + else => .standard, + }; + + // OSC 52 uses an empty payload to clear the selected clipboard. + if (data.len == 0) { + _ = func(self, .{ + .location = location, + .contents = &.{}, + }); + return; + } + + const decoder = std.base64.standard.Decoder; + const decoded_len = try decoder.calcSizeForSlice(data); + const alloc = self.terminal.gpa(); + const decoded = try alloc.alloc(u8, decoded_len); + defer alloc.free(decoded); + try decoder.decode(decoded, data); + + const contents = [_]clipboard.Content{.{ + .mime = "text/plain", + .data = decoded, + }}; + _ = func(self, .{ + .location = location, + .contents = &contents, + }); + } + fn reportDeviceAttributes(self: *Handler, req: device_attributes.Req) void { const func = self.effects.device_attributes orelse return; const attrs = func(self); @@ -325,10 +406,11 @@ pub const Handler = struct { .color_scheme => { const func = self.effects.color_scheme orelse return; const scheme = func(self) orelse return; - self.writePty(switch (scheme) { - .dark => "\x1B[?997;1n", - .light => "\x1B[?997;2n", - }); + var buf: [device_status.max_color_scheme_report_encode_size + 1]u8 = undefined; + var writer: std.Io.Writer = .fixed(buf[0..device_status.max_color_scheme_report_encode_size]); + device_status.encodeColorSchemeReport(&writer, scheme) catch return; + buf[writer.end] = 0; + self.writePty(buf[0..writer.end :0]); }, } } @@ -420,6 +502,29 @@ pub const Handler = struct { func(self); } + fn reportPwd(self: *Handler, url_raw: []const u8) void { + // Prevent DoS attacks by limiting url length. Headroom for + // Linux PATH_MAX (4096) plus URI scheme/host and percent-encoding. + const max_url_len = 4096; + const url = if (url_raw.len > max_url_len) url: { + log.warn("pwd url length {d} exceeds max length {d}, truncating", .{ + url_raw.len, + max_url_len, + }); + break :url url_raw[0..max_url_len]; + } else url_raw; + + // We store the raw payload unparsed. Embedders read it via + // getPwd() and are responsible for decoding any URI scheme. + self.terminal.setPwd(url) catch |err| { + log.warn("error setting pwd err={}", .{err}); + return; + }; + + const func = self.effects.pwd_changed orelse return; + func(self); + } + fn requestMode(self: *Handler, mode: modes.Mode) void { const report = self.terminal.modes.getReport(.fromMode(mode)); self.sendModeReport(report); @@ -550,12 +655,17 @@ pub const Handler = struct { fn colorOperation( self: *Handler, - op: osc_color.Operation, requests: *const osc_color.List, + terminator: osc.Terminator, ) !void { - _ = op; if (requests.count() == 0) return; + var stack = std.heap.stackFallback(1024, self.terminal.gpa()); + const alloc = stack.get(); + var response: std.Io.Writer.Allocating = .init(alloc); + defer response.deinit(); + const writer = &response.writer; + var it = requests.constIterator(0); while (it.next()) |req| { switch (req.*) { @@ -613,17 +723,67 @@ pub const Handler = struct { mask.* = .initEmpty(); }, - .query, - .reset_special, - => {}, + .query => |target| { + if (self.effects.write_pty == null) continue; + const c = self.terminal.colorForXterm(target) orelse continue; + try writeXtermColorReport(writer, target, c, terminator); + }, + + .reset_special => {}, } } + + if (response.written().len > 0) { + const resp = try response.toOwnedSliceSentinel(0); + defer alloc.free(resp); + self.writePty(resp); + } + } + + fn writeXtermColorReport( + writer: *std.Io.Writer, + target: osc_color.Target, + c: color.RGB, + terminator: osc.Terminator, + ) !void { + switch (target) { + .palette => |i| { + try writer.print("\x1b]4;{d};", .{i}); + try c.encodeRgb16(writer); + try writer.writeAll(terminator.string()); + }, + .dynamic => |dynamic| switch (dynamic) { + .foreground, + .background, + .cursor, + => { + try writer.print("\x1b]{d};", .{@intFromEnum(dynamic)}); + try c.encodeRgb16(writer); + try writer.writeAll(terminator.string()); + }, + .pointer_foreground, + .pointer_background, + .tektronix_foreground, + .tektronix_background, + .highlight_background, + .tektronix_cursor, + .highlight_foreground, + => {}, + }, + .special => {}, + } } fn kittyColorOperation( self: *Handler, request: kitty_color.OSC, ) !void { + var stack = std.heap.stackFallback(1024, self.terminal.gpa()); + const alloc = stack.get(); + var response: std.Io.Writer.Allocating = .init(alloc); + defer response.deinit(); + const writer = &response.writer; + for (request.list.items) |item| { switch (item) { .set => |v| switch (v.key) { @@ -650,9 +810,28 @@ pub const Handler = struct { else => {}, }, }, - .query => {}, + .query => |key| { + if (self.effects.write_pty == null) continue; + const c = self.terminal.colorForKitty(key) orelse { + if (!key.hasTerminalQueryColor()) continue; + if (response.written().len == 0) try writer.writeAll("\x1b]21"); + try writer.print(";{f}=", .{key}); + continue; + }; + + if (response.written().len == 0) try writer.writeAll("\x1b]21"); + try writer.print(";{f}=", .{key}); + try c.encodeRgb8(writer); + }, } } + + if (response.written().len > 0) { + try writer.writeAll(request.terminator.string()); + const resp = try response.toOwnedSliceSentinel(0); + defer alloc.free(resp); + self.writePty(resp); + } } fn apcEnd(self: *Handler) void { @@ -679,6 +858,25 @@ pub const Handler = struct { if (final.len > 3) self.writePty(final[0 .. final.len - 1 :0]); } }, + + .glyph => |*glyph_req| { + const resp = self.terminal.glyphProtocol(alloc, glyph_req); + if (resp) |r| resp_block: { + // Don't waste time encoding if we can't write responses + // anyways. + if (self.effects.write_pty == null) break :resp_block; + + // Glyph responses are short and bounded by the protocol + // fields we emit, so this matches the Kitty response + // buffer size above with ample headroom. + var buf: [apc.glyph.Response.max_wire_bytes]u8 = undefined; + var writer: std.Io.Writer = .fixed(&buf); + r.formatWire(&writer) catch return; + writer.writeByte(0) catch return; + const final = writer.buffered(); + self.writePty(final[0 .. final.len - 1 :0]); + } + }, } } }; @@ -900,6 +1098,8 @@ test "full reset" { s.nextSlice("\x1B[10;20H"); s.nextSlice("\x1B[5;20r"); // Set scroll region s.nextSlice("\x1B[?7l"); // Disable wraparound + s.nextSlice("\x1B_25a1;r;cp=e0a0;AAAAAAAAAAAAAA==\x1B\\"); + try testing.expect(t.glyph_glossary.contains(0xE0A0)); // Full reset s.nextSlice("\x1Bc"); @@ -910,6 +1110,35 @@ test "full reset" { try testing.expectEqual(@as(usize, 0), t.scrolling_region.top); try testing.expectEqual(@as(usize, 23), t.scrolling_region.bottom); try testing.expect(t.modes.get(.wraparound)); + try testing.expect(!t.glyph_glossary.contains(0xE0A0)); +} + +test "glyph protocol APC with write_pty callback" { + var t: Terminal = try .init(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 { + if (last_response) |old| testing.allocator.free(old); + last_response = testing.allocator.dupeZ(u8, data) catch @panic("OOM"); + } + }; + S.last_response = null; + defer if (S.last_response) |old| testing.allocator.free(old); + + var handler: Handler = .init(&t); + handler.effects.write_pty = &S.writePty; + + var s: Stream = .initAlloc(testing.allocator, handler); + defer s.deinit(); + + s.nextSlice("\x1B_25a1;s\x1B\\"); + try testing.expectEqualStrings("\x1B_25a1;s;fmt=glyf\x1B\\", S.last_response.?); + + s.nextSlice("\x1B_25a1;r;cp=e0a0;AAAAAAAAAAAAAA==\x1B\\"); + try testing.expectEqualStrings("\x1B_25a1;r;cp=e0a0;status=0\x1B\\", S.last_response.?); + try testing.expect(t.glyph_glossary.contains(0xE0A0)); } test "ignores query actions" { @@ -923,6 +1152,8 @@ test "ignores query actions" { s.nextSlice("\x1B[c"); // Device attributes s.nextSlice("\x1B[5n"); // Device status report s.nextSlice("\x1B[6n"); // Cursor position report + s.nextSlice("\x1B]4;0;?\x1B\\"); // OSC color query + s.nextSlice("\x1B]21;foreground=?\x1B\\"); // Kitty color query // Terminal should still be functional s.nextSlice("Test"); @@ -1039,6 +1270,63 @@ test "OSC 12 set and reset cursor color" { // After reset, cursor might be null (using default) } +test "OSC color query responses" { + var t: Terminal = try .init(testing.allocator, .{ .cols = 10, .rows = 10 }); + defer t.deinit(testing.allocator); + + const S = struct { + var last_response: ?[:0]const u8 = null; + + fn reset() void { + if (last_response) |old| testing.allocator.free(old); + last_response = null; + } + + fn writePty(_: *Handler, data: [:0]const u8) void { + reset(); + last_response = testing.allocator.dupeZ(u8, data) catch @panic("OOM"); + } + }; + S.last_response = null; + defer S.reset(); + + var handler: Handler = .init(&t); + handler.effects.write_pty = &S.writePty; + + var s: Stream = .initAlloc(testing.allocator, handler); + defer s.deinit(); + + s.nextSlice("\x1b]10;?\x1b\\"); + try testing.expect(S.last_response == null); + + s.nextSlice("\x1b]11;?\x1b\\"); + try testing.expect(S.last_response == null); + + s.nextSlice("\x1b]4;2;rgb:12/34/56;2;?\x1b\\"); + try testing.expectEqualStrings( + "\x1b]4;2;rgb:1212/3434/5656\x1b\\", + S.last_response.?, + ); + + s.nextSlice("\x1b]10;rgb:01/02/03\x1b\\"); + s.nextSlice("\x1b]11;rgb:04/05/06\x1b\\"); + s.nextSlice("\x1b]12;rgb:07/08/09\x1b\\"); + s.nextSlice("\x1b]10;?;?;?\x1b\\"); + try testing.expectEqualStrings( + "\x1b]10;rgb:0101/0202/0303\x1b\\" ++ + "\x1b]11;rgb:0404/0505/0606\x1b\\" ++ + "\x1b]12;rgb:0707/0808/0909\x1b\\", + S.last_response.?, + ); + + s.nextSlice("\x1b]112\x1b\\"); + s.nextSlice("\x1b]12;?\x07"); + try testing.expectEqualStrings( + "\x1b]12;rgb:0101/0202/0303\x07", + S.last_response.?, + ); +} + test "kitty color protocol set palette" { var t: Terminal = try .init(testing.allocator, .{ .cols = 10, .rows = 10 }); defer t.deinit(testing.allocator); @@ -1133,6 +1421,46 @@ test "kitty color protocol reset foreground" { try testing.expect(t.colors.foreground.get() == null); } +test "kitty color protocol query responses" { + var t: Terminal = try .init(testing.allocator, .{ .cols = 10, .rows = 10 }); + defer t.deinit(testing.allocator); + + const S = struct { + var last_response: ?[:0]const u8 = null; + + fn reset() void { + if (last_response) |old| testing.allocator.free(old); + last_response = null; + } + + fn writePty(_: *Handler, data: [:0]const u8) void { + reset(); + last_response = testing.allocator.dupeZ(u8, data) catch @panic("OOM"); + } + }; + S.last_response = null; + defer S.reset(); + + var handler: Handler = .init(&t); + handler.effects.write_pty = &S.writePty; + + var s: Stream = .initAlloc(testing.allocator, handler); + defer s.deinit(); + + s.nextSlice("\x1b]21;background=?\x1b\\"); + try testing.expectEqualStrings( + "\x1b]21;background=\x1b\\", + S.last_response.?, + ); + + s.nextSlice("\x1b]21;foreground=rgb:12/34/56;2=rgb:aa/bb/cc\x1b\\"); + s.nextSlice("\x1b]21;foreground=?;background=?;2=?\x1b\\"); + try testing.expectEqualStrings( + "\x1b]21;foreground=rgb:12/34/56;background=;2=rgb:aa/bb/cc\x1b\\", + S.last_response.?, + ); +} + test "palette dirty flag set on color change" { var t: Terminal = try .init(testing.allocator, .{ .cols = 10, .rows = 10 }); defer t.deinit(testing.allocator); @@ -1321,6 +1649,155 @@ test "bell effect callback" { } } +test "clipboard_write effect callback" { + var t: Terminal = try .init(testing.allocator, .{ .cols = 80, .rows = 24 }); + defer t.deinit(testing.allocator); + + // A null callback (the default readonly effects) silently ignores writes. + { + var s: Stream = .initAlloc(testing.allocator, .init(&t)); + defer s.deinit(); + + s.nextSlice("\x1B]52;c;aGVsbG8=\x1B\\"); + + // Terminal should still be functional after the ignored sequence + s.nextSlice("AfterClipboard"); + const str = try t.plainString(testing.allocator); + defer testing.allocator.free(str); + try testing.expectEqualStrings("AfterClipboard", str); + } + + t.fullReset(); + + const S = struct { + var count: usize = 0; + var result: clipboard.WriteResult = .success; + var last_location: clipboard.Location = .standard; + var last_contents_len: usize = 0; + var last_mime: ?[]u8 = null; + var last_data: ?[]u8 = null; + + fn clearCapture() void { + if (last_mime) |value| testing.allocator.free(value); + if (last_data) |value| testing.allocator.free(value); + last_mime = null; + last_data = null; + last_contents_len = 0; + } + + fn clipboardWrite(_: *Handler, write: clipboard.Write) clipboard.WriteResult { + clearCapture(); + count += 1; + last_location = write.location; + last_contents_len = write.contents.len; + if (write.contents.len > 0) { + last_mime = testing.allocator.dupe(u8, write.contents[0].mime) catch + @panic("failed to capture clipboard MIME type"); + last_data = testing.allocator.dupe(u8, write.contents[0].data) catch + @panic("failed to capture clipboard data"); + } + return result; + } + }; + S.count = 0; + S.result = .denied; + S.clearCapture(); + defer S.clearCapture(); + + var handler: Handler = .init(&t); + handler.effects.clipboard_write = &S.clipboardWrite; + + var s: Stream = .initAlloc(testing.allocator, handler); + defer s.deinit(); + + // Selectors are normalized and payloads are decoded before the callback. + const cases = [_]struct { + sequence: []const u8, + location: clipboard.Location, + data: []const u8, + }{ + .{ .sequence = "\x1B]52;c;aGVsbG8=\x1B\\", .location = .standard, .data = "hello" }, + .{ .sequence = "\x1B]52;s;d29ybGQ=\x07", .location = .selection, .data = "world" }, + .{ .sequence = "\x1B]52;p;cHJpbWFyeQ==\x1B\\", .location = .primary, .data = "primary" }, + .{ .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" }, + }; + + for (cases, 1..) |case, expected_count| { + s.nextSlice(case.sequence); + try testing.expectEqual(expected_count, S.count); + try testing.expectEqual(case.location, S.last_location); + try testing.expectEqual(@as(usize, 1), S.last_contents_len); + try testing.expectEqualStrings("text/plain", S.last_mime.?); + try testing.expectEqualSlices(u8, case.data, S.last_data.?); + } + + // Empty data is a clear, represented by an empty contents slice. + s.nextSlice("\x1B]52;s;\x1B\\"); + try testing.expectEqual(@as(usize, cases.len + 1), S.count); + try testing.expectEqual(clipboard.Location.selection, S.last_location); + try testing.expectEqual(@as(usize, 0), S.last_contents_len); + try testing.expect(S.last_mime == null); + try testing.expect(S.last_data == null); + + // Reads and malformed base64 are ignored. + s.nextSlice("\x1B]52;c;?\x1B\\"); + s.nextSlice("\x1B]52;c;***\x1B\\"); + try testing.expectEqual(@as(usize, cases.len + 1), S.count); + + // OSC 1337 Copy shares the normalized clipboard write path. + s.nextSlice("\x1B]1337;Copy=:aVRlcm0y\x1B\\"); + try testing.expectEqual(@as(usize, cases.len + 2), S.count); + try testing.expectEqual(clipboard.Location.standard, S.last_location); + try testing.expectEqualStrings("text/plain", S.last_mime.?); + try testing.expectEqualStrings("iTerm2", S.last_data.?); + + // Parsing across write boundaries still invokes exactly one atomic write. + s.nextSlice("\x1B]52;p;ZnJh"); + s.nextSlice("Z21lbnRlZA==\x1B"); + s.nextSlice("\\"); + try testing.expectEqual(@as(usize, cases.len + 3), S.count); + try testing.expectEqual(clipboard.Location.primary, S.last_location); + 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); +} + +test "clipboard_write allocation failure is ignored" { + var t: Terminal = try .init(testing.allocator, .{ .cols = 80, .rows = 24 }); + defer t.deinit(testing.allocator); + + const S = struct { + var count: usize = 0; + + fn clipboardWrite(_: *Handler, _: clipboard.Write) clipboard.WriteResult { + count += 1; + return .success; + } + }; + S.count = 0; + + var handler: Handler = .init(&t); + handler.effects.clipboard_write = &S.clipboardWrite; + + var s: Stream = .initAlloc(testing.allocator, handler); + defer s.deinit(); + + // Only the decoded scratch data uses the terminal allocator here. Swap in + // an allocator that always fails, then restore it before terminal teardown. + { + const alloc = t.screens.active.alloc; + t.screens.active.alloc = testing.failing_allocator; + defer t.screens.active.alloc = alloc; + s.nextSlice("\x1B]52;c;aGVsbG8=\x1B\\"); + } + try testing.expectEqual(@as(usize, 0), S.count); +} + test "request mode DECRQM with write_pty callback" { var t: Terminal = try .init(testing.allocator, .{ .cols = 80, .rows = 24 }); defer t.deinit(testing.allocator); diff --git a/vendor/libghostty-vt/src/terminal/style.zig b/vendor/libghostty-vt/src/terminal/style.zig index ae9488af..720789bb 100644 --- a/vendor/libghostty-vt/src/terminal/style.zig +++ b/vendor/libghostty-vt/src/terminal/style.zig @@ -966,6 +966,36 @@ test "Set capacities" { _ = Set.Layout.init(16384); } +test "Set zero capacity" { + const testing = std.testing; + const alloc = testing.allocator; + + // A zero-capacity set is a valid special case (see Layout.init). + // It occurs in practice for pages with exact capacities where no + // cell is styled (see Page.exactRowCapacity). + const layout: Set.Layout = .init(0); + try testing.expectEqual(0, layout.total_size); + + // We allocate a nonzero buffer filled with 0xFF to simulate the + // set being embedded in a larger structure (e.g. a Page) where + // other data follows it. Lookups must not probe the zero-size + // table: table[0] would read this adjacent memory and treat it + // as an item ID, leading to far out-of-bounds item reads. + const buf = try alloc.alignedAlloc(u8, Set.base_align, 64); + defer alloc.free(buf); + @memset(buf, 0xFF); + + var set = Set.init(.init(buf), layout, .{}); + + const style: Style = .{ .flags = .{ .bold = true } }; + try testing.expectEqual(null, set.lookup(buf, style)); + try testing.expectError(error.OutOfMemory, set.add(buf, style)); + try testing.expectEqual(0, set.count()); + + // The adjacent memory must be untouched. + for (buf) |b| try testing.expectEqual(0xFF, b); +} + test "Style HTML formatting basic bold" { const testing = std.testing; const alloc = testing.allocator; diff --git a/vendor/libghostty-vt/src/terminal/x11_color.zig b/vendor/libghostty-vt/src/terminal/x11_color.zig index 97f3a7ce..7239a47d 100644 --- a/vendor/libghostty-vt/src/terminal/x11_color.zig +++ b/vendor/libghostty-vt/src/terminal/x11_color.zig @@ -2,6 +2,20 @@ const std = @import("std"); const assert = @import("../quirks.zig").inlineAssert; const RGB = @import("color.zig").RGB; +/// A single X11 color entry. +pub const Entry = struct { + /// Color name. Null-terminated so it can be exposed through the C API + /// without runtime allocation. + name: [:0]const u8, + color: RGB, +}; + +/// All X11 colors in rgb.txt file order. +/// +/// This is kept separate from `map` because the C API needs stable file order +/// and null-terminated names. `ColorMap` keys do not provide that contract. +pub const entries: []const Entry = entriesArray(); + /// The map of all available X11 colors. pub const map = colorMap(); @@ -10,15 +24,10 @@ pub const ColorMap = std.StaticStringMapWithEql( std.static_string_map.eqlAsciiIgnoreCase, ); -fn colorMap() ColorMap { - @setEvalBranchQuota(500_000); - - const KV = struct { []const u8, RGB }; - - // The length of our data is the number of lines in the rgb file. +fn entriesArray() []const Entry { + @setEvalBranchQuota(1_000_000); const len = std.mem.count(u8, data, "\n"); - var kvs: [len]KV = undefined; - + var result: [len]Entry = undefined; // Parse the line. This is not very robust parsing, because we expect // a very exact format for rgb.txt. However, this is all done at comptime // so if our data is bad, we should hopefully get an error here or one @@ -37,11 +46,29 @@ fn colorMap() ColorMap { const g = try std.fmt.parseInt(u8, std.mem.trim(u8, line[4..7], " "), 10); const b = try std.fmt.parseInt(u8, std.mem.trim(u8, line[8..11], " "), 10); const name = std.mem.trim(u8, line[12..], " \t"); - kvs[i] = .{ name, .{ .r = r, .g = g, .b = b } }; + var name_z: [name.len:0]u8 = undefined; + @memcpy(name_z[0..name.len], name); + name_z[name.len] = 0; + const final_name = name_z; + result[i] = .{ + .name = final_name[0..name.len :0], + .color = .{ .r = r, .g = g, .b = b }, + }; i += 1; } assert(i == len); + const final = result; + return &final; +} + +fn colorMap() ColorMap { + @setEvalBranchQuota(1_000_000); + + const KV = struct { []const u8, RGB }; + var kvs: [entries.len]KV = undefined; + for (entries, 0..) |entry, i| kvs[i] = .{ entry.name, entry.color }; + return .initComptime(kvs); } @@ -67,3 +94,14 @@ test { try testing.expectEqual(RGB{ .r = 0, .g = 250, .b = 154 }, map.get("mediumspringgreen")); try testing.expectEqual(RGB{ .r = 34, .g = 139, .b = 34 }, map.get("forestgreen")); } + +test "entries" { + const testing = std.testing; + + try testing.expect(entries.len > 700); + for (entries) |entry| { + try testing.expectEqual(entry.color, map.get(entry.name).?); + try testing.expectEqual(@as(u8, 0), entry.name.ptr[entry.name.len]); + } + try testing.expectEqualStrings("snow", entries[0].name); +} diff --git a/vendor/libghostty-vt/src/termio/Exec.zig b/vendor/libghostty-vt/src/termio/Exec.zig index 87d47807..0894f87b 100644 --- a/vendor/libghostty-vt/src/termio/Exec.zig +++ b/vendor/libghostty-vt/src/termio/Exec.zig @@ -1240,23 +1240,149 @@ const Subprocess = struct { } }; -/// The read thread sits in a loop doing the following pseudo code: +/// The read thread works with a companion gather thread to form a two-stage +/// pipeline that moves pty output into the terminal: +/// +/// io-gather: read()/poll() the pty into one of a few rotating +/// buffers, batching bulk output. +/// io-reader: hand each filled buffer to processOutput (terminal +/// lock, VT parse, state update, render scheduling). +/// +/// This used to be a single serial loop (and still is on Windows): /// /// while (true) { blocking_read(); exit_if_eof(); process(); } /// -/// Almost all terminal-modifying activity is from the pty read, so -/// putting this on a dedicated thread keeps performance very predictable -/// while also almost optimal. "Locking is fast, lock contention is slow." -/// and since we rarely have contention, this is fast. +/// I found on macOS that the kernel tty output queue caps every read +/// on the master at 1 KB no matter how large the read buffer is. This means +/// that producers (e.g. `cat`) stall with the above architecture because +/// there are windows in the `process()` part where we aren't draining +/// the kernel pty fd. +/// +/// Instead, having a separate thread gather and drain the kernel pty +/// into a rotating set of preallocated buffers minimizes this stall +/// period to effectively zero: while the io-reader thread parses +/// one batch, the gather thread is draining the kernel queue. There is +/// still stalling (our VT parse is a bottleneck now), but we don't stall +/// between them. /// -/// This is also empirically fast compared to putting the read into -/// an async mechanism like io_uring/epoll because the reads are generally -/// small. +/// Interactive latency is preserved: a batch is delivered on the +/// first EAGAIN unless the stream is saturated (>= 1 KiB gathered +/// means the writer filled the kernel queue), in which case we bridge +/// the producer's microsecond refill gaps with a short poll, bounded +/// by a small total budget per batch that is well under a display +/// frame. This means that small outputs that are more typical continue +/// to be interactive. /// -/// We use a basic poll syscall here because we are only monitoring two -/// fds and this is still much faster and lower overhead than any async -/// mechanism. +/// We use basic poll/read syscalls here because we are only +/// monitoring two fds and this is still much faster and lower +/// overhead than any async mechanism. pub const ReadThread = struct { + /// The number of buffers rotated between the gather and parse + /// stages. The gather stage can run at most this many batches + /// ahead of the parse stage before it blocks, which (via the + /// kernel pty queue) is also what preserves flow control to the + /// child. Empirically chosen through measurements on an M4 Max. + /// Less than 4 there are minor slowdowns, above 4 there are no + /// improvements. + const buffer_count = 4; + + /// The capacity of each gather buffer. One batch is also the unit + /// of work the parse stage does per terminal lock acquisition, so + /// this bounds both gather latency and lock hold time. + const buffer_capacity = 64 * 1024; + + /// How many gathered bytes mark a stream as saturated. The macOS + /// kernel tty output queue hands the master at most about 1 KiB + /// per read, so gathering a full 1 KiB means the writer filled + /// the queue (a bulk stream worth briefly waiting on), while + /// anything smaller is an interactive trickle that must be + /// delivered with no added latency. + const bridge_threshold = 1024; + + /// How many times an EAGAIN on a saturated stream is retried + /// with an immediate read before we're willing to sleep in poll. + /// Basically, a spin retry. + /// + /// The writer refills the drained kernel queue within a few + /// microseconds, while a sleep and wakeup through poll costs + /// several more. If the gather stage sleeps on every refill gap, + /// the whole pipeline degenerates to lockstep with the writer at + /// about 1 KiB per wakeup. A short burst of nonblocking reads + /// bridges nearly all refill gaps without sleeping. Measured, 8 + /// to 16 retries catches over 90% of the gaps and nearly doubles + /// the saturated drain rate, and larger values helped little. + /// The cost is bounded to at most this many extra ~0.5us read + /// syscalls per gap, and we only spin on streams that already + /// gathered a full kernel queue, so an idle or interactive + /// terminal never spins. + const bridge_spin_max = 16; + + /// How long one bridge poll waits for the writer's next refill + /// once the spin retries above have failed. If the stream is + /// quiet for this long the burst is over and we deliver what we + /// have. + const bridge_poll_timeout_ms = 1; + + /// The longest one batch may spend bridging refill gaps before it + /// is delivered regardless. This bounds output latency for + /// streams that produce just enough to keep bridging. Three + /// milliseconds is well under one display frame, so batching is + /// invisible on screen. + const gather_budget_ns = 3 * std.time.ns_per_ms; + + /// The state shared between the gather and parse stages. This is + /// a fixed ring of buffers plus the metadata to rotate ownership + /// between the two threads. A buffer is owned by exactly one + /// stage at a time, so buffer contents need no locking. Only the + /// ring metadata is guarded by the mutex. + const Pipeline = struct { + mutex: std.Thread.Mutex = .{}, + + /// Signaled when a batch is published or the gather stage is + /// done. Waited on by the parse stage. + batch_ready: std.Thread.Condition = .{}, + + /// Signaled when a batch has been consumed. Waited on by the + /// gather stage when all buffers are in flight (backpressure). + slot_free: std.Thread.Condition = .{}, + + /// The number of valid bytes in each buffer. Set at publish + /// time by the gather stage, read by the parse stage. + lens: [buffer_count]usize = @splat(0), + + /// Ring state: head is the next slot the gather stage fills, + /// tail is the next slot the parse stage consumes, count is + /// the number of published, unconsumed batches. + head: usize = 0, + tail: usize = 0, + count: usize = 0, + + /// Set by the gather stage (under the mutex) while it sleeps + /// in a bridge poll. The parse stage only writes to the idle + /// pipe when this is set, so an interactive terminal never + /// pays the pipe syscalls. + bridging: bool = false, + + /// A self-pipe the parse stage uses to interrupt the gather + /// stage's bridge poll the moment it runs out of batches. + /// Bridging a refill gap is only free while the parse stage + /// is busy; once it goes idle, every additional microsecond + /// spent bridging is added straight to output latency. The + /// write end is used by the parse stage, the read end is + /// polled by the gather stage. -1 when unavailable, in which + /// case bridge polls are bounded by their timeout only. + idle_read_fd: posix.fd_t = -1, + idle_write_fd: posix.fd_t = -1, + + /// Set by the gather stage when the stream is over (quit + /// signal, EOF, or pty error). The parse stage drains any + /// remaining batches and then exits. + done: bool = false, + + /// The buffer storage itself. + bufs: [buffer_count][buffer_capacity]u8 = undefined, + }; + fn threadMainPosix(fd: posix.fd_t, io: *termio.Termio, quit: posix.fd_t) void { // Always close our end of the pipe when we exit. defer posix.close(quit); @@ -1266,6 +1392,7 @@ pub const ReadThread = struct { // so instead we use this code to name the thread instead. if (builtin.os.tag.isDarwin()) { internal_os.macos.pthread_setname_np(&"io-reader".*); + setQosClass(); } // Setup our crash metadata @@ -1275,71 +1402,292 @@ pub const ReadThread = struct { }; defer crash.sentry.thread_state = null; - // First thing, we want to set the fd to non-blocking. We do this - // so that we can try to read from the fd in a tight loop and only - // check the quit fd occasionally. - if (posix.fcntl(fd, posix.F.GETFL, 0)) |flags| { - _ = posix.fcntl( - fd, - posix.F.SETFL, - flags | @as(u32, @bitCast(posix.O{ .NONBLOCK = true })), - ) catch |err| { - log.warn("read thread failed to set flags err={}", .{err}); - log.warn("this isn't a fatal error, but may cause performance issues", .{}); - }; + // Set the fd to non-blocking so the gather stage can drain it + // in a tight loop and fall back to poll for readiness. The + // pipeline can't run with a blocking fd (a blocking read + // would hang the gather stage on a quiet pty), but this also + // can't realistically fail on a valid pty master. + if (!setNonblock(fd)) { + log.err("read thread exiting, pty fd must be non-blocking", .{}); + return; + } + + // Shared pipeline + var pipeline: Pipeline = .{}; + + // The idle self-pipe (see the Pipeline field docs). If we + // can't create it we still run correctly, bridge polls are + // just bounded by their timeout instead of being interrupted + // when the parse stage goes idle. + if (posix.pipe2(.{ + .CLOEXEC = true, + .NONBLOCK = true, + })) |fds| { + pipeline.idle_read_fd = fds[0]; + pipeline.idle_write_fd = fds[1]; } else |err| { - log.warn("read thread failed to get flags err={}", .{err}); - log.warn("this isn't a fatal error, but may cause performance issues", .{}); + log.warn("read thread failed to create idle pipe err={}", .{err}); } + defer if (pipeline.idle_read_fd >= 0) { + posix.close(pipeline.idle_read_fd); + posix.close(pipeline.idle_write_fd); + }; - // Build up the list of fds we're going to poll. We are looking - // for data on the pty and our quit notification. - var pollfds: [2]posix.pollfd = .{ + const gather_thread = std.Thread.spawn( + .{}, + gatherMainPosix, + .{ fd, quit, &pipeline }, + ) catch |err| { + // If we can't spawn a thread the process is already + // doomed (every surface spawns several), so don't try + // to limp along. + log.err("read thread exiting, failed to spawn gather thread err={}", .{err}); + return; + }; + defer gather_thread.join(); + if (comptime !builtin.os.tag.isDarwin()) { + gather_thread.setName("io-gather") catch {}; + } + + // This thread is the parse stage. We consume batches in ring + // order until the gather stage reports the stream is over and + // the ring is drained. + while (true) { + const batch: []const u8 = batch: { + pipeline.mutex.lock(); + defer pipeline.mutex.unlock(); + while (pipeline.count == 0) { + if (pipeline.done) return; + pipeline.batch_ready.wait(&pipeline.mutex); + } + const slot = pipeline.tail; + break :batch pipeline.bufs[slot][0..pipeline.lens[slot]]; + }; + + // The batch buffer is owned by this stage until we advance + // the tail below, so it is safe to read outside the lock. + io.processOutput(batch); + + { + pipeline.mutex.lock(); + pipeline.tail = (pipeline.tail + 1) % buffer_count; + pipeline.count -= 1; + const wake = pipeline.count == 0 and + pipeline.bridging and + pipeline.idle_write_fd >= 0; + pipeline.mutex.unlock(); + pipeline.slot_free.signal(); + + // We ran out of batches while the gather stage is + // bridging a refill gap: interrupt its poll so it + // delivers what it has instead of sleeping out the + // timeout while we sit idle. + if (wake) { + _ = posix.write(pipeline.idle_write_fd, "i") catch {}; + } + } + + // Batch boundary: hand the renderer state mutex off if + // the renderer is waiting. See renderer.State.lockDemand. + io.renderer_state.yieldToDemand(); + } + } + + /// The gather stage. This drains the pty into rotating buffers, + /// bridging the kernel queue's refill gaps for saturated streams, + /// and publishes each batch to the parse stage. This thread owns + /// all fd monitoring, including the quit fd. + fn gatherMainPosix(fd: posix.fd_t, quit: posix.fd_t, pipeline: *Pipeline) void { + if (builtin.os.tag.isDarwin()) { + internal_os.macos.pthread_setname_np(&"io-gather".*); + setQosClass(); + } + + // However we exit, tell the parse stage the stream is over so + // it drains the ring and joins us. + defer { + pipeline.mutex.lock(); + pipeline.done = true; + pipeline.mutex.unlock(); + pipeline.batch_ready.signal(); + } + + // The fds we poll: data on the pty, our quit notification, + // and the parse stage's idle wake. The idle fd only + // participates in bridge polls (the parse stage only writes + // while we're bridging), so the outer poll slices it off. + var pollfds: [3]posix.pollfd = .{ .{ .fd = fd, .events = posix.POLL.IN, .revents = undefined }, .{ .fd = quit, .events = posix.POLL.IN, .revents = undefined }, + .{ .fd = pipeline.idle_read_fd, .events = posix.POLL.IN, .revents = undefined }, }; - var buf: [1024]u8 = undefined; while (true) { - // We try to read from the file descriptor as long as possible - // to maximize performance. We only check the quit fd if the - // main fd blocks. This optimizes for the realistic scenario that - // the data will eventually stop while we're trying to quit. This - // is always true because we kill the process. - while (true) { - const n = posix.read(fd, &buf) catch |err| { - switch (err) { - // This means our pty is closed. We're probably - // gracefully shutting down. - error.NotOpenForReading, - error.InputOutput, - => { - log.info("io reader exiting", .{}); - return; - }, + // Claim the next free buffer. This blocks only when the + // parse stage is a full ring behind, which is exactly when + // we should stop reading and let the kernel queue exert + // backpressure on the child. + const buf: *[buffer_capacity]u8 = buf: { + pipeline.mutex.lock(); + defer pipeline.mutex.unlock(); + while (pipeline.count == buffer_count) { + pipeline.slot_free.wait(&pipeline.mutex); + } + break :buf &pipeline.bufs[pipeline.head]; + }; - // No more data, fall back to poll and check for - // exit conditions. - error.WouldBlock => break, + var total: usize = 0; + var bridge_start: ?std.time.Instant = null; + var spins: usize = 0; + var fatal = false; + + // Fill the buffer from the pty. For a saturated stream the + // kernel queue momentarily runs dry while the writer + // refills it in parallel, so we bridge those gaps with + // spin retries and a short poll instead of delivering a + // tiny batch. + gather: while (total < buffer_capacity) { + const n = posix.read( + fd, + buf[total..], + ) catch |err| switch (err) { + error.WouldBlock => { + // Anything below the threshold is interactive. + if (total < bridge_threshold) break :gather; + + // The stream is saturated, so we bridge the + // gap. First retry the read directly a bounded + // number of times, since the refill usually + // lands within microseconds. + if (spins < bridge_spin_max) { + spins += 1; + continue :gather; + } - else => { - log.err("io reader error err={}", .{err}); - unreachable; - }, - } + // Still dry, so we want to sleep in poll for + // the next refill, within our latency budget. + const now = std.time.Instant.now() catch + break :gather; + if (bridge_start) |start| { + if (now.since(start) >= gather_budget_ns) + break :gather; + } else bridge_start = now; + + // Bridging a refill gap is only free while the + // parse stage is busy, since the wait hides + // behind parse time. Once the parser is idle, + // every microsecond we hold this batch is + // added straight to output latency, and for a + // request/response producer (a burst ending + // in a query, e.g. frame + cursor position + // report) the writer is blocked on a reply to + // data sitting in this buffer, so a poll here + // would always sleep its full timeout. Deliver + // now if the parser is idle, and otherwise arm + // the idle wake so it can interrupt our poll + // the moment that changes. + { + pipeline.mutex.lock(); + defer pipeline.mutex.unlock(); + if (pipeline.count == 0) break :gather; + pipeline.bridging = true; + } + + const r = posix.poll( + &pollfds, + bridge_poll_timeout_ms, + ) catch |poll_err| { + clearBridging(pipeline); + log.warn("bridge poll failed err={}", .{poll_err}); + break :gather; + }; + clearBridging(pipeline); + + // Quiet for a full timeout means the burst + // ended. + if (r == 0) break :gather; + + // On a quit signal we deliver what we have + // and stop. + if (pollfds[1].revents & posix.POLL.IN != 0) { + log.info("read thread got quit signal", .{}); + fatal = true; + break :gather; + } + + // The parse stage went idle: drain the wake + // and deliver what we have. The pty may have + // data as well, but the next batch can pick + // that up; an idle parser means delivery must + // not wait. + if (pollfds[2].revents & posix.POLL.IN != 0) { + var trash: [16]u8 = undefined; + while (true) { + const drained = posix.read( + pipeline.idle_read_fd, + &trash, + ) catch break; + if (drained < trash.len) break; + } + break :gather; + } + + // HUP without IN means no more data is + // coming. Deliver and let the outer poll + // decide what to do. + if (pollfds[0].revents & posix.POLL.IN == 0) + break :gather; + + continue :gather; + }, + + // The pty is closed. We're probably gracefully + // shutting down. + error.NotOpenForReading, + error.InputOutput, + => { + log.info("io gather exiting", .{}); + fatal = true; + break :gather; + }, + + else => { + log.err("io gather error err={}", .{err}); + unreachable; + }, }; // This happens on macOS instead of WouldBlock when the - // child process dies. To be safe, we just break the loop - // and let our poll happen. - if (n == 0) break; + // child process dies. Deliver what we have and let the + // outer poll detect HUP. + if (n == 0) break :gather; - // log.info("DATA: {d}", .{n}); - @call(.always_inline, termio.Termio.processOutput, .{ io, buf[0..n] }); + total += n; + + // Each refill gap gets a fresh spin budget. + spins = 0; + } + + // Publish the batch (if any) to the parse stage and rotate + // to the next buffer. + if (total > 0) { + pipeline.mutex.lock(); + pipeline.lens[pipeline.head] = total; + pipeline.head = (pipeline.head + 1) % buffer_count; + pipeline.count += 1; + pipeline.mutex.unlock(); + pipeline.batch_ready.signal(); } - // Wait for data. - _ = posix.poll(&pollfds, -1) catch |err| { + if (fatal) return; + + // A full buffer means the stream is still hot, so go + // claim the next buffer without an intervening poll. + if (total == buffer_capacity) continue; + + // Wait for data. The idle fd is sliced off: the parse + // stage only writes to it while we're bridging. + _ = posix.poll(pollfds[0..2], -1) catch |err| { log.warn("poll failed on read thread, exiting early err={}", .{err}); return; }; @@ -1359,6 +1707,50 @@ pub const ReadThread = struct { } } + /// Clears the bridging flag armed before a bridge poll, closing + /// the window in which the parse stage writes idle wakes. + fn clearBridging(pipeline: *Pipeline) void { + pipeline.mutex.lock(); + defer pipeline.mutex.unlock(); + pipeline.bridging = false; + } + + /// Sets the QoS class of the calling thread for the read pipeline + /// (macOS only). Both pipeline threads feed content the user is + /// actively watching, and at default QoS the scheduler may place + /// them on efficiency cores with wakeup latencies that are large + /// compared to the ~10us cadence of the pty producer/consumer + /// dance. Measured on an M4 Max, this results in a 15% throughput + /// difference (on the change, not 15% total). + fn setQosClass() void { + internal_os.macos.setQosClass(.user_initiated) catch |err| { + log.warn("error setting QoS class err={}", .{err}); + }; + } + + /// Sets the fd to non-blocking mode. Returns false on failure. + fn setNonblock(fd: posix.fd_t) bool { + const flags = posix.fcntl( + fd, + posix.F.GETFL, + 0, + ) catch |err| { + log.warn("read thread failed to get flags err={}", .{err}); + return false; + }; + + _ = posix.fcntl( + fd, + posix.F.SETFL, + flags | @as(u32, @bitCast(posix.O{ .NONBLOCK = true })), + ) catch |err| { + log.warn("read thread failed to set flags err={}", .{err}); + return false; + }; + + return true; + } + fn threadMainWindows(fd: posix.fd_t, io: *termio.Termio, quit: posix.fd_t) void { // Always close our end of the pipe when we exit. defer posix.close(quit); @@ -1388,6 +1780,11 @@ pub const ReadThread = struct { } @call(.always_inline, termio.Termio.processOutput, .{ io, buf[0..n] }); + + // See threadMainPosix: hand the renderer state mutex + // off if the renderer is waiting, since this loop + // would otherwise starve it under heavy output. + io.renderer_state.yieldToDemand(); } var quit_bytes: windows.DWORD = 0; diff --git a/vendor/libghostty-vt/src/termio/Termio.zig b/vendor/libghostty-vt/src/termio/Termio.zig index 1d1bfe25..1b08c5e9 100644 --- a/vendor/libghostty-vt/src/termio/Termio.zig +++ b/vendor/libghostty-vt/src/termio/Termio.zig @@ -712,11 +712,15 @@ pub fn colorSchemeReportLocked(self: *Termio, td: *ThreadData, force: bool) !voi if (!force and !self.renderer_state.terminal.modes.get(.report_color_scheme)) { return; } - const output = switch (self.config.conditional_state.theme) { - .light => "\x1B[?997;2n", - .dark => "\x1B[?997;1n", + const scheme: terminalpkg.device_status.ColorScheme = switch (self.config.conditional_state.theme) { + .light => .light, + .dark => .dark, }; - try self.queueWrite(td, output, false); + + var buf: [terminalpkg.device_status.max_color_scheme_report_encode_size]u8 = undefined; + var writer: std.Io.Writer = .fixed(&buf); + try terminalpkg.device_status.encodeColorSchemeReport(&writer, scheme); + try self.queueWrite(td, writer.buffered(), false); } /// ThreadData is the data created and stored in the termio thread diff --git a/vendor/libghostty-vt/src/termio/stream_handler.zig b/vendor/libghostty-vt/src/termio/stream_handler.zig index fb3a6b3f..43c5d611 100644 --- a/vendor/libghostty-vt/src/termio/stream_handler.zig +++ b/vendor/libghostty-vt/src/termio/stream_handler.zig @@ -205,6 +205,10 @@ pub const StreamHandler = struct { @branchHint(.likely); try self.terminal.print(value.cp); }, + .print_slice => { + @branchHint(.likely); + try self.terminal.printSlice(value.cps); + }, .print_repeat => try self.terminal.printRepeat(value), .bell => self.bell(), .backspace => self.terminal.backspace(), @@ -357,6 +361,7 @@ pub const StreamHandler = struct { .apc_start => self.apc.start(), .apc_end => try self.apcEnd(), .apc_put => self.apc.feed(self.alloc, value), + .apc_put_slice => self.apc.feedSlice(self.alloc, value.bytes), // Unimplemented .title_push, @@ -559,6 +564,23 @@ pub const StreamHandler = struct { } } }, + + .glyph => |*glyph_req| { + const resp = self.terminal.glyphProtocol(self.alloc, glyph_req); + switch (glyph_req.*) { + .register, .clear => try self.queueRender(), + .support, .query => {}, + } + + if (resp) |r| { + var buf: [terminal.apc.glyph.Response.max_wire_bytes]u8 = undefined; + var writer: std.Io.Writer = .fixed(&buf); + try r.formatWire(&writer); + const final = writer.buffered(); + log.debug("glyph protocol response: {x}", .{final}); + self.messageWriter(try termio.Message.writeReq(self.alloc, final)); + } + }, } } diff --git a/vendor/libghostty-vt/src/unicode/grapheme.zig b/vendor/libghostty-vt/src/unicode/grapheme.zig index d233dec7..73782dd6 100644 --- a/vendor/libghostty-vt/src/unicode/grapheme.zig +++ b/vendor/libghostty-vt/src/unicode/grapheme.zig @@ -2,6 +2,31 @@ const std = @import("std"); const table = @import("props_table.zig").table; const uucode = @import("uucode"); +/// Width change requested by a codepoint that continues a grapheme cluster. +pub const GraphemeWidthEffect = enum { + /// Do not append the codepoint to the cluster and leave break state as it + /// was before seeing it. + ignore, + + /// Append the codepoint but leave the current cluster width unchanged. + no_change, + + /// Make the cluster occupy two terminal cells. + wide, + + /// Make the cluster occupy one terminal cell. + narrow, +}; + +/// Result of measuring the first grapheme cluster in a codepoint slice. +pub const GraphemeWidth = struct { + /// Number of codepoints consumed from the input slice. + len: usize, + + /// Display width in terminal cells. + width: u2, +}; + /// Determines if there is a grapheme break between two codepoints. This /// must be called sequentially maintaining the state between calls. /// @@ -21,6 +46,99 @@ pub fn graphemeBreak(cp1: u21, cp2: u21, state: *uucode.grapheme.BreakState) boo return value.result; } +/// Returns the width effect of appending cp after prev within a grapheme. +/// +/// This is the shared width-decision kernel for the streaming terminal +/// printer and for graphemeWidth. It assumes graphemeBreak has already said +/// there is no break between prev and cp; it does not perform segmentation. +/// +/// The .ignore result is important for invalid emoji variation selectors. The +/// terminal does not store those selectors in the cell, so callers must also +/// restore their grapheme break state and leave prev unchanged when they see +/// .ignore. +pub inline fn graphemeWidthEffect(prev: u21, cp: u21) GraphemeWidthEffect { + // Emoji variation selectors modify the width of a valid base: + // VS16 makes the grapheme wide and VS15 makes it narrow. Check that + // prev forms a valid variation sequence in emoji-variation-sequences.txt; + // if it does not, ignore the selector entirely. + if (cp == 0xFE0F or cp == 0xFE0E) { + const prev_props = table.get(prev); + if (!prev_props.emoji_vs_base) return .ignore; + + return switch (cp) { + 0xFE0F => .wide, + 0xFE0E => .narrow, + else => unreachable, + }; + } + + // If a code point contributes to the width of a grapheme, the whole + // grapheme is at least width 2 because the first code point must be at + // least width 1 to start. Prepend code points could effectively mean + // the first code point should be width 0, but we don't handle that yet. + if (!table.get(cp).width_zero_in_grapheme) return .wide; + + return .no_change; +} + +/// Measures the first grapheme cluster in cps using the same segmentation and +/// width rules as Terminal.print with mode 2027. +/// +/// This is not a streaming API: cps must contain a complete first grapheme +/// cluster or the logical end of the string. If bytes/codepoints arrive in +/// chunks, keep buffering when this consumes all available codepoints and more +/// input may still arrive. +/// +/// For codepoint types wider than u21, values greater than U+10FFFF are +/// accepted so FFI-facing callers can use u32 input without trapping. An +/// invalid value consumes one codepoint at width 1 when it starts the slice, +/// and terminates the current cluster when it appears later. For u21 callers +/// these checks are comptime-dead. +pub fn graphemeWidth(comptime T: type, cps: []const T) GraphemeWidth { + const check_invalid = comptime @bitSizeOf(T) > @bitSizeOf(u21); + + if (cps.len == 0) return .{ .len = 0, .width = 0 }; + + // The C API accepts u32 codepoints, so it can receive values outside + // Unicode's range. Guard before narrowing to u21; native u21 callers + // skip this path at comptime. + if (check_invalid and invalidCodepoint(cps[0])) return .{ .len = 1, .width = 1 }; + + var len: usize = 1; + var width = table.get(@as(u21, @intCast(cps[0]))).width; + var prev: u21 = @intCast(cps[0]); + var state: uucode.grapheme.BreakState = .default; + + while (len < cps.len) : (len += 1) { + // Treat invalid u32 input as a boundary so a valid prefix cluster can + // still be returned without attempting to narrow the invalid value. + if (check_invalid and invalidCodepoint(cps[len])) break; + + const cp: u21 = @intCast(cps[len]); + const state_before = state; + if (graphemeBreak(prev, cp, &state)) break; + + switch (graphemeWidthEffect(prev, cp)) { + .ignore => state = state_before, + .no_change => prev = cp, + .wide => { + width = 2; + prev = cp; + }, + .narrow => { + width = 1; + prev = cp; + }, + } + } + + return .{ .len = len, .width = width }; +} + +inline fn invalidCodepoint(cp: anytype) bool { + return cp > 0x10FFFF; +} + /// This is all the structures and data for the precomputed lookup table /// for all possible permutations of state and grapheme break properties. /// Precomputation requires 2^13 keys of 4 bit values so the whole table is @@ -178,3 +296,70 @@ test "long emoji zwj sequences" { try std.testing.expect(cp1 == 0x1F466); // 👦 try std.testing.expect(graphemeBreak(cp1, cp2, &state)); // break } + +test "grapheme width: variation selectors" { + const testing = std.testing; + + try testing.expectEqual(GraphemeWidthEffect.wide, graphemeWidthEffect(0x2764, 0xFE0F)); + try testing.expectEqual(GraphemeWidthEffect.narrow, graphemeWidthEffect(0x23, 0xFE0E)); + try testing.expectEqual(GraphemeWidthEffect.ignore, graphemeWidthEffect('x', 0xFE0F)); + + try testing.expectEqual(GraphemeWidth{ .len = 2, .width = 2 }, graphemeWidth(u21, &.{ 0x2764, 0xFE0F })); + try testing.expectEqual(GraphemeWidth{ .len = 2, .width = 2 }, graphemeWidth(u21, &.{ 0x23, 0xFE0F })); + try testing.expectEqual(GraphemeWidth{ .len = 2, .width = 1 }, graphemeWidth(u21, &.{ 'x', 0xFE0F })); + try testing.expectEqual(GraphemeWidth{ .len = 3, .width = 1 }, graphemeWidth(u21, &.{ 'x', 0xFE0F, 0xFE0F })); + try testing.expectEqual(GraphemeWidth{ .len = 2, .width = 1 }, graphemeWidth(u21, &.{ 0x23, 0xFE0E })); + try testing.expectEqual(GraphemeWidth{ .len = 2, .width = 1 }, graphemeWidth(u21, &.{ 0x231A, 0xFE0E })); + try testing.expectEqual(GraphemeWidth{ .len = 3, .width = 1 }, graphemeWidth(u21, &.{ 0x231A, 0xFE0E, 0xFE0F })); + try testing.expectEqual(GraphemeWidth{ .len = 4, .width = 2 }, graphemeWidth(u21, &.{ 0x1F3F4, 0x200D, 0x2620, 0xFE0F })); +} + +test "grapheme width: emoji sequences" { + const testing = std.testing; + + try testing.expectEqual(GraphemeWidth{ .len = 5, .width = 2 }, graphemeWidth(u21, &.{ 0x1F468, 0x200D, 0x1F469, 0x200D, 0x1F467 })); + try testing.expectEqual(GraphemeWidth{ .len = 3, .width = 2 }, graphemeWidth(u21, &.{ 0x23, 0xFE0F, 0x20E3 })); + try testing.expectEqual(GraphemeWidth{ .len = 2, .width = 1 }, graphemeWidth(u21, &.{ '1', 0x20E3 })); + try testing.expectEqual(GraphemeWidth{ .len = 2, .width = 2 }, graphemeWidth(u21, &.{ 0x1F44B, 0x1F3FF })); +} + +test "grapheme width: spacing marks can widen narrow clusters" { + const testing = std.testing; + + var mark: ?u21 = null; + for (0..0x110000) |cp_usize| { + const cp: u21 = @intCast(cp_usize); + const props = table.get(cp); + if (props.width != 1 or props.width_zero_in_grapheme) continue; + + var state: uucode.grapheme.BreakState = .default; + if (!graphemeBreak('a', cp, &state)) { + mark = cp; + break; + } + } + + try testing.expect(mark != null); + const cp = mark.?; + try testing.expectEqual(@as(u2, 1), table.get(cp).width); + try testing.expect(!table.get(cp).width_zero_in_grapheme); + try testing.expectEqual(GraphemeWidth{ .len = 2, .width = 2 }, graphemeWidth(u21, &.{ 'a', cp })); +} + +test "grapheme width: segmentation" { + const testing = std.testing; + + try testing.expectEqual(GraphemeWidth{ .len = 1, .width = 1 }, graphemeWidth(u21, &.{'a'})); + try testing.expectEqual(GraphemeWidth{ .len = 1, .width = 1 }, graphemeWidth(u21, &.{ 'a', 'b' })); + try testing.expectEqual(GraphemeWidth{ .len = 2, .width = 2 }, graphemeWidth(u21, &.{ 0x1F1E6, 0x1F1E7, 0x1F1E8 })); + try testing.expectEqual(GraphemeWidth{ .len = 1, .width = 2 }, graphemeWidth(u21, &.{0x1F1E8})); + try testing.expectEqual(GraphemeWidth{ .len = 0, .width = 0 }, graphemeWidth(u21, &.{})); + try testing.expectEqual(GraphemeWidth{ .len = 2, .width = 0 }, graphemeWidth(u21, &.{ 0x0301, 0x0302 })); +} + +test "grapheme width: u32 invalid codepoints stand alone" { + const testing = std.testing; + + try testing.expectEqual(GraphemeWidth{ .len = 1, .width = 1 }, graphemeWidth(u32, &.{ 0x110000, 0x0301 })); + try testing.expectEqual(GraphemeWidth{ .len = 1, .width = 1 }, graphemeWidth(u32, &.{ 'a', 0x110000 })); +} diff --git a/vendor/libghostty-vt/src/unicode/main.zig b/vendor/libghostty-vt/src/unicode/main.zig index 11ecbd90..fb18b7d1 100644 --- a/vendor/libghostty-vt/src/unicode/main.zig +++ b/vendor/libghostty-vt/src/unicode/main.zig @@ -3,7 +3,60 @@ pub const lut = @import("lut.zig"); const grapheme = @import("grapheme.zig"); pub const table = @import("props_table.zig").table; pub const Properties = @import("props.zig").Properties; +pub const GraphemeWidthEffect = grapheme.GraphemeWidthEffect; +pub const GraphemeWidth = grapheme.GraphemeWidth; pub const graphemeBreak = grapheme.graphemeBreak; +pub const graphemeWidth = grapheme.graphemeWidth; +pub const graphemeWidthEffect = grapheme.graphemeWidthEffect; + +/// Returns the terminal display width of a codepoint in terminal +/// grid cells: 0, 1, or 2. +/// +/// This is the same width table the terminal uses when laying out +/// printed text: 0 for zero-width codepoints (controls, combining +/// marks, default-ignorables, surrogates), 2 for wide codepoints +/// (East Asian Wide/Fullwidth, regional indicators, clamped at 2), +/// and 1 otherwise. +/// +/// This operates on a single codepoint and cannot account for +/// grapheme-cluster-level width rules (VS16, combining sequences); +/// callers needing cluster-accurate widths should use graphemeWidth(). +/// Summing per-codepoint widths is only correct when mode 2027 is +/// disabled. +pub fn codepointWidth(cp: u21) u2 { + return table.get(cp).width; +} + +test "codepointWidth" { + const testing = @import("std").testing; + + // Narrow (width 1) + try testing.expectEqual(1, codepointWidth('a')); + try testing.expectEqual(1, codepointWidth(' ')); + try testing.expectEqual(1, codepointWidth(0x10FFFF)); // max codepoint + + // C0/C1 control characters (width 0) + try testing.expectEqual(0, codepointWidth(0x00)); // NUL + try testing.expectEqual(0, codepointWidth(0x07)); // BEL + try testing.expectEqual(0, codepointWidth(0x1B)); // ESC + try testing.expectEqual(0, codepointWidth(0x7F)); // DEL + try testing.expectEqual(0, codepointWidth(0x80)); // C1 PAD + + // Zero-width codepoints + try testing.expectEqual(0, codepointWidth(0x0301)); // combining acute + try testing.expectEqual(0, codepointWidth(0x200B)); // zero width space + try testing.expectEqual(0, codepointWidth(0x200D)); // ZWJ + try testing.expectEqual(0, codepointWidth(0xFE0F)); // VS16 + try testing.expectEqual(0, codepointWidth(0xD800)); // surrogate + + // Wide (width 2) + try testing.expectEqual(2, codepointWidth(0x4E00)); // CJK ideograph + try testing.expectEqual(2, codepointWidth(0xFF21)); // fullwidth A + try testing.expectEqual(2, codepointWidth(0xAC00)); // Hangul syllable + try testing.expectEqual(2, codepointWidth(0x1F600)); // emoji + try testing.expectEqual(2, codepointWidth(0x1F1E6)); // regional indicator + try testing.expectEqual(2, codepointWidth(0x2E3B)); // three-em dash (clamped) +} test { @import("std").testing.refAllDecls(@This()); diff --git a/vendor/patches/libghostty-vt/0001-backport-resizecols-cursor-subtraction.patch b/vendor/patches/libghostty-vt/0001-backport-resizecols-cursor-subtraction.patch deleted file mode 100644 index 1f8d0f36..00000000 --- a/vendor/patches/libghostty-vt/0001-backport-resizecols-cursor-subtraction.patch +++ /dev/null @@ -1,115 +0,0 @@ -From 0000000000000000000000000000000000000000 Mon Sep 17 00:00:00 2001 -From: herdr maintainers <maintainers@herdr.dev> -Date: Thu, 4 Jun 2026 00:00:00 +0000 -Subject: [PATCH] backport resizeCols cursor subtraction saturation - -Backport ghostty-org/ghostty#12907 onto the vendored libghostty-vt source. -Shrink-both resize can leave the old cursor row past the new row count after -resizeWithoutReflow reduces self.rows. Saturating subtraction treats rows below -that cursor as zero instead of trapping in safety builds. - -Herdr issue: https://github.com/ogulcancelik/herdr/issues/465 -Upstream discussion: https://github.com/ghostty-org/ghostty/discussions/12905 -Upstream PR: https://github.com/ghostty-org/ghostty/pull/12907 -Vendored base: 0f7cd84b880b203c98683e520e84b9db0c5938d8 ---- - vendor/libghostty-vt/src/terminal/PageList.zig | 33 +++++++++++++++++++++-- - vendor/libghostty-vt/src/terminal/c/terminal.zig | 23 ++++++++++++++++ - 2 files changed, 54 insertions(+), 2 deletions(-) - -diff --git a/vendor/libghostty-vt/src/terminal/PageList.zig b/vendor/libghostty-vt/src/terminal/PageList.zig -index 0000000..0000000 100644 ---- a/vendor/libghostty-vt/src/terminal/PageList.zig -+++ b/vendor/libghostty-vt/src/terminal/PageList.zig -@@ -1059,7 +1059,7 @@ fn resizeCols( - break :cursor .{ - .tracked_pin = c.pin orelse try self.trackPin(p), - .untrack = c.pin == null, -- .remaining_rows = self.rows - c.y - 1, -+ .remaining_rows = self.rows -| (c.y + 1), - .wrapped_rows = wrapped, - }; - } else null; -@@ -1192,7 +1192,7 @@ fn resizeCols( - break :wrapped wrapped; - }; - -- const current = self.rows - active_pt.active.y - 1; -+ const current = self.rows -| (active_pt.active.y + 1); - - var req_rows = c.remaining_rows; - req_rows -|= wrapped -| c.wrapped_rows; -@@ -10828,6 +10828,37 @@ test "PageList resize (no reflow) less rows and cols" { - } - } - -+test "PageList resize less rows and cols cursor at bottom" { -+ const testing = std.testing; -+ const alloc = testing.allocator; -+ -+ var s = try init(alloc, 80, 24, 0); -+ defer s.deinit(); -+ -+ const cursor_pin = try s.trackPin(s.pin(.{ .active = .{ -+ .x = 0, -+ .y = s.rows - 1, -+ } }).?); -+ defer s.untrackPin(cursor_pin); -+ -+ // Shrink both axes so the original cursor.y is strictly past the new row -+ // count, after resizeWithoutReflow has already reduced self.rows. -+ try s.resize(.{ -+ .cols = 79, -+ .rows = 20, -+ .reflow = true, -+ .cursor = .{ .x = 0, .y = 23, .pin = cursor_pin }, -+ }); -+ try testing.expectEqual(@as(usize, 79), s.cols); -+ try testing.expectEqual(@as(usize, 20), s.rows); -+ -+ // remaining_rows saturates to 0, so the cursor lands on the new bottom row. -+ try testing.expectEqual(point.Point{ .active = .{ -+ .x = 0, -+ .y = s.rows - 1, -+ } }, s.pointFromPin(.active, cursor_pin.*).?); -+} -+ - test "PageList resize (no reflow) more rows and less cols" { - const testing = std.testing; - const alloc = testing.allocator; -diff --git a/vendor/libghostty-vt/src/terminal/c/terminal.zig b/vendor/libghostty-vt/src/terminal/c/terminal.zig -index 0000000..0000000 100644 ---- a/vendor/libghostty-vt/src/terminal/c/terminal.zig -+++ b/vendor/libghostty-vt/src/terminal/c/terminal.zig -@@ -1005,6 +1005,29 @@ test "resize invalid value" { - try testing.expectEqual(Result.invalid_value, resize(t, 80, 0, 9, 18)); - } - -+test "resize shrinks both axes with cursor at bottom" { -+ var t: Terminal = null; -+ try testing.expectEqual(Result.success, new( -+ &lib.alloc.test_allocator, -+ &t, -+ .{ -+ .cols = 80, -+ .rows = 24, -+ .max_scrollback = 0, -+ }, -+ )); -+ defer free(t); -+ -+ // CSI 24;1H parks the cursor on the bottom row. -+ const move = "\x1b[24;1H"; -+ vt_write(t, move, move.len); -+ -+ // Shrink both axes; pre-resize cursor.y sits past the new bottom row. -+ try testing.expectEqual(Result.success, resize(t, 79, 23, 8, 16)); -+ try testing.expectEqual(79, t.?.terminal.cols); -+ try testing.expectEqual(23, t.?.terminal.rows); -+} -+ - test "mode_get and mode_set" { - var t: Terminal = null; - try testing.expectEqual(Result.success, new( --- -2.49.0 diff --git a/vendor/patches/libghostty-vt/0001-default-grapheme-cluster-mode.patch b/vendor/patches/libghostty-vt/0001-default-grapheme-cluster-mode.patch new file mode 100644 index 00000000..546112c7 --- /dev/null +++ b/vendor/patches/libghostty-vt/0001-default-grapheme-cluster-mode.patch @@ -0,0 +1,22 @@ +From 0000000000000000000000000000000000000000 Mon Sep 17 00:00:00 2001 +From: herdr maintainers <maintainers@herdr.dev> +Date: Wed, 15 Jul 2026 00:00:00 +0000 +Subject: [PATCH] default lib-vt panes to grapheme clustering + +Herdr renders libghostty-vt cells directly and requires DEC mode 2027 to +store multi-codepoint grapheme clusters in one terminal cell. Set it in the +terminal's default modes so RIS restores clustering instead of disabling it. + +Herdr issue: https://github.com/ogulcancelik/herdr/issues/243 +Vendored base: c5a21edfcbc2d5b46540ad91b7980aca31f5f1f3 +--- +diff --git a/vendor/libghostty-vt/src/terminal/c/terminal.zig b/vendor/libghostty-vt/src/terminal/c/terminal.zig +index 1c3b9f0c..a896c0fd 100644 +--- a/vendor/libghostty-vt/src/terminal/c/terminal.zig ++++ b/vendor/libghostty-vt/src/terminal/c/terminal.zig +@@ -344,4 +344,5 @@ fn new_( + .rows = opts.rows, + .max_scrollback = opts.max_scrollback, ++ .default_modes = .{ .grapheme_cluster = true }, + }); + errdefer t.deinit(alloc); diff --git a/vendor/patches/libghostty-vt/0002-expose-kitty-image-transmit-time-ns.patch b/vendor/patches/libghostty-vt/0002-expose-kitty-image-transmit-time-ns.patch deleted file mode 100644 index 4289740c..00000000 --- a/vendor/patches/libghostty-vt/0002-expose-kitty-image-transmit-time-ns.patch +++ /dev/null @@ -1,112 +0,0 @@ -diff --git a/vendor/libghostty-vt/include/ghostty/vt/kitty_graphics.h b/vendor/libghostty-vt/include/ghostty/vt/kitty_graphics.h -index 9bace3a..d5b5006 100644 ---- a/vendor/libghostty-vt/include/ghostty/vt/kitty_graphics.h -+++ b/vendor/libghostty-vt/include/ghostty/vt/kitty_graphics.h -@@ -343,6 +343,15 @@ typedef enum GHOSTTY_ENUM_TYPED { - */ - GHOSTTY_KITTY_IMAGE_DATA_DATA_LEN = 8, - -+ /** -+ * Timestamp of the image's most recent transmission, in nanoseconds. -+ * The epoch is unspecified; only equality and ordering between values -+ * read from the same terminal are meaningful. -+ * -+ * Output type: uint64_t * -+ */ -+ GHOSTTY_KITTY_IMAGE_DATA_TRANSMIT_TIME_NS = 9, -+ - GHOSTTY_KITTY_IMAGE_DATA_MAX_VALUE = GHOSTTY_ENUM_MAX_VALUE, - } GhosttyKittyGraphicsImageData; - -diff --git a/vendor/libghostty-vt/src/terminal/c/kitty_graphics.zig b/vendor/libghostty-vt/src/terminal/c/kitty_graphics.zig -index d50b8c4..a00632e 100644 ---- a/vendor/libghostty-vt/src/terminal/c/kitty_graphics.zig -+++ b/vendor/libghostty-vt/src/terminal/c/kitty_graphics.zig -@@ -178,6 +178,7 @@ pub const ImageData = enum(c_int) { - compression = 6, - data_ptr = 7, - data_len = 8, -+ transmit_time_ns = 9, - - pub fn OutType(comptime self: ImageData) type { - return switch (self) { -@@ -187,6 +188,7 @@ pub const ImageData = enum(c_int) { - .compression => ImageCompression, - .data_ptr => [*]const u8, - .data_len => usize, -+ .transmit_time_ns => u64, - }; - } - }; -@@ -258,11 +260,22 @@ fn imageGetTyped( - .compression => out.* = image.compression, - .data_ptr => out.* = image.data.ptr, - .data_len => out.* = image.data.len, -+ .transmit_time_ns => out.* = instantNanos(image.transmit_time), - } - - return .success; - } - -+/// Flattens an Instant's platform timestamp to nanoseconds for the C API. -+/// The epoch is unspecified; only equality and ordering are meaningful. -+fn instantNanos(instant: std.time.Instant) u64 { -+ if (@TypeOf(instant.timestamp) == u64) return instant.timestamp; -+ const ts = instant.timestamp; -+ const sec: u64 = @intCast(@max(ts.sec, 0)); -+ const nsec: u64 = @intCast(@max(ts.nsec, 0)); -+ return sec *| std.time.ns_per_s +| nsec; -+} -+ - pub fn placement_iterator_new( - alloc_: ?*const CAllocator, - out: *PlacementIterator, -@@ -977,6 +990,48 @@ test "image_get_handle and image_get with transmitted image" { - try testing.expect(data_len > 0); - } - -+test "image_get transmit_time_ns changes on retransmission" { -+ if (comptime !build_options.kitty_graphics) return error.SkipZigTest; -+ -+ var t: terminal_c.Terminal = null; -+ try testing.expectEqual(Result.success, terminal_c.new( -+ &lib.alloc.test_allocator, -+ &t, -+ .{ .cols = 80, .rows = 24, .max_scrollback = 0 }, -+ )); -+ defer terminal_c.free(t); -+ -+ const cmd = "\x1b_Ga=T,t=d,f=24,i=1,p=1,s=1,v=2;////////\x1b\\"; -+ terminal_c.vt_write(t, cmd.ptr, cmd.len); -+ -+ var graphics: KittyGraphics = undefined; -+ try testing.expectEqual(Result.success, terminal_c.get( -+ t, -+ .kitty_graphics, -+ @ptrCast(&graphics), -+ )); -+ -+ var first: u64 = undefined; -+ try testing.expectEqual(Result.success, image_get( -+ image_get_handle(graphics, 1), -+ .transmit_time_ns, -+ @ptrCast(&first), -+ )); -+ try testing.expect(first > 0); -+ -+ std.Thread.sleep(1 * std.time.ns_per_ms); -+ const retransmit = "\x1b_Ga=t,t=d,f=24,i=1,s=1,v=2;AAAAAAAA\x1b\\"; -+ terminal_c.vt_write(t, retransmit.ptr, retransmit.len); -+ -+ var second: u64 = undefined; -+ try testing.expectEqual(Result.success, image_get( -+ image_get_handle(graphics, 1), -+ .transmit_time_ns, -+ @ptrCast(&second), -+ )); -+ try testing.expect(second > first); -+} -+ - test "placement_rect with transmit and display" { - if (comptime !build_options.kitty_graphics) return error.SkipZigTest; - |
