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/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)), 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 {