aboutsummaryrefslogtreecommitdiff
path: root/vendor
diff options
context:
space:
mode:
authorOgulcan Celik <ogulcancelik@gmail.com>2026-07-16 23:28:05 +0300
committerOgulcan Celik <ogulcancelik@gmail.com>2026-07-16 23:29:28 +0300
commit4a3302d19a2baa3a33e910e0088c24e780905b90 (patch)
treedcaf76fae1c82102d933c9225b17ca7ac5d3a847 /vendor
parent550e9f8cc085f428429f5cf2da0bb2dba897e8b8 (diff)
chore: update vendored libghostty-vt
refs #276
Diffstat (limited to 'vendor')
-rw-r--r--vendor/libghostty-vt.patches.md76
-rw-r--r--vendor/libghostty-vt.vendor.json7
-rw-r--r--vendor/libghostty-vt/.github/VOUCHED.td23
-rw-r--r--vendor/libghostty-vt/.github/workflows/nix.yml6
-rw-r--r--vendor/libghostty-vt/.github/workflows/release-tag.yml14
-rw-r--r--vendor/libghostty-vt/.github/workflows/release-tip.yml112
-rw-r--r--vendor/libghostty-vt/.github/workflows/snap.yml2
-rw-r--r--vendor/libghostty-vt/.github/workflows/test.yml238
-rw-r--r--vendor/libghostty-vt/.github/workflows/update-colorschemes.yml6
-rw-r--r--vendor/libghostty-vt/.github/workflows/vouch-check-issue.yml4
-rw-r--r--vendor/libghostty-vt/.github/workflows/vouch-check-pr.yml2
-rw-r--r--vendor/libghostty-vt/.github/workflows/vouch-manage-by-discussion.yml4
-rw-r--r--vendor/libghostty-vt/.github/workflows/vouch-manage-by-issue.yml4
-rw-r--r--vendor/libghostty-vt/.github/workflows/vouch-sync-codeowners.yml4
-rw-r--r--vendor/libghostty-vt/VERSION2
-rw-r--r--vendor/libghostty-vt/build.zig.zon4
-rw-r--r--vendor/libghostty-vt/build.zig.zon.json6
-rw-r--r--vendor/libghostty-vt/build.zig.zon.nix6
-rw-r--r--vendor/libghostty-vt/build.zig.zon.txt2
-rw-r--r--vendor/libghostty-vt/include/ghostty.h1
-rw-r--r--vendor/libghostty-vt/include/ghostty/vt.h9
-rw-r--r--vendor/libghostty-vt/include/ghostty/vt/color.h432
-rw-r--r--vendor/libghostty-vt/include/ghostty/vt/color_scheme.h73
-rw-r--r--vendor/libghostty-vt/include/ghostty/vt/kitty_graphics.h89
-rw-r--r--vendor/libghostty-vt/include/ghostty/vt/render.h66
-rw-r--r--vendor/libghostty-vt/include/ghostty/vt/sgr.h7
-rw-r--r--vendor/libghostty-vt/include/ghostty/vt/terminal.h351
-rw-r--r--vendor/libghostty-vt/include/ghostty/vt/unicode.h154
-rw-r--r--vendor/libghostty-vt/src/Surface.zig77
-rw-r--r--vendor/libghostty-vt/src/apprt/action.zig5
-rw-r--r--vendor/libghostty-vt/src/apprt/gtk/class/application.zig3
-rw-r--r--vendor/libghostty-vt/src/apprt/gtk/class/global_shortcuts.zig54
-rw-r--r--vendor/libghostty-vt/src/apprt/gtk/class/split_tree.zig10
-rw-r--r--vendor/libghostty-vt/src/apprt/gtk/class/surface.zig61
-rw-r--r--vendor/libghostty-vt/src/apprt/gtk/class/window.zig42
-rw-r--r--vendor/libghostty-vt/src/apprt/gtk/ui/1.2/surface.blp5
-rw-r--r--vendor/libghostty-vt/src/benchmark/ApcParser.zig143
-rw-r--r--vendor/libghostty-vt/src/benchmark/HyperlinkMap.zig155
-rw-r--r--vendor/libghostty-vt/src/benchmark/PageCompression.zig519
-rw-r--r--vendor/libghostty-vt/src/benchmark/ScreenClone.zig113
-rw-r--r--vendor/libghostty-vt/src/benchmark/ScrollbackCompression.zig343
-rw-r--r--vendor/libghostty-vt/src/benchmark/TerminalStream.zig49
-rw-r--r--vendor/libghostty-vt/src/benchmark/cli.zig8
-rw-r--r--vendor/libghostty-vt/src/benchmark/main.zig3
-rw-r--r--vendor/libghostty-vt/src/config/Config.zig129
-rw-r--r--vendor/libghostty-vt/src/font/CodepointResolver.zig2
-rw-r--r--vendor/libghostty-vt/src/font/Glyph.zig611
-rw-r--r--vendor/libghostty-vt/src/font/Metrics.zig4
-rw-r--r--vendor/libghostty-vt/src/font/SharedGrid.zig2
-rw-r--r--vendor/libghostty-vt/src/font/face.zig596
-rw-r--r--vendor/libghostty-vt/src/font/face/coretext.zig2
-rw-r--r--vendor/libghostty-vt/src/font/face/freetype.zig4
-rw-r--r--vendor/libghostty-vt/src/font/face/web_canvas.zig2
-rw-r--r--vendor/libghostty-vt/src/font/glyf_rasterize.zig825
-rw-r--r--vendor/libghostty-vt/src/font/glyf_rasterize_png_test.zig301
-rw-r--r--vendor/libghostty-vt/src/font/main.zig1
-rw-r--r--vendor/libghostty-vt/src/font/nerd_font_attributes.zig2
-rw-r--r--vendor/libghostty-vt/src/font/opentype/glyf.zig461
-rw-r--r--vendor/libghostty-vt/src/font/opentype/sfnt.zig6
-rw-r--r--vendor/libghostty-vt/src/font/shaper/coretext.zig2
-rw-r--r--vendor/libghostty-vt/src/font/shaper/harfbuzz.zig2
-rw-r--r--vendor/libghostty-vt/src/font/sprite/Face.zig124
-rw-r--r--vendor/libghostty-vt/src/font/testdata/glyf_rasterize.pngbin0 -> 1215 bytes
-rw-r--r--vendor/libghostty-vt/src/inspector/widgets/page.zig19
-rw-r--r--vendor/libghostty-vt/src/inspector/widgets/pagelist.zig272
-rw-r--r--vendor/libghostty-vt/src/inspector/widgets/screen.zig2
-rw-r--r--vendor/libghostty-vt/src/lib_vt.zig27
-rw-r--r--vendor/libghostty-vt/src/renderer/State.zig73
-rw-r--r--vendor/libghostty-vt/src/renderer/Thread.zig142
-rw-r--r--vendor/libghostty-vt/src/renderer/generic.zig22
-rw-r--r--vendor/libghostty-vt/src/renderer/image.zig30
-rw-r--r--vendor/libghostty-vt/src/renderer/link.zig96
-rw-r--r--vendor/libghostty-vt/src/shell-integration/nushell/vendor/autoload/ghostty.nu2
-rw-r--r--vendor/libghostty-vt/src/simd/vt.cpp161
-rw-r--r--vendor/libghostty-vt/src/simd/vt.zig94
-rw-r--r--vendor/libghostty-vt/src/synthetic/Kitty.zig88
-rw-r--r--vendor/libghostty-vt/src/synthetic/cli.zig2
-rw-r--r--vendor/libghostty-vt/src/synthetic/cli/Kitty.zig70
-rw-r--r--vendor/libghostty-vt/src/synthetic/main.zig1
-rw-r--r--vendor/libghostty-vt/src/terminal/PageList.zig4215
-rw-r--r--vendor/libghostty-vt/src/terminal/Screen.zig1111
-rw-r--r--vendor/libghostty-vt/src/terminal/Selection.zig92
-rw-r--r--vendor/libghostty-vt/src/terminal/SelectionGesture.zig119
-rw-r--r--vendor/libghostty-vt/src/terminal/Tabstops.zig17
-rw-r--r--vendor/libghostty-vt/src/terminal/Terminal.zig1647
-rw-r--r--vendor/libghostty-vt/src/terminal/apc.zig308
-rw-r--r--vendor/libghostty-vt/src/terminal/apc/glyph.zig164
-rw-r--r--vendor/libghostty-vt/src/terminal/apc/glyph/AGENTS.md9
-rw-r--r--vendor/libghostty-vt/src/terminal/apc/glyph/Glossary.zig444
-rw-r--r--vendor/libghostty-vt/src/terminal/apc/glyph/execute.zig367
-rw-r--r--vendor/libghostty-vt/src/terminal/apc/glyph/request.zig855
-rw-r--r--vendor/libghostty-vt/src/terminal/apc/glyph/response.zig346
-rw-r--r--vendor/libghostty-vt/src/terminal/bitmap_allocator.zig81
-rw-r--r--vendor/libghostty-vt/src/terminal/c/color.zig575
-rw-r--r--vendor/libghostty-vt/src/terminal/c/color_scheme.zig63
-rw-r--r--vendor/libghostty-vt/src/terminal/c/grid_ref.zig17
-rw-r--r--vendor/libghostty-vt/src/terminal/c/kitty_graphics.zig317
-rw-r--r--vendor/libghostty-vt/src/terminal/c/main.zig23
-rw-r--r--vendor/libghostty-vt/src/terminal/c/render.zig173
-rw-r--r--vendor/libghostty-vt/src/terminal/c/terminal.zig711
-rw-r--r--vendor/libghostty-vt/src/terminal/c/types.zig19
-rw-r--r--vendor/libghostty-vt/src/terminal/c/unicode.zig85
-rw-r--r--vendor/libghostty-vt/src/terminal/clipboard.zig36
-rw-r--r--vendor/libghostty-vt/src/terminal/color.zig199
-rw-r--r--vendor/libghostty-vt/src/terminal/compress.zig15
-rw-r--r--vendor/libghostty-vt/src/terminal/compress/AGENTS.md90
-rw-r--r--vendor/libghostty-vt/src/terminal/compress/Page.zig398
-rw-r--r--vendor/libghostty-vt/src/terminal/compress/lz4.zig891
-rw-r--r--vendor/libghostty-vt/src/terminal/compress/lz4_differential.zig493
-rw-r--r--vendor/libghostty-vt/src/terminal/device_status.zig42
-rw-r--r--vendor/libghostty-vt/src/terminal/formatter.zig146
-rw-r--r--vendor/libghostty-vt/src/terminal/hash_map.zig583
-rw-r--r--vendor/libghostty-vt/src/terminal/hyperlink.zig2
-rw-r--r--vendor/libghostty-vt/src/terminal/kitty/color.zig21
-rw-r--r--vendor/libghostty-vt/src/terminal/kitty/graphics.zig1
-rw-r--r--vendor/libghostty-vt/src/terminal/kitty/graphics_command.zig71
-rw-r--r--vendor/libghostty-vt/src/terminal/kitty/graphics_exec.zig85
-rw-r--r--vendor/libghostty-vt/src/terminal/kitty/graphics_image.zig22
-rw-r--r--vendor/libghostty-vt/src/terminal/kitty/graphics_storage.zig299
-rw-r--r--vendor/libghostty-vt/src/terminal/main.zig6
-rw-r--r--vendor/libghostty-vt/src/terminal/mem.zig222
-rw-r--r--vendor/libghostty-vt/src/terminal/osc.zig20
-rw-r--r--vendor/libghostty-vt/src/terminal/osc/parsers.zig1
-rw-r--r--vendor/libghostty-vt/src/terminal/osc/parsers/kitty_dnd_protocol.zig363
-rw-r--r--vendor/libghostty-vt/src/terminal/osc/parsers/semantic_prompt.zig24
-rw-r--r--vendor/libghostty-vt/src/terminal/page.zig336
-rw-r--r--vendor/libghostty-vt/src/terminal/ref_counted_set.zig9
-rw-r--r--vendor/libghostty-vt/src/terminal/render.zig1028
-rw-r--r--vendor/libghostty-vt/src/terminal/search/Thread.zig100
-rw-r--r--vendor/libghostty-vt/src/terminal/search/active.zig16
-rw-r--r--vendor/libghostty-vt/src/terminal/search/pagelist.zig129
-rw-r--r--vendor/libghostty-vt/src/terminal/search/screen.zig482
-rw-r--r--vendor/libghostty-vt/src/terminal/search/sliding_window.zig143
-rw-r--r--vendor/libghostty-vt/src/terminal/search/viewport.zig229
-rw-r--r--vendor/libghostty-vt/src/terminal/stream.zig537
-rw-r--r--vendor/libghostty-vt/src/terminal/stream_terminal.zig509
-rw-r--r--vendor/libghostty-vt/src/terminal/style.zig30
-rw-r--r--vendor/libghostty-vt/src/terminal/x11_color.zig56
-rw-r--r--vendor/libghostty-vt/src/termio/Exec.zig517
-rw-r--r--vendor/libghostty-vt/src/termio/Termio.zig12
-rw-r--r--vendor/libghostty-vt/src/termio/stream_handler.zig22
-rw-r--r--vendor/libghostty-vt/src/unicode/grapheme.zig185
-rw-r--r--vendor/libghostty-vt/src/unicode/main.zig53
-rw-r--r--vendor/patches/libghostty-vt/0001-backport-resizecols-cursor-subtraction.patch115
-rw-r--r--vendor/patches/libghostty-vt/0001-default-grapheme-cluster-mode.patch22
-rw-r--r--vendor/patches/libghostty-vt/0002-expose-kitty-image-transmit-time-ns.patch112
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
new file mode 100644
index 00000000..f5f8fb81
--- /dev/null
+++ b/vendor/libghostty-vt/src/font/testdata/glyf_rasterize.png
Binary files differ
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 = &current.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 = &current.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, &register_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, &reg1);
+
+ var reg2 = try testParse(alloc, "r;cp=e0a1;AAAAAAAAAAAAAA==");
+ defer reg2.deinit(alloc);
+ _ = testExecute(alloc, &glossary, &reg2);
+
+ 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, &reg1);
+
+ var reg2 = try testParse(alloc, "r;cp=e0a1;AAAAAAAAAAAAAA==");
+ defer reg2.deinit(alloc);
+ _ = testExecute(alloc, &glossary, &reg2);
+
+ 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, &reg1);
+
+ var reg2 = try testParse(alloc, "r;cp=e0a1;AAAAAAAAAAAAAA==");
+ defer reg2.deinit(alloc);
+ _ = testExecute(alloc, &glossary, &reg2);
+
+ 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, &reg);
+
+ 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;
-