From 2355550a9410f0f10bc1e88e677a9f9ed091bb71 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Sat, 23 May 2026 13:56:12 -0700 Subject: [PATCH 1/2] libghostty: add tracked grid ref API Add a C API for tracked pins, known as a tracked grid ref in C. The new API can create tracked refs from terminal points, snapshot them back to regular grid refs for cell access, convert them to coordinates, move them to a new point, report when their semantic location was lost, and free the tracked pin bookkeeping. This is backed by PageList tracked pins and exposed through the libghostty-vt export layer and headers. --- example/c-vt-grid-ref-tracked/README.md | 19 ++ example/c-vt-grid-ref-tracked/build.zig | 42 +++++ example/c-vt-grid-ref-tracked/build.zig.zon | 24 +++ example/c-vt-grid-ref-tracked/src/main.c | 94 ++++++++++ include/ghostty/vt.h | 7 + include/ghostty/vt/grid_ref.h | 80 +++++++-- include/ghostty/vt/grid_ref_tracked.h | 134 ++++++++++++++ include/ghostty/vt/terminal.h | 31 ++++ include/ghostty/vt/types.h | 10 ++ src/lib_vt.zig | 6 + src/terminal/ScreenSet.zig | 43 +++++ src/terminal/c/grid_ref_tracked.zig | 187 ++++++++++++++++++++ src/terminal/c/main.zig | 8 + src/terminal/c/terminal.zig | 39 +++- src/terminal/point.zig | 10 ++ 15 files changed, 715 insertions(+), 19 deletions(-) create mode 100644 example/c-vt-grid-ref-tracked/README.md create mode 100644 example/c-vt-grid-ref-tracked/build.zig create mode 100644 example/c-vt-grid-ref-tracked/build.zig.zon create mode 100644 example/c-vt-grid-ref-tracked/src/main.c create mode 100644 include/ghostty/vt/grid_ref_tracked.h create mode 100644 src/terminal/c/grid_ref_tracked.zig diff --git a/example/c-vt-grid-ref-tracked/README.md b/example/c-vt-grid-ref-tracked/README.md new file mode 100644 index 000000000..e2e9ac980 --- /dev/null +++ b/example/c-vt-grid-ref-tracked/README.md @@ -0,0 +1,19 @@ +# Example: `ghostty-vt` Tracked Grid References + +This contains a simple example of how to use the `ghostty-vt` terminal and +tracked grid reference APIs to keep a long-lived reference to a cell as the +terminal scrolls, detect when that reference loses its meaningful location, +and move the same tracked handle to a new point. + +This uses a `build.zig` and `Zig` to build the C program so that we +can reuse a lot of our build logic and depend directly on our source +tree, but Ghostty emits a standard C library that can be used with any +C tooling. + +## Usage + +Run the program: + +```shell-session +zig build run +``` diff --git a/example/c-vt-grid-ref-tracked/build.zig b/example/c-vt-grid-ref-tracked/build.zig new file mode 100644 index 000000000..ec3df2da7 --- /dev/null +++ b/example/c-vt-grid-ref-tracked/build.zig @@ -0,0 +1,42 @@ +const std = @import("std"); + +pub fn build(b: *std.Build) void { + const target = b.standardTargetOptions(.{}); + const optimize = b.standardOptimizeOption(.{}); + + const run_step = b.step("run", "Run the app"); + + const exe_mod = b.createModule(.{ + .target = target, + .optimize = optimize, + }); + exe_mod.addCSourceFiles(.{ + .root = b.path("src"), + .files = &.{"main.c"}, + }); + + // You'll want to use a lazy dependency here so that ghostty is only + // downloaded if you actually need it. + if (b.lazyDependency("ghostty", .{ + // Setting simd to false will force a pure static build that + // doesn't even require libc, but it has a significant performance + // penalty. If your embedding app requires libc anyway, you should + // always keep simd enabled. + // .simd = false, + })) |dep| { + exe_mod.linkLibrary(dep.artifact("ghostty-vt")); + } + + // Exe + const exe = b.addExecutable(.{ + .name = "c_vt_grid_ref_tracked", + .root_module = exe_mod, + }); + b.installArtifact(exe); + + // Run + const run_cmd = b.addRunArtifact(exe); + run_cmd.step.dependOn(b.getInstallStep()); + if (b.args) |args| run_cmd.addArgs(args); + run_step.dependOn(&run_cmd.step); +} diff --git a/example/c-vt-grid-ref-tracked/build.zig.zon b/example/c-vt-grid-ref-tracked/build.zig.zon new file mode 100644 index 000000000..ecb6f110e --- /dev/null +++ b/example/c-vt-grid-ref-tracked/build.zig.zon @@ -0,0 +1,24 @@ +.{ + .name = .c_vt_grid_ref_tracked, + .version = "0.0.0", + .fingerprint = 0x64bd14b59e76c294, + .minimum_zig_version = "0.15.1", + .dependencies = .{ + // Ghostty dependency. In reality, you'd probably use a URL-based + // dependency like the one showed (and commented out) below this one. + // We use a path dependency here for simplicity and to ensure our + // examples always test against the source they're bundled with. + .ghostty = .{ .path = "../../" }, + + // Example of what a URL-based dependency looks like: + // .ghostty = .{ + // .url = "https://github.com/ghostty-org/ghostty/archive/COMMIT.tar.gz", + // .hash = "N-V-__8AAMVLTABmYkLqhZPLXnMl-KyN38R8UVYqGrxqO36s", + // }, + }, + .paths = .{ + "build.zig", + "build.zig.zon", + "src", + }, +} diff --git a/example/c-vt-grid-ref-tracked/src/main.c b/example/c-vt-grid-ref-tracked/src/main.c new file mode 100644 index 000000000..a914a9727 --- /dev/null +++ b/example/c-vt-grid-ref-tracked/src/main.c @@ -0,0 +1,94 @@ +#include +#include +#include +#include +#include + +//! [grid-ref-tracked] +static uint32_t codepoint_at_tracked_ref(GhosttyTrackedGridRef tracked) { + GhosttyGridRef snapshot = GHOSTTY_INIT_SIZED(GhosttyGridRef); + GhosttyResult result = ghostty_tracked_grid_ref_snapshot(tracked, &snapshot); + assert(result == GHOSTTY_SUCCESS); + + GhosttyCell cell; + result = ghostty_grid_ref_cell(&snapshot, &cell); + assert(result == GHOSTTY_SUCCESS); + + bool has_text = false; + ghostty_cell_get(cell, GHOSTTY_CELL_DATA_HAS_TEXT, &has_text); + assert(has_text); + + uint32_t codepoint = 0; + ghostty_cell_get(cell, GHOSTTY_CELL_DATA_CODEPOINT, &codepoint); + return codepoint; +} + +int main() { + GhosttyTerminal terminal; + GhosttyTerminalOptions opts = { + .cols = 8, + .rows = 3, + .max_scrollback = 100, + }; + GhosttyResult result = ghostty_terminal_new(NULL, &terminal, opts); + assert(result == GHOSTTY_SUCCESS); + + const char *text = "alpha\r\n" + "bravo\r\n" + "charlie"; + ghostty_terminal_vt_write( + terminal, (const uint8_t *)text, strlen(text)); + + GhosttyTrackedGridRef tracked = NULL; + GhosttyPoint alpha = { + .tag = GHOSTTY_POINT_TAG_ACTIVE, + .value = { .coordinate = { .x = 0, .y = 0 } }, + }; + result = ghostty_terminal_grid_ref_track(terminal, alpha, &tracked); + assert(result == GHOSTTY_SUCCESS); + + // Writing another line scrolls the original "alpha" row into scrollback. + // The tracked ref still follows the same cell. + const char *more = "\r\ndelta"; + ghostty_terminal_vt_write( + terminal, (const uint8_t *)more, strlen(more)); + + assert(ghostty_tracked_grid_ref_has_value(tracked)); + printf("tracked codepoint after scroll: %c\n", + (char)codepoint_at_tracked_ref(tracked)); + + GhosttyPointCoordinate screen = {0}; + result = ghostty_tracked_grid_ref_point( + tracked, GHOSTTY_POINT_TAG_SCREEN, &screen); + assert(result == GHOSTTY_SUCCESS); + printf("tracked screen point: %u,%u\n", screen.x, screen.y); + + // Resetting the terminal discards the old grid contents. The tracked + // handle remains valid, but no longer has a meaningful location. + ghostty_terminal_reset(terminal); + assert(!ghostty_tracked_grid_ref_has_value(tracked)); + + GhosttyGridRef discarded = GHOSTTY_INIT_SIZED(GhosttyGridRef); + result = ghostty_tracked_grid_ref_snapshot(tracked, &discarded); + assert(result == GHOSTTY_NO_VALUE); + + // The same handle can be moved to a new point after it loses its value. + const char *replacement = "echo"; + ghostty_terminal_vt_write( + terminal, (const uint8_t *)replacement, strlen(replacement)); + + GhosttyPoint echo = { + .tag = GHOSTTY_POINT_TAG_ACTIVE, + .value = { .coordinate = { .x = 0, .y = 0 } }, + }; + result = ghostty_tracked_grid_ref_set(tracked, terminal, echo); + assert(result == GHOSTTY_SUCCESS); + assert(ghostty_tracked_grid_ref_has_value(tracked)); + printf("tracked codepoint after reset/set: %c\n", + (char)codepoint_at_tracked_ref(tracked)); + + ghostty_tracked_grid_ref_free(tracked); + ghostty_terminal_free(terminal); + return 0; +} +//! [grid-ref-tracked] diff --git a/include/ghostty/vt.h b/include/ghostty/vt.h index 649ab1d4d..75bbb3b5b 100644 --- a/include/ghostty/vt.h +++ b/include/ghostty/vt.h @@ -54,6 +54,7 @@ * - @ref c-vt-sgr/src/main.c - SGR parser example * - @ref c-vt-formatter/src/main.c - Terminal formatter example * - @ref c-vt-grid-traverse/src/main.c - Grid traversal example using grid refs + * - @ref c-vt-grid-ref-tracked/src/main.c - Tracked grid ref example * */ @@ -98,6 +99,11 @@ * grid refs to inspect cell codepoints, row wrap state, and cell styles. */ +/** @example c-vt-grid-ref-tracked/src/main.c + * This example demonstrates how to track a grid ref as the terminal scrolls, + * detect when it loses its value, and move it to a new point. + */ + /** @example c-vt-kitty-graphics/src/main.c * This example demonstrates how to use the system interface to install a * PNG decoder callback and send a Kitty Graphics Protocol image. @@ -120,6 +126,7 @@ extern "C" { #include #include #include +#include #include #include #include diff --git a/include/ghostty/vt/grid_ref.h b/include/ghostty/vt/grid_ref.h index 1f9f52b9b..ca857a499 100644 --- a/include/ghostty/vt/grid_ref.h +++ b/include/ghostty/vt/grid_ref.h @@ -20,24 +20,78 @@ extern "C" { /** @defgroup grid_ref Grid Reference * - * A grid reference is a resolved reference to a specific cell position in the - * terminal's internal page structure. Obtain a grid reference from - * ghostty_terminal_grid_ref(), then extract the cell or row via - * ghostty_grid_ref_cell() and ghostty_grid_ref_row(). + * A grid reference is a reference to a specific cell position in the + * terminal. Obtain a grid reference from `ghostty_terminal_grid_ref` + * for untracked or `ghostty_terminal_grid_ref_track` for tracked. Untracked + * vs tracked is explained next. * - * A grid reference is only valid until the next update to the terminal - * instance. There is no guarantee that a grid reference will remain - * valid after ANY operation, even if a seemingly unrelated part of - * the grid is changed, so any information related to the grid reference - * should be read and cached immediately after obtaining the grid reference. + * Important: The grid reference APIs are not meant to be used as the core of a render + * loop. They are not built to sustain the framerates needed for rendering large + * screens. Use the render state API for that. * - * This API is not meant to be used as the core of render loop. It isn't - * built to sustain the framerates needed for rendering large screens. - * Use the render state API for that. + * ## Untracked vs Tracked References + * + * ### Untracked Reference * - * ## Example + * An untracked grid reference is a value type that snapshots a specific + * cell. It is only valid until the next update to the terminal instance. + * There is no guarantee that it will remain valid after any operation, + * even if a seemingly unrelated part of the grid is changed. These are meant + * to be read and have their values cached immediately after obtaining it. + * + * An untracked grid reference has a performance cost in its initial lookup, + * but doesn't affect the ongoing performance of the terminal in any way, + * since it is a one-time snapshot. + * + * ### Tracked Reference + * + * A tracked grid reference follows its cell across normal screen operations. + * For example scrolling, scrollback pruning, resize/reflow, and other + * terminal mutations update the tracked reference automatically. + * + * A tracked reference can still lose its original semantic location. This can + * happen when the underlying grid is reset, pruned, or otherwise discarded in a + * way that cannot be mapped to a meaningful new cell. In that state, + * ghostty_tracked_grid_ref_has_value() returns false and + * ghostty_tracked_grid_ref_snapshot() / ghostty_tracked_grid_ref_point() return + * GHOSTTY_NO_VALUE. The handle remains valid, and callers may move it to a new + * point with ghostty_tracked_grid_ref_set(). + * + * To read cell data from a tracked reference, first snapshot it with + * ghostty_tracked_grid_ref_snapshot(). The returned `GhosttyGridRef` is again + * an untracked reference and follows the same short lifetime rules as any other + * untracked grid reference. + * + * A tracked reference belongs to the terminal screen/page-list that was active + * when it was created or last set. Converting it to a point uses that owning + * screen/page-list, even if the terminal has since switched between primary and + * alternate screens. Calling ghostty_tracked_grid_ref_set() resolves the new + * point against the terminal's currently active screen/page-list and may move + * the tracked reference between screens. + * + * Tracked references are owned by the caller and must be freed with + * ghostty_tracked_grid_ref_free() before the terminal that created them is + * freed. + * + * Each tracked reference adds bookkeeping to terminal mutations. Use them + * sparingly for long-lived anchors such as selections, search state, marks, + * or application-side bookmarks. + * + * ## Lifetime + * + * An untracked reference is a snapshot. It doesn't need to be freed. + * The safety of accessing the value is documented explicitly above: it + * is only safe to access any data until the next terminal mutating + * operation (including free). + * + * A tracked reference is allocated and must be freed when it is no + * longer needed. All tracked references must be freed prior to the + * terminal being freed. + * + * ## Examples * * @snippet c-vt-grid-traverse/src/main.c grid-ref-traverse + * @snippet c-vt-grid-ref-tracked/src/main.c grid-ref-tracked * * @{ */ diff --git a/include/ghostty/vt/grid_ref_tracked.h b/include/ghostty/vt/grid_ref_tracked.h new file mode 100644 index 000000000..f80d7fbad --- /dev/null +++ b/include/ghostty/vt/grid_ref_tracked.h @@ -0,0 +1,134 @@ +/** + * @file grid_ref_tracked.h + * + * Tracked terminal grid references. + */ + +#ifndef GHOSTTY_VT_GRID_REF_TRACKED_H +#define GHOSTTY_VT_GRID_REF_TRACKED_H + +#include +#include +#include +#include + +#ifdef __cplusplus +extern "C" { +#endif + +/** + * Tracked grid references are owned grid references that move with the + * terminal. See @ref grid_ref for the full overview of tracked and untracked + * grid reference behavior. + * + * @ingroup grid_ref + */ + +/** + * Free a tracked grid reference. + * + * Passing NULL is allowed and has no effect. The reference must be freed before + * the terminal that created it is freed. + * + * @param ref Tracked grid reference to free. + * + * @ingroup grid_ref + */ +GHOSTTY_API void ghostty_tracked_grid_ref_free(GhosttyTrackedGridRef ref); + +/** + * Return whether a tracked grid reference currently has a meaningful value. + * + * @param ref Tracked grid reference. + * @return true if the reference currently has a meaningful value. + * + * @ingroup grid_ref + */ +GHOSTTY_API bool ghostty_tracked_grid_ref_has_value( + GhosttyTrackedGridRef ref); + +/** + * Convert a tracked grid reference to a point in the requested coordinate + * space. + * + * This is the tracked equivalent of ghostty_terminal_point_from_grid_ref(). + * Unlike snapshotting, this does not expose an intermediate untracked + * GhosttyGridRef. + * + * A tracked reference is resolved against the terminal screen/page-list that + * currently owns the reference. If the terminal has switched between primary + * and alternate screens since the reference was created or last set, this may + * be different from the terminal's currently active screen. + * + * If the tracked reference no longer has a meaningful value, this returns + * GHOSTTY_NO_VALUE. GHOSTTY_NO_VALUE is also returned when the reference cannot + * be represented in the requested coordinate space. + * + * @param ref Tracked grid reference. + * @param tag Coordinate space to convert into. + * @param[out] out_point On success, receives the coordinate. May be NULL. + * @return GHOSTTY_SUCCESS on success, GHOSTTY_INVALID_VALUE if ref is invalid, + * or GHOSTTY_NO_VALUE if there is no representable value. + * + * @ingroup grid_ref + */ +GHOSTTY_API GhosttyResult ghostty_tracked_grid_ref_point( + GhosttyTrackedGridRef ref, + GhosttyPointTag tag, + GhosttyPointCoordinate *out_point); + +/** + * Move an existing tracked grid reference to a new terminal point. + * + * On success, the tracked reference begins tracking the new point and any prior + * "no value" state is cleared. On GHOSTTY_OUT_OF_MEMORY, the original tracked + * reference is left unchanged. + * + * The terminal must be the same terminal that created the tracked reference. + * The point is resolved against the terminal screen/page-list that is active at + * the time this function is called. If the terminal has switched between + * primary and alternate screens, this may move the tracked reference from one + * screen/page-list to the other. + * + * @param ref Tracked grid reference. + * @param terminal Terminal instance that owns the reference. + * @param point New point to track. + * @return GHOSTTY_SUCCESS on success, GHOSTTY_INVALID_VALUE if ref, terminal, + * or point is invalid, or GHOSTTY_OUT_OF_MEMORY if allocation fails. + * + * @ingroup grid_ref + */ +GHOSTTY_API GhosttyResult ghostty_tracked_grid_ref_set( + GhosttyTrackedGridRef ref, + GhosttyTerminal terminal, + GhosttyPoint point); + +/** + * Snapshot a tracked grid reference into a regular GhosttyGridRef. + * + * The returned GhosttyGridRef is an untracked snapshot and has the same + * lifetime rules as ghostty_terminal_grid_ref(): it is only valid until the + * next terminal update. Snapshot immediately before calling + * ghostty_grid_ref_cell(), ghostty_grid_ref_row(), + * ghostty_grid_ref_graphemes(), ghostty_grid_ref_hyperlink_uri(), or + * ghostty_grid_ref_style(). + * + * If the tracked reference no longer has a meaningful value, this returns + * GHOSTTY_NO_VALUE. + * + * @param ref Tracked grid reference. + * @param[out] out_ref On success, receives an untracked snapshot. May be NULL. + * @return GHOSTTY_SUCCESS on success, GHOSTTY_INVALID_VALUE if ref is invalid, + * or GHOSTTY_NO_VALUE if the tracked location was discarded. + * + * @ingroup grid_ref + */ +GHOSTTY_API GhosttyResult ghostty_tracked_grid_ref_snapshot( + GhosttyTrackedGridRef ref, + GhosttyGridRef *out_ref); + +#ifdef __cplusplus +} +#endif + +#endif /* GHOSTTY_VT_GRID_REF_TRACKED_H */ diff --git a/include/ghostty/vt/terminal.h b/include/ghostty/vt/terminal.h index 0e6d048e1..1751aa126 100644 --- a/include/ghostty/vt/terminal.h +++ b/include/ghostty/vt/terminal.h @@ -1120,6 +1120,37 @@ GHOSTTY_API GhosttyResult ghostty_terminal_grid_ref(GhosttyTerminal terminal, GhosttyPoint point, GhosttyGridRef *out_ref); +/** + * Create an owned tracked grid reference for a terminal point. + * + * This is the tracked variant of ghostty_terminal_grid_ref(). The returned + * handle follows the referenced cell as the terminal's page list is modified: + * scrolling, pruning, resize/reflow, and other page-list operations update the + * tracked reference automatically. + * + * The reference is attached to the terminal screen/page-list that is active at + * creation time. + * + * If the point is outside the requested coordinate space, this returns + * GHOSTTY_INVALID_VALUE and writes NULL to out_ref. + * + * The returned handle must be freed with ghostty_tracked_grid_ref_free() before + * the terminal is freed. + * + * @param terminal Terminal instance. + * @param point Point to track. + * @param[out] out_ref On success, receives the tracked reference handle. + * @return GHOSTTY_SUCCESS on success, GHOSTTY_INVALID_VALUE if terminal, + * point, or out_ref is invalid, or GHOSTTY_OUT_OF_MEMORY if allocation + * fails. + * + * @ingroup terminal + */ +GHOSTTY_API GhosttyResult ghostty_terminal_grid_ref_track( + GhosttyTerminal terminal, + GhosttyPoint point, + GhosttyTrackedGridRef *out_ref); + /** * Convert a grid reference back to a point in the given coordinate system. * diff --git a/include/ghostty/vt/types.h b/include/ghostty/vt/types.h index e0be0b77d..e8e976207 100644 --- a/include/ghostty/vt/types.h +++ b/include/ghostty/vt/types.h @@ -94,6 +94,16 @@ typedef enum GHOSTTY_ENUM_TYPED { */ typedef struct GhosttyTerminalImpl* GhosttyTerminal; +/** + * Opaque handle to a tracked grid reference. + * + * A tracked grid reference is owned by the caller and must be freed with + * ghostty_tracked_grid_ref_free() before the terminal that created it is freed. + * + * @ingroup grid_ref + */ +typedef struct GhosttyTrackedGridRefImpl* GhosttyTrackedGridRef; + /** * Opaque handle to a Kitty graphics image storage. * diff --git a/src/lib_vt.zig b/src/lib_vt.zig index ae0c87b1e..12aa66bfe 100644 --- a/src/lib_vt.zig +++ b/src/lib_vt.zig @@ -240,6 +240,7 @@ comptime { @export(&c.terminal_get, .{ .name = "ghostty_terminal_get" }); @export(&c.terminal_get_multi, .{ .name = "ghostty_terminal_get_multi" }); @export(&c.terminal_grid_ref, .{ .name = "ghostty_terminal_grid_ref" }); + @export(&c.terminal_grid_ref_track, .{ .name = "ghostty_terminal_grid_ref_track" }); @export(&c.terminal_point_from_grid_ref, .{ .name = "ghostty_terminal_point_from_grid_ref" }); @export(&c.kitty_graphics_get, .{ .name = "ghostty_kitty_graphics_get" }); @export(&c.kitty_graphics_image, .{ .name = "ghostty_kitty_graphics_image" }); @@ -262,6 +263,11 @@ comptime { @export(&c.grid_ref_graphemes, .{ .name = "ghostty_grid_ref_graphemes" }); @export(&c.grid_ref_hyperlink_uri, .{ .name = "ghostty_grid_ref_hyperlink_uri" }); @export(&c.grid_ref_style, .{ .name = "ghostty_grid_ref_style" }); + @export(&c.tracked_grid_ref_free, .{ .name = "ghostty_tracked_grid_ref_free" }); + @export(&c.tracked_grid_ref_has_value, .{ .name = "ghostty_tracked_grid_ref_has_value" }); + @export(&c.tracked_grid_ref_point, .{ .name = "ghostty_tracked_grid_ref_point" }); + @export(&c.tracked_grid_ref_set, .{ .name = "ghostty_tracked_grid_ref_set" }); + @export(&c.tracked_grid_ref_snapshot, .{ .name = "ghostty_tracked_grid_ref_snapshot" }); @export(&c.build_info, .{ .name = "ghostty_build_info" }); @export(&c.type_json, .{ .name = "ghostty_type_json" }); @export(&c.alloc_alloc, .{ .name = "ghostty_alloc" }); diff --git a/src/terminal/ScreenSet.zig b/src/terminal/ScreenSet.zig index d0856da51..4d3689e3b 100644 --- a/src/terminal/ScreenSet.zig +++ b/src/terminal/ScreenSet.zig @@ -30,6 +30,11 @@ active: *Screen, /// All screens that are initialized. all: std.EnumMap(Key, *Screen), +/// Monotonic generation counter for each screen key. This changes whenever a +/// screen is removed so external handles can distinguish a newly initialized +/// screen from stale references into destroyed screen storage. +generations: std.EnumMap(Key, usize), + pub fn init( alloc: Allocator, opts: Screen.Options, @@ -42,6 +47,7 @@ pub fn init( .active_key = .primary, .active = screen, .all = .init(.{ .primary = screen }), + .generations = .initFull(0), }; } @@ -59,6 +65,11 @@ pub fn get(self: *const ScreenSet, key: Key) ?*Screen { return self.all.get(key); } +/// Get the current generation for the given screen key. +pub fn generation(self: *const ScreenSet, key: Key) usize { + return self.generations.get(key).?; +} + /// Get the screen for the given key, initializing it if necessary. pub fn getInit( self: *ScreenSet, @@ -82,6 +93,7 @@ pub fn remove( ) void { assert(key != .primary); if (self.all.fetchRemove(key)) |screen| { + self.generations.put(key, self.generation(key) +% 1); screen.deinit(); alloc.destroy(screen); } @@ -99,9 +111,40 @@ test ScreenSet { var set: ScreenSet = try .init(alloc, .default); defer set.deinit(alloc); try testing.expectEqual(.primary, set.active_key); + try testing.expectEqual(@as(usize, 0), set.generation(.primary)); + try testing.expectEqual(@as(usize, 0), set.generation(.alternate)); // Initialize a secondary screen _ = try set.getInit(alloc, .alternate, .default); + try testing.expectEqual(@as(usize, 0), set.generation(.alternate)); + set.switchTo(.alternate); try testing.expectEqual(.alternate, set.active_key); } + +test "ScreenSet generations" { + const alloc = testing.allocator; + var set: ScreenSet = try .init(alloc, .default); + defer set.deinit(alloc); + + try testing.expectEqual(@as(usize, 0), set.generation(.primary)); + try testing.expectEqual(@as(usize, 0), set.generation(.alternate)); + + // A no-op removal doesn't change the generation. + set.remove(alloc, .alternate); + try testing.expectEqual(@as(usize, 0), set.generation(.alternate)); + + // Initializing a screen doesn't change the generation. + _ = try set.getInit(alloc, .alternate, .default); + try testing.expectEqual(@as(usize, 0), set.generation(.alternate)); + + const alternate_generation = set.generation(.alternate); + set.remove(alloc, .alternate); + try testing.expectEqual(alternate_generation +% 1, set.generation(.alternate)); + + // Reinitializing keeps the generation from the last removal, so stale + // handles can distinguish the new screen from the destroyed screen. + _ = try set.getInit(alloc, .alternate, .default); + try testing.expectEqual(alternate_generation +% 1, set.generation(.alternate)); + try testing.expectEqual(@as(usize, 0), set.generation(.primary)); +} diff --git a/src/terminal/c/grid_ref_tracked.zig b/src/terminal/c/grid_ref_tracked.zig new file mode 100644 index 000000000..3233bb054 --- /dev/null +++ b/src/terminal/c/grid_ref_tracked.zig @@ -0,0 +1,187 @@ +const std = @import("std"); +const testing = std.testing; +const lib = @import("../lib.zig"); +const PageList = @import("../PageList.zig"); +const point = @import("../point.zig"); +const grid_ref_c = @import("grid_ref.zig"); +const terminal_c = @import("terminal.zig"); +const Result = @import("result.zig").Result; + +/// C: GhosttyTrackedGridRef +/// +/// An owned tracked reference to a position in the terminal grid. The +/// underlying PageList pin is automatically updated as the PageList changes. +pub const CTrackedGridRef = ?*TrackedGridRef; + +pub const TrackedGridRef = struct { + alloc: std.mem.Allocator, + terminal: terminal_c.Terminal, + screen_key: terminal_c.TerminalScreen, + screen_generation: usize, + pin: *PageList.Pin, + + /// Return the PageList that owns this tracked ref's pin, or null if the + /// owning screen has been removed/reinitialized since the ref was created. + fn pageList(ref: *const TrackedGridRef) ?*PageList { + const wrapper = ref.terminal orelse return null; + const t = wrapper.terminal; + if (t.screens.generation(ref.screen_key) != ref.screen_generation) return null; + const screen = t.screens.get(ref.screen_key) orelse return null; + return &screen.pages; + } +}; + +pub fn tracked_grid_ref_free(ref_: CTrackedGridRef) callconv(lib.calling_conv) void { + const ref = ref_ orelse return; + if (ref.pageList()) |list| list.untrackPin(ref.pin); + ref.alloc.destroy(ref); +} + +pub fn tracked_grid_ref_has_value(ref_: CTrackedGridRef) callconv(lib.calling_conv) bool { + const ref = ref_ orelse return false; + _ = ref.pageList() orelse return false; + return !ref.pin.garbage; +} + +pub fn tracked_grid_ref_snapshot( + ref_: CTrackedGridRef, + out_ref: ?*grid_ref_c.CGridRef, +) callconv(lib.calling_conv) Result { + const ref = ref_ orelse return .invalid_value; + _ = ref.pageList() orelse return .no_value; + if (ref.pin.garbage) return .no_value; + if (out_ref) |out| out.* = grid_ref_c.CGridRef.fromPin(ref.pin.*); + return .success; +} + +pub fn tracked_grid_ref_point( + ref_: CTrackedGridRef, + tag: point.Tag, + out: ?*point.Coordinate, +) callconv(lib.calling_conv) Result { + const ref = ref_ orelse return .invalid_value; + const list = ref.pageList() orelse return .no_value; + if (ref.pin.garbage) return .no_value; + const pt = list.pointFromPin(tag, ref.pin.*) orelse return .no_value; + if (out) |o| o.* = pt.coord(); + return .success; +} + +pub fn tracked_grid_ref_set( + ref_: CTrackedGridRef, + terminal_: terminal_c.Terminal, + pt: point.Point.C, +) callconv(lib.calling_conv) Result { + const ref = ref_ orelse return .invalid_value; + const wrapper = terminal_ orelse return .invalid_value; + if (ref.terminal != terminal_) return .invalid_value; + + const t = wrapper.terminal; + const list = &t.screens.active.pages; + const p = list.pin(point.Point.fromC(pt)) orelse return .invalid_value; + const tracked_pin = list.trackPin(p) catch return .out_of_memory; + + if (ref.pageList()) |old_list| old_list.untrackPin(ref.pin); + ref.screen_key = t.screens.active_key; + ref.screen_generation = t.screens.generation(ref.screen_key); + ref.pin = tracked_pin; + return .success; +} + +test "tracked_grid_ref snapshots after terminal scroll" { + var terminal: terminal_c.Terminal = null; + try testing.expectEqual(Result.success, terminal_c.new( + &lib.alloc.test_allocator, + &terminal, + .{ .cols = 5, .rows = 2, .max_scrollback = 10_000 }, + )); + defer terminal_c.free(terminal); + + terminal_c.vt_write(terminal, "A", 1); + + var ref: CTrackedGridRef = null; + try testing.expectEqual(Result.success, terminal_c.grid_ref_track( + terminal, + point.Point.cval(.{ .active = .{ .x = 0, .y = 0 } }), + &ref, + )); + defer tracked_grid_ref_free(ref); + + terminal_c.vt_write(terminal, "\r\nB\r\nC", 6); + try testing.expect(tracked_grid_ref_has_value(ref)); + + var snapshot: grid_ref_c.CGridRef = undefined; + try testing.expectEqual(Result.success, tracked_grid_ref_snapshot(ref, &snapshot)); + + var buf: [1]u32 = undefined; + var len: usize = undefined; + try testing.expectEqual(Result.success, grid_ref_c.grid_ref_graphemes(&snapshot, &buf, buf.len, &len)); + try testing.expectEqual(@as(usize, 1), len); + try testing.expectEqual(@as(u32, 'A'), buf[0]); +} + +test "tracked_grid_ref reports no value after reset" { + var terminal: terminal_c.Terminal = null; + try testing.expectEqual(Result.success, terminal_c.new( + &lib.alloc.test_allocator, + &terminal, + .{ .cols = 5, .rows = 2, .max_scrollback = 10_000 }, + )); + defer terminal_c.free(terminal); + + terminal_c.vt_write(terminal, "A", 1); + + var ref: CTrackedGridRef = null; + try testing.expectEqual(Result.success, terminal_c.grid_ref_track( + terminal, + point.Point.cval(.{ .active = .{ .x = 0, .y = 0 } }), + &ref, + )); + defer tracked_grid_ref_free(ref); + + terminal_c.reset(terminal); + try testing.expect(!tracked_grid_ref_has_value(ref)); + + var snapshot: grid_ref_c.CGridRef = undefined; + try testing.expectEqual(Result.no_value, tracked_grid_ref_snapshot(ref, &snapshot)); +} + +test "tracked_grid_ref reports no value after alternate screen reset" { + var terminal: terminal_c.Terminal = null; + try testing.expectEqual(Result.success, terminal_c.new( + &lib.alloc.test_allocator, + &terminal, + .{ .cols = 5, .rows = 2, .max_scrollback = 10_000 }, + )); + defer terminal_c.free(terminal); + + terminal_c.vt_write(terminal, "\x1b[?1049hA", 9); + + var ref: CTrackedGridRef = null; + try testing.expectEqual(Result.success, terminal_c.grid_ref_track( + terminal, + point.Point.cval(.{ .active = .{ .x = 0, .y = 0 } }), + &ref, + )); + defer tracked_grid_ref_free(ref); + + terminal_c.vt_write(terminal, "\x1bc", 2); + try testing.expect(!tracked_grid_ref_has_value(ref)); + + var snapshot: grid_ref_c.CGridRef = undefined; + try testing.expectEqual(Result.no_value, tracked_grid_ref_snapshot(ref, &snapshot)); + + var coord: point.Coordinate = undefined; + try testing.expectEqual(Result.no_value, tracked_grid_ref_point(ref, .active, &coord)); + + terminal_c.vt_write(terminal, "\x1b[?1049h", 8); + try testing.expect(!tracked_grid_ref_has_value(ref)); + + try testing.expectEqual(Result.success, tracked_grid_ref_set( + ref, + terminal, + point.Point.cval(.{ .active = .{ .x = 0, .y = 0 } }), + )); + try testing.expect(tracked_grid_ref_has_value(ref)); + try testing.expectEqual(Result.success, tracked_grid_ref_snapshot(ref, &snapshot)); +} diff --git a/src/terminal/c/main.zig b/src/terminal/c/main.zig index 126b66401..ab6ab719b 100644 --- a/src/terminal/c/main.zig +++ b/src/terminal/c/main.zig @@ -8,6 +8,7 @@ pub const color = @import("color.zig"); pub const focus = @import("focus.zig"); pub const formatter = @import("formatter.zig"); pub const grid_ref = @import("grid_ref.zig"); +pub const grid_ref_tracked = @import("grid_ref_tracked.zig"); pub const kitty_graphics = @import("kitty_graphics.zig"); pub const kitty_graphics_get = kitty_graphics.get; pub const kitty_graphics_image = kitty_graphics.image_get_handle; @@ -170,6 +171,7 @@ pub const terminal_mode_set = terminal.mode_set; pub const terminal_get = terminal.get; pub const terminal_get_multi = terminal.get_multi; pub const terminal_grid_ref = terminal.grid_ref; +pub const terminal_grid_ref_track = terminal.grid_ref_track; pub const terminal_point_from_grid_ref = terminal.point_from_grid_ref; pub const type_json = types.get_json; @@ -179,6 +181,11 @@ pub const grid_ref_row = grid_ref.grid_ref_row; pub const grid_ref_graphemes = grid_ref.grid_ref_graphemes; pub const grid_ref_hyperlink_uri = grid_ref.grid_ref_hyperlink_uri; pub const grid_ref_style = grid_ref.grid_ref_style; +pub const tracked_grid_ref_free = grid_ref_tracked.tracked_grid_ref_free; +pub const tracked_grid_ref_has_value = grid_ref_tracked.tracked_grid_ref_has_value; +pub const tracked_grid_ref_point = grid_ref_tracked.tracked_grid_ref_point; +pub const tracked_grid_ref_set = grid_ref_tracked.tracked_grid_ref_set; +pub const tracked_grid_ref_snapshot = grid_ref_tracked.tracked_grid_ref_snapshot; test { _ = allocator; @@ -186,6 +193,7 @@ test { _ = cell; _ = color; _ = grid_ref; + _ = grid_ref_tracked; _ = kitty_graphics; _ = row; _ = focus; diff --git a/src/terminal/c/terminal.zig b/src/terminal/c/terminal.zig index d3293f3bc..662a2ec03 100644 --- a/src/terminal/c/terminal.zig +++ b/src/terminal/c/terminal.zig @@ -19,6 +19,7 @@ const size_report = @import("../size_report.zig"); const cell_c = @import("cell.zig"); const row_c = @import("row.zig"); const grid_ref_c = @import("grid_ref.zig"); +const grid_ref_tracked_c = @import("grid_ref_tracked.zig"); const style_c = @import("style.zig"); const color = @import("../color.zig"); const Result = @import("result.zig").Result; @@ -723,18 +724,44 @@ pub fn grid_ref( out_ref: ?*grid_ref_c.CGridRef, ) callconv(lib.calling_conv) Result { const t: *ZigTerminal = (terminal_ orelse return .invalid_value).terminal; - const zig_pt: point.Point = switch (pt.tag) { - .active => .{ .active = pt.value.active }, - .viewport => .{ .viewport = pt.value.viewport }, - .screen => .{ .screen = pt.value.screen }, - .history => .{ .history = pt.value.history }, - }; + const zig_pt: point.Point = .fromC(pt); const p = t.screens.active.pages.pin(zig_pt) orelse return .invalid_value; if (out_ref) |out| out.* = grid_ref_c.CGridRef.fromPin(p); return .success; } +pub fn grid_ref_track( + terminal_: Terminal, + pt: point.Point.C, + out_ref: ?*grid_ref_tracked_c.CTrackedGridRef, +) callconv(lib.calling_conv) Result { + const wrapper = terminal_ orelse return .invalid_value; + const out = out_ref orelse return .invalid_value; + out.* = null; + + const t: *ZigTerminal = wrapper.terminal; + const list = &t.screens.active.pages; + const p = list.pin(.fromC(pt)) orelse return .invalid_value; + const tracked_pin = list.trackPin(p) catch return .out_of_memory; + + const alloc = t.gpa(); + const ref = alloc.create(grid_ref_tracked_c.TrackedGridRef) catch { + list.untrackPin(tracked_pin); + return .out_of_memory; + }; + ref.* = .{ + .alloc = alloc, + .terminal = wrapper, + .screen_key = t.screens.active_key, + .screen_generation = t.screens.generation(t.screens.active_key), + .pin = tracked_pin, + }; + + out.* = ref; + return .success; +} + pub fn point_from_grid_ref( terminal_: Terminal, ref: *const grid_ref_c.CGridRef, diff --git a/src/terminal/point.zig b/src/terminal/point.zig index 4297bf5b5..f6bf25c39 100644 --- a/src/terminal/point.zig +++ b/src/terminal/point.zig @@ -76,6 +76,16 @@ pub const Point = union(Tag) { pub const C = c_union.C; pub const CValue = c_union.CValue; pub const cval = c_union.cval; + + /// Convert a C ABI point into the native Zig tagged union. + pub fn fromC(pt: C) Point { + return switch (pt.tag) { + .active => .{ .active = pt.value.active }, + .viewport => .{ .viewport = pt.value.viewport }, + .screen => .{ .screen = pt.value.screen }, + .history => .{ .history = pt.value.history }, + }; + } }; pub const Coordinate = extern struct { From 60f767dd84cac94d5a6f4e827847361c42d1c078 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Sat, 23 May 2026 14:42:33 -0700 Subject: [PATCH 2/2] core: guard surface left-click pins with screen generations Left-click mouse state stored a tracked pin with only the screen key that owned it. If the alternate screen was removed and later recreated, the key could match again even though the stored pin belonged to destroyed PageList storage. Store the screen generation alongside the left-click pin and resolve the pin through helpers that require both the key and generation to match. This keeps selection scrolling, link hover checks, pressure selection, and drag selection from dereferencing stale tracked pins after screen teardown. --- src/Surface.zig | 56 ++++++++++++++++++++++++++++++++++++------------- 1 file changed, 41 insertions(+), 15 deletions(-) diff --git a/src/Surface.zig b/src/Surface.zig index 5d16f3326..525e73a9e 100644 --- a/src/Surface.zig +++ b/src/Surface.zig @@ -228,6 +228,7 @@ const Mouse = struct { /// coordinates so that scrolling preserves the location. left_click_pin: ?*terminal.Pin = null, left_click_screen: terminal.ScreenSet.Key = .primary, + left_click_screen_generation: usize = 0, /// The starting xpos/ypos of the left click. Note that if scrolling occurs, /// these will point to different "cells", but the xpos/ypos will stay @@ -261,6 +262,22 @@ const Mouse = struct { /// The last x/y in the cursor position for links. We use this to /// only process link hover events when the mouse actually moves cells. link_point: ?terminal.point.Coordinate = null, + + /// Return the PageList that owns the left-click pin, or null if the screen + /// has been removed/reinitialized since the pin was tracked. + fn leftClickPageList(self: *const Mouse, screens: *const terminal.ScreenSet) ?*terminal.PageList { + if (screens.generation(self.left_click_screen) != self.left_click_screen_generation) return null; + const screen = screens.get(self.left_click_screen) orelse return null; + return &screen.pages; + } + + /// Return the left-click pin only if it still belongs to the active screen. + fn activeLeftClickPin(self: *const Mouse, screens: *const terminal.ScreenSet) ?*terminal.Pin { + const pin = self.left_click_pin orelse return null; + if (self.left_click_screen != screens.active_key) return null; + _ = self.leftClickPageList(screens) orelse return null; + return pin; + } }; /// Keyboard state for the surface. @@ -1192,9 +1209,9 @@ fn selectionScrollTick(self: *Surface) !void { defer self.renderer_state.mutex.unlock(); const t: *terminal.Terminal = self.renderer_state.terminal; - // If our screen changed while this is happening, we stop our - // selection scroll. - if (self.mouse.left_click_screen != t.screens.active_key) { + // If our left-click pin no longer belongs to the active screen, we stop + // our selection scroll. + if (self.mouse.activeLeftClickPin(&t.screens) == null) { self.queueIo( .{ .selection_scroll = false }, .locked, @@ -1592,7 +1609,7 @@ fn mouseRefreshLinks( // mouse actions. const left_idx = @intFromEnum(input.MouseButton.left); if (self.mouse.click_state[left_idx] == .press) click: { - const pin = self.mouse.left_click_pin orelse break :click; + const pin = self.mouse.activeLeftClickPin(&self.io.terminal.screens) orelse break :click; const click_pt = self.io.terminal.screens.active.pages.pointFromPin( .viewport, pin.*, @@ -3927,15 +3944,14 @@ pub fn mouseButtonCallback( } if (self.mouse.left_click_pin) |prev| { - if (t.screens.get(self.mouse.left_click_screen)) |pin_screen| { - pin_screen.pages.untrackPin(prev); - } + if (self.mouse.leftClickPageList(&t.screens)) |pages| pages.untrackPin(prev); self.mouse.left_click_pin = null; } // Store it self.mouse.left_click_pin = pin; self.mouse.left_click_screen = t.screens.active_key; + self.mouse.left_click_screen_generation = t.screens.generation(t.screens.active_key); self.mouse.left_click_xpos = pos.x; self.mouse.left_click_ypos = pos.y; @@ -4466,7 +4482,7 @@ pub fn mousePressureCallback( // This should always be set in this state but we don't want // to handle state inconsistency here. - const pin = self.mouse.left_click_pin orelse break :select; + const pin = self.mouse.activeLeftClickPin(&self.io.terminal.screens) orelse break :select; const sel = self.io.terminal.screens.active.selectWord( pin.*, self.config.selection_word_chars, @@ -4631,11 +4647,12 @@ pub fn cursorPosCallback( // count because we don't want to handle selection. if (self.mouse.left_click_count == 0) break :select; - // If our terminal screen changed then we don't process this. We don't - // invalidate our pin or mouse state because if the screen switches - // back then we can continue our selection. + // If our left-click pin no longer belongs to the active screen then we + // don't process this. We don't invalidate our pin or mouse state + // because if the same screen switches back then we can continue our + // selection. const t: *terminal.Terminal = self.renderer_state.terminal; - if (self.mouse.left_click_screen != t.screens.active_key) break :select; + if (self.mouse.activeLeftClickPin(&t.screens) == null) break :select; // All roads lead to requiring a re-render at this point. try self.queueRender(); @@ -4690,7 +4707,7 @@ fn dragLeftClickDouble( drag_pin: terminal.Pin, ) !void { const screen: *terminal.Screen = self.io.terminal.screens.active; - const click_pin = self.mouse.left_click_pin.?.*; + const click_pin = (self.mouse.activeLeftClickPin(&self.io.terminal.screens) orelse return).*; // Get the word closest to our starting click. const word_start = screen.selectWordBetween( @@ -4735,7 +4752,11 @@ fn dragLeftClickTriple( drag_pin: terminal.Pin, ) !void { const screen: *terminal.Screen = self.io.terminal.screens.active; - const click_pin = self.mouse.left_click_pin.?.*; + const click_pin: terminal.Pin = pin: { + const set: *terminal.ScreenSet = &self.io.terminal.screens; + const tracked = self.mouse.activeLeftClickPin(set) orelse return; + break :pin tracked.*; + }; // Get the line selection under our current drag point. If there isn't a // line, do nothing. @@ -4762,8 +4783,13 @@ fn dragLeftClickSingle( drag_x: f64, ) !void { // This logic is in a separate function so that it can be unit tested. + const click_pin: terminal.Pin = pin: { + const set: *terminal.ScreenSet = &self.io.terminal.screens; + const tracked = self.mouse.activeLeftClickPin(set) orelse return; + break :pin tracked.*; + }; try self.io.terminal.screens.active.select(mouseSelection( - self.mouse.left_click_pin.?.*, + click_pin, drag_pin, @intFromFloat(@max(0.0, self.mouse.left_click_xpos)), @intFromFloat(@max(0.0, drag_x)),